Merge pull request 'develop' (#1) from develop into main
Reviewed-on: akhdanre/Genso_quiz_app#1
This commit is contained in:
commit
cf52ccedec
|
@ -46,3 +46,5 @@ app.*.map.json
|
||||||
|
|
||||||
# FVM Version Cache
|
# FVM Version Cache
|
||||||
.fvm/
|
.fvm/
|
||||||
|
|
||||||
|
*.env
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "quiz_app",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart"
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// "name": "quiz_app (profile mode)",
|
||||||
|
// "request": "launch",
|
||||||
|
// "type": "dart",
|
||||||
|
// "flutterMode": "profile"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "name": "quiz_app (release mode)",
|
||||||
|
// "request": "launch",
|
||||||
|
// "type": "dart",
|
||||||
|
// "flutterMode": "release"
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
|
@ -30,12 +30,35 @@ android {
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
keyAlias = "keyDebugQuiz"
|
||||||
|
keyPassword = "uppercase12"
|
||||||
|
storeFile = file("debugKeystore.jks")
|
||||||
|
storePassword = "uppercase12"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
|
keyAlias = "genso-prod"
|
||||||
|
keyPassword = "oukenzeumasio"
|
||||||
|
storeFile = file("my-release-key.jks")
|
||||||
|
storePassword = "oukenzeumasio"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.debug
|
signingConfig = signingConfigs.debug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application
|
<application
|
||||||
android:label="quiz_app"
|
android:label="quiz_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
@ -21,8 +22,8 @@
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
@ -38,8 +39,8 @@
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="default_web_client_id">
|
||||||
|
730226042143-mv9dlpk9cesirgjh2o0f9hvsk0ks8r2f.apps.googleusercontent.com</string>
|
||||||
|
</resources>
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -0,0 +1,157 @@
|
||||||
|
{
|
||||||
|
"greeting_time": {
|
||||||
|
"morning": "Good Morning",
|
||||||
|
"afternoon": "Good Afternoon",
|
||||||
|
"evening": "Good Evening",
|
||||||
|
"night": "Good Night"
|
||||||
|
},
|
||||||
|
|
||||||
|
"greeting_user": "Hello {user}",
|
||||||
|
"create_room": "Create Room",
|
||||||
|
"join_room": "Join Room",
|
||||||
|
"create_quiz": "Create Quiz",
|
||||||
|
"ready_new_challenge": "Ready for a new challenge?",
|
||||||
|
"search_or_select_category": "Search or select by category",
|
||||||
|
"search_for_quizzes": "Search for quizzes...",
|
||||||
|
"quiz_recommendation": "Recommended Quiz",
|
||||||
|
"log_in": "Log In",
|
||||||
|
"sign_in": "Sign In",
|
||||||
|
"email": "Email",
|
||||||
|
"enter_your_email": "Enter Your Email",
|
||||||
|
"password": "Password",
|
||||||
|
"enter_your_password": "Enter Your Password",
|
||||||
|
"or": "OR",
|
||||||
|
"register_title": "Register",
|
||||||
|
"full_name": "Full Name",
|
||||||
|
"birth_date": "Birth Date",
|
||||||
|
"phone_optional": "Phone Number (Optional)",
|
||||||
|
"verify_password": "Verify Password",
|
||||||
|
"register_button": "Register",
|
||||||
|
"nav_home": "Home",
|
||||||
|
"nav_search": "Search",
|
||||||
|
"nav_library": "Library",
|
||||||
|
"nav_history": "History",
|
||||||
|
"nav_profile": "Profile",
|
||||||
|
"quiz_popular": "Popular Quiz",
|
||||||
|
"see_all": "See All",
|
||||||
|
|
||||||
|
"library_title": "Quiz Library",
|
||||||
|
"library_description": "A collection of quiz questions created for study.",
|
||||||
|
"no_quiz_available": "No quizzes available yet.",
|
||||||
|
"quiz_count_label": "Quiz",
|
||||||
|
"quiz_count_named": "{total} Quiz",
|
||||||
|
|
||||||
|
"history_title": "Quiz History",
|
||||||
|
"history_subtitle": "Review the quizzes you've taken",
|
||||||
|
"no_history": "You don't have any quiz history yet",
|
||||||
|
"score_label": "Score: {correct}/{total}",
|
||||||
|
"duration_minutes": "{minute} minutes",
|
||||||
|
|
||||||
|
"edit_profile": "Edit Profile",
|
||||||
|
"logout": "Logout",
|
||||||
|
"total_quiz": "Total Quiz",
|
||||||
|
"avg_score": "Average Score",
|
||||||
|
|
||||||
|
"history_detail_title": "Quiz Detail",
|
||||||
|
"score": "Score",
|
||||||
|
"time_taken": "Time",
|
||||||
|
"duration_seconds": "{second}s",
|
||||||
|
|
||||||
|
"question_type_option": "Multiple Choice",
|
||||||
|
"question_type_fill": "Fill in the Blank",
|
||||||
|
"question_type_true_false": "True / False",
|
||||||
|
"question_type_unknown": "Unknown Type",
|
||||||
|
|
||||||
|
"enter_room_code": "Enter Room Code",
|
||||||
|
"room_code_hint": "AB123C",
|
||||||
|
"join_now": "Join Now",
|
||||||
|
|
||||||
|
"create_quiz_title": "Create Quiz",
|
||||||
|
"save_all": "Save All",
|
||||||
|
"mode_generate": "Generate",
|
||||||
|
"mode_manual": "Manual",
|
||||||
|
|
||||||
|
"quiz_play_title": "Answer Quiz",
|
||||||
|
"ready_in": "Ready in {second}",
|
||||||
|
"question_indicator": "Question {current} of {total}",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"next": "Next",
|
||||||
|
|
||||||
|
"quiz_preview_title": "Preview Quiz",
|
||||||
|
"quiz_title_label": "Title",
|
||||||
|
"quiz_description_label": "Short Description",
|
||||||
|
"quiz_subject_label": "Subject",
|
||||||
|
"make_quiz_public": "Make Quiz Public",
|
||||||
|
"save_quiz": "Save Quiz",
|
||||||
|
|
||||||
|
"select_language": "Select Language",
|
||||||
|
"change_language": "Change Language",
|
||||||
|
|
||||||
|
"auto_generate_quiz": "Auto Generate Quiz",
|
||||||
|
"ready_to_compete": "Ready to Compete?",
|
||||||
|
"enter_code_to_join": "Enter the quiz code and show your skills!",
|
||||||
|
"join_quiz_now": "Join Quiz Now",
|
||||||
|
|
||||||
|
"total_solve": "Total Solved",
|
||||||
|
"personal_info": "Personal Information",
|
||||||
|
"phone": "Phone Number",
|
||||||
|
"location": "Location",
|
||||||
|
"joined": "Joined",
|
||||||
|
"education": "Education",
|
||||||
|
"not_set": "Not Set",
|
||||||
|
"not_available": "Not Available",
|
||||||
|
"settings": "Settings",
|
||||||
|
"legal_and_support": "Legal & Support",
|
||||||
|
"privacy_policy": "Privacy Policy",
|
||||||
|
"terms_of_service": "Terms of Service",
|
||||||
|
"help_center": "Help Center",
|
||||||
|
"contact_us": "Contact Us",
|
||||||
|
"about_app": "About App",
|
||||||
|
"version": "Version",
|
||||||
|
"close": "Close",
|
||||||
|
|
||||||
|
"your_answer": "Your answer: {answer}",
|
||||||
|
"correct": "Correct",
|
||||||
|
"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",
|
||||||
|
|
||||||
|
"quiz_detail_title": "Quiz Detail",
|
||||||
|
"question_label": "Question",
|
||||||
|
"duration_label": "Duration",
|
||||||
|
"minutes_suffix": "minutes",
|
||||||
|
"start_quiz": "Start Quiz",
|
||||||
|
|
||||||
|
"duration": {
|
||||||
|
"second": "{} second",
|
||||||
|
"minute": "{} minute",
|
||||||
|
"hour": "{} hour"
|
||||||
|
},
|
||||||
|
"duration_suffix": {
|
||||||
|
"second": "{} s",
|
||||||
|
"minute": "{} m",
|
||||||
|
"hour": "{} h"
|
||||||
|
},
|
||||||
|
|
||||||
|
"get_ready": "Get Ready",
|
||||||
|
"quiz_starting_soon": "Quiz Starting Soon",
|
||||||
|
|
||||||
|
"waiting_room": {
|
||||||
|
"title": "Waiting Room",
|
||||||
|
"participants_joined": "Participants Joined:",
|
||||||
|
"leave_room": "Leave Room",
|
||||||
|
"session_code": "Session Code:",
|
||||||
|
"copy_code": "Copy Code",
|
||||||
|
"quiz_info": "Quiz Information:",
|
||||||
|
"quiz_title": "Title",
|
||||||
|
"quiz_description": "Description",
|
||||||
|
"quiz_total_question": "Total Questions",
|
||||||
|
"quiz_duration": "Duration"
|
||||||
|
},
|
||||||
|
|
||||||
|
"save_changes" : "Save Changes"
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
{
|
||||||
|
"greeting_time": {
|
||||||
|
"morning": "Selamat Pagi",
|
||||||
|
"afternoon": "Selamat Siang",
|
||||||
|
"evening": "Selamat Sore",
|
||||||
|
"night": "Selamat Malam"
|
||||||
|
},
|
||||||
|
"greeting_user": "Halo {user}",
|
||||||
|
"create_room": "Buat Ruangan",
|
||||||
|
"join_room": "Gabung Ruang",
|
||||||
|
"create_quiz": "Buat Kuis",
|
||||||
|
"ready_new_challenge": "Siap untuk tantangan baru?",
|
||||||
|
"search_or_select_category": "Cari atau pilih berdasarkan kategori",
|
||||||
|
"search_for_quizzes": "Cari kuis...",
|
||||||
|
"quiz_recommendation": "Rekomendasi Kuis",
|
||||||
|
"log_in": "Masuk",
|
||||||
|
"sign_in": "Masuk",
|
||||||
|
"email": "Email",
|
||||||
|
"enter_your_email": "Masukkan Email Kamu",
|
||||||
|
"password": "Kata Sandi",
|
||||||
|
"enter_your_password": "Masukkan Kata Sandi Kamu",
|
||||||
|
"or": "ATAU",
|
||||||
|
"register_title": "Daftar",
|
||||||
|
"full_name": "Nama Lengkap",
|
||||||
|
"birth_date": "Tanggal Lahir",
|
||||||
|
"phone_optional": "Nomor Telepon (Opsional)",
|
||||||
|
"verify_password": "Verifikasi Kata Sandi",
|
||||||
|
"register_button": "Daftar",
|
||||||
|
"nav_home": "Beranda",
|
||||||
|
"nav_search": "Cari",
|
||||||
|
"nav_library": "Pustaka",
|
||||||
|
"nav_history": "Riwayat",
|
||||||
|
"nav_profile": "Profil",
|
||||||
|
"quiz_popular": "Kuis Populer",
|
||||||
|
"see_all": "Lihat Semua",
|
||||||
|
"library_title": "Pustaka Kuis",
|
||||||
|
"library_description": "Kumpulan pertanyaan kuis untuk belajar.",
|
||||||
|
"no_quiz_available": "Belum ada kuis yang tersedia.",
|
||||||
|
"quiz_count_label": "Kuis",
|
||||||
|
"quiz_count_named": "{total} Kuis",
|
||||||
|
"history_title": "Riwayat Kuis",
|
||||||
|
"history_subtitle": "Tinjau kuis yang telah kamu kerjakan",
|
||||||
|
"no_history": "Kamu belum memiliki riwayat kuis",
|
||||||
|
"score_label": "Skor: {correct}/{total}",
|
||||||
|
"duration_minutes": "{minute} menit",
|
||||||
|
"edit_profile": "Edit Profil",
|
||||||
|
"logout": "Keluar",
|
||||||
|
"total_quiz": "Total Kuis",
|
||||||
|
"avg_score": "Skor Rata-rata",
|
||||||
|
"history_detail_title": "Detail Kuis",
|
||||||
|
"score": "Skor",
|
||||||
|
"time_taken": "Waktu",
|
||||||
|
"duration_seconds": "{second} detik",
|
||||||
|
"question_type_option": "Pilihan Ganda",
|
||||||
|
"question_type_fill": "Isian Kosong",
|
||||||
|
"question_type_true_false": "Benar / Salah",
|
||||||
|
"question_type_unknown": "Tipe Tidak Dikenal",
|
||||||
|
"enter_room_code": "Masukkan Kode Ruangan",
|
||||||
|
"room_code_hint": "AB123C",
|
||||||
|
"join_now": "Gabung Sekarang",
|
||||||
|
"create_quiz_title": "Buat Kuis",
|
||||||
|
"save_all": "Simpan Semua",
|
||||||
|
"mode_generate": "Otomatis",
|
||||||
|
"mode_manual": "Manual",
|
||||||
|
"quiz_play_title": "Kerjakan Kuis",
|
||||||
|
"ready_in": "Siap dalam {second}",
|
||||||
|
"question_indicator": "Pertanyaan {current} dari {total}",
|
||||||
|
"yes": "Ya",
|
||||||
|
"no": "Tidak",
|
||||||
|
"next": "Berikutnya",
|
||||||
|
"quiz_preview_title": "Pratinjau Kuis",
|
||||||
|
"quiz_title_label": "Judul",
|
||||||
|
"quiz_description_label": "Deskripsi Singkat",
|
||||||
|
"quiz_subject_label": "Mata Pelajaran",
|
||||||
|
"make_quiz_public": "Jadikan Kuis Publik",
|
||||||
|
"save_quiz": "Simpan Kuis",
|
||||||
|
"select_language": "Pilih Bahasa",
|
||||||
|
"change_language": "Ganti Bahasa",
|
||||||
|
"auto_generate_quiz": "Buat Kuis Otomatis",
|
||||||
|
"ready_to_compete": "Siap untuk Bertanding?",
|
||||||
|
"enter_code_to_join": "Masukkan kode kuis dan tunjukkan kemampuanmu!",
|
||||||
|
"join_quiz_now": "Gabung Kuis Sekarang",
|
||||||
|
"total_solve": "Total Diselesaikan",
|
||||||
|
"personal_info": "Informasi Pribadi",
|
||||||
|
"phone": "Nomor Telepon",
|
||||||
|
"location": "Lokasi",
|
||||||
|
"joined": "Bergabung",
|
||||||
|
"education": "Pendidikan",
|
||||||
|
"not_set": "Belum Diatur",
|
||||||
|
"not_available": "Tidak Tersedia",
|
||||||
|
"settings": "Pengaturan",
|
||||||
|
"legal_and_support": "Legal & Bantuan",
|
||||||
|
"privacy_policy": "Kebijakan Privasi",
|
||||||
|
"terms_of_service": "Syarat dan Ketentuan",
|
||||||
|
"help_center": "Pusat Bantuan",
|
||||||
|
"contact_us": "Hubungi Kami",
|
||||||
|
"about_app": "Tentang Aplikasi",
|
||||||
|
"version": "Versi",
|
||||||
|
"close": "Tutup",
|
||||||
|
"your_answer": "Jawabanmu: {answer}",
|
||||||
|
"correct": "Benar",
|
||||||
|
"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",
|
||||||
|
"quiz_detail_title": "Detail Kuis",
|
||||||
|
"question_label": "Pertanyaan",
|
||||||
|
"duration_label": "Durasi",
|
||||||
|
"minutes_suffix": "menit",
|
||||||
|
"start_quiz": "Start Quiz",
|
||||||
|
"duration": {
|
||||||
|
"second": "{} detik",
|
||||||
|
"minute": "{} menit",
|
||||||
|
"hour": "{} jam"
|
||||||
|
},
|
||||||
|
"duration_suffix": {
|
||||||
|
"second": "{} d",
|
||||||
|
"minute": "{} m",
|
||||||
|
"hour": "{} j"
|
||||||
|
},
|
||||||
|
|
||||||
|
"get_ready": "Bersiaplah",
|
||||||
|
"quiz_starting_soon": "Kuis akan segera dimulai",
|
||||||
|
"waiting_room": {
|
||||||
|
"title": "Ruang Tunggu",
|
||||||
|
"participants_joined": "Peserta Bergabung:",
|
||||||
|
"leave_room": "Keluar dari Ruangan",
|
||||||
|
"session_code": "Kode Sesi:",
|
||||||
|
"copy_code": "Salin Kode",
|
||||||
|
"quiz_info": "Informasi Kuis:",
|
||||||
|
"quiz_title": "Judul",
|
||||||
|
"quiz_description": "Deskripsi",
|
||||||
|
"quiz_total_question": "Total Pertanyaan",
|
||||||
|
"quiz_duration": "Durasi"
|
||||||
|
},
|
||||||
|
"save_changes": "Simpan Perubahan"
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"greeting_time": {
|
||||||
|
"morning": "Selamat Pagi",
|
||||||
|
"afternoon": "Selamat Tengah Hari",
|
||||||
|
"evening": "Selamat Petang",
|
||||||
|
"night": "Selamat Malam"
|
||||||
|
},
|
||||||
|
"greeting_user": "Hai {user}",
|
||||||
|
"create_room": "Cipta Bilik",
|
||||||
|
"join_room": "Sertai Bilik",
|
||||||
|
"create_quiz": "Cipta Kuiz",
|
||||||
|
"ready_new_challenge": "Sedia untuk cabaran baru?",
|
||||||
|
"search_or_select_category": "Cari atau pilih ikut kategori",
|
||||||
|
"search_for_quizzes": "Cari kuiz...",
|
||||||
|
"quiz_recommendation": "Kuiz Disyorkan",
|
||||||
|
"log_in": "Log Masuk",
|
||||||
|
"sign_in": "Log Masuk",
|
||||||
|
"email": "Emel",
|
||||||
|
"enter_your_email": "Masukkan Emel Kamu",
|
||||||
|
"password": "Kata Laluan",
|
||||||
|
"enter_your_password": "Masukkan Kata Laluan Kamu",
|
||||||
|
"or": "ATAU",
|
||||||
|
"register_title": "Daftar",
|
||||||
|
"full_name": "Nama Penuh",
|
||||||
|
"birth_date": "Tarikh Lahir",
|
||||||
|
"phone_optional": "Nombor Telefon (Opsyenal)",
|
||||||
|
"verify_password": "Sahkan Kata Laluan",
|
||||||
|
"register_button": "Daftar",
|
||||||
|
"nav_home": "Laman Utama",
|
||||||
|
"nav_search": "Cari",
|
||||||
|
"nav_library": "Perpustakaan",
|
||||||
|
"nav_history": "Sejarah",
|
||||||
|
"nav_profile": "Profil",
|
||||||
|
"quiz_popular": "Kuiz Popular",
|
||||||
|
"see_all": "Lihat Semua",
|
||||||
|
"library_title": "Perpustakaan Kuiz",
|
||||||
|
"library_description": "Koleksi soalan kuiz untuk belajar.",
|
||||||
|
"no_quiz_available": "Tiada kuiz tersedia buat masa ini.",
|
||||||
|
"quiz_count_label": "Kuiz",
|
||||||
|
"quiz_count_named": "{total} Kuiz",
|
||||||
|
"history_title": "Sejarah Kuiz",
|
||||||
|
"history_subtitle": "Semak semula kuiz yang kamu dah jawab",
|
||||||
|
"no_history": "Kamu belum ada sejarah kuiz",
|
||||||
|
"score_label": "Skor: {correct}/{total}",
|
||||||
|
"duration_minutes": "{minute} minit",
|
||||||
|
"edit_profile": "Edit Profil",
|
||||||
|
"logout": "Log Keluar",
|
||||||
|
"total_quiz": "Jumlah Kuiz",
|
||||||
|
"avg_score": "Skor Purata",
|
||||||
|
"history_detail_title": "Butiran Kuiz",
|
||||||
|
"score": "Skor",
|
||||||
|
"time_taken": "Masa Diambil",
|
||||||
|
"duration_seconds": "{second} saat",
|
||||||
|
"question_type_option": "Pilihan Jawapan",
|
||||||
|
"question_type_fill": "Isian Kosong",
|
||||||
|
"question_type_true_false": "Betul / Salah",
|
||||||
|
"question_type_unknown": "Jenis Tidak Diketahui",
|
||||||
|
"enter_room_code": "Masukkan Kod Bilik",
|
||||||
|
"room_code_hint": "AB123C",
|
||||||
|
"join_now": "Sertai Sekarang",
|
||||||
|
"create_quiz_title": "Cipta Kuiz",
|
||||||
|
"save_all": "Simpan Semua",
|
||||||
|
"mode_generate": "Auto",
|
||||||
|
"mode_manual": "Manual",
|
||||||
|
"quiz_play_title": "Jawab Kuiz",
|
||||||
|
"ready_in": "Sedia dalam {second}",
|
||||||
|
"question_indicator": "Soalan {current} daripada {total}",
|
||||||
|
"yes": "Ya",
|
||||||
|
"no": "Tidak",
|
||||||
|
"next": "Seterusnya",
|
||||||
|
"quiz_preview_title": "Pratonton Kuiz",
|
||||||
|
"quiz_title_label": "Tajuk",
|
||||||
|
"quiz_description_label": "Deskripsi Ringkas",
|
||||||
|
"quiz_subject_label": "Subjek",
|
||||||
|
"make_quiz_public": "Jadikan Kuiz Umum",
|
||||||
|
"save_quiz": "Simpan Kuiz",
|
||||||
|
"select_language": "Pilih Bahasa",
|
||||||
|
"change_language": "Tukar Bahasa",
|
||||||
|
"auto_generate_quiz": "Cipta Kuiz Automatik",
|
||||||
|
"ready_to_compete": "Sedia untuk Bertanding?",
|
||||||
|
"enter_code_to_join": "Masukkan kod kuiz dan tunjukkan kebolehan kamu!",
|
||||||
|
"join_quiz_now": "Sertai Kuiz Sekarang",
|
||||||
|
"total_solve": "Jumlah Diselesaikan",
|
||||||
|
"personal_info": "Maklumat Peribadi",
|
||||||
|
"phone": "Nombor Telefon",
|
||||||
|
"location": "Lokasi",
|
||||||
|
"joined": "Mendaftar",
|
||||||
|
"education": "Pendidikan",
|
||||||
|
"not_set": "Belum Ditentukan",
|
||||||
|
"not_available": "Tidak Tersedia",
|
||||||
|
"settings": "Tetapan",
|
||||||
|
"legal_and_support": "Perundangan & Sokongan",
|
||||||
|
"privacy_policy": "Dasar Privasi",
|
||||||
|
"terms_of_service": "Terma Perkhidmatan",
|
||||||
|
"help_center": "Pusat Bantuan",
|
||||||
|
"contact_us": "Hubungi Kami",
|
||||||
|
"about_app": "Tentang Aplikasi",
|
||||||
|
"version": "Versi",
|
||||||
|
"close": "Tutup",
|
||||||
|
"your_answer": "Jawapan kamu: {answer}",
|
||||||
|
"correct": "Betul",
|
||||||
|
"correct_answer": "Jawapan yang betul: {answer}",
|
||||||
|
"not_answered": "Belum Dijawab",
|
||||||
|
"seconds_suffix": "saat",
|
||||||
|
"quiz_type_option": "Pilihan Jawapan",
|
||||||
|
"quiz_type_true_false": "Betul atau Salah",
|
||||||
|
"quiz_type_fill_the_blank": "Isian Kosong",
|
||||||
|
"quiz_detail_title": "Butiran Kuiz",
|
||||||
|
"question_label": "Soalan",
|
||||||
|
"duration_label": "Durasi",
|
||||||
|
"minutes_suffix": "minit",
|
||||||
|
"start_quiz": "Mula Kuiz",
|
||||||
|
|
||||||
|
"duration": {
|
||||||
|
"second": "{} saat",
|
||||||
|
"minute": "{} minit",
|
||||||
|
"hour": "{} jam"
|
||||||
|
},
|
||||||
|
|
||||||
|
"duration_suffix": {
|
||||||
|
"second": "{} s",
|
||||||
|
"minute": "{} m",
|
||||||
|
"hour": "{} j"
|
||||||
|
},
|
||||||
|
|
||||||
|
"get_ready": "Bersedia",
|
||||||
|
"quiz_starting_soon": "Kuiz akan bermula sebentar lagi",
|
||||||
|
"waiting_room": {
|
||||||
|
"title": "Bilik Menunggu",
|
||||||
|
"participants_joined": "Peserta Telah Sertai:",
|
||||||
|
"leave_room": "Tinggalkan Bilik",
|
||||||
|
"session_code": "Kod Sesi:",
|
||||||
|
"copy_code": "Salin Kod",
|
||||||
|
"quiz_info": "Maklumat Kuiz:",
|
||||||
|
"quiz_title": "Tajuk",
|
||||||
|
"quiz_description": "Penerangan",
|
||||||
|
"quiz_total_question": "Jumlah Soalan",
|
||||||
|
"quiz_duration": "Tempoh"
|
||||||
|
},
|
||||||
|
"save_changes": "Simpan Perubahan"
|
||||||
|
}
|
|
@ -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:
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/bindings/initial_bindings.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GetMaterialApp(
|
||||||
|
title: 'Quiz App',
|
||||||
|
locale: Get.locale ?? context.locale,
|
||||||
|
fallbackLocale: const Locale('id', 'ID'),
|
||||||
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
supportedLocales: context.supportedLocales,
|
||||||
|
initialBinding: InitialBindings(),
|
||||||
|
initialRoute: AppRoutes.splashScreen,
|
||||||
|
getPages: AppPages.routes,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/controllers/user_controller.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/user_storage_service.dart';
|
||||||
|
|
||||||
|
class InitialBindings extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
Get.put<UserStorageService>(UserStorageService());
|
||||||
|
Get.put(ConnectionService());
|
||||||
|
Get.putAsync(() => ApiClient().init());
|
||||||
|
Get.put<UserController>(UserController(Get.find<UserStorageService>()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
class AppColors {
|
||||||
|
static const Color primaryBlue = Color(0xFF0052CC);
|
||||||
|
static const Color darkText = Color(0xFF172B4D);
|
||||||
|
static const Color softGrayText = Color(0xFF6B778C);
|
||||||
|
static const Color background = Color(0xFFFAFBFC);
|
||||||
|
static const Color background2 = Color(0xFFF9FAFB);
|
||||||
|
|
||||||
|
static const Color borderLight = Color(0xFFE1E4E8);
|
||||||
|
static const Color accentBlue = Color(0xFFD6E4FF);
|
||||||
|
static const Color shadowPrimary = Color(0x330052CC);
|
||||||
|
static const Color disabledBackground = Color(0xFFE0E0E0);
|
||||||
|
static const Color disabledText = Color(0xFF9E9E9E);
|
||||||
|
|
||||||
|
static const Color scoreExcellent = Color(0xFF36B37E);
|
||||||
|
static const Color scoreGood = Color(0xFF00B8D9);
|
||||||
|
static const Color scoreAverage = Color(0xFFFF991F);
|
||||||
|
static const Color scorePoor = Color(0xFFFF5630);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
enum ListingType { recomendation, populer, subject }
|
|
@ -0,0 +1 @@
|
||||||
|
enum QuestionType { fillTheBlank, option, trueOrFalse }
|
|
@ -0,0 +1,5 @@
|
||||||
|
extension StringCasingExtension on String {
|
||||||
|
String toTitleCase() {
|
||||||
|
return split(' ').map((word) => word.isNotEmpty ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}' : '').join(' ');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:quiz_app/app/const/colors/app_colors.dart';
|
||||||
|
|
||||||
|
class AppTextStyles {
|
||||||
|
/// Title: strong and modern using Roboto
|
||||||
|
static final TextStyle title = GoogleFonts.roboto(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Subtitle: clean and readable using Inter
|
||||||
|
static final TextStyle subtitle = GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Body: neutral and easy-to-read using Inter
|
||||||
|
static final TextStyle body = GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Caption: friendly and soft using Nunito
|
||||||
|
static final TextStyle caption = GoogleFonts.nunito(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Stat value: bold and standout using Poppins
|
||||||
|
static final TextStyle statValue = GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Option text: clean and consistent using Inter
|
||||||
|
static final TextStyle optionText = GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// DateTime: subtle and elegant using Nunito Italic
|
||||||
|
static final TextStyle dateTime = GoogleFonts.nunito(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/data/services/user_storage_service.dart';
|
||||||
|
|
||||||
|
class AuthMiddleware extends GetMiddleware {
|
||||||
|
@override
|
||||||
|
RouteSettings? redirect(String? route) {
|
||||||
|
final UserStorageService _storageService = Get.find();
|
||||||
|
if (!_storageService.isLogged) {
|
||||||
|
return const RouteSettings(name: AppRoutes.loginPage);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
import 'package:get/get_navigation/src/routes/get_route.dart';
|
||||||
|
import 'package:quiz_app/app/middleware/auth_middleware.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/bindings/admin_result_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/bindings/detail_participant_result_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/view/detail_participant_result_page.dart';
|
||||||
|
import 'package:quiz_app/feature/history/binding/detail_history_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/history/binding/history_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/history/view/detail_history_view.dart';
|
||||||
|
import 'package:quiz_app/feature/home/binding/home_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/home/view/home_page.dart';
|
||||||
|
import 'package:quiz_app/feature/detail_quiz/binding/detail_quiz_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/join_room/binding/join_room_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/join_room/view/join_room_view.dart';
|
||||||
|
import 'package:quiz_app/feature/library/binding/library_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/detail_quiz/view/detail_quix_view.dart';
|
||||||
|
import 'package:quiz_app/feature/listing_quiz/binding/listing_quiz_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/listing_quiz/view/listing_quiz_view.dart';
|
||||||
|
import 'package:quiz_app/feature/login/bindings/login_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/login/view/login_page.dart';
|
||||||
|
import 'package:quiz_app/feature/monitor_quiz/binding/monitor_quiz_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/monitor_quiz/view/monitor_quiz_view.dart';
|
||||||
|
import 'package:quiz_app/feature/navigation/bindings/navigation_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/navigation/views/navbar_view.dart';
|
||||||
|
import 'package:quiz_app/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart';
|
||||||
|
import 'package:quiz_app/feature/profile/binding/profile_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/profile/binding/update_profile_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/profile/view/update_profile_view.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_play/binding/quiz_play_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_play/view/quiz_play_view.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_preview/binding/quiz_preview_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_preview/view/quiz_preview.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_result/binding/quiz_result_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_result/view/quiz_result_view.dart';
|
||||||
|
import 'package:quiz_app/feature/register/binding/register_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/register/view/register_page.dart';
|
||||||
|
import 'package:quiz_app/feature/room_maker/binding/room_maker_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/room_maker/view/room_maker_view.dart';
|
||||||
|
import 'package:quiz_app/feature/search/binding/search_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart';
|
||||||
|
import 'package:quiz_app/feature/waiting_room/binding/waiting_room_binding.dart';
|
||||||
|
import 'package:quiz_app/feature/waiting_room/view/waiting_room_view.dart';
|
||||||
|
|
||||||
|
part 'app_routes.dart';
|
||||||
|
|
||||||
|
class AppPages {
|
||||||
|
static List<GetPage<dynamic>> routes = [
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.splashScreen,
|
||||||
|
page: () => SplashScreenView(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.loginPage,
|
||||||
|
page: () => LoginView(),
|
||||||
|
binding: LoginBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.registerPage,
|
||||||
|
page: () => RegisterView(),
|
||||||
|
binding: RegisterBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.homePage,
|
||||||
|
page: () => HomeView(),
|
||||||
|
binding: HomeBinding(),
|
||||||
|
middlewares: [AuthMiddleware()],
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.mainPage,
|
||||||
|
page: () => NavbarView(),
|
||||||
|
bindings: [
|
||||||
|
NavbarBinding(),
|
||||||
|
HomeBinding(),
|
||||||
|
SearchBinding(),
|
||||||
|
LibraryBinding(),
|
||||||
|
HistoryBinding(),
|
||||||
|
ProfileBinding(),
|
||||||
|
],
|
||||||
|
middlewares: [AuthMiddleware()],
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.quizCreatePage,
|
||||||
|
page: () => QuizCreationView(),
|
||||||
|
binding: QuizCreationBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.quizPreviewPage,
|
||||||
|
page: () => QuizPreviewPage(),
|
||||||
|
binding: QuizPreviewBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.detailQuizPage,
|
||||||
|
page: () => DetailQuizView(),
|
||||||
|
binding: DetailQuizBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.playQuizPage,
|
||||||
|
page: () => QuizPlayView(),
|
||||||
|
binding: QuizPlayBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.resultQuizPage,
|
||||||
|
page: () => QuizResultView(),
|
||||||
|
binding: QuizResultBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.listingQuizPage,
|
||||||
|
page: () => ListingsQuizView(),
|
||||||
|
binding: ListingQuizBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.detailHistoryPage,
|
||||||
|
page: () => DetailHistoryView(),
|
||||||
|
binding: DetailHistoryBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.roomPage,
|
||||||
|
page: () => RoomMakerView(),
|
||||||
|
binding: RoomMakerBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.waitRoomPage,
|
||||||
|
page: () => WaitingRoomView(),
|
||||||
|
binding: WaitingRoomBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.joinRoomPage,
|
||||||
|
page: () => JoinRoomView(),
|
||||||
|
binding: JoinRoomBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.monitorQuizMPLPage,
|
||||||
|
page: () => MonitorQuizView(),
|
||||||
|
binding: MonitorQuizBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.playQuizMPLPage,
|
||||||
|
page: () => PlayQuizMultiplayerView(),
|
||||||
|
binding: PlayQuizMultiplayerBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.updateProfilePage,
|
||||||
|
page: () => UpdateProfilePage(),
|
||||||
|
binding: UpdateProfileBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.monitorResultMPLPage,
|
||||||
|
page: () => AdminResultPage(),
|
||||||
|
binding: AdminResultBinding(),
|
||||||
|
),
|
||||||
|
GetPage(
|
||||||
|
name: AppRoutes.quizMPLResultPage,
|
||||||
|
page: () => ParticipantDetailPage(),
|
||||||
|
binding: DetailParticipantResultBinding(),
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
part of 'app_pages.dart';
|
||||||
|
|
||||||
|
abstract class AppRoutes {
|
||||||
|
static const splashScreen = "/splashscreen";
|
||||||
|
static const loginPage = "/login";
|
||||||
|
static const registerPage = "/register";
|
||||||
|
static const homePage = '/home';
|
||||||
|
|
||||||
|
static const mainPage = '/main';
|
||||||
|
|
||||||
|
static const quizCreatePage = "/quiz/creation";
|
||||||
|
static const quizPreviewPage = "/quiz/preview";
|
||||||
|
static const listingQuizPage = "/quiz/listing";
|
||||||
|
static const detailQuizPage = "/quiz/detail";
|
||||||
|
|
||||||
|
static const playQuizPage = "/quiz/play";
|
||||||
|
static const resultQuizPage = "/quiz/result";
|
||||||
|
|
||||||
|
static const detailHistoryPage = "/history/detail";
|
||||||
|
|
||||||
|
static const roomPage = "/room/quiz";
|
||||||
|
static const joinRoomPage = "/room/quiz/join";
|
||||||
|
static const waitRoomPage = "/room/quiz/waiting";
|
||||||
|
|
||||||
|
static const playQuizMPLPage = "/room/quiz/play";
|
||||||
|
static const monitorQuizMPLPage = "/room/quiz/monitor";
|
||||||
|
static const monitorResultMPLPage = "/room/quiz/monitor/result";
|
||||||
|
|
||||||
|
static const updateProfilePage = "/profile/update";
|
||||||
|
|
||||||
|
static const quizMPLResultPage = "/room/quiz/result";
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppName extends StatelessWidget {
|
||||||
|
final double fontSize;
|
||||||
|
|
||||||
|
const AppName({super.key, this.fontSize = 36});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"GEN",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF172B4D),
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"SO",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF0052CC),
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum ButtonType { primary, secondary, disabled }
|
||||||
|
|
||||||
|
class GlobalButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String text;
|
||||||
|
final ButtonType type;
|
||||||
|
final Color baseColor;
|
||||||
|
|
||||||
|
const GlobalButton({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
required this.onPressed,
|
||||||
|
this.baseColor = const Color(0xFF0052CC),
|
||||||
|
this.type = ButtonType.primary,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool isDisabled = type == ButtonType.disabled || onPressed == null;
|
||||||
|
|
||||||
|
Color backgroundColor;
|
||||||
|
Color foregroundColor;
|
||||||
|
Color? borderColor;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case ButtonType.primary:
|
||||||
|
backgroundColor = baseColor;
|
||||||
|
foregroundColor = Colors.white;
|
||||||
|
break;
|
||||||
|
case ButtonType.secondary:
|
||||||
|
backgroundColor = Colors.white;
|
||||||
|
foregroundColor = baseColor;
|
||||||
|
borderColor = const Color(0xFF0052CC);
|
||||||
|
break;
|
||||||
|
case ButtonType.disabled:
|
||||||
|
backgroundColor = const Color(0xFFE0E0E0);
|
||||||
|
foregroundColor = const Color(0xFF9E9E9E);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
foregroundColor: foregroundColor,
|
||||||
|
elevation: isDisabled ? 0 : 4,
|
||||||
|
shadowColor: !isDisabled ? backgroundColor.withValues(alpha: 0.3) : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: borderColor != null ? BorderSide(color: borderColor, width: 2) : BorderSide.none,
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: isDisabled ? null : onPressed,
|
||||||
|
child: Text(text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class GlobalDropdownField<T> extends StatelessWidget {
|
||||||
|
final T value;
|
||||||
|
final List<DropdownMenuItem<T>> items;
|
||||||
|
final ValueChanged<T?> onChanged;
|
||||||
|
|
||||||
|
const GlobalDropdownField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.items,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color.fromARGB(255, 234, 234, 235),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFF0052CC),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<T>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
onChanged: onChanged,
|
||||||
|
items: items,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class GlobalTextField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hintText;
|
||||||
|
final String? labelText;
|
||||||
|
final int limitTextLine;
|
||||||
|
final bool isPassword;
|
||||||
|
final bool obscureText;
|
||||||
|
final VoidCallback? onToggleVisibility;
|
||||||
|
final TextInputType textInputType;
|
||||||
|
final bool forceUpperCase;
|
||||||
|
|
||||||
|
const GlobalTextField(
|
||||||
|
{super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.hintText,
|
||||||
|
this.labelText,
|
||||||
|
this.limitTextLine = 1,
|
||||||
|
this.isPassword = false,
|
||||||
|
this.obscureText = false,
|
||||||
|
this.onToggleVisibility,
|
||||||
|
this.forceUpperCase = false,
|
||||||
|
this.textInputType = TextInputType.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: textInputType,
|
||||||
|
obscureText: isPassword ? obscureText : false,
|
||||||
|
maxLines: limitTextLine,
|
||||||
|
textCapitalization: forceUpperCase ? TextCapitalization.characters : TextCapitalization.none,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
color: Color(0xFF6B778C),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
hintText: hintText,
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
color: Color(0xFF6B778C),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Color.fromARGB(255, 234, 234, 235),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFF0052CC),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
suffixIcon: isPassword
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
|
color: Color(0xFF6B778C),
|
||||||
|
),
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LabelTextField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double fontSize;
|
||||||
|
final FontWeight fontWeight;
|
||||||
|
final Alignment alignment;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const LabelTextField({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.fontSize = 16,
|
||||||
|
this.fontWeight = FontWeight.bold,
|
||||||
|
this.alignment = Alignment.centerLeft,
|
||||||
|
this.color, // Tambahkan warna opsional
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), // padding lebih natural
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: color ?? const Color(0xFF172B4D), // default modern dark text
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:quiz_app/app/const/colors/app_colors.dart';
|
||||||
|
|
||||||
|
class DeleteQuestionDialog {
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required VoidCallback onDelete,
|
||||||
|
}) async {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Hapus Soal?",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: AppColors.darkText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
"Soal ini akan dihapus dari daftar kuis. Yakin ingin menghapus?",
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.softGrayText),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.primaryBlue,
|
||||||
|
side: const BorderSide(color: AppColors.primaryBlue),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text("Batal"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onDelete();
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primaryBlue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text("Hapus"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,206 @@
|
||||||
|
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,
|
||||||
|
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 Text(
|
||||||
|
"Keluar tanpa menyimpan?",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
"Perubahan yang belum disimpan akan hilang. Anda yakin ingin keluar?",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.primaryBlue,
|
||||||
|
side: const BorderSide(color: AppColors.primaryBlue),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text("Batal"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context); // Tutup dialog
|
||||||
|
Navigator.pop(context); // Kembali halaman
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primaryBlue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text("Keluar"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool?> showConfirmationDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
String cancelText = "Batal",
|
||||||
|
String confirmText = "Yakin",
|
||||||
|
Color confirmColor = AppColors.primaryBlue,
|
||||||
|
}) async {
|
||||||
|
return showDialog<bool>(
|
||||||
|
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: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.primaryBlue,
|
||||||
|
side: const BorderSide(color: AppColors.primaryBlue),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: Text(cancelText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: confirmColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: Text(confirmText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
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/data/models/quiz/quiz_listing_model.dart';
|
||||||
|
|
||||||
|
class QuizContainerComponent extends StatelessWidget {
|
||||||
|
final QuizListingModel data;
|
||||||
|
final void Function(String quizId) onTap;
|
||||||
|
|
||||||
|
const QuizContainerComponent({
|
||||||
|
required this.data,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onTap(data.quizId),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE1E4E8)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildIconBox(),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildQuizInfo()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIconBox() {
|
||||||
|
return Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0052CC),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.box, color: Colors.white, size: 28),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuizInfo() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF172B4D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Created by ${data.authorName}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B778C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.format_list_bulleted, size: 14, color: Color(0xFF6B778C)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${data.totalQuiz} Quizzes',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B778C)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Icon(Icons.access_time, size: 14, color: Color(0xFF6B778C)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${data.duration} menit',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B778C)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
class ContainerSkeleton extends StatelessWidget {
|
||||||
|
const ContainerSkeleton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE1E4E8)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildBox(width: 50, height: 50, borderRadius: 8),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildBox(width: double.infinity, height: 16, borderRadius: 4),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildBox(width: 100, height: 12, borderRadius: 4),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildCircleBox(size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildBox(width: 60, height: 10, borderRadius: 4),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildCircleBox(size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildBox(width: 60, height: 10, borderRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBox({
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
double borderRadius = 6,
|
||||||
|
}) {
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: Colors.grey.shade300,
|
||||||
|
highlightColor: Colors.grey.shade100,
|
||||||
|
child: Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCircleBox({required double size}) {
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: Colors.grey.shade300,
|
||||||
|
highlightColor: Colors.grey.shade100,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LoadingWidget extends StatelessWidget {
|
||||||
|
const LoadingWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
"Memuat data...",
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,245 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:quiz_app/app/const/colors/app_colors.dart';
|
||||||
|
import 'package:quiz_app/app/const/enums/question_type.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/quiestion_data_model.dart';
|
||||||
|
import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart';
|
||||||
|
|
||||||
|
class QuestionContainerWidget extends StatelessWidget {
|
||||||
|
final QuestionData question;
|
||||||
|
final AnsweredQuestion? answeredQuestion;
|
||||||
|
|
||||||
|
const QuestionContainerWidget({
|
||||||
|
super.key,
|
||||||
|
required this.question,
|
||||||
|
this.answeredQuestion,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: _containerDecoration,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTitle(),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildTypeLabel(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildQuestionText(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAnswerSection(),
|
||||||
|
if (answeredQuestion != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAnsweredSection(question, answeredQuestion!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildDurationInfo(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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: 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),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCorrect ? AppColors.primaryBlue.withValues(alpha: 0.1) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isCorrect ? Icons.check_circle_rounded : Icons.circle_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: isCorrect ? AppColors.primaryBlue : AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isCorrect ? AppColors.primaryBlue : AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFillInBlankAnswers() {
|
||||||
|
final variations = _generateFillBlankVariations(question.answer ?? '-');
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: variations.map((text) => _buildBulletText(text)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrueFalseAnswer() {
|
||||||
|
return Text(
|
||||||
|
'Jawaban: ${question.answer ?? '-'}',
|
||||||
|
style: const TextStyle(color: AppColors.softGrayText),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
answered.isCorrect ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: answered.isCorrect ? Colors.green : Colors.red,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
answer,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: answered.isCorrect ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBulletText(String text) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.arrow_right, size: 18, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(color: AppColors.darkText),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}' : '').join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalizeFirstWordOnly(String text) {
|
||||||
|
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.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(2, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,288 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// Single quiz result tile.
|
||||||
|
/// Shows the question, the user's answer, correctness, time spent, and per‑option 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 {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// One‑based 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 option‑type 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: 12, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(.04),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ————————————————— Question text
|
||||||
|
Text(
|
||||||
|
'$index. $question',
|
||||||
|
softWrap: true,
|
||||||
|
style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_isOptionType && options != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_OptionsList(
|
||||||
|
options: options!,
|
||||||
|
userAnswer: userAnswer,
|
||||||
|
targetAnswer: targetAnswer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AnswerIndicator(
|
||||||
|
isCorrect: isCorrect,
|
||||||
|
isAnswered: userAnswer != null && userAnswer != -1,
|
||||||
|
userAnswerText: _buildUserAnswerText(),
|
||||||
|
correctAnswerText: _buildCorrectAnswerText(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(height: 24, color: AppColors.shadowPrimary),
|
||||||
|
_MetaBar(type: type, timeSpent: timeSpent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——————————————————————————————————————————————————————— 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————————————————————————————————————————————————————————— Sub‑widgets
|
||||||
|
|
||||||
|
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: List.generate(options.length, (i) {
|
||||||
|
final text = options[i];
|
||||||
|
final bool isCorrectAnswer = i == targetAnswer;
|
||||||
|
final bool isUserWrongAnswer = i == userAnswer && !isCorrectAnswer;
|
||||||
|
|
||||||
|
Color? bg;
|
||||||
|
IconData icon = LucideIcons.circle;
|
||||||
|
Color iconColor = AppColors.shadowPrimary;
|
||||||
|
|
||||||
|
if (isCorrectAnswer) {
|
||||||
|
bg = AppColors.primaryBlue.withOpacity(.15);
|
||||||
|
icon = LucideIcons.checkCircle2;
|
||||||
|
iconColor = AppColors.primaryBlue;
|
||||||
|
} else if (isUserWrongAnswer) {
|
||||||
|
bg = Colors.red.withOpacity(.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: 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),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: AppTextStyles.optionText,
|
||||||
|
softWrap: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
// "Jawabanmu: <answer>"
|
||||||
|
tr('your_answer', namedArgs: {'answer': userAnswerText}),
|
||||||
|
style: AppTextStyles.statValue,
|
||||||
|
softWrap: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!isCorrect && isAnswered) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: tr(
|
||||||
|
'quiz_type_$type',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_MetaItem(
|
||||||
|
icon: LucideIcons.clock3,
|
||||||
|
label: '${timeSpent.toStringAsFixed(1)}${tr('seconds_suffix')}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: AppTextStyles.caption),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:quiz_app/component/quiz_container_component.dart';
|
||||||
|
import 'package:quiz_app/component/widget/container_skeleton_widget.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart';
|
||||||
|
|
||||||
|
class RecomendationComponent extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<QuizListingModel> datas;
|
||||||
|
final Function(String) itemOnTap;
|
||||||
|
final Function() allOnTap;
|
||||||
|
|
||||||
|
const RecomendationComponent({
|
||||||
|
required this.title,
|
||||||
|
required this.datas,
|
||||||
|
required this.itemOnTap,
|
||||||
|
required this.allOnTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSectionTitle(context, title),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
datas.isNotEmpty
|
||||||
|
? ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: datas.length,
|
||||||
|
itemBuilder: (context, index) => QuizContainerComponent(
|
||||||
|
data: datas[index],
|
||||||
|
onTap: itemOnTap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 3,
|
||||||
|
itemBuilder: (context, index) => ContainerSkeleton(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget _label() {
|
||||||
|
// return const Padding(
|
||||||
|
// padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
// child: Text(
|
||||||
|
// "Quiz Recommendation",
|
||||||
|
// style: TextStyle(
|
||||||
|
// fontSize: 18,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// color: Color(0xFF172B4D), // dark text
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(BuildContext context, String title) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: allOnTap,
|
||||||
|
child: Text(
|
||||||
|
context.tr('see_all'),
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.blue.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
class APIEndpoint {
|
||||||
|
// static const String baseUrl = "http://192.168.1.13:5000";
|
||||||
|
static const String baseUrl = "http://103.193.178.121:5000";
|
||||||
|
static const String api = "$baseUrl/api";
|
||||||
|
|
||||||
|
static const String login = "/login";
|
||||||
|
static const String loginGoogle = "/login/google";
|
||||||
|
|
||||||
|
static const String register = "/register";
|
||||||
|
|
||||||
|
static const String quiz = "/quiz";
|
||||||
|
static const String quizGenerate = "/quiz/ai";
|
||||||
|
static const String quizAnswer = "/quiz/answer";
|
||||||
|
static const String quizAnswerSession = "/quiz/answer/session";
|
||||||
|
|
||||||
|
static const String userQuiz = "/quiz/user";
|
||||||
|
static const String quizPopuler = "/quiz/populer";
|
||||||
|
static const String quizRecommendation = "/quiz/recommendation";
|
||||||
|
static const String quizSearch = "/quiz/search";
|
||||||
|
|
||||||
|
static const String historyQuiz = "/history";
|
||||||
|
static const String detailHistoryQuiz = "/history/detail";
|
||||||
|
|
||||||
|
static const String subject = "/subject";
|
||||||
|
|
||||||
|
static const String session = "/session";
|
||||||
|
|
||||||
|
static const String sessionHistory = "/history/session";
|
||||||
|
|
||||||
|
static const String userData = "/user";
|
||||||
|
static const String userUpdate = "/user/update";
|
||||||
|
static const String userStat = "/user/status";
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'package:quiz_app/core/utils/custom_notification.dart';
|
||||||
|
|
||||||
|
class ConnectionNotification {
|
||||||
|
static void internetConnected() {
|
||||||
|
CustomNotification.success(
|
||||||
|
title: "Terkoneksi kembali",
|
||||||
|
message: "Terhubugn dengan koneksi",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void noInternedConnection() {
|
||||||
|
CustomNotification.error(
|
||||||
|
title: "Tidak ada internet",
|
||||||
|
message: "cek kembali koneksi internet kamu",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SizeConfig {
|
||||||
|
late double screenWidth;
|
||||||
|
late double screenHeight;
|
||||||
|
double baseSize = 8.0;
|
||||||
|
|
||||||
|
SizeConfig(BuildContext context) {
|
||||||
|
final mediaQueryData = MediaQuery.of(context);
|
||||||
|
screenWidth = mediaQueryData.size.width;
|
||||||
|
screenHeight = mediaQueryData.size.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
double size(double multiplier) {
|
||||||
|
return baseSize * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
double height(double multiplier) {
|
||||||
|
return screenHeight * (multiplier / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
double width(double multiplier) {
|
||||||
|
return screenWidth * (multiplier / 100);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CustomFloatingLoading {
|
||||||
|
static OverlayEntry? _overlayEntry;
|
||||||
|
|
||||||
|
static void showLoading(BuildContext context) {
|
||||||
|
if (_overlayEntry != null) return;
|
||||||
|
|
||||||
|
_overlayEntry = OverlayEntry(
|
||||||
|
builder: (_) => Stack(
|
||||||
|
children: [
|
||||||
|
ModalBarrier(
|
||||||
|
dismissible: false,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void hideLoading() {
|
||||||
|
if (_overlayEntry?.mounted == true) {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
}
|
||||||
|
_overlayEntry = null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class CustomNotification {
|
||||||
|
static void _showSnackbar({
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required IconData icon,
|
||||||
|
required Color backgroundColor,
|
||||||
|
Color textColor = Colors.white,
|
||||||
|
Color iconColor = Colors.white,
|
||||||
|
}) {
|
||||||
|
Get.snackbar(
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon: Icon(icon, color: iconColor),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
colorText: textColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
snackPosition: SnackPosition.TOP,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
isDismissible: true,
|
||||||
|
forwardAnimationCurve: Curves.easeOutBack,
|
||||||
|
reverseAnimationCurve: Curves.easeInBack,
|
||||||
|
boxShadows: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black26,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void success({required String title, required String message}) {
|
||||||
|
_showSnackbar(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
backgroundColor: Colors.green.shade600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void error({required String title, required String message}) {
|
||||||
|
_showSnackbar(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
backgroundColor: Colors.red.shade600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void warning({required String title, required String message}) {
|
||||||
|
_showSnackbar(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
backgroundColor: Colors.orange.shade700,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'package:flutter/foundation.dart'; // For kDebugMode
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
class AppLogger {
|
||||||
|
static final Logger _debugLogger = Logger(
|
||||||
|
printer: PrettyPrinter(
|
||||||
|
errorMethodCount: 5,
|
||||||
|
colors: true,
|
||||||
|
printEmojis: false,
|
||||||
|
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final Logger _releaseLogger = Logger(
|
||||||
|
printer: SimplePrinter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Logger get instance => kDebugMode ? _debugLogger : _releaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// debug print custom
|
||||||
|
Logger get logC => AppLogger.instance;
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'package:get/get_rx/src/rx_types/rx_types.dart';
|
||||||
|
import 'package:get/get_state_manager/src/simple/get_controllers.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/entity/user/user_entity.dart';
|
||||||
|
import 'package:quiz_app/data/services/user_storage_service.dart';
|
||||||
|
|
||||||
|
class UserController extends GetxController {
|
||||||
|
final UserStorageService _userStorageService;
|
||||||
|
|
||||||
|
UserController(this._userStorageService);
|
||||||
|
|
||||||
|
Rx<String> userName = "".obs;
|
||||||
|
Rx<String?> userImage = Rx<String?>(null);
|
||||||
|
Rx<String> email = "".obs;
|
||||||
|
|
||||||
|
UserEntity? userData;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
loadUser();
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadUser() async {
|
||||||
|
final data = await _userStorageService.loadUser();
|
||||||
|
if (data != null) {
|
||||||
|
userData = data;
|
||||||
|
userName.value = data.name;
|
||||||
|
userImage.value = data.picUrl;
|
||||||
|
email.value = data.email;
|
||||||
|
logC.i("Loaded user: ${data.toJson()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUserFromEntity(UserEntity data) {
|
||||||
|
final userEntity = data;
|
||||||
|
userData = userEntity;
|
||||||
|
userName.value = userEntity.name;
|
||||||
|
userImage.value = userEntity.picUrl;
|
||||||
|
email.value = userEntity.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearUser() {
|
||||||
|
userData = null;
|
||||||
|
userName.value = "";
|
||||||
|
userImage.value = "";
|
||||||
|
email.value = '';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/quiz_info_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/session/session_info_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/session/session_response_model.dart';
|
||||||
|
|
||||||
|
class WaitingRoomDTO {
|
||||||
|
final bool isAdmin;
|
||||||
|
final SessionResponseModel data;
|
||||||
|
final SessionInfo sessionInfo;
|
||||||
|
final QuizInfo quizInfo;
|
||||||
|
|
||||||
|
WaitingRoomDTO({
|
||||||
|
required this.isAdmin,
|
||||||
|
required this.data,
|
||||||
|
required this.sessionInfo,
|
||||||
|
required this.quizInfo,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
class UserEntity {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String? picUrl;
|
||||||
|
final String? birthDate;
|
||||||
|
final String? locale;
|
||||||
|
final String? phone;
|
||||||
|
final String? createdAt;
|
||||||
|
|
||||||
|
UserEntity({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
this.picUrl,
|
||||||
|
this.birthDate,
|
||||||
|
this.locale,
|
||||||
|
this.phone,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserEntity.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserEntity(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
picUrl: json['pic_url'],
|
||||||
|
birthDate: json['birth_date'],
|
||||||
|
locale: json['locale'],
|
||||||
|
phone: json['phone'],
|
||||||
|
createdAt: json['created_at'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'pic_url': picUrl,
|
||||||
|
'birth_date': birthDate,
|
||||||
|
'locale': locale,
|
||||||
|
'phone': phone,
|
||||||
|
"created_at": createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
class AnswerModel {
|
||||||
|
final int questionIndex;
|
||||||
|
final dynamic answer; // String, bool, atau int
|
||||||
|
final bool isCorrect;
|
||||||
|
final double timeSpent;
|
||||||
|
|
||||||
|
AnswerModel({
|
||||||
|
required this.questionIndex,
|
||||||
|
required this.answer,
|
||||||
|
required this.isCorrect,
|
||||||
|
required this.timeSpent,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AnswerModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AnswerModel(
|
||||||
|
questionIndex: json['question_index'],
|
||||||
|
answer: json['answer'],
|
||||||
|
isCorrect: json['is_correct'],
|
||||||
|
timeSpent: (json['time_spent'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'question_index': questionIndex,
|
||||||
|
'answer': answer,
|
||||||
|
'is_correct': isCorrect,
|
||||||
|
'time_spent': timeSpent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:quiz_app/data/models/answer/answer_model.dart';
|
||||||
|
|
||||||
|
class QuizAnswerSubmissionModel {
|
||||||
|
final String sessionId;
|
||||||
|
final String quizId;
|
||||||
|
final String userId;
|
||||||
|
final DateTime answeredAt;
|
||||||
|
final List<AnswerModel> answers;
|
||||||
|
|
||||||
|
QuizAnswerSubmissionModel({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.quizId,
|
||||||
|
required this.userId,
|
||||||
|
required this.answeredAt,
|
||||||
|
required this.answers,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizAnswerSubmissionModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizAnswerSubmissionModel(
|
||||||
|
sessionId: json['session_id'],
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
userId: json['user_id'],
|
||||||
|
answeredAt: DateTime.parse(json['answered_at']),
|
||||||
|
answers: (json['answers'] as List).map((e) => AnswerModel.fromJson(e)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'user_id': userId,
|
||||||
|
'answered_at': answeredAt.toIso8601String(),
|
||||||
|
'answers': answers.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
class BaseResponseModel<T> {
|
||||||
|
final String message;
|
||||||
|
final T? data;
|
||||||
|
final MetaModel? meta;
|
||||||
|
|
||||||
|
BaseResponseModel({
|
||||||
|
required this.message,
|
||||||
|
this.data,
|
||||||
|
this.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BaseResponseModel.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(dynamic) fromJsonT,
|
||||||
|
) {
|
||||||
|
return BaseResponseModel<T>(
|
||||||
|
message: json['message'],
|
||||||
|
data: json['data'] != null ? fromJsonT(json['data']) : null,
|
||||||
|
meta: json['meta'] != null ? MetaModel.fromJson(json['meta']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetaModel {
|
||||||
|
final int totalPage;
|
||||||
|
final int currentPage;
|
||||||
|
final int totalData;
|
||||||
|
final int totalAllData;
|
||||||
|
|
||||||
|
MetaModel({
|
||||||
|
required this.totalPage,
|
||||||
|
required this.currentPage,
|
||||||
|
required this.totalData,
|
||||||
|
required this.totalAllData,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MetaModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MetaModel(
|
||||||
|
totalPage: json['total_page'],
|
||||||
|
currentPage: json['current_page'],
|
||||||
|
totalData: json['total_data'],
|
||||||
|
totalAllData: json['total_all_data'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'total_page': totalPage,
|
||||||
|
'current_page': currentPage,
|
||||||
|
'total_data': totalData,
|
||||||
|
'total_all_data': totalAllData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
class QuizAnswerResult {
|
||||||
|
final String answerId;
|
||||||
|
final String quizId;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String authorId;
|
||||||
|
final String answeredAt;
|
||||||
|
final int totalCorrect;
|
||||||
|
final int totalScore;
|
||||||
|
final double totalSolveTime;
|
||||||
|
final List<QuestionAnswerItem> questionListings;
|
||||||
|
|
||||||
|
QuizAnswerResult({
|
||||||
|
required this.answerId,
|
||||||
|
required this.quizId,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.authorId,
|
||||||
|
required this.answeredAt,
|
||||||
|
required this.totalCorrect,
|
||||||
|
required this.totalScore,
|
||||||
|
required this.totalSolveTime,
|
||||||
|
required this.questionListings,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizAnswerResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizAnswerResult(
|
||||||
|
answerId: json['answer_id'],
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
title: json['title'],
|
||||||
|
description: json['description'],
|
||||||
|
authorId: json['author_id'],
|
||||||
|
answeredAt: json['answered_at'],
|
||||||
|
totalCorrect: json['total_correct'],
|
||||||
|
totalScore: json['total_score'],
|
||||||
|
totalSolveTime: (json['total_solve_time'] as num).toDouble(),
|
||||||
|
questionListings: (json['question_listings'] as List).map((e) => QuestionAnswerItem.fromJson(e)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuestionAnswerItem {
|
||||||
|
final int index;
|
||||||
|
final String question;
|
||||||
|
final String type;
|
||||||
|
final dynamic targetAnswer;
|
||||||
|
final dynamic userAnswer;
|
||||||
|
final bool isCorrect;
|
||||||
|
final double timeSpent;
|
||||||
|
final List<String>? options;
|
||||||
|
|
||||||
|
QuestionAnswerItem({
|
||||||
|
required this.index,
|
||||||
|
required this.question,
|
||||||
|
required this.type,
|
||||||
|
required this.targetAnswer,
|
||||||
|
required this.userAnswer,
|
||||||
|
required this.isCorrect,
|
||||||
|
required this.timeSpent,
|
||||||
|
this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuestionAnswerItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuestionAnswerItem(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
type: json['type'],
|
||||||
|
targetAnswer: json['target_answer'],
|
||||||
|
userAnswer: json['user_answer'],
|
||||||
|
isCorrect: json['is_correct'],
|
||||||
|
timeSpent: (json['time_spent'] as num).toDouble(),
|
||||||
|
options: json['options'] != null ? List<String>.from(json['options']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
class QuestionAnswer {
|
||||||
|
final int index;
|
||||||
|
final String question;
|
||||||
|
final dynamic targetAnswer;
|
||||||
|
final int duration;
|
||||||
|
final String type;
|
||||||
|
final List<String>? options;
|
||||||
|
final String answer;
|
||||||
|
final bool isCorrect;
|
||||||
|
final double timeSpent;
|
||||||
|
|
||||||
|
QuestionAnswer({
|
||||||
|
required this.index,
|
||||||
|
required this.question,
|
||||||
|
required this.targetAnswer,
|
||||||
|
required this.duration,
|
||||||
|
required this.type,
|
||||||
|
required this.options,
|
||||||
|
required this.answer,
|
||||||
|
required this.isCorrect,
|
||||||
|
required this.timeSpent,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuestionAnswer.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuestionAnswer(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
targetAnswer: json['target_answer'],
|
||||||
|
duration: json['duration'],
|
||||||
|
type: json['type'],
|
||||||
|
options: json['options'] != null ? List<String>.from(json['options']) : null,
|
||||||
|
answer: json['answer'],
|
||||||
|
isCorrect: json['is_correct'],
|
||||||
|
timeSpent: (json['time_spent'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParticipantResult {
|
||||||
|
final String id;
|
||||||
|
final String sessionId;
|
||||||
|
final String quizId;
|
||||||
|
final String userId;
|
||||||
|
final String answeredAt;
|
||||||
|
final List<QuestionAnswer> answers;
|
||||||
|
final int totalScore;
|
||||||
|
final int totalCorrect;
|
||||||
|
|
||||||
|
ParticipantResult({
|
||||||
|
required this.id,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.quizId,
|
||||||
|
required this.userId,
|
||||||
|
required this.answeredAt,
|
||||||
|
required this.answers,
|
||||||
|
required this.totalScore,
|
||||||
|
required this.totalCorrect,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ParticipantResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ParticipantResult(
|
||||||
|
id: json['id'],
|
||||||
|
sessionId: json['session_id'],
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
userId: json['user_id'],
|
||||||
|
answeredAt: json['answered_at'],
|
||||||
|
answers: (json['answers'] as List).map((e) => QuestionAnswer.fromJson(e)).toList(),
|
||||||
|
totalScore: json['total_score'],
|
||||||
|
totalCorrect: json['total_correct'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get scorePercent => (totalCorrect / answers.length) * 100;
|
||||||
|
|
||||||
|
int get totalQuestions => answers.length;
|
||||||
|
|
||||||
|
String get name => "User $userId";
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
class QuizHistory {
|
||||||
|
final String quizId;
|
||||||
|
final String answerId;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final int totalCorrect;
|
||||||
|
final int totalQuestion;
|
||||||
|
final String date;
|
||||||
|
|
||||||
|
QuizHistory({
|
||||||
|
required this.quizId,
|
||||||
|
required this.answerId,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.totalCorrect,
|
||||||
|
required this.totalQuestion,
|
||||||
|
required this.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizHistory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizHistory(
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
answerId: json['answer_id'],
|
||||||
|
title: json['title'],
|
||||||
|
description: json['description'],
|
||||||
|
totalCorrect: json['total_correct'],
|
||||||
|
totalQuestion: json['total_question'],
|
||||||
|
date: json["date"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'answer_id': answerId,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'total_correct': totalCorrect,
|
||||||
|
'total_question': totalQuestion,
|
||||||
|
'date': date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
class Participant {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int score;
|
||||||
|
|
||||||
|
Participant({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.score,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Participant.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Participant(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
score: json['score'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'score': score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionHistory {
|
||||||
|
final String id;
|
||||||
|
final String sessionCode;
|
||||||
|
final String quizId;
|
||||||
|
final String hostId;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? startedAt;
|
||||||
|
final DateTime? endedAt;
|
||||||
|
final bool isActive;
|
||||||
|
final int participantLimit;
|
||||||
|
final List<Participant> participants;
|
||||||
|
final int currentQuestionIndex;
|
||||||
|
|
||||||
|
SessionHistory({
|
||||||
|
required this.id,
|
||||||
|
required this.sessionCode,
|
||||||
|
required this.quizId,
|
||||||
|
required this.hostId,
|
||||||
|
required this.createdAt,
|
||||||
|
this.startedAt,
|
||||||
|
this.endedAt,
|
||||||
|
required this.isActive,
|
||||||
|
required this.participantLimit,
|
||||||
|
required this.participants,
|
||||||
|
required this.currentQuestionIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SessionHistory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SessionHistory(
|
||||||
|
id: json['id'],
|
||||||
|
sessionCode: json['session_code'],
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
hostId: json['host_id'],
|
||||||
|
createdAt: DateTime.parse(json['created_at']),
|
||||||
|
startedAt: json['started_at'] != null ? DateTime.parse(json['started_at']) : null,
|
||||||
|
endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null,
|
||||||
|
isActive: json['is_active'],
|
||||||
|
participantLimit: json['participan_limit'], // Typo di JSON, harusnya "participant_limit"
|
||||||
|
participants: (json['participants'] as List).map((p) => Participant.fromJson(p)).toList(),
|
||||||
|
currentQuestionIndex: json['current_question_index'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'session_code': sessionCode,
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'host_id': hostId,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'started_at': startedAt?.toIso8601String(),
|
||||||
|
'ended_at': endedAt?.toIso8601String(),
|
||||||
|
'is_active': isActive,
|
||||||
|
'participan_limit': participantLimit, // Tetap gunakan sesuai field JSON yang ada
|
||||||
|
'participants': participants.map((p) => p.toJson()).toList(),
|
||||||
|
'current_question_index': currentQuestionIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
class LoginRequestModel {
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
LoginRequestModel({
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginRequestModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LoginRequestModel(
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
password: json['password'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
class LoginResponseModel {
|
||||||
|
final String? id;
|
||||||
|
final String? googleId;
|
||||||
|
final String email;
|
||||||
|
final String name;
|
||||||
|
final String? birthDate;
|
||||||
|
final String? picUrl;
|
||||||
|
final String? phone;
|
||||||
|
final String locale;
|
||||||
|
final String? createdAt;
|
||||||
|
// final String? updatedAt;
|
||||||
|
|
||||||
|
LoginResponseModel({
|
||||||
|
this.id,
|
||||||
|
this.googleId,
|
||||||
|
required this.email,
|
||||||
|
required this.name,
|
||||||
|
this.birthDate,
|
||||||
|
this.picUrl,
|
||||||
|
this.phone,
|
||||||
|
this.locale = "en-US",
|
||||||
|
this.createdAt,
|
||||||
|
// this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginResponseModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LoginResponseModel(
|
||||||
|
id: json['id'],
|
||||||
|
googleId: json['google_id'],
|
||||||
|
email: json['email'],
|
||||||
|
name: json['name'],
|
||||||
|
birthDate: json['birth_date'],
|
||||||
|
picUrl: json['pic_url'],
|
||||||
|
phone: json['phone'],
|
||||||
|
locale: json['locale'] ?? 'en-US',
|
||||||
|
createdAt: json['created_at'],
|
||||||
|
// updatedAt: json['updated_at'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'google_id': googleId,
|
||||||
|
'email': email,
|
||||||
|
'name': name,
|
||||||
|
'birth_date': birthDate,
|
||||||
|
'pic_url': picUrl,
|
||||||
|
'phone': phone,
|
||||||
|
'locale': locale,
|
||||||
|
'created_at': createdAt,
|
||||||
|
// 'updated_at': updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart';
|
||||||
|
|
||||||
|
class QuizData {
|
||||||
|
final String id;
|
||||||
|
final String authorId;
|
||||||
|
final String subjectId;
|
||||||
|
final String subjectName;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final bool isPublic;
|
||||||
|
final String? date;
|
||||||
|
final String? time;
|
||||||
|
final int totalQuiz;
|
||||||
|
final int limitDuration;
|
||||||
|
final List<BaseQuestionModel> questionListings;
|
||||||
|
|
||||||
|
QuizData({
|
||||||
|
required this.id,
|
||||||
|
required this.authorId,
|
||||||
|
required this.subjectId,
|
||||||
|
required this.subjectName,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
required this.isPublic,
|
||||||
|
this.date,
|
||||||
|
this.time,
|
||||||
|
required this.totalQuiz,
|
||||||
|
required this.limitDuration,
|
||||||
|
required this.questionListings,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizData(
|
||||||
|
id: json["id"],
|
||||||
|
authorId: json['author_id'],
|
||||||
|
subjectId: json['subject_id'],
|
||||||
|
subjectName: json['subject_alias'],
|
||||||
|
title: json['title'],
|
||||||
|
description: json['description'],
|
||||||
|
isPublic: json['is_public'],
|
||||||
|
date: json['date'],
|
||||||
|
time: json['time'],
|
||||||
|
totalQuiz: json['total_quiz'],
|
||||||
|
limitDuration: json['limit_duration'],
|
||||||
|
questionListings: (json['question_listings'] as List).map((e) => BaseQuestionModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'author_id': authorId,
|
||||||
|
'subject_id': subjectId,
|
||||||
|
'subject_alias': subjectName,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'is_public': isPublic,
|
||||||
|
'date': date,
|
||||||
|
'time': time,
|
||||||
|
'total_quiz': totalQuiz,
|
||||||
|
'limit_duration': limitDuration,
|
||||||
|
'question_listings': questionListings.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
abstract class BaseQuestionModel {
|
||||||
|
final int index;
|
||||||
|
final String question;
|
||||||
|
final int duration;
|
||||||
|
final String type;
|
||||||
|
|
||||||
|
BaseQuestionModel({
|
||||||
|
required this.index,
|
||||||
|
required this.question,
|
||||||
|
required this.duration,
|
||||||
|
required this.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BaseQuestionModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
switch (json['type']) {
|
||||||
|
case 'fill_the_blank':
|
||||||
|
return FillInTheBlankQuestion.fromJson(json);
|
||||||
|
case 'true_false':
|
||||||
|
return TrueFalseQuestion.fromJson(json);
|
||||||
|
case 'option':
|
||||||
|
return OptionQuestion.fromJson(json);
|
||||||
|
default:
|
||||||
|
throw Exception('Unsupported question type: ${json['type']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart';
|
||||||
|
|
||||||
|
class FillInTheBlankQuestion extends BaseQuestionModel {
|
||||||
|
final String targetAnswer;
|
||||||
|
|
||||||
|
FillInTheBlankQuestion({
|
||||||
|
required super.index,
|
||||||
|
required super.question,
|
||||||
|
required super.duration,
|
||||||
|
required this.targetAnswer,
|
||||||
|
}) : super(type: 'fill_the_blank');
|
||||||
|
|
||||||
|
factory FillInTheBlankQuestion.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FillInTheBlankQuestion(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
duration: json['duration'],
|
||||||
|
targetAnswer: json['target_answer'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'index': index,
|
||||||
|
'question': question,
|
||||||
|
'duration': duration,
|
||||||
|
'type': type,
|
||||||
|
'target_answer': targetAnswer,
|
||||||
|
'options': null,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart';
|
||||||
|
|
||||||
|
class OptionQuestion extends BaseQuestionModel {
|
||||||
|
final int targetAnswer;
|
||||||
|
final List<String> options;
|
||||||
|
|
||||||
|
OptionQuestion({
|
||||||
|
required super.index,
|
||||||
|
required super.question,
|
||||||
|
required super.duration,
|
||||||
|
required this.targetAnswer,
|
||||||
|
required this.options,
|
||||||
|
}) : super(type: 'option');
|
||||||
|
|
||||||
|
factory OptionQuestion.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OptionQuestion(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
duration: json['duration'],
|
||||||
|
targetAnswer: json['target_answer'],
|
||||||
|
options: List<String>.from(json['options']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'index': index,
|
||||||
|
'question': question,
|
||||||
|
'duration': duration,
|
||||||
|
'type': type,
|
||||||
|
'target_answer': targetAnswer,
|
||||||
|
'options': options,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart';
|
||||||
|
|
||||||
|
class TrueFalseQuestion extends BaseQuestionModel {
|
||||||
|
final bool targetAnswer;
|
||||||
|
|
||||||
|
TrueFalseQuestion({
|
||||||
|
required super.index,
|
||||||
|
required super.question,
|
||||||
|
required super.duration,
|
||||||
|
required this.targetAnswer,
|
||||||
|
}) : super(type: 'true_false');
|
||||||
|
|
||||||
|
factory TrueFalseQuestion.fromJson(Map<String, dynamic> json) {
|
||||||
|
print(json['target_answer']);
|
||||||
|
|
||||||
|
return TrueFalseQuestion(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
duration: json['duration'],
|
||||||
|
targetAnswer: json['target_answer'].toString().toLowerCase() == 'true',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'index': index,
|
||||||
|
'question': question,
|
||||||
|
'duration': duration,
|
||||||
|
'type': type,
|
||||||
|
'target_answer': targetAnswer,
|
||||||
|
'options': null,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import 'package:quiz_app/data/models/quiz/question_listings_model.dart';
|
||||||
|
|
||||||
|
class QuizCreateRequestModel {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final bool isPublic;
|
||||||
|
final String date;
|
||||||
|
final int totalQuiz;
|
||||||
|
final int limitDuration;
|
||||||
|
final String authorId;
|
||||||
|
final String subjectId;
|
||||||
|
final List<QuestionListing> questionListings;
|
||||||
|
|
||||||
|
QuizCreateRequestModel({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.isPublic,
|
||||||
|
required this.date,
|
||||||
|
required this.totalQuiz,
|
||||||
|
required this.limitDuration,
|
||||||
|
required this.authorId,
|
||||||
|
required this.subjectId,
|
||||||
|
required this.questionListings,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'is_public': isPublic,
|
||||||
|
'date': date,
|
||||||
|
'total_quiz': totalQuiz,
|
||||||
|
'limit_duration': limitDuration,
|
||||||
|
'author_id': authorId,
|
||||||
|
"subject_id": subjectId,
|
||||||
|
'question_listings': questionListings.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// class QuestionListing {
|
||||||
|
// final String question;
|
||||||
|
// final String targetAnswer;
|
||||||
|
// final int duration;
|
||||||
|
// final String type;
|
||||||
|
// final List<String>? options;
|
||||||
|
|
||||||
|
// QuestionListing({
|
||||||
|
// required this.question,
|
||||||
|
// required this.targetAnswer,
|
||||||
|
// required this.duration,
|
||||||
|
// required this.type,
|
||||||
|
// this.options,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Map<String, dynamic> toJson() {
|
||||||
|
// final map = <String, dynamic>{
|
||||||
|
// 'question': question,
|
||||||
|
// 'target_answer': targetAnswer,
|
||||||
|
// 'duration': duration,
|
||||||
|
// 'type': type,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// if (options != null && options!.isNotEmpty) {
|
||||||
|
// map['options'] = options;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return map;
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -0,0 +1,39 @@
|
||||||
|
class QuestionListing {
|
||||||
|
final int index;
|
||||||
|
final String question;
|
||||||
|
final dynamic targetAnswer;
|
||||||
|
final int duration;
|
||||||
|
final String type;
|
||||||
|
final List<String>? options;
|
||||||
|
|
||||||
|
QuestionListing({
|
||||||
|
required this.index,
|
||||||
|
required this.question,
|
||||||
|
required this.targetAnswer,
|
||||||
|
required this.duration,
|
||||||
|
required this.type,
|
||||||
|
this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuestionListing.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuestionListing(
|
||||||
|
index: json['index'],
|
||||||
|
question: json['question'],
|
||||||
|
targetAnswer: json['target_answer'],
|
||||||
|
duration: json['duration'],
|
||||||
|
type: json['type'],
|
||||||
|
options: json['options'] != null ? List<String>.from(json['options']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'index': index,
|
||||||
|
'question': question,
|
||||||
|
'target_answer': targetAnswer,
|
||||||
|
'duration': duration,
|
||||||
|
'type': type,
|
||||||
|
'options': options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:quiz_app/app/const/enums/question_type.dart';
|
||||||
|
|
||||||
|
class OptionData {
|
||||||
|
final int index;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
OptionData({required this.index, required this.text});
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuestionData {
|
||||||
|
final int index;
|
||||||
|
final String? question;
|
||||||
|
final String? answer;
|
||||||
|
final List<OptionData>? options;
|
||||||
|
final int? correctAnswerIndex;
|
||||||
|
final QuestionType? type;
|
||||||
|
final int duration;
|
||||||
|
|
||||||
|
QuestionData({
|
||||||
|
required this.index,
|
||||||
|
this.question,
|
||||||
|
this.answer,
|
||||||
|
this.options,
|
||||||
|
this.correctAnswerIndex,
|
||||||
|
this.duration = 30,
|
||||||
|
this.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
QuestionData copyWith({int? index, String? question, String? answer, List<OptionData>? options, int? correctAnswerIndex, QuestionType? type, int? duration}) {
|
||||||
|
return QuestionData(
|
||||||
|
index: index ?? this.index,
|
||||||
|
question: question ?? this.question,
|
||||||
|
answer: answer ?? this.answer,
|
||||||
|
options: options ?? this.options,
|
||||||
|
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
|
||||||
|
type: type ?? this.type,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
class QuizInfo {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final int totalQuiz;
|
||||||
|
final int limitDuration;
|
||||||
|
|
||||||
|
QuizInfo({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.totalQuiz,
|
||||||
|
required this.limitDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizInfo(
|
||||||
|
title: json['title'],
|
||||||
|
description: json['description'],
|
||||||
|
totalQuiz: json['total_quiz'],
|
||||||
|
limitDuration: json['limit_duration'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'question_count': totalQuiz,
|
||||||
|
'limit_duration': limitDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
class QuizListingModel {
|
||||||
|
final String quizId;
|
||||||
|
final String authorId;
|
||||||
|
final String authorName;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String date;
|
||||||
|
final int totalQuiz;
|
||||||
|
final int duration;
|
||||||
|
|
||||||
|
QuizListingModel({
|
||||||
|
required this.quizId,
|
||||||
|
required this.authorId,
|
||||||
|
required this.authorName,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.date,
|
||||||
|
required this.duration,
|
||||||
|
required this.totalQuiz,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuizListingModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuizListingModel(
|
||||||
|
quizId: json['quiz_id'] as String,
|
||||||
|
authorId: json['author_id'] as String,
|
||||||
|
authorName: json['author_name'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
date: json['date'] as String,
|
||||||
|
duration: json['duration'] as int,
|
||||||
|
totalQuiz: json["total_quiz"] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'author_id': authorId,
|
||||||
|
'author_name': authorName,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'date': date,
|
||||||
|
'duration': duration,
|
||||||
|
"total_quiz": totalQuiz
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
class RegisterRequestModel {
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
final String name;
|
||||||
|
final String birthDate;
|
||||||
|
final String? phone;
|
||||||
|
|
||||||
|
RegisterRequestModel({
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
required this.name,
|
||||||
|
required this.birthDate,
|
||||||
|
this.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'name': name,
|
||||||
|
'birth_date': birthDate,
|
||||||
|
if (phone != null) 'phone': phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:quiz_app/data/models/user/user_model.dart';
|
||||||
|
|
||||||
|
class SessionInfo {
|
||||||
|
final String id;
|
||||||
|
final String sessionCode;
|
||||||
|
final String roomName;
|
||||||
|
final String quizId;
|
||||||
|
final String hostId;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? startedAt;
|
||||||
|
final DateTime? endedAt;
|
||||||
|
final bool isActive;
|
||||||
|
final int participantLimit;
|
||||||
|
final List<UserModel> participants;
|
||||||
|
final int currentQuestionIndex;
|
||||||
|
|
||||||
|
SessionInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.sessionCode,
|
||||||
|
required this.roomName,
|
||||||
|
required this.quizId,
|
||||||
|
required this.hostId,
|
||||||
|
required this.createdAt,
|
||||||
|
this.startedAt,
|
||||||
|
this.endedAt,
|
||||||
|
required this.isActive,
|
||||||
|
required this.participantLimit,
|
||||||
|
required this.participants,
|
||||||
|
required this.currentQuestionIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SessionInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SessionInfo(
|
||||||
|
id: json['id'],
|
||||||
|
sessionCode: json['session_code'],
|
||||||
|
roomName: json["room_name"],
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
hostId: json['host_id'],
|
||||||
|
createdAt: DateTime.parse(json['created_at']),
|
||||||
|
startedAt: json['started_at'] != null ? DateTime.parse(json['started_at']) : null,
|
||||||
|
endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null,
|
||||||
|
isActive: json['is_active'],
|
||||||
|
participantLimit: json['participan_limit'],
|
||||||
|
participants: (json['participants'] as List<dynamic>?)?.map((e) => UserModel.fromJson(e as Map<String, dynamic>)).toList() ?? [],
|
||||||
|
currentQuestionIndex: json['current_question_index'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'session_code': sessionCode,
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'host_id': hostId,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'started_at': startedAt?.toIso8601String(),
|
||||||
|
'ended_at': endedAt?.toIso8601String(),
|
||||||
|
'is_active': isActive,
|
||||||
|
'participant_limit': participantLimit,
|
||||||
|
'participants': participants,
|
||||||
|
'current_question_index': currentQuestionIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
class SessionRequestModel {
|
||||||
|
final String quizId;
|
||||||
|
final String hostId;
|
||||||
|
final String roomName;
|
||||||
|
final int limitParticipan;
|
||||||
|
|
||||||
|
SessionRequestModel({
|
||||||
|
required this.quizId,
|
||||||
|
required this.hostId,
|
||||||
|
required this.roomName,
|
||||||
|
required this.limitParticipan,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SessionRequestModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SessionRequestModel(
|
||||||
|
quizId: json['quiz_id'],
|
||||||
|
hostId: json['host_id'],
|
||||||
|
roomName: json['room_name'],
|
||||||
|
limitParticipan: json['limit_participan'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'quiz_id': quizId,
|
||||||
|
'host_id': hostId,
|
||||||
|
'room_name': roomName,
|
||||||
|
'limit_participan': limitParticipan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
class SessionResponseModel {
|
||||||
|
final String sessionId;
|
||||||
|
final String sessionCode;
|
||||||
|
|
||||||
|
SessionResponseModel({required this.sessionId, required this.sessionCode});
|
||||||
|
|
||||||
|
factory SessionResponseModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SessionResponseModel(
|
||||||
|
sessionId: json['session_id'],
|
||||||
|
sessionCode: json['session_code'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'session_code': sessionCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
class SubjectModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String alias;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
SubjectModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.alias,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SubjectModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SubjectModel(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
alias: json['alias'],
|
||||||
|
description: json['description'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'alias': alias,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
class UserFullModel {
|
||||||
|
final String id;
|
||||||
|
final String googleId;
|
||||||
|
final String email;
|
||||||
|
final String name;
|
||||||
|
final String birthDate;
|
||||||
|
final String picUrl;
|
||||||
|
final String phone;
|
||||||
|
final String locale;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
|
||||||
|
UserFullModel({
|
||||||
|
required this.id,
|
||||||
|
required this.googleId,
|
||||||
|
required this.email,
|
||||||
|
required this.name,
|
||||||
|
required this.birthDate,
|
||||||
|
required this.picUrl,
|
||||||
|
required this.phone,
|
||||||
|
required this.locale,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserFullModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserFullModel(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
googleId: json['google_id'] ?? '',
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
birthDate: json['birth_date'] ?? '',
|
||||||
|
picUrl: json['pic_url'] ?? '',
|
||||||
|
phone: json['phone'] ?? '',
|
||||||
|
locale: json['locale'] ?? '',
|
||||||
|
createdAt: json['created_at'] ?? '',
|
||||||
|
updatedAt: json['updated_at'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'google_id': googleId,
|
||||||
|
'email': email,
|
||||||
|
'name': name,
|
||||||
|
'birth_date': birthDate,
|
||||||
|
'pic_url': picUrl,
|
||||||
|
'phone': phone,
|
||||||
|
'locale': locale,
|
||||||
|
'created_at': createdAt,
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
class UserModel {
|
||||||
|
final String id;
|
||||||
|
final String username;
|
||||||
|
final String userPic;
|
||||||
|
final DateTime joinedAt;
|
||||||
|
|
||||||
|
UserModel({
|
||||||
|
required this.id,
|
||||||
|
required this.username,
|
||||||
|
required this.userPic,
|
||||||
|
required this.joinedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserModel(
|
||||||
|
id: json['id'],
|
||||||
|
username: json['username'],
|
||||||
|
userPic: json['user_pic'] ?? "",
|
||||||
|
joinedAt: DateTime.parse(json['joined_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
class UserStatModel {
|
||||||
|
final double avgScore;
|
||||||
|
final int totalSolve;
|
||||||
|
final int totalQuiz;
|
||||||
|
|
||||||
|
UserStatModel({
|
||||||
|
required this.avgScore,
|
||||||
|
required this.totalSolve,
|
||||||
|
required this.totalQuiz,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factory constructor to create an instance from JSON
|
||||||
|
factory UserStatModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserStatModel(
|
||||||
|
avgScore: (json['avg_score'] as num).toDouble(),
|
||||||
|
totalSolve: json['total_solve'] as int,
|
||||||
|
totalQuiz: json['total_quiz'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert instance to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'avg_score': avgScore,
|
||||||
|
'total_solve': totalSolve,
|
||||||
|
'total_quiz': totalQuiz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
|
||||||
|
class ApiClient extends GetxService {
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
Future<ApiClient> init() async {
|
||||||
|
dio = Dio(BaseOptions(
|
||||||
|
baseUrl: APIEndpoint.api,
|
||||||
|
connectTimeout: const Duration(minutes: 3),
|
||||||
|
receiveTimeout: const Duration(minutes: 10),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
logC.i('''
|
||||||
|
➡️ [REQUEST]
|
||||||
|
[${options.method}] ${options.uri}
|
||||||
|
Headers: ${options.headers}
|
||||||
|
Body: ${options.data}
|
||||||
|
''');
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
onResponse: (response, handler) {
|
||||||
|
logC.i('''
|
||||||
|
✅ [RESPONSE]
|
||||||
|
[${response.statusCode}] ${response.requestOptions.uri}
|
||||||
|
Data: ${response.data}
|
||||||
|
''');
|
||||||
|
return handler.next(response);
|
||||||
|
},
|
||||||
|
onError: (DioException e, handler) {
|
||||||
|
logC.e('''
|
||||||
|
❌ [ERROR]
|
||||||
|
[${e.response?.statusCode}] ${e.requestOptions.uri}
|
||||||
|
Message: ${e.message}
|
||||||
|
Error Data: ${e.response?.data}
|
||||||
|
''');
|
||||||
|
return handler.next(e);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/participant_history_result.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class AnswerService extends GetxService {
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel?> submitQuizAnswers(Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
await dio.post(
|
||||||
|
APIEndpoint.quizAnswer,
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
return BaseResponseModel(message: "success");
|
||||||
|
} on DioException catch (e) {
|
||||||
|
logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<ParticipantResult>?> getAnswerSession(String sessionId, String userId) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post(APIEndpoint.quizAnswerSession, data: {
|
||||||
|
"session_id": sessionId,
|
||||||
|
"user_id": userId,
|
||||||
|
});
|
||||||
|
final parsedResponse = BaseResponseModel<ParticipantResult>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => ParticipantResult.fromJson(data),
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedResponse;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/login/login_request_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/login/login_response_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/register/register_request.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class AuthService extends GetxService {
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> register(RegisterRequestModel request) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post(
|
||||||
|
APIEndpoint.register,
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 409) {
|
||||||
|
// Status 409 = Conflict = User already exists
|
||||||
|
throw Exception("Email sudah dipakai");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other Dio errors
|
||||||
|
final errorMessage = e.response?.data['message'] ?? "Pendaftaran gagal";
|
||||||
|
throw Exception(errorMessage);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception("Terjadi kesalahan saat mendaftar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponseModel> loginWithEmail(LoginRequestModel request) async {
|
||||||
|
try {
|
||||||
|
final data = request.toJson();
|
||||||
|
final response = await dio.post(APIEndpoint.login, data: data);
|
||||||
|
|
||||||
|
final baseResponse = BaseResponseModel<LoginResponseModel>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(json) => LoginResponseModel.fromJson(json),
|
||||||
|
);
|
||||||
|
return baseResponse.data!;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final errorMessage = e.response?.data['message'] ?? "Login gagal";
|
||||||
|
throw Exception(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponseModel> loginWithGoogle(String idToken) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post(
|
||||||
|
APIEndpoint.loginGoogle,
|
||||||
|
data: {"token_id": idToken},
|
||||||
|
);
|
||||||
|
|
||||||
|
final baseResponse = BaseResponseModel<LoginResponseModel>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(json) => LoginResponseModel.fromJson(json),
|
||||||
|
);
|
||||||
|
return baseResponse.data!;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final errorMessage = e.response?.data['message'] ?? "Login Google gagal";
|
||||||
|
throw Exception(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
/// [ConnectionService] is a GetX Service that monitors internet connectivity status.
|
||||||
|
///
|
||||||
|
/// It utilizes the [Connectivity] class from the `connectivity_plus` package.
|
||||||
|
class ConnectionService extends GetxService {
|
||||||
|
final Connectivity _connectivity = Connectivity();
|
||||||
|
|
||||||
|
/// Subscription to the connectivity change stream.
|
||||||
|
late StreamSubscription<List<ConnectivityResult>> _subscription;
|
||||||
|
|
||||||
|
/// Reactive boolean to indicate the current internet connection status.
|
||||||
|
/// `true` means the device is connected to the internet via Wi-Fi, mobile data, or other means.
|
||||||
|
final RxBool isConnected = true.obs;
|
||||||
|
|
||||||
|
bool get isCurrentlyConnected => isConnected.value;
|
||||||
|
|
||||||
|
/// Called when the service is first initialized.
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_initConnectivity();
|
||||||
|
_subscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the initial connectivity status when the service is initialized.
|
||||||
|
Future<void> _initConnectivity() async {
|
||||||
|
try {
|
||||||
|
final result = await _connectivity.checkConnectivity();
|
||||||
|
_updateConnectionStatus(result); // Wrap in a list for consistency
|
||||||
|
} catch (e) {
|
||||||
|
isConnected.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback function to handle changes in connectivity status.
|
||||||
|
/// @param results A list of [ConnectivityResult] representing all active network connections.
|
||||||
|
void _updateConnectionStatus(List<ConnectivityResult> results) {
|
||||||
|
// If all results are `none`, the device is considered offline.
|
||||||
|
isConnected.value = results.any((result) => result != ConnectivityResult.none);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isHaveConnection() async {
|
||||||
|
final result = await _connectivity.checkConnectivity();
|
||||||
|
return !result.contains(ConnectivityResult.none);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the connectivity subscription when the service is closed.
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_subscription.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
|
||||||
|
class GoogleAuthService {
|
||||||
|
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
||||||
|
scopes: ['email', 'profile', 'openid'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<GoogleSignInAccount?> signIn() async {
|
||||||
|
try {
|
||||||
|
return await _googleSignIn.signIn();
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await _googleSignIn.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getIdToken() async {
|
||||||
|
final account = await _googleSignIn.signIn();
|
||||||
|
if (account == null) return null;
|
||||||
|
|
||||||
|
final auth = await account.authentication;
|
||||||
|
return auth.idToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isSignedIn() async {
|
||||||
|
return await _googleSignIn.isSignedIn();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/detail_quiz_history.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/quiz_history.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/session_history.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class HistoryService extends GetxService {
|
||||||
|
late final Dio _dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
_dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<QuizHistory>?> getHistory(String userId) async {
|
||||||
|
try {
|
||||||
|
final result = await _dio.get("${APIEndpoint.historyQuiz}/$userId");
|
||||||
|
|
||||||
|
final parsedResponse = BaseResponseModel<List<QuizHistory>>.fromJson(
|
||||||
|
result.data,
|
||||||
|
(data) => (data as List).map((e) => QuizHistory.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
return parsedResponse.data;
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
logC.e(e, stackTrace: stacktrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<QuizAnswerResult>?> getDetailHistory(String answerId) async {
|
||||||
|
try {
|
||||||
|
final result = await _dio.get("${APIEndpoint.detailHistoryQuiz}/$answerId");
|
||||||
|
|
||||||
|
final parsedResponse = BaseResponseModel<QuizAnswerResult>.fromJson(
|
||||||
|
result.data,
|
||||||
|
(data) => QuizAnswerResult.fromJson(data as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
logC.e(e, stackTrace: stacktrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<SessionHistory>?> getSessionHistory(String sessionId) async {
|
||||||
|
try {
|
||||||
|
final result = await _dio.get("${APIEndpoint.sessionHistory}/$sessionId");
|
||||||
|
|
||||||
|
final parsedResponse = BaseResponseModel<SessionHistory>.fromJson(
|
||||||
|
result.data,
|
||||||
|
(data) => SessionHistory.fromJson(data),
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedResponse;
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
logC.e(e, stackTrace: stacktrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,199 @@
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/library_quiz_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/question_create_request.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class QuizService extends GetxService {
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createQuiz(QuizCreateRequestModel request) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post(
|
||||||
|
APIEndpoint.quiz,
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw Exception("Quiz creation failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Quiz creation error: $e");
|
||||||
|
throw Exception("Quiz creation error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<RawQuizModel>>> createQuizAuto(String sentence) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post(
|
||||||
|
APIEndpoint.quizGenerate,
|
||||||
|
data: {"sentence": sentence},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
print(response.data);
|
||||||
|
|
||||||
|
// Parsing response using BaseResponseModel
|
||||||
|
final parseResponse = BaseResponseModel<List<RawQuizModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((item) => RawQuizModel.fromJson(item as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseResponse;
|
||||||
|
} else {
|
||||||
|
throw Exception("Quiz creation failed with status: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Quiz creation error: $e");
|
||||||
|
throw Exception("Quiz creation error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<QuizListingModel>>?> userQuiz(String userId, int page) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.get("${APIEndpoint.userQuiz}/$userId?page=$page");
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<List<QuizListingModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Error fetching user quizzes: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<QuizListingModel>>?> populerQuiz({int page = 1, int amount = 3}) async {
|
||||||
|
try {
|
||||||
|
Locale locale = Localizations.localeOf(Get.context!);
|
||||||
|
final response = await dio.get("${APIEndpoint.quizPopuler}?page=$page&limit=$amount&lang_code=${locale.languageCode}");
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<List<QuizListingModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Error fetching recommendation quizzes: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<QuizListingModel>>?> recommendationQuiz({int page = 1, int amount = 3, String userId = ""}) async {
|
||||||
|
try {
|
||||||
|
Locale locale = Localizations.localeOf(Get.context!);
|
||||||
|
final response = await dio.get("${APIEndpoint.quizRecommendation}?page=$page&limit=$amount&user_id=$userId&lang_code=${locale.languageCode}");
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<List<QuizListingModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
logC.e("Failed to fetch recommendation quizzes. Status: ${response.statusCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Error fetching recommendation quizzes: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<QuizListingModel>>?> searchQuiz(String keyword, int page, {int limit = 10, String? subjectId}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = {
|
||||||
|
"keyword": keyword,
|
||||||
|
"page": page.toString(),
|
||||||
|
"limit": limit.toString(),
|
||||||
|
if (subjectId != null && subjectId.isNotEmpty) "subject_id": subjectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
final uri = Uri.parse(APIEndpoint.quizSearch).replace(queryParameters: queryParams);
|
||||||
|
final response = await dio.getUri(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<List<QuizListingModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
logC.e("Failed to fetch search quizzes. Status: ${response.statusCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("Error fetching search quizzes: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<QuizData>?> getQuizById(String quizId) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.get("${APIEndpoint.quiz}/$quizId");
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<QuizData>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => QuizData.fromJson(data),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
logC.e("Failed to fetch quiz by id. Status: ${response.statusCode}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e, stacktrace) {
|
||||||
|
logC.e("Error fetching quiz by id $e", stackTrace: stacktrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RawQuizModel {
|
||||||
|
final String qustion;
|
||||||
|
final dynamic answer;
|
||||||
|
|
||||||
|
RawQuizModel({
|
||||||
|
required this.qustion,
|
||||||
|
required this.answer,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RawQuizModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RawQuizModel(
|
||||||
|
qustion: json['qustion'] as String,
|
||||||
|
answer: json['answer'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'qustion': qustion,
|
||||||
|
'answer': answer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/session/session_request_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/session/session_response_model.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class SessionService extends GetxService {
|
||||||
|
late final Dio _dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
_dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<SessionResponseModel>?> createSession(SessionRequestModel data) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(APIEndpoint.session, data: data.toJson());
|
||||||
|
if (response.statusCode != 201) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseResponseModel.fromJson(response.data, (e) => SessionResponseModel.fromJson(e));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
print('Error creating session: ${e.response?.data ?? e.message}');
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
print('Unexpected error: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||||
|
|
||||||
|
class SocketService {
|
||||||
|
late io.Socket socket;
|
||||||
|
|
||||||
|
final _roomMessageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _chatMessageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _questionUpdateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _quizStartedController = StreamController<void>.broadcast();
|
||||||
|
final _answerSubmittedController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _scoreUpdateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _quizDoneController = StreamController<void>.broadcast();
|
||||||
|
final _roomClosedController = StreamController<String>.broadcast();
|
||||||
|
final _errorController = StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
// Public streams
|
||||||
|
Stream<Map<String, dynamic>> get roomMessages => _roomMessageController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get questionUpdate => _questionUpdateController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get chatMessages => _chatMessageController.stream;
|
||||||
|
Stream<void> get quizStarted => _quizStartedController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get answerSubmitted => _answerSubmittedController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get scoreUpdates => _scoreUpdateController.stream;
|
||||||
|
Stream<void> get quizDone => _quizDoneController.stream;
|
||||||
|
Stream<String> get roomClosed => _roomClosedController.stream;
|
||||||
|
Stream<String> get errors => _errorController.stream;
|
||||||
|
|
||||||
|
void initSocketConnection() {
|
||||||
|
socket = io.io(
|
||||||
|
APIEndpoint.baseUrl,
|
||||||
|
io.OptionBuilder().setTransports(['websocket']).disableAutoConnect().build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.connect();
|
||||||
|
|
||||||
|
socket.onConnect((_) {
|
||||||
|
logC.i('✅ Connected: ${socket.id}');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.onDisconnect((_) {
|
||||||
|
logC.i('❌ Disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connection_response', (data) {
|
||||||
|
logC.i('🟢 Connection response: $data');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room_message', (data) {
|
||||||
|
logC.i('📥 Room Message: $data');
|
||||||
|
_roomMessageController.add(Map<String, dynamic>.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('receive_message', (data) {
|
||||||
|
logC.i('💬 Chat from ${data['from']}: ${data['message']}');
|
||||||
|
_chatMessageController.add(Map<String, dynamic>.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('quiz_started', (_) {
|
||||||
|
logC.i('🚀 Quiz Started!');
|
||||||
|
_quizStartedController.add(null);
|
||||||
|
});
|
||||||
|
socket.on('quiz_question', (data) {
|
||||||
|
logC.i('🚀 question getted!');
|
||||||
|
_questionUpdateController.add(Map<String, dynamic>.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('answer_submitted', (data) {
|
||||||
|
logC.i('✅ Answer Submitted: $data');
|
||||||
|
_answerSubmittedController.add(Map<String, dynamic>.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('score_update', (data) {
|
||||||
|
logC.i('📊 Score Update: $data');
|
||||||
|
_scoreUpdateController.add(Map<String, dynamic>.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('quiz_done', (_) {
|
||||||
|
logC.i('🏁 Quiz Finished!');
|
||||||
|
_quizDoneController.add(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room_closed', (data) {
|
||||||
|
logC.i('🔒 Room Closed: $data');
|
||||||
|
_roomClosedController.add(data['room'].toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (data) {
|
||||||
|
logC.e('⚠️ Socket Error: $data');
|
||||||
|
_errorController.add(data.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void joinRoom({required String sessionCode, required String userId}) {
|
||||||
|
socket.emit('join_room', {
|
||||||
|
'session_code': sessionCode,
|
||||||
|
'user_id': userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void leaveRoom({required String sessionId, required String userId, String username = "anonymous"}) {
|
||||||
|
socket.emit('leave_room', {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'user_id': userId,
|
||||||
|
'username': username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendMessage({
|
||||||
|
required String sessionId,
|
||||||
|
required String message,
|
||||||
|
String username = "anonymous",
|
||||||
|
}) {
|
||||||
|
socket.emit('send_message', {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'message': message,
|
||||||
|
'username': username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void startQuiz({required String sessionId}) {
|
||||||
|
socket.emit('start_quiz', {
|
||||||
|
'session_id': sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendAnswer({
|
||||||
|
required String sessionId,
|
||||||
|
required String userId,
|
||||||
|
required int questionIndex,
|
||||||
|
required int timeSpent,
|
||||||
|
required dynamic answer,
|
||||||
|
}) {
|
||||||
|
socket.emit('submit_answer', {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'user_id': userId,
|
||||||
|
'question_index': questionIndex,
|
||||||
|
'answer': answer,
|
||||||
|
'time_spent': timeSpent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void endSession({required String sessionId, required String userId}) {
|
||||||
|
socket.emit('end_session', {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'user_id': userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
socket.dispose();
|
||||||
|
_roomMessageController.close();
|
||||||
|
_chatMessageController.close();
|
||||||
|
_quizStartedController.close();
|
||||||
|
_answerSubmittedController.close();
|
||||||
|
_scoreUpdateController.close();
|
||||||
|
_quizDoneController.close();
|
||||||
|
_roomClosedController.close();
|
||||||
|
_errorController.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/subject/subject_model.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class SubjectService extends GetxService {
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<List<SubjectModel>>> getSubject() async {
|
||||||
|
final response = await dio.get(APIEndpoint.subject);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<List<SubjectModel>>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => (data as List).map((e) => SubjectModel.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to fetch subjects. Status code: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/user/user_full_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/user/user_stat_model.dart';
|
||||||
|
import 'package:quiz_app/data/providers/dio_client.dart';
|
||||||
|
|
||||||
|
class UserService extends GetxService {
|
||||||
|
late final Dio _dio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
_dio = Get.find<ApiClient>().dio;
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> updateProfileData(String id, String name, {String? birthDate, String? locale, String? phone}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(APIEndpoint.userUpdate, data: {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"birth_date": birthDate,
|
||||||
|
"locale": locale,
|
||||||
|
"phone": phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("update profile error: $e");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<UserFullModel>?> getUserData(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get("${APIEndpoint.userData}/$id");
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<UserFullModel>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => UserFullModel.fromJson(data),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("get user data error: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BaseResponseModel<UserStatModel>?> getUserStat(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get("${APIEndpoint.userStat}/$id");
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final parsedResponse = BaseResponseModel<UserStatModel>.fromJson(
|
||||||
|
response.data,
|
||||||
|
(data) => UserStatModel.fromJson(data),
|
||||||
|
);
|
||||||
|
return parsedResponse;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logC.e("get user data error: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:quiz_app/data/entity/user/user_entity.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// A lightweight wrapper around [SharedPreferences] that persists
|
||||||
|
/// the logged‑in user plus UI/feature preferences such as theme and
|
||||||
|
/// push‑notification opt‑in.
|
||||||
|
class UserStorageService {
|
||||||
|
// ───────────────────── Keys ─────────────────────
|
||||||
|
static const _userKey = 'user_data';
|
||||||
|
static const _darkModeKey = 'pref_dark_mode';
|
||||||
|
static const _pushNotifKey = 'pref_push_notification';
|
||||||
|
|
||||||
|
/// Cached flag used by splash / root to decide initial route.
|
||||||
|
bool isLogged = false;
|
||||||
|
|
||||||
|
// ───────────────────── User CRUD ─────────────────────
|
||||||
|
Future<void> saveUser(UserEntity user) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_userKey, jsonEncode(user.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserEntity?> loadUser() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_userKey);
|
||||||
|
if (jsonString == null) return null;
|
||||||
|
return UserEntity.fromJson(jsonDecode(jsonString));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearUser() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isLoggedIn() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.containsKey(_userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── UI Preferences ─────────────────────
|
||||||
|
/// Persist the user’s theme choice.
|
||||||
|
Future<void> setDarkMode(bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_darkModeKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the stored theme choice. Defaults to *false* (light mode).
|
||||||
|
Future<bool> getDarkMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_darkModeKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the user’s push‑notification preference.
|
||||||
|
Future<void> setPushNotification(bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_pushNotifKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the stored push‑notification preference. Defaults to *true*.
|
||||||
|
Future<bool> getPushNotification() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_pushNotifKey) ?? true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.dart';
|
||||||
|
|
||||||
|
class AdminResultBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
Get.lazyPut(() => HistoryService());
|
||||||
|
Get.lazyPut(() => AdminResultController(Get.find<HistoryService>()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/services/answer_service.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart';
|
||||||
|
|
||||||
|
class DetailParticipantResultBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
Get.lazyPut(() => AnswerService());
|
||||||
|
Get.lazyPut(() => ParticipantResultController(Get.find<AnswerService>()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/session_history.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
|
||||||
|
class AdminResultController extends GetxController {
|
||||||
|
final HistoryService _historyService;
|
||||||
|
|
||||||
|
AdminResultController(this._historyService);
|
||||||
|
|
||||||
|
SessionHistory? sessionHistory;
|
||||||
|
RxBool isLoading = false.obs;
|
||||||
|
|
||||||
|
String sessionId = "";
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
loadData();
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadData() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
sessionId = Get.arguments as String;
|
||||||
|
final result = await _historyService.getSessionHistory(sessionId);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
sessionHistory = result.data!;
|
||||||
|
print(sessionHistory!.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void goToDetailParticipants(String userId, String username) => Get.toNamed(
|
||||||
|
AppRoutes.quizMPLResultPage,
|
||||||
|
arguments: {
|
||||||
|
"user_id": userId,
|
||||||
|
"session_id": sessionId,
|
||||||
|
"username": username,
|
||||||
|
"is_admin": true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/participant_history_result.dart';
|
||||||
|
import 'package:quiz_app/data/services/answer_service.dart';
|
||||||
|
|
||||||
|
class ParticipantResultController extends GetxController {
|
||||||
|
final AnswerService _answerService;
|
||||||
|
|
||||||
|
ParticipantResultController(this._answerService);
|
||||||
|
|
||||||
|
final Rx<ParticipantResult?> participantResult = Rx<ParticipantResult?>(null);
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
|
||||||
|
RxString participantName = "".obs;
|
||||||
|
bool isAdmin = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
loadData();
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadData() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
final args = Get.arguments;
|
||||||
|
participantName.value = args["username"];
|
||||||
|
isAdmin = args["is_admin"];
|
||||||
|
final response = await _answerService.getAnswerSession(args["session_id"], args["user_id"]);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
participantResult.value = response.data;
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double calculateScorePercent() {
|
||||||
|
if (participantResult.value == null) return 0;
|
||||||
|
return participantResult.value!.scorePercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getTotalCorrect() {
|
||||||
|
return participantResult.value?.totalCorrect ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getTotalQuestions() {
|
||||||
|
return participantResult.value?.totalQuestions ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void goBackPage() {
|
||||||
|
if (isAdmin) {
|
||||||
|
Get.back();
|
||||||
|
} else {
|
||||||
|
Get.offAllNamed(AppRoutes.mainPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPop(bool isPop, dynamic value) {
|
||||||
|
if (isAdmin) {
|
||||||
|
Get.back();
|
||||||
|
} else {
|
||||||
|
Get.offAllNamed(AppRoutes.mainPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,249 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart';
|
||||||
|
import 'package:get/get_state_manager/src/simple/get_view.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/data/models/history/session_history.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.dart';
|
||||||
|
|
||||||
|
class AdminResultPage extends GetView<AdminResultController> {
|
||||||
|
const AdminResultPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.sessionHistory == null) {
|
||||||
|
return const Center(child: Text("Data tidak ditemukan."));
|
||||||
|
}
|
||||||
|
|
||||||
|
final participants = controller.sessionHistory!.participants;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSectionHeader("Hasil Akhir Kuis"),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSummaryCard(participants),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSectionHeader('Peringkat Peserta'),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: participants.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final participant = participants[index];
|
||||||
|
return _buildParticipantResultCard(
|
||||||
|
participant,
|
||||||
|
position: index + 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(title, style: AppTextStyles.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryCard(List<Participant> participants) {
|
||||||
|
final avgScore = participants.isNotEmpty ? participants.map((p) => p.score).reduce((a, b) => a + b) / participants.length : 0.0;
|
||||||
|
|
||||||
|
final passCount = participants.where((p) => p.score >= 60).length;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
shadowColor: AppColors.shadowPrimary.withOpacity(0.2),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.assignment_turned_in, color: AppColors.primaryBlue, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
"RINGKASAN KUIS",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.primaryBlue,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
_buildSummaryItem(
|
||||||
|
icon: Icons.group,
|
||||||
|
value: "${participants.length}",
|
||||||
|
label: "Total Peserta",
|
||||||
|
),
|
||||||
|
_buildSummaryItem(
|
||||||
|
icon: Icons.percent,
|
||||||
|
value: "${avgScore.toStringAsFixed(1)}%",
|
||||||
|
label: "Rata-Rata Nilai",
|
||||||
|
valueColor: _getScoreColor(avgScore),
|
||||||
|
),
|
||||||
|
_buildSummaryItem(
|
||||||
|
icon: Icons.emoji_events,
|
||||||
|
value: "$passCount/${participants.length}",
|
||||||
|
label: "Peserta Lulus",
|
||||||
|
valueColor: AppColors.scoreGood,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String value,
|
||||||
|
required String label,
|
||||||
|
Color? valueColor,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.softGrayText, size: 22),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: valueColor ?? AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: AppTextStyles.caption,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParticipantResultCard(Participant participant, {required int position}) {
|
||||||
|
final scorePercent = participant.score.toDouble();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
shadowColor: AppColors.shadowPrimary.withOpacity(0.2),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => controller.goToDetailParticipants(participant.id, participant.name),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _getPositionColor(position),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
position.toString(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(participant.name, style: AppTextStyles.subtitle),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text("Skor: ${participant.score}", style: AppTextStyles.caption),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _getScoreColor(scorePercent).withOpacity(0.1),
|
||||||
|
border: Border.all(
|
||||||
|
color: _getScoreColor(scorePercent),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
"${participant.score}%",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _getScoreColor(scorePercent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Icon(Icons.chevron_right, color: AppColors.softGrayText, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getScoreColor(double score) {
|
||||||
|
if (score >= 80) return AppColors.scoreExcellent;
|
||||||
|
if (score >= 70) return AppColors.scoreGood;
|
||||||
|
if (score >= 60) return AppColors.scoreAverage;
|
||||||
|
return AppColors.scorePoor;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getPositionColor(int position) {
|
||||||
|
if (position == 1) return const Color(0xFFFFD700); // Gold
|
||||||
|
if (position == 2) return const Color(0xFFC0C0C0); // Silver
|
||||||
|
if (position == 3) return const Color(0xFFCD7F32); // Bronze
|
||||||
|
return AppColors.softGrayText;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,322 @@
|
||||||
|
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/data/models/history/participant_history_result.dart';
|
||||||
|
import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart';
|
||||||
|
|
||||||
|
class ParticipantDetailPage extends GetView<ParticipantResultController> {
|
||||||
|
const ParticipantDetailPage({
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: controller.onPop,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
body: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final participant = controller.participantResult.value;
|
||||||
|
if (participant == null) {
|
||||||
|
return const Center(child: Text('Data peserta tidak tersedia.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
color: AppColors.darkText,
|
||||||
|
onPressed: controller.goBackPage,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Detail Peserta',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.darkText,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Body Content
|
||||||
|
_buildParticipantHeader(participant),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: participant.answers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildAnswerCard(participant.answers[index], index + 1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParticipantHeader(ParticipantResult participant) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.borderLight,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
backgroundColor: AppColors.accentBlue,
|
||||||
|
child: Text(
|
||||||
|
controller.participantName.value[0].toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.primaryBlue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.participantName.value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
"Jumlah Soal: ${participant.totalQuestions}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getScoreColor(participant.scorePercent).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: _getScoreColor(participant.scorePercent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.percent,
|
||||||
|
size: 16,
|
||||||
|
color: _getScoreColor(participant.scorePercent),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
"${participant.scorePercent.toInt()}%",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _getScoreColor(participant.scorePercent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnswerCard(QuestionAnswer answer, int number) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: answer.isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : AppColors.scorePoor.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
number.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Soal $number: ${answer.question}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle,
|
||||||
|
color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Divider(color: AppColors.borderLight),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildAnswerRow(
|
||||||
|
label: "Tipe Soal:",
|
||||||
|
answer: answer.type,
|
||||||
|
isCorrect: true,
|
||||||
|
),
|
||||||
|
_buildAnswerRow(
|
||||||
|
label: "Waktu Diberikan:",
|
||||||
|
answer: "${answer.duration} detik",
|
||||||
|
isCorrect: true,
|
||||||
|
),
|
||||||
|
_buildAnswerRow(
|
||||||
|
label: "Waktu Dihabiskan:",
|
||||||
|
answer: "${answer.timeSpent} detik",
|
||||||
|
isCorrect: true,
|
||||||
|
),
|
||||||
|
_buildAnswerRow(
|
||||||
|
label: "Jawaban Siswa:",
|
||||||
|
answer: answer.answer,
|
||||||
|
isCorrect: answer.isCorrect,
|
||||||
|
),
|
||||||
|
if (!answer.isCorrect) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildAnswerRow(
|
||||||
|
label: "Jawaban Benar:",
|
||||||
|
answer: answer.targetAnswer.toString(),
|
||||||
|
isCorrect: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (answer.options != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildOptions(answer.options!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOptions(List<String> options) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Pilihan Jawaban:",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...options.map((opt) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Text(
|
||||||
|
"- $opt",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnswerRow({
|
||||||
|
required String label,
|
||||||
|
required String answer,
|
||||||
|
required bool isCorrect,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
answer,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getScoreColor(double score) {
|
||||||
|
if (score >= 70) return AppColors.scoreGood;
|
||||||
|
if (score >= 60) return AppColors.scoreAverage;
|
||||||
|
return AppColors.scorePoor;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/quiz_service.dart';
|
||||||
|
import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart';
|
||||||
|
|
||||||
|
class DetailQuizBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
if (!Get.isRegistered<QuizService>()) {
|
||||||
|
Get.lazyPut<QuizService>(() => QuizService());
|
||||||
|
}
|
||||||
|
Get.lazyPut<DetailQuizController>(
|
||||||
|
() => DetailQuizController(
|
||||||
|
Get.find<QuizService>(),
|
||||||
|
Get.find<ConnectionService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/core/helper/connection_check.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/library_quiz_model.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/quiz_service.dart';
|
||||||
|
|
||||||
|
class DetailQuizController extends GetxController {
|
||||||
|
final QuizService _quizService;
|
||||||
|
final ConnectionService _connectionService;
|
||||||
|
|
||||||
|
DetailQuizController(this._quizService, this._connectionService);
|
||||||
|
|
||||||
|
RxBool isLoading = true.obs;
|
||||||
|
|
||||||
|
QuizData? data;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadData() async {
|
||||||
|
final quizId = Get.arguments as String;
|
||||||
|
if (!await _connectionService.isHaveConnection()) {
|
||||||
|
ConnectionNotification.noInternedConnection();
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getQuizData(quizId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void getQuizData(String quizId) async {
|
||||||
|
BaseResponseModel? response = await _quizService.getQuizById(quizId);
|
||||||
|
if (response != null) {
|
||||||
|
data = response.data;
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void goToPlayPage() {
|
||||||
|
if (!_connectionService.isCurrentlyConnected) {
|
||||||
|
ConnectionNotification.noInternedConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Get.toNamed(AppRoutes.playQuizPage, arguments: data);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,178 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
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_button.dart';
|
||||||
|
import 'package:quiz_app/component/widget/loading_widget.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart';
|
||||||
|
import 'package:quiz_app/feature/detail_quiz/controller/detail_quiz_controller.dart';
|
||||||
|
|
||||||
|
class DetailQuizView extends GetView<DetailQuizController> {
|
||||||
|
const DetailQuizView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
tr('quiz_detail_title'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.darkText,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.darkText),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: LoadingWidget());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.data == null) {
|
||||||
|
return const Center(child: Text("Tidak Ditemukan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Section
|
||||||
|
Text(
|
||||||
|
controller.data!.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
controller.data!.description ?? "",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today_rounded, size: 16, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
controller.data!.date ?? "",
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Icon(Icons.timer_rounded, size: 16, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'${controller.data!.limitDuration ~/ 60} ${tr('minutes_suffix')}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
GlobalButton(text: tr('start_quiz'), onPressed: controller.goToPlayPage),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
const Divider(thickness: 1.2, color: AppColors.borderLight),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Soal Section
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: controller.data!.questionListings.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final question = controller.data!.questionListings[index];
|
||||||
|
return _buildQuestionItem(question, index + 1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuestionItem(BaseQuestionModel question, int index) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${tr('question_label')} $index',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_mapQuestionTypeToText(question.type),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
question.question,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${tr('duration_label')}: ${question.duration} ${tr('seconds_suffix')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mapQuestionTypeToText(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'option':
|
||||||
|
return tr('question_type_option');
|
||||||
|
case 'fill_the_blank':
|
||||||
|
return tr('question_type_fill');
|
||||||
|
case 'true_false':
|
||||||
|
return tr('question_type_true_false');
|
||||||
|
default:
|
||||||
|
return tr('question_type_unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
import 'package:quiz_app/feature/history/controller/detail_history_controller.dart';
|
||||||
|
|
||||||
|
class DetailHistoryBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
if (!Get.isRegistered<HistoryService>()) Get.lazyPut(() => HistoryService());
|
||||||
|
Get.lazyPut<DetailHistoryController>(() => DetailHistoryController(Get.find<HistoryService>()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/controllers/user_controller.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
import 'package:quiz_app/feature/history/controller/history_controller.dart';
|
||||||
|
|
||||||
|
class HistoryBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
Get.lazyPut<HistoryService>(() => HistoryService());
|
||||||
|
Get.lazyPut(
|
||||||
|
() => HistoryController(
|
||||||
|
Get.find<HistoryService>(),
|
||||||
|
Get.find<UserController>(),
|
||||||
|
Get.find<ConnectionService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/detail_quiz_history.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
|
||||||
|
class DetailHistoryController extends GetxController {
|
||||||
|
final HistoryService _historyService;
|
||||||
|
|
||||||
|
DetailHistoryController(this._historyService);
|
||||||
|
|
||||||
|
late QuizAnswerResult quizAnswer;
|
||||||
|
|
||||||
|
RxBool isLoading = true.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
_loadData();
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadData() async {
|
||||||
|
String answerId = Get.arguments as String;
|
||||||
|
BaseResponseModel<QuizAnswerResult>? result = await _historyService.getDetailHistory(answerId);
|
||||||
|
if (result != null) {
|
||||||
|
if (result.data != null) quizAnswer = result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/core/helper/connection_check.dart';
|
||||||
|
import 'package:quiz_app/data/controllers/user_controller.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/quiz_history.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/history_service.dart';
|
||||||
|
|
||||||
|
class HistoryController extends GetxController {
|
||||||
|
final HistoryService _historyService;
|
||||||
|
final UserController _userController;
|
||||||
|
final ConnectionService _connectionService;
|
||||||
|
|
||||||
|
HistoryController(
|
||||||
|
this._historyService,
|
||||||
|
this._userController,
|
||||||
|
this._connectionService,
|
||||||
|
);
|
||||||
|
|
||||||
|
RxBool isLoading = true.obs;
|
||||||
|
|
||||||
|
final historyList = <QuizHistory>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadHistory() async {
|
||||||
|
if (!await _connectionService.isHaveConnection()) {
|
||||||
|
ConnectionNotification.noInternedConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyList.value = await _historyService.getHistory(_userController.userData!.id) ?? [];
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void goToDetailHistory(String answerId) => Get.toNamed(AppRoutes.detailHistoryPage, arguments: answerId);
|
||||||
|
}
|
|
@ -0,0 +1,156 @@
|
||||||
|
// 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';
|
||||||
|
// import 'package:quiz_app/data/models/history/detail_quiz_history.dart';
|
||||||
|
|
||||||
|
// class QuizItemComponent extends StatelessWidget {
|
||||||
|
// final QuestionAnswerItem item;
|
||||||
|
|
||||||
|
// const QuizItemComponent({super.key, required this.item});
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Widget build(BuildContext context) {
|
||||||
|
// final bool isOptionType = item.type == 'option';
|
||||||
|
|
||||||
|
// 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.withValues(alpha: 0.04),
|
||||||
|
// blurRadius: 6,
|
||||||
|
// offset: const Offset(0, 2),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// child: Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// _buildQuestionText(),
|
||||||
|
// const SizedBox(height: 16),
|
||||||
|
// if (isOptionType && item.options != null) _buildOptions(),
|
||||||
|
// const SizedBox(height: 12),
|
||||||
|
// _buildAnswerIndicator(),
|
||||||
|
// const SizedBox(height: 16),
|
||||||
|
// const Divider(height: 24, color: AppColors.shadowPrimary),
|
||||||
|
// _buildMetadata(),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildQuestionText() {
|
||||||
|
// return Text(
|
||||||
|
// '${item.index}. ${item.question}',
|
||||||
|
// style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildOptions() {
|
||||||
|
// return Column(
|
||||||
|
// children: item.options!.asMap().entries.map((entry) {
|
||||||
|
// final int index = entry.key;
|
||||||
|
// final String text = entry.value;
|
||||||
|
|
||||||
|
// final bool isCorrectAnswer = index == item.targetAnswer;
|
||||||
|
// final bool isUserWrongAnswer = index == item.userAnswer && !isCorrectAnswer;
|
||||||
|
|
||||||
|
// Color? backgroundColor;
|
||||||
|
// IconData icon = LucideIcons.circle;
|
||||||
|
// Color iconColor = AppColors.shadowPrimary;
|
||||||
|
|
||||||
|
// if (isCorrectAnswer) {
|
||||||
|
// backgroundColor = AppColors.primaryBlue.withValues(alpha: 0.15);
|
||||||
|
// icon = LucideIcons.checkCircle2;
|
||||||
|
// iconColor = AppColors.primaryBlue;
|
||||||
|
// } else if (isUserWrongAnswer) {
|
||||||
|
// backgroundColor = Colors.red.withValues(alpha: 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 correctIcon = item.isCorrect ? LucideIcons.checkCircle2 : LucideIcons.xCircle;
|
||||||
|
// final correctColor = item.isCorrect ? AppColors.primaryBlue : Colors.red;
|
||||||
|
|
||||||
|
// final String userAnswerText = item.type == 'option' ? item.options![item.userAnswer] : item.userAnswer.toString();
|
||||||
|
|
||||||
|
// final String correctAnswerText = item.targetAnswer.toString();
|
||||||
|
|
||||||
|
// return Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// Row(
|
||||||
|
// children: [
|
||||||
|
// Icon(correctIcon, color: correctColor, size: 18),
|
||||||
|
// const SizedBox(width: 8),
|
||||||
|
// Text(
|
||||||
|
// 'Jawabanmu: $userAnswerText',
|
||||||
|
// style: AppTextStyles.statValue,
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// if (item.type != 'option' && !item.isCorrect) ...[
|
||||||
|
// const SizedBox(height: 6),
|
||||||
|
// Row(
|
||||||
|
// children: [
|
||||||
|
// const SizedBox(width: 26), // offset for icon + spacing
|
||||||
|
// Text(
|
||||||
|
// 'Jawaban benar: $correctAnswerText',
|
||||||
|
// style: AppTextStyles.caption,
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildMetadata() {
|
||||||
|
// return Row(
|
||||||
|
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
// children: [
|
||||||
|
// _metaItem(icon: LucideIcons.helpCircle, label: item.type),
|
||||||
|
// _metaItem(icon: LucideIcons.clock3, label: '${item.timeSpent}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),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get_state_manager/get_state_manager.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';
|
||||||
|
import 'package:quiz_app/component/widget/loading_widget.dart';
|
||||||
|
import 'package:quiz_app/component/widget/quiz_item_wa_component.dart';
|
||||||
|
import 'package:quiz_app/feature/history/controller/detail_history_controller.dart';
|
||||||
|
|
||||||
|
class DetailHistoryView extends GetView<DetailHistoryController> {
|
||||||
|
const DetailHistoryView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
context.tr('history_detail_title'),
|
||||||
|
style: AppTextStyles.title.copyWith(fontSize: 24),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.darkText),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: LoadingWidget());
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
quizMetaInfo(context),
|
||||||
|
...quizListings(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> quizListings() {
|
||||||
|
return controller.quizAnswer.questionListings
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) => QuizItemWAComponent(
|
||||||
|
index: entry.key + 1,
|
||||||
|
isCorrect: entry.value.isCorrect,
|
||||||
|
question: entry.value.question,
|
||||||
|
targetAnswer: entry.value.targetAnswer,
|
||||||
|
timeSpent: entry.value.timeSpent,
|
||||||
|
type: entry.value.type,
|
||||||
|
userAnswer: entry.value.userAnswer,
|
||||||
|
options: entry.value.options,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget quizMetaInfo(BuildContext context) {
|
||||||
|
final quiz = controller.quizAnswer;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(quiz.title, style: AppTextStyles.title),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
quiz.description,
|
||||||
|
textAlign: TextAlign.justify,
|
||||||
|
style: AppTextStyles.caption,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.calendar, size: 16, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(quiz.answeredAt, style: AppTextStyles.dateTime),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.clock, size: 16, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('12:00', style: AppTextStyles.dateTime), // Replace with quiz.timeAnswered if available
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Divider(height: 24, thickness: 1, color: AppColors.shadowPrimary),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildStatItem(
|
||||||
|
icon: LucideIcons.checkCircle2,
|
||||||
|
label: tr('correct'),
|
||||||
|
value: "${quiz.totalCorrect}/${quiz.questionListings.length}",
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
icon: LucideIcons.award,
|
||||||
|
label: context.tr('score'),
|
||||||
|
value: quiz.totalScore.toString(),
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
icon: LucideIcons.clock3,
|
||||||
|
label: context.tr('time_taken'),
|
||||||
|
value: tr('duration_seconds', namedArgs: {"second": quiz.totalSolveTime.toString()}),
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 28),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(value, style: AppTextStyles.statValue),
|
||||||
|
Text(label, style: AppTextStyles.caption),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.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/component/widget/container_skeleton_widget.dart';
|
||||||
|
import 'package:quiz_app/data/models/history/quiz_history.dart';
|
||||||
|
import 'package:quiz_app/feature/history/controller/history_controller.dart';
|
||||||
|
|
||||||
|
class HistoryView extends GetView<HistoryController> {
|
||||||
|
const HistoryView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.tr("history_title"),
|
||||||
|
style: AppTextStyles.title.copyWith(fontSize: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
context.tr("history_subtitle"),
|
||||||
|
style: AppTextStyles.subtitle,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: 3,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ContainerSkeleton();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final historyList = controller.historyList;
|
||||||
|
|
||||||
|
if (historyList.isEmpty) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(context.tr("no_history"), style: AppTextStyles.body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: historyList.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = historyList[index];
|
||||||
|
return _buildHistoryCard(item);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryCard(QuizHistory item) {
|
||||||
|
final scorePercentage = item.totalCorrect / item.totalQuestion;
|
||||||
|
final scoreColor = scorePercentage >= 0.7 ? AppColors.primaryBlue : AppColors.scorePoor;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => controller.goToDetailHistory(item.answerId),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildIconBox(scoreColor),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildHistoryInfo(item, scorePercentage)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIconBox(Color scoreColor) {
|
||||||
|
return Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scoreColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.assignment_turned_in,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryInfo(QuizHistory item, double scorePercentage) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.darkText,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Completed on ${item.date}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.softGrayText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle_outline, size: 14, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${item.totalCorrect}/${item.totalQuestion} Correct',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Icon(Icons.access_time, size: 14, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
tr("duration_minutes", namedArgs: {"minute": "3"}),
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Icon(Icons.percent, size: 14, color: AppColors.softGrayText),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${(scorePercentage * 100).toInt()}%',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.softGrayText),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/data/controllers/user_controller.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/quiz_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/subject_service.dart';
|
||||||
|
import 'package:quiz_app/feature/home/controller/home_controller.dart';
|
||||||
|
|
||||||
|
class HomeBinding extends Bindings {
|
||||||
|
@override
|
||||||
|
void dependencies() {
|
||||||
|
Get.lazyPut<QuizService>(() => QuizService());
|
||||||
|
Get.lazyPut<SubjectService>(() => SubjectService());
|
||||||
|
Get.lazyPut<HomeController>(
|
||||||
|
() => HomeController(
|
||||||
|
Get.find<UserController>(),
|
||||||
|
Get.find<QuizService>(),
|
||||||
|
Get.find<SubjectService>(),
|
||||||
|
Get.find<ConnectionService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:quiz_app/app/const/enums/listing_type.dart';
|
||||||
|
import 'package:quiz_app/app/routes/app_pages.dart';
|
||||||
|
import 'package:quiz_app/core/helper/connection_check.dart';
|
||||||
|
import 'package:quiz_app/core/utils/logger.dart';
|
||||||
|
import 'package:quiz_app/data/controllers/user_controller.dart';
|
||||||
|
import 'package:quiz_app/data/models/base/base_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart';
|
||||||
|
import 'package:quiz_app/data/models/subject/subject_model.dart';
|
||||||
|
import 'package:quiz_app/data/services/connection_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/quiz_service.dart';
|
||||||
|
import 'package:quiz_app/data/services/subject_service.dart';
|
||||||
|
import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart';
|
||||||
|
|
||||||
|
class HomeController extends GetxController {
|
||||||
|
final UserController _userController;
|
||||||
|
final QuizService _quizService;
|
||||||
|
final SubjectService _subjectService;
|
||||||
|
final ConnectionService _connectionService;
|
||||||
|
|
||||||
|
HomeController(
|
||||||
|
this._userController,
|
||||||
|
this._quizService,
|
||||||
|
this._subjectService,
|
||||||
|
this._connectionService,
|
||||||
|
);
|
||||||
|
|
||||||
|
RxInt timeStatus = 1.obs;
|
||||||
|
|
||||||
|
Rx<String> get userName => _userController.userName;
|
||||||
|
Rx<String?> get userImage => _userController.userImage;
|
||||||
|
|
||||||
|
RxList<QuizListingModel> data = <QuizListingModel>[].obs;
|
||||||
|
|
||||||
|
RxList<SubjectModel> subjects = <SubjectModel>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
_getRecomendationQuiz();
|
||||||
|
_getGreetingStatusByTime();
|
||||||
|
loadSubjectData();
|
||||||
|
super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _getRecomendationQuiz() async {
|
||||||
|
if (!await _connectionService.isHaveConnection()) {
|
||||||
|
ConnectionNotification.noInternedConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BaseResponseModel? response = await _quizService.recommendationQuiz(userId: _userController.userData!.id);
|
||||||
|
if (response != null) {
|
||||||
|
data.assignAll(response.data as List<QuizListingModel>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadSubjectData() async {
|
||||||
|
if (!_connectionService.isCurrentlyConnected) return;
|
||||||
|
try {
|
||||||
|
final response = await _subjectService.getSubject();
|
||||||
|
subjects.assignAll(response.data!);
|
||||||
|
} catch (e) {
|
||||||
|
logC.e(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void goToQuizCreation() => Get.toNamed(AppRoutes.quizCreatePage);
|
||||||
|
|
||||||
|
void goToRoomMaker() => Get.toNamed(AppRoutes.roomPage);
|
||||||
|
|
||||||
|
void goToJoinRoom() => Get.toNamed(AppRoutes.joinRoomPage);
|
||||||
|
|
||||||
|
void goToSearch() {
|
||||||
|
final navController = Get.find<NavigationController>();
|
||||||
|
navController.changePage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onRecommendationTap(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId);
|
||||||
|
|
||||||
|
void goToListingsQuizPage(ListingType page, {String? subjectId, String? subjecName}) => Get.toNamed(
|
||||||
|
AppRoutes.listingQuizPage,
|
||||||
|
arguments: {"page": page, "id": subjectId, "subject_name": subjecName},
|
||||||
|
);
|
||||||
|
|
||||||
|
void _getGreetingStatusByTime() {
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
timeStatus.value = 1;
|
||||||
|
} else if (hour >= 12 && hour < 15) {
|
||||||
|
timeStatus.value = 2;
|
||||||
|
} else if (hour >= 15 && hour < 18) {
|
||||||
|
timeStatus.value = 3;
|
||||||
|
} else {
|
||||||
|
timeStatus.value = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue