Compare commits

...

105 Commits

Author SHA1 Message Date
akhdanre cf52ccedec Merge pull request 'develop' (#1) from develop into main
Reviewed-on: akhdanre/Genso_quiz_app#1
2025-07-10 12:38:49 +07:00
akhdanre 0a138793ae fix: adding back limitation 2025-06-12 04:11:25 +07:00
akhdanre 576f5b27ea feat: penyesuaian pada beberapa logic component 2025-06-07 14:35:48 +07:00
akhdanre 17cee7b7d7 feat: adjust minor user entity 2025-06-05 20:48:28 +07:00
akhdanre 3cce4faadd feat: update data profile 2025-06-05 20:44:43 +07:00
akhdanre ae49bb34d0 fix: adding loading and connection limitation 2025-06-05 20:35:35 +07:00
akhdanre d5de5fb712 feat: adding limitation on the join and create room 2025-06-05 12:43:01 +07:00
akhdanre ede385041e feat: adjustement on the serveral part on the profile and quiz result 2025-05-29 15:59:13 +07:00
akhdanre b77229c26a fix: adding adjustment on the login app 2025-05-27 19:04:19 +07:00
akhdanre 0283806cf3 fix: waiting room localization and adding room name 2025-05-26 19:05:47 +07:00
akhdanre a7f5f98cf5 fix: index restul quiz, translation lang and clean up 2025-05-26 17:54:16 +07:00
akhdanre 655103247e fix: language 2025-05-26 13:41:09 +07:00
akhdanre 27a1b9ada6 fix: history skeleton container 2025-05-26 12:35:15 +07:00
akhdanre 9a0ccc5c61 feat: adding shimmer loading 2025-05-26 12:24:08 +07:00
akhdanre 43fe1b275a fix: user profile data 2025-05-25 22:05:55 +07:00
akhdanre e5f84ee727 feat: adding populer quiz logic and fix the interface 2025-05-25 21:11:51 +07:00
akhdanre 5e27f09921 feat: adjustment on the translation 2025-05-25 17:32:58 +07:00
akhdanre 661930d2bd fix: logic on the interface greating time for user 2025-05-25 16:03:52 +07:00
akhdanre c84133a372 fix: interface on the quiz result 2025-05-25 15:26:45 +07:00
akhdanre a7f6ce8e7e feat: adding keep alive app when open 2025-05-25 15:08:13 +07:00
akhdanre 2a93a9a371 feat: adjusting on the quiz option 2025-05-25 13:58:52 +07:00
akhdanre 1e45cc271b feat: adding new profile interface 2025-05-25 13:01:22 +07:00
akhdanre 7d3f94dee1 feat: adjust the quiz preview 2025-05-24 22:10:43 +07:00
akhdanre 128afe9ad6 feat: adjustment on the interface 2025-05-23 20:28:22 +07:00
akhdanre 20017d5bf1 feat: adjustment on the interface play quiz multiplayer 2025-05-23 19:30:46 +07:00
akhdanre c2d838d17d feat: adjust the loading 2025-05-23 19:20:45 +07:00
akhdanre 1555ce5558 feat: adjust interface on the quiz play 2025-05-23 19:09:53 +07:00
akhdanre bfa253ceec fix: register page 2025-05-23 15:04:04 +07:00
akhdanre bfd959a5df fix: notification setup 2025-05-23 13:42:51 +07:00
akhdanre e3d2cbb7a6 fix: adjustment on the notification and loading 2025-05-21 22:29:22 +07:00
akhdanre 048410786b feat: adding unit test on the auth and subject service 2025-05-19 03:09:06 +07:00
akhdanre fab876b4ec feat: adding unit test on the auth and subject service 2025-05-19 03:08:45 +07:00
akhdanre 15e4a9295c fix: navigation on the quiz multiplayer and the result page 2025-05-19 02:09:30 +07:00
akhdanre abe21031ec feat: done final result on the multiplayer quiz 2025-05-19 01:42:15 +07:00
akhdanre 871ec13c31 feat: done implement admin result page 2025-05-19 00:44:42 +07:00
akhdanre b8c7d62c8c fix: the quiz answering and add loading on the join quiz 2025-05-18 22:12:23 +07:00
akhdanre 1c6ce0d023 feat: done working on the profile update 2025-05-18 21:20:20 +07:00
akhdanre 175f4e6668 feat: clean up environtment 2025-05-18 15:55:09 +07:00
akhdanre a233c844ca feat: adjust the join room interface 2025-05-18 15:52:33 +07:00
akhdanre 60091b8031 feat: done create update profile interface 2025-05-18 15:36:39 +07:00
akhdanre d925a22bb0 feat: adding create automatic quiz 2025-05-18 15:00:55 +07:00
akhdanre 737f0f775a feat: adding admin result page 2025-05-18 02:30:54 +07:00
akhdanre 82f4a1ec41 fix: issue on the play quiz controller 2025-05-17 20:57:25 +07:00
akhdanre 81d900878f fix: join system 2025-05-17 16:20:18 +07:00
akhdanre 1cce1aba2c feat: quiz listings on the room maker 2025-05-17 14:54:03 +07:00
akhdanre 381be0db1e feat: creating test for login and register controller 2025-05-15 21:47:09 +07:00
akhdanre 053c7db78c feat: adjust play quiz multiplayer 2025-05-13 02:55:58 +07:00
akhdanre e060f32593 feat: adding monitor quiz logic 2025-05-13 02:24:38 +07:00
akhdanre 5f54ca6c8c feat: adding leave page in the waiting room 2025-05-12 22:41:14 +07:00
akhdanre da6597f42b feat: change language 2025-05-07 22:26:49 +07:00
akhdanre 7dc0994162 feat: adding localization 2025-05-07 21:45:36 +07:00
akhdanre b575f75f6d feat: adding limit in the request with loading not dissmisable 2025-05-07 18:26:12 +07:00
akhdanre dda268d2d5 feat: preparaiion on socket connection 2025-05-07 18:07:24 +07:00
akhdanre edb7ab0fdf feat: play quiz multiplayer done 2025-05-07 12:32:33 +07:00
akhdanre 7e126e24a6 feat: done working on join room 2025-05-07 10:08:20 +07:00
akhdanre 49e33d00a9 feat: waiting room 2025-05-07 00:32:38 +07:00
akhdanre 481bfbe228 feat: done working on the room maker 2025-05-05 14:40:26 +07:00
akhdanre ca9e9cde7d feat: quiz done 2025-05-05 13:48:14 +07:00
akhdanre 93ab86e833 feat: done working on subject quiz listing 2025-05-05 11:38:40 +07:00
akhdanre cd38b79bef feat: finish implement subject on the home and search 2025-05-05 09:39:19 +07:00
akhdanre 80e6704bec fix: limitation on the quiz creation 2025-05-05 00:09:53 +07:00
akhdanre 1a465fd0d1 fix: make the quiz answer component into global 2025-05-04 22:06:02 +07:00
akhdanre 55d96c3baf fix: game play on quiz 2025-05-04 21:53:20 +07:00
akhdanre 488479befa feat: google sign in auth service 2025-05-04 11:40:32 +07:00
akhdanre 140b8f103c feat: adding global text style 2025-05-04 03:07:15 +07:00
akhdanre 572808a40d fix: adjustment on the detail history minor 2025-05-04 02:46:36 +07:00
akhdanre 8f7ca1b457 feat: detail histroy done 2025-05-04 02:39:26 +07:00
akhdanre 6bf48df48a fix: listing request model and logic 2025-05-04 01:15:56 +07:00
akhdanre 9df43d451e fix: info not showing 2025-05-02 04:13:58 +07:00
akhdanre 668c7eac27 feat: listing quiz done 2025-05-02 03:56:05 +07:00
akhdanre c026a53d6f fix: detail quiz 2025-05-02 03:09:22 +07:00
akhdanre cf9483834e feat: search endpoint 2025-05-02 00:16:45 +07:00
akhdanre 14cd51c65b feat: working on the history binding 2025-05-01 20:22:20 +07:00
akhdanre aa6b35f422 fix: interface and logic on the result 2025-04-30 14:14:11 +07:00
akhdanre 92f349e8ba feat: add index and input validation 2025-04-29 23:00:48 +07:00
akhdanre 51182b8c7b feat: done on quiz result 2025-04-28 20:58:45 +07:00
akhdanre 9797fd4a4f feat: quiz result page 2025-04-28 20:32:16 +07:00
akhdanre b80303b9c0 feat: quiz play logic 2025-04-28 19:30:09 +07:00
akhdanre d4d9f0d85d feat: done working on quiz app 2025-04-28 15:10:09 +07:00
akhdanre 5b1f579b13 feat: working on library data from network done 2025-04-28 14:48:43 +07:00
akhdanre e4ac170a21 fix: login id not registered 2025-04-28 13:04:29 +07:00
akhdanre 6adcb2e471 feat: navigasi with param 2025-04-27 22:26:53 +07:00
akhdanre effaa4cdd7 feat: done logic request on the add new quiz 2025-04-27 22:21:23 +07:00
akhdanre 7a90e7ea16 feat: library view 2025-04-27 16:39:42 +07:00
akhdanre 261d094d94 feat: adding index delete on the question maker 2025-04-27 15:19:13 +07:00
akhdanre eaf97e969f feat: addin pop up confirmation for back 2025-04-27 15:06:20 +07:00
akhdanre 01e92c14ab feat: add default value untuk option answer 2025-04-27 14:57:26 +07:00
akhdanre 9db744679d feat: done on quiz preview 2025-04-27 14:16:15 +07:00
akhdanre e801962e4c feat: logic for quiz input is done 2025-04-27 13:40:10 +07:00
akhdanre 05a22f3360 feat: interface on the quiz creation 2025-04-27 00:30:25 +07:00
akhdanre 39ab35b2a8 feat: add quiz controller 2025-04-26 20:58:16 +07:00
akhdanre dbb3bd8c90 feat: slicing profile page 2025-04-26 16:25:26 +07:00
akhdanre 26ed9797b8 feat: slicing history page 2025-04-26 16:19:52 +07:00
akhdanre baee8e35db feat: done slicing search 2025-04-26 16:06:03 +07:00
akhdanre e2d801b8a5 feat: navigation 2025-04-26 15:54:17 +07:00
akhdanre 4041384733 fix: interface and logic 2025-04-26 15:30:18 +07:00
akhdanre 9c36507fb7 feat: done creating home page and fix the interface 2025-04-26 14:02:22 +07:00
akhdanre 837823f937 fix: the login request into dio and fix the model 2025-04-22 19:34:03 +07:00
akhdanre f479acac91 feat: register feature 2025-04-22 15:08:02 +07:00
akhdanre 32404aceae feat: login page done 2025-04-22 13:38:37 +07:00
akhdanre 2763575e1b fix: token id null 2025-03-12 14:47:23 +07:00
akhdanre b81ebefc6f feat: login session with google 2025-03-06 10:34:37 +07:00
akhdanre 5787cea803 feat: login page 2025-03-01 13:03:55 +07:00
akhdanre f1bb4abd6d fix: minor test 2025-03-01 12:31:00 +07:00
akhdanre a498687e15 feat: envirotment preparation 2025-02-10 00:52:06 +07:00
177 changed files with 15095 additions and 157 deletions

4
.gitignore vendored
View File

@ -45,4 +45,6 @@ app.*.map.json
/android/app/release
# FVM Version Cache
.fvm/
.fvm/
*.env

25
.vscode/launch.json vendored Normal file
View File

@ -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"
// }
]
}

View File

@ -30,12 +30,35 @@ android {
versionName = flutter.versionName
}
buildTypes {
signingConfigs {
debug {
keyAlias = "keyDebugQuiz"
keyPassword = "uppercase12"
storeFile = file("debugKeystore.jks")
storePassword = "uppercase12"
}
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.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
release {
signingConfig = signingConfigs.release
}
}
}

View File

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="quiz_app"
android:name="${applicationName}"
@ -17,12 +18,12 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 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. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>

View File

@ -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>

BIN
assets/logo/google_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

3
devtools_options.yaml Normal file
View File

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

24
lib/app/app.dart Normal file
View File

@ -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,
);
}
}

View File

@ -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>()));
}
}

View File

@ -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);
}

View File

@ -0,0 +1 @@
enum ListingType { recomendation, populer, subject }

View File

@ -0,0 +1 @@
enum QuestionType { fillTheBlank, option, trueOrFalse }

View File

@ -0,0 +1,5 @@
extension StringCasingExtension on String {
String toTitleCase() {
return split(' ').map((word) => word.isNotEmpty ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}' : '').join(' ');
}
}

View File

@ -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,
);
}

View File

@ -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;
}
}

View File

@ -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(),
)
];
}

View File

@ -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";
}

View File

@ -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,
),
),
],
);
}
}

View File

@ -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),
),
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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,
),
);
}
}

View File

@ -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
),
),
),
);
}
}

View File

@ -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"),
),
),
],
),
],
),
),
);
},
);
}
}

View File

@ -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),
),
),
],
)
],
),
),
);
},
);
}
}

View File

@ -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)),
),
],
),
],
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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),
),
],
),
);
}
}

View File

@ -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),
),
],
);
}

View File

@ -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 peroption feedback.
///
/// * Text strings are fully localised via `easy_localization`.
/// * Long answers now wrap to the next line rather than overflowing.
/// * Option chips highlight both the correct answer and the user's incorrect choice (if any).
class QuizItemWAComponent extends StatelessWidget {
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,
});
/// Onebased question index.
final int index;
/// The question text.
final String question;
/// Question type: `option`, `true_false`, or `fill_the_blank`.
final String type;
/// Raw user answer (index, bool or string). `-1`/`null` means no answer.
final dynamic userAnswer;
/// Raw correct answer (index, bool or string).
final dynamic targetAnswer;
/// Whether the user answered correctly.
final bool isCorrect;
/// Time spent answering (seconds).
final double timeSpent;
/// Option texts for optiontype questions.
final List<String>? options;
bool get _isOptionType => type == 'option';
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 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();
}
}
// Subwidgets
class _OptionsList extends StatelessWidget {
const _OptionsList({
required this.options,
required this.userAnswer,
required this.targetAnswer,
});
final List<String> options;
final dynamic userAnswer;
final dynamic targetAnswer;
@override
Widget build(BuildContext context) {
return Column(
children: 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),
],
);
}
}

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -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";
}

View File

@ -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",
);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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,
);
}
}

View File

@ -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;

View File

@ -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 = '';
}
}

View File

@ -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,
});
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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(),
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
);
}
}

View File

@ -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";
}

View File

@ -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
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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(),
};
}
}

View File

@ -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();
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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;
// }
// }

View File

@ -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,
};
}
}

View File

@ -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,
);
}
}

View File

@ -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,
};
}
}

View File

@ -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
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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']),
);
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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}');
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 loggedin user plus UI/feature preferences such as theme and
/// pushnotification optin.
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 users 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 users pushnotification preference.
Future<void> setPushNotification(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_pushNotifKey, value);
}
/// Retrieve the stored pushnotification preference. Defaults to *true*.
Future<bool> getPushNotification() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_pushNotifKey) ?? true;
}
}

View File

@ -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>()));
}
}

View File

@ -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>()));
}
}

View File

@ -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,
},
);
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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>(),
),
);
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}
}

View File

@ -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>()));
}
}

View File

@ -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>(),
),
);
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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),
// ],
// );
// }
// }

View File

@ -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),
],
);
}
}

View File

@ -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),
),
],
),
],
);
}
}

View File

@ -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>(),
),
);
}
}

View File

@ -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