diff --git a/.gitignore b/.gitignore index b6323af..69216e7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ app.*.map.json /android/app/release # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +*.env \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5706166 --- /dev/null +++ b/.vscode/launch.json @@ -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" + // } + ] +} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 2648e2a..2cfd4a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 + } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9e55388..3d04507 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - + + - - + + - + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..50b360e --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + 730226042143-mv9dlpk9cesirgjh2o0f9hvsk0ks8r2f.apps.googleusercontent.com + \ No newline at end of file diff --git a/assets/logo/google_logo.png b/assets/logo/google_logo.png new file mode 100644 index 0000000..a4a9918 Binary files /dev/null and b/assets/logo/google_logo.png differ diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json new file mode 100644 index 0000000..1d589f6 --- /dev/null +++ b/assets/translations/en-US.json @@ -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" +} diff --git a/assets/translations/id-ID.json b/assets/translations/id-ID.json new file mode 100644 index 0000000..2413acc --- /dev/null +++ b/assets/translations/id-ID.json @@ -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" + +} diff --git a/assets/translations/ms-MY.json b/assets/translations/ms-MY.json new file mode 100644 index 0000000..31bbb63 --- /dev/null +++ b/assets/translations/ms-MY.json @@ -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" +} diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..0714483 --- /dev/null +++ b/lib/app/app.dart @@ -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, + ); + } +} diff --git a/lib/app/bindings/initial_bindings.dart b/lib/app/bindings/initial_bindings.dart new file mode 100644 index 0000000..cc212a4 --- /dev/null +++ b/lib/app/bindings/initial_bindings.dart @@ -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()); + Get.put(ConnectionService()); + Get.putAsync(() => ApiClient().init()); + Get.put(UserController(Get.find())); + } +} diff --git a/lib/app/const/colors/app_colors.dart b/lib/app/const/colors/app_colors.dart new file mode 100644 index 0000000..398a4d3 --- /dev/null +++ b/lib/app/const/colors/app_colors.dart @@ -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); +} diff --git a/lib/app/const/enums/listing_type.dart b/lib/app/const/enums/listing_type.dart new file mode 100644 index 0000000..ac76db8 --- /dev/null +++ b/lib/app/const/enums/listing_type.dart @@ -0,0 +1 @@ +enum ListingType { recomendation, populer, subject } diff --git a/lib/app/const/enums/question_type.dart b/lib/app/const/enums/question_type.dart new file mode 100644 index 0000000..d61ca6f --- /dev/null +++ b/lib/app/const/enums/question_type.dart @@ -0,0 +1 @@ +enum QuestionType { fillTheBlank, option, trueOrFalse } diff --git a/lib/app/const/text/string_extension.dart b/lib/app/const/text/string_extension.dart new file mode 100644 index 0000000..060a04f --- /dev/null +++ b/lib/app/const/text/string_extension.dart @@ -0,0 +1,5 @@ +extension StringCasingExtension on String { + String toTitleCase() { + return split(' ').map((word) => word.isNotEmpty ? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}' : '').join(' '); + } +} diff --git a/lib/app/const/text/text_style.dart b/lib/app/const/text/text_style.dart new file mode 100644 index 0000000..f6697cc --- /dev/null +++ b/lib/app/const/text/text_style.dart @@ -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, + ); +} diff --git a/lib/app/middleware/auth_middleware.dart b/lib/app/middleware/auth_middleware.dart new file mode 100644 index 0000000..2dcea74 --- /dev/null +++ b/lib/app/middleware/auth_middleware.dart @@ -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; + } +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart new file mode 100644 index 0000000..81fcddc --- /dev/null +++ b/lib/app/routes/app_pages.dart @@ -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> 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(), + ) + ]; +} diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart new file mode 100644 index 0000000..c20d3af --- /dev/null +++ b/lib/app/routes/app_routes.dart @@ -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"; +} diff --git a/lib/component/app_name.dart b/lib/component/app_name.dart new file mode 100644 index 0000000..0154c68 --- /dev/null +++ b/lib/component/app_name.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/component/global_button.dart b/lib/component/global_button.dart new file mode 100644 index 0000000..9effaae --- /dev/null +++ b/lib/component/global_button.dart @@ -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), + ), + ); + } +} diff --git a/lib/component/global_dropdown_field.dart b/lib/component/global_dropdown_field.dart new file mode 100644 index 0000000..9802887 --- /dev/null +++ b/lib/component/global_dropdown_field.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class GlobalDropdownField extends StatelessWidget { + final T value; + final List> items; + final ValueChanged 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( + value: value, + isExpanded: true, + onChanged: onChanged, + items: items, + ), + ), + ); + } +} diff --git a/lib/component/global_text_field.dart b/lib/component/global_text_field.dart new file mode 100644 index 0000000..2e25c7f --- /dev/null +++ b/lib/component/global_text_field.dart @@ -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, + ), + ); + } +} diff --git a/lib/component/label_text_field.dart b/lib/component/label_text_field.dart new file mode 100644 index 0000000..59f3008 --- /dev/null +++ b/lib/component/label_text_field.dart @@ -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 + ), + ), + ), + ); + } +} diff --git a/lib/component/notification/delete_confirmation.dart b/lib/component/notification/delete_confirmation.dart new file mode 100644 index 0000000..2cad6c2 --- /dev/null +++ b/lib/component/notification/delete_confirmation.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class DeleteQuestionDialog { + static Future 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"), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/component/notification/pop_up_confirmation.dart b/lib/component/notification/pop_up_confirmation.dart new file mode 100644 index 0000000..46cbe7e --- /dev/null +++ b/lib/component/notification/pop_up_confirmation.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class AppDialog { + static Future 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 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 showConfirmationDialog( + BuildContext context, { + required String title, + required String message, + String cancelText = "Batal", + String confirmText = "Yakin", + Color confirmColor = AppColors.primaryBlue, + }) async { + return 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: [ + 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), + ), + ), + ], + ) + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/component/quiz_container_component.dart b/lib/component/quiz_container_component.dart new file mode 100644 index 0000000..d9fd6c6 --- /dev/null +++ b/lib/component/quiz_container_component.dart @@ -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)), + ), + ], + ), + ], + ); + } +} diff --git a/lib/component/widget/container_skeleton_widget.dart b/lib/component/widget/container_skeleton_widget.dart new file mode 100644 index 0000000..39f96aa --- /dev/null +++ b/lib/component/widget/container_skeleton_widget.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/component/widget/loading_widget.dart b/lib/component/widget/loading_widget.dart new file mode 100644 index 0000000..35b24c8 --- /dev/null +++ b/lib/component/widget/loading_widget.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/lib/component/widget/question_container_widget.dart b/lib/component/widget/question_container_widget.dart new file mode 100644 index 0000000..448652b --- /dev/null +++ b/lib/component/widget/question_container_widget.dart @@ -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 _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), + ), + ], + ); +} diff --git a/lib/component/widget/quiz_item_wa_component.dart b/lib/component/widget/quiz_item_wa_component.dart new file mode 100644 index 0000000..04caebd --- /dev/null +++ b/lib/component/widget/quiz_item_wa_component.dart @@ -0,0 +1,288 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +/// Single quiz result tile. +/// Shows the question, the user's answer, correctness, time spent, and per‑option feedback. +/// +/// * Text strings are fully localised via `easy_localization`. +/// * Long answers now wrap to the next line rather than overflowing. +/// * Option chips highlight both the correct answer and the user's incorrect choice (if any). +class QuizItemWAComponent extends StatelessWidget { + const QuizItemWAComponent({ + super.key, + required this.index, + required this.question, + required this.type, + required this.userAnswer, + required this.targetAnswer, + required this.isCorrect, + required this.timeSpent, + this.options, + }); + + /// One‑based question index. + final int index; + + /// The question text. + final String question; + + /// Question type: `option`, `true_false`, or `fill_the_blank`. + final String type; + + /// Raw user answer (index, bool or string). `-1`/`null` means no answer. + final dynamic userAnswer; + + /// Raw correct answer (index, bool or string). + final dynamic targetAnswer; + + /// Whether the user answered correctly. + final bool isCorrect; + + /// Time spent answering (seconds). + final double timeSpent; + + /// Option texts for option‑type questions. + final List? options; + + bool get _isOptionType => type == 'option'; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ————————————————— Question text + Text( + '$index. $question', + softWrap: true, + style: AppTextStyles.title.copyWith(fontSize: 16, fontWeight: FontWeight.w600), + ), + + if (_isOptionType && options != null) ...[ + const SizedBox(height: 16), + _OptionsList( + options: options!, + userAnswer: userAnswer, + targetAnswer: targetAnswer, + ), + ], + + const SizedBox(height: 12), + _AnswerIndicator( + isCorrect: isCorrect, + isAnswered: userAnswer != null && userAnswer != -1, + userAnswerText: _buildUserAnswerText(), + correctAnswerText: _buildCorrectAnswerText(), + ), + const SizedBox(height: 16), + const Divider(height: 24, color: AppColors.shadowPrimary), + _MetaBar(type: type, timeSpent: timeSpent), + ], + ), + ); + } + + // ——————————————————————————————————————————————————————— Helpers + String _buildUserAnswerText() { + if (userAnswer == null || userAnswer == -1) { + return tr('not_answered'); + } + if (_isOptionType) { + final idx = int.tryParse(userAnswer.toString()) ?? -1; + if (idx >= 0 && idx < (options?.length ?? 0)) return options![idx]; + } + return userAnswer.toString(); + } + + String _buildCorrectAnswerText() { + if (_isOptionType && options != null) { + final idx = int.tryParse(targetAnswer.toString()) ?? -1; + if (idx >= 0 && idx < options!.length) return options![idx]; + } + return targetAnswer.toString(); + } +} + +// ————————————————————————————————————————————————————————— Sub‑widgets + +class _OptionsList extends StatelessWidget { + const _OptionsList({ + required this.options, + required this.userAnswer, + required this.targetAnswer, + }); + + final List 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: " + 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: " + 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), + ], + ); + } +} diff --git a/lib/component/widget/recomendation_component.dart b/lib/component/widget/recomendation_component.dart new file mode 100644 index 0000000..203e461 --- /dev/null +++ b/lib/component/widget/recomendation_component.dart @@ -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 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart new file mode 100644 index 0000000..bd4e5ee --- /dev/null +++ b/lib/core/endpoint/api_endpoint.dart @@ -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"; +} diff --git a/lib/core/helper/connection_check.dart b/lib/core/helper/connection_check.dart new file mode 100644 index 0000000..a35d433 --- /dev/null +++ b/lib/core/helper/connection_check.dart @@ -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", + ); + } +} diff --git a/lib/core/helper/responsive.dart b/lib/core/helper/responsive.dart new file mode 100644 index 0000000..007718c --- /dev/null +++ b/lib/core/helper/responsive.dart @@ -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); + } +} diff --git a/lib/core/utils/custom_floating_loading.dart b/lib/core/utils/custom_floating_loading.dart new file mode 100644 index 0000000..0bf1aa4 --- /dev/null +++ b/lib/core/utils/custom_floating_loading.dart @@ -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; + } +} diff --git a/lib/core/utils/custom_notification.dart b/lib/core/utils/custom_notification.dart new file mode 100644 index 0000000..f69c5ca --- /dev/null +++ b/lib/core/utils/custom_notification.dart @@ -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, + ); + } +} diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart new file mode 100644 index 0000000..a5b7695 --- /dev/null +++ b/lib/core/utils/logger.dart @@ -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; diff --git a/lib/data/controllers/user_controller.dart b/lib/data/controllers/user_controller.dart new file mode 100644 index 0000000..0ee2a54 --- /dev/null +++ b/lib/data/controllers/user_controller.dart @@ -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 userName = "".obs; + Rx userImage = Rx(null); + Rx email = "".obs; + + UserEntity? userData; + + @override + void onInit() { + loadUser(); + super.onInit(); + } + + Future 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 = ''; + } +} diff --git a/lib/data/dto/waiting_room_dto.dart b/lib/data/dto/waiting_room_dto.dart new file mode 100644 index 0000000..bad643e --- /dev/null +++ b/lib/data/dto/waiting_room_dto.dart @@ -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, + }); +} diff --git a/lib/data/entity/user/user_entity.dart b/lib/data/entity/user/user_entity.dart new file mode 100644 index 0000000..fd3b375 --- /dev/null +++ b/lib/data/entity/user/user_entity.dart @@ -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 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 toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'pic_url': picUrl, + 'birth_date': birthDate, + 'locale': locale, + 'phone': phone, + "created_at": createdAt, + }; + } +} diff --git a/lib/data/models/answer/answer_model.dart b/lib/data/models/answer/answer_model.dart new file mode 100644 index 0000000..10c2bd6 --- /dev/null +++ b/lib/data/models/answer/answer_model.dart @@ -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 json) { + return AnswerModel( + questionIndex: json['question_index'], + answer: json['answer'], + isCorrect: json['is_correct'], + timeSpent: (json['time_spent'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'question_index': questionIndex, + 'answer': answer, + 'is_correct': isCorrect, + 'time_spent': timeSpent, + }; + } +} diff --git a/lib/data/models/answer/quiz_answer_submition_model.dart b/lib/data/models/answer/quiz_answer_submition_model.dart new file mode 100644 index 0000000..980ed7d --- /dev/null +++ b/lib/data/models/answer/quiz_answer_submition_model.dart @@ -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 answers; + + QuizAnswerSubmissionModel({ + required this.sessionId, + required this.quizId, + required this.userId, + required this.answeredAt, + required this.answers, + }); + + factory QuizAnswerSubmissionModel.fromJson(Map 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 toJson() { + return { + 'session_id': sessionId, + 'quiz_id': quizId, + 'user_id': userId, + 'answered_at': answeredAt.toIso8601String(), + 'answers': answers.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/lib/data/models/base/base_model.dart b/lib/data/models/base/base_model.dart new file mode 100644 index 0000000..d5aac4e --- /dev/null +++ b/lib/data/models/base/base_model.dart @@ -0,0 +1,54 @@ +class BaseResponseModel { + final String message; + final T? data; + final MetaModel? meta; + + BaseResponseModel({ + required this.message, + this.data, + this.meta, + }); + + factory BaseResponseModel.fromJson( + Map json, + T Function(dynamic) fromJsonT, + ) { + return BaseResponseModel( + 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 json) { + return MetaModel( + totalPage: json['total_page'], + currentPage: json['current_page'], + totalData: json['total_data'], + totalAllData: json['total_all_data'], + ); + } + + Map toJson() { + return { + 'total_page': totalPage, + 'current_page': currentPage, + 'total_data': totalData, + 'total_all_data': totalAllData, + }; + } +} diff --git a/lib/data/models/history/detail_quiz_history.dart b/lib/data/models/history/detail_quiz_history.dart new file mode 100644 index 0000000..226fb46 --- /dev/null +++ b/lib/data/models/history/detail_quiz_history.dart @@ -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 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 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? 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 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.from(json['options']) : null, + ); + } +} diff --git a/lib/data/models/history/participant_history_result.dart b/lib/data/models/history/participant_history_result.dart new file mode 100644 index 0000000..dad6a43 --- /dev/null +++ b/lib/data/models/history/participant_history_result.dart @@ -0,0 +1,78 @@ +class QuestionAnswer { + final int index; + final String question; + final dynamic targetAnswer; + final int duration; + final String type; + final List? 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 json) { + return QuestionAnswer( + index: json['index'], + question: json['question'], + targetAnswer: json['target_answer'], + duration: json['duration'], + type: json['type'], + options: json['options'] != null ? List.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 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 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"; +} diff --git a/lib/data/models/history/quiz_history.dart b/lib/data/models/history/quiz_history.dart new file mode 100644 index 0000000..5a73af1 --- /dev/null +++ b/lib/data/models/history/quiz_history.dart @@ -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 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 toJson() { + return { + 'quiz_id': quizId, + 'answer_id': answerId, + 'title': title, + 'description': description, + 'total_correct': totalCorrect, + 'total_question': totalQuestion, + 'date': date + }; + } +} diff --git a/lib/data/models/history/session_history.dart b/lib/data/models/history/session_history.dart new file mode 100644 index 0000000..4391410 --- /dev/null +++ b/lib/data/models/history/session_history.dart @@ -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 json) { + return Participant( + id: json['id'], + name: json['name'], + score: json['score'], + ); + } + + Map 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 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 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 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, + }; + } +} diff --git a/lib/data/models/login/login_request_model.dart b/lib/data/models/login/login_request_model.dart new file mode 100644 index 0000000..c54a862 --- /dev/null +++ b/lib/data/models/login/login_request_model.dart @@ -0,0 +1,23 @@ +class LoginRequestModel { + final String email; + final String password; + + LoginRequestModel({ + required this.email, + required this.password, + }); + + factory LoginRequestModel.fromJson(Map json) { + return LoginRequestModel( + email: json['email'] ?? '', + password: json['password'] ?? '', + ); + } + + Map toJson() { + return { + 'email': email, + 'password': password, + }; + } +} diff --git a/lib/data/models/login/login_response_model.dart b/lib/data/models/login/login_response_model.dart new file mode 100644 index 0000000..df1be0b --- /dev/null +++ b/lib/data/models/login/login_response_model.dart @@ -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 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 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, + }; + } +} diff --git a/lib/data/models/quiz/library_quiz_model.dart b/lib/data/models/quiz/library_quiz_model.dart new file mode 100644 index 0000000..1bae33d --- /dev/null +++ b/lib/data/models/quiz/library_quiz_model.dart @@ -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 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 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)).toList(), + ); + } + + Map 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(), + }; + } +} diff --git a/lib/data/models/quiz/question/base_qustion_model.dart b/lib/data/models/quiz/question/base_qustion_model.dart new file mode 100644 index 0000000..31843b2 --- /dev/null +++ b/lib/data/models/quiz/question/base_qustion_model.dart @@ -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 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 toJson(); +} diff --git a/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart new file mode 100644 index 0000000..b917875 --- /dev/null +++ b/lib/data/models/quiz/question/fill_in_the_blank_question_model.dart @@ -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 json) { + return FillInTheBlankQuestion( + index: json['index'], + question: json['question'], + duration: json['duration'], + targetAnswer: json['target_answer'], + ); + } + + @override + Map toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': null, + }; +} diff --git a/lib/data/models/quiz/question/option_question_model.dart b/lib/data/models/quiz/question/option_question_model.dart new file mode 100644 index 0000000..9b5ce6f --- /dev/null +++ b/lib/data/models/quiz/question/option_question_model.dart @@ -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 options; + + OptionQuestion({ + required super.index, + required super.question, + required super.duration, + required this.targetAnswer, + required this.options, + }) : super(type: 'option'); + + factory OptionQuestion.fromJson(Map json) { + return OptionQuestion( + index: json['index'], + question: json['question'], + duration: json['duration'], + targetAnswer: json['target_answer'], + options: List.from(json['options']), + ); + } + + @override + Map toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': options, + }; +} diff --git a/lib/data/models/quiz/question/true_false_question_model.dart b/lib/data/models/quiz/question/true_false_question_model.dart new file mode 100644 index 0000000..af01464 --- /dev/null +++ b/lib/data/models/quiz/question/true_false_question_model.dart @@ -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 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 toJson() => { + 'index': index, + 'question': question, + 'duration': duration, + 'type': type, + 'target_answer': targetAnswer, + 'options': null, + }; +} diff --git a/lib/data/models/quiz/question_create_request.dart b/lib/data/models/quiz/question_create_request.dart new file mode 100644 index 0000000..a6466ba --- /dev/null +++ b/lib/data/models/quiz/question_create_request.dart @@ -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 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 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? options; + +// QuestionListing({ +// required this.question, +// required this.targetAnswer, +// required this.duration, +// required this.type, +// this.options, +// }); + +// Map toJson() { +// final map = { +// 'question': question, +// 'target_answer': targetAnswer, +// 'duration': duration, +// 'type': type, +// }; + +// if (options != null && options!.isNotEmpty) { +// map['options'] = options; +// } + +// return map; +// } +// } diff --git a/lib/data/models/quiz/question_listings_model.dart b/lib/data/models/quiz/question_listings_model.dart new file mode 100644 index 0000000..32ab571 --- /dev/null +++ b/lib/data/models/quiz/question_listings_model.dart @@ -0,0 +1,39 @@ +class QuestionListing { + final int index; + final String question; + final dynamic targetAnswer; + final int duration; + final String type; + final List? options; + + QuestionListing({ + required this.index, + required this.question, + required this.targetAnswer, + required this.duration, + required this.type, + this.options, + }); + + factory QuestionListing.fromJson(Map json) { + return QuestionListing( + index: json['index'], + question: json['question'], + targetAnswer: json['target_answer'], + duration: json['duration'], + type: json['type'], + options: json['options'] != null ? List.from(json['options']) : null, + ); + } + + Map toJson() { + return { + 'index': index, + 'question': question, + 'target_answer': targetAnswer, + 'duration': duration, + 'type': type, + 'options': options, + }; + } +} diff --git a/lib/data/models/quiz/quiestion_data_model.dart b/lib/data/models/quiz/quiestion_data_model.dart new file mode 100644 index 0000000..55d06a7 --- /dev/null +++ b/lib/data/models/quiz/quiestion_data_model.dart @@ -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? 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? 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, + ); + } +} diff --git a/lib/data/models/quiz/quiz_info_model.dart b/lib/data/models/quiz/quiz_info_model.dart new file mode 100644 index 0000000..5a54afd --- /dev/null +++ b/lib/data/models/quiz/quiz_info_model.dart @@ -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 json) { + return QuizInfo( + title: json['title'], + description: json['description'], + totalQuiz: json['total_quiz'], + limitDuration: json['limit_duration'], + ); + } + + Map toJson() { + return { + 'title': title, + 'description': description, + 'question_count': totalQuiz, + 'limit_duration': limitDuration, + }; + } +} diff --git a/lib/data/models/quiz/quiz_listing_model.dart b/lib/data/models/quiz/quiz_listing_model.dart new file mode 100644 index 0000000..3df0073 --- /dev/null +++ b/lib/data/models/quiz/quiz_listing_model.dart @@ -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 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 toJson() { + return { + 'quiz_id': quizId, + 'author_id': authorId, + 'author_name': authorName, + 'title': title, + 'description': description, + 'date': date, + 'duration': duration, + "total_quiz": totalQuiz + }; + } +} diff --git a/lib/data/models/register/register_request.dart b/lib/data/models/register/register_request.dart new file mode 100644 index 0000000..8059bcc --- /dev/null +++ b/lib/data/models/register/register_request.dart @@ -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 toJson() { + return { + 'email': email, + 'password': password, + 'name': name, + 'birth_date': birthDate, + if (phone != null) 'phone': phone, + }; + } +} diff --git a/lib/data/models/register/register_response.dart b/lib/data/models/register/register_response.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/models/session/session_info_model.dart b/lib/data/models/session/session_info_model.dart new file mode 100644 index 0000000..db7e3de --- /dev/null +++ b/lib/data/models/session/session_info_model.dart @@ -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 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 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?)?.map((e) => UserModel.fromJson(e as Map)).toList() ?? [], + currentQuestionIndex: json['current_question_index'], + ); + } + + Map 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, + }; + } +} diff --git a/lib/data/models/session/session_request_model.dart b/lib/data/models/session/session_request_model.dart new file mode 100644 index 0000000..2e0dfc8 --- /dev/null +++ b/lib/data/models/session/session_request_model.dart @@ -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 json) { + return SessionRequestModel( + quizId: json['quiz_id'], + hostId: json['host_id'], + roomName: json['room_name'], + limitParticipan: json['limit_participan'], + ); + } + + Map toJson() { + return { + 'quiz_id': quizId, + 'host_id': hostId, + 'room_name': roomName, + 'limit_participan': limitParticipan, + }; + } +} diff --git a/lib/data/models/session/session_response_model.dart b/lib/data/models/session/session_response_model.dart new file mode 100644 index 0000000..e9a58e3 --- /dev/null +++ b/lib/data/models/session/session_response_model.dart @@ -0,0 +1,20 @@ +class SessionResponseModel { + final String sessionId; + final String sessionCode; + + SessionResponseModel({required this.sessionId, required this.sessionCode}); + + factory SessionResponseModel.fromJson(Map json) { + return SessionResponseModel( + sessionId: json['session_id'], + sessionCode: json['session_code'], + ); + } + + Map toJson() { + return { + 'session_id': sessionId, + 'session_code': sessionCode, + }; + } +} diff --git a/lib/data/models/subject/subject_model.dart b/lib/data/models/subject/subject_model.dart new file mode 100644 index 0000000..ddc5755 --- /dev/null +++ b/lib/data/models/subject/subject_model.dart @@ -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 json) { + return SubjectModel( + id: json['id'], + name: json['name'], + alias: json['alias'], + description: json['description'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'alias': alias, + 'description': description, + }; + } +} diff --git a/lib/data/models/user/user_full_model.dart b/lib/data/models/user/user_full_model.dart new file mode 100644 index 0000000..46f6ad6 --- /dev/null +++ b/lib/data/models/user/user_full_model.dart @@ -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 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 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, + }; + } +} diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart new file mode 100644 index 0000000..72769d4 --- /dev/null +++ b/lib/data/models/user/user_model.dart @@ -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 json) { + return UserModel( + id: json['id'], + username: json['username'], + userPic: json['user_pic'] ?? "", + joinedAt: DateTime.parse(json['joined_at']), + ); + } +} diff --git a/lib/data/models/user/user_stat_model.dart b/lib/data/models/user/user_stat_model.dart new file mode 100644 index 0000000..769a73f --- /dev/null +++ b/lib/data/models/user/user_stat_model.dart @@ -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 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 toJson() { + return { + 'avg_score': avgScore, + 'total_solve': totalSolve, + 'total_quiz': totalQuiz, + }; + } +} diff --git a/lib/data/providers/dio_client.dart b/lib/data/providers/dio_client.dart new file mode 100644 index 0000000..456597d --- /dev/null +++ b/lib/data/providers/dio_client.dart @@ -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 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; + } +} diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart new file mode 100644 index 0000000..79b344a --- /dev/null +++ b/lib/data/services/answer_service.dart @@ -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().dio; + super.onInit(); + } + + Future submitQuizAnswers(Map 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?> getAnswerSession(String sessionId, String userId) async { + try { + final response = await dio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + }); + final parsedResponse = BaseResponseModel.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; + } + } +} diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart new file mode 100644 index 0000000..cf1de61 --- /dev/null +++ b/lib/data/services/auth_service.dart @@ -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().dio; + super.onInit(); + } + + Future 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 loginWithEmail(LoginRequestModel request) async { + try { + final data = request.toJson(); + final response = await dio.post(APIEndpoint.login, data: data); + + final baseResponse = BaseResponseModel.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 loginWithGoogle(String idToken) async { + try { + final response = await dio.post( + APIEndpoint.loginGoogle, + data: {"token_id": idToken}, + ); + + final baseResponse = BaseResponseModel.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); + } + } +} diff --git a/lib/data/services/connection_service.dart b/lib/data/services/connection_service.dart new file mode 100644 index 0000000..d929023 --- /dev/null +++ b/lib/data/services/connection_service.dart @@ -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> _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 _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 results) { + // If all results are `none`, the device is considered offline. + isConnected.value = results.any((result) => result != ConnectivityResult.none); + } + + Future 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(); + } +} diff --git a/lib/data/services/google_auth_service.dart b/lib/data/services/google_auth_service.dart new file mode 100644 index 0000000..9ad6096 --- /dev/null +++ b/lib/data/services/google_auth_service.dart @@ -0,0 +1,35 @@ +import 'package:google_sign_in/google_sign_in.dart'; + +class GoogleAuthService { + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: ['email', 'profile', 'openid'], + ); + + Future signIn() async { + try { + return await _googleSignIn.signIn(); + } catch (e) { + rethrow; + } + } + + Future signOut() async { + try { + await _googleSignIn.signOut(); + } catch (e) { + rethrow; + } + } + + Future getIdToken() async { + final account = await _googleSignIn.signIn(); + if (account == null) return null; + + final auth = await account.authentication; + return auth.idToken; + } + + Future isSignedIn() async { + return await _googleSignIn.isSignedIn(); + } +} diff --git a/lib/data/services/history_service.dart b/lib/data/services/history_service.dart new file mode 100644 index 0000000..b3816d2 --- /dev/null +++ b/lib/data/services/history_service.dart @@ -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().dio; + super.onInit(); + } + + Future?> getHistory(String userId) async { + try { + final result = await _dio.get("${APIEndpoint.historyQuiz}/$userId"); + + final parsedResponse = BaseResponseModel>.fromJson( + result.data, + (data) => (data as List).map((e) => QuizHistory.fromJson(e as Map)).toList(), + ); + return parsedResponse.data; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + return null; + } + } + + Future?> getDetailHistory(String answerId) async { + try { + final result = await _dio.get("${APIEndpoint.detailHistoryQuiz}/$answerId"); + + final parsedResponse = BaseResponseModel.fromJson( + result.data, + (data) => QuizAnswerResult.fromJson(data as Map), + ); + return parsedResponse; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + return null; + } + } + + Future?> getSessionHistory(String sessionId) async { + try { + final result = await _dio.get("${APIEndpoint.sessionHistory}/$sessionId"); + + final parsedResponse = BaseResponseModel.fromJson( + result.data, + (data) => SessionHistory.fromJson(data), + ); + + return parsedResponse; + } catch (e, stacktrace) { + logC.e(e, stackTrace: stacktrace); + return null; + } + } +} diff --git a/lib/data/services/quiz_service.dart b/lib/data/services/quiz_service.dart new file mode 100644 index 0000000..f62a8e0 --- /dev/null +++ b/lib/data/services/quiz_service.dart @@ -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().dio; + super.onInit(); + } + + Future 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>> 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>.fromJson( + response.data, + (data) => (data as List).map((item) => RawQuizModel.fromJson(item as Map)).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>?> userQuiz(String userId, int page) async { + try { + final response = await dio.get("${APIEndpoint.userQuiz}/$userId?page=$page"); + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).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>?> 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>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).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>?> 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>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).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>?> 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>.fromJson( + response.data, + (data) => (data as List).map((e) => QuizListingModel.fromJson(e as Map)).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?> getQuizById(String quizId) async { + try { + final response = await dio.get("${APIEndpoint.quiz}/$quizId"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.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 json) { + return RawQuizModel( + qustion: json['qustion'] as String, + answer: json['answer'], + ); + } + + Map toJson() { + return { + 'qustion': qustion, + 'answer': answer, + }; + } +} diff --git a/lib/data/services/session_service.dart b/lib/data/services/session_service.dart new file mode 100644 index 0000000..0759009 --- /dev/null +++ b/lib/data/services/session_service.dart @@ -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().dio; + super.onInit(); + } + + Future?> 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; + } + } +} diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart new file mode 100644 index 0000000..7403c3e --- /dev/null +++ b/lib/data/services/socket_service.dart @@ -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>.broadcast(); + final _chatMessageController = StreamController>.broadcast(); + final _questionUpdateController = StreamController>.broadcast(); + final _quizStartedController = StreamController.broadcast(); + final _answerSubmittedController = StreamController>.broadcast(); + final _scoreUpdateController = StreamController>.broadcast(); + final _quizDoneController = StreamController.broadcast(); + final _roomClosedController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + + // Public streams + Stream> get roomMessages => _roomMessageController.stream; + Stream> get questionUpdate => _questionUpdateController.stream; + Stream> get chatMessages => _chatMessageController.stream; + Stream get quizStarted => _quizStartedController.stream; + Stream> get answerSubmitted => _answerSubmittedController.stream; + Stream> get scoreUpdates => _scoreUpdateController.stream; + Stream get quizDone => _quizDoneController.stream; + Stream get roomClosed => _roomClosedController.stream; + Stream 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.from(data)); + }); + + socket.on('receive_message', (data) { + logC.i('💬 Chat from ${data['from']}: ${data['message']}'); + _chatMessageController.add(Map.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.from(data)); + }); + + socket.on('answer_submitted', (data) { + logC.i('✅ Answer Submitted: $data'); + _answerSubmittedController.add(Map.from(data)); + }); + + socket.on('score_update', (data) { + logC.i('📊 Score Update: $data'); + _scoreUpdateController.add(Map.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(); + } +} diff --git a/lib/data/services/subject_service.dart b/lib/data/services/subject_service.dart new file mode 100644 index 0000000..83579dd --- /dev/null +++ b/lib/data/services/subject_service.dart @@ -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().dio; + super.onInit(); + } + + Future>> getSubject() async { + final response = await dio.get(APIEndpoint.subject); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel>.fromJson( + response.data, + (data) => (data as List).map((e) => SubjectModel.fromJson(e as Map)).toList(), + ); + + return parsedResponse; + } else { + throw Exception('Failed to fetch subjects. Status code: ${response.statusCode}'); + } + } +} diff --git a/lib/data/services/user_service.dart b/lib/data/services/user_service.dart new file mode 100644 index 0000000..bd90103 --- /dev/null +++ b/lib/data/services/user_service.dart @@ -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().dio; + super.onInit(); + } + + Future 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?> getUserData(String id) async { + try { + final response = await _dio.get("${APIEndpoint.userData}/$id"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => UserFullModel.fromJson(data), + ); + return parsedResponse; + } else { + return null; + } + } catch (e) { + logC.e("get user data error: $e"); + return null; + } + } + + Future?> getUserStat(String id) async { + try { + final response = await _dio.get("${APIEndpoint.userStat}/$id"); + + if (response.statusCode == 200) { + final parsedResponse = BaseResponseModel.fromJson( + response.data, + (data) => UserStatModel.fromJson(data), + ); + return parsedResponse; + } else { + return null; + } + } catch (e) { + logC.e("get user data error: $e"); + return null; + } + } +} diff --git a/lib/data/services/user_storage_service.dart b/lib/data/services/user_storage_service.dart new file mode 100644 index 0000000..0ce0641 --- /dev/null +++ b/lib/data/services/user_storage_service.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A lightweight wrapper around [SharedPreferences] that persists +/// the logged‑in user plus UI/feature preferences such as theme and +/// push‑notification opt‑in. +class UserStorageService { + // ───────────────────── Keys ───────────────────── + static const _userKey = 'user_data'; + static const _darkModeKey = 'pref_dark_mode'; + static const _pushNotifKey = 'pref_push_notification'; + + /// Cached flag used by splash / root to decide initial route. + bool isLogged = false; + + // ───────────────────── User CRUD ───────────────────── + Future saveUser(UserEntity user) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_userKey, jsonEncode(user.toJson())); + } + + Future loadUser() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_userKey); + if (jsonString == null) return null; + return UserEntity.fromJson(jsonDecode(jsonString)); + } + + Future clearUser() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_userKey); + } + + Future isLoggedIn() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.containsKey(_userKey); + } + + // ───────────────────── UI Preferences ───────────────────── + /// Persist the user’s theme choice. + Future 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 getDarkMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_darkModeKey) ?? false; + } + + /// Persist the user’s push‑notification preference. + Future setPushNotification(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_pushNotifKey, value); + } + + /// Retrieve the stored push‑notification preference. Defaults to *true*. + Future getPushNotification() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_pushNotifKey) ?? true; + } +} diff --git a/lib/feature/admin_result_page/bindings/admin_result_binding.dart b/lib/feature/admin_result_page/bindings/admin_result_binding.dart new file mode 100644 index 0000000..73d8731 --- /dev/null +++ b/lib/feature/admin_result_page/bindings/admin_result_binding.dart @@ -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())); + } +} diff --git a/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart b/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart new file mode 100644 index 0000000..2aa6220 --- /dev/null +++ b/lib/feature/admin_result_page/bindings/detail_participant_result_binding.dart @@ -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())); + } +} diff --git a/lib/feature/admin_result_page/controller/admin_result_controller.dart b/lib/feature/admin_result_page/controller/admin_result_controller.dart new file mode 100644 index 0000000..c309f68 --- /dev/null +++ b/lib/feature/admin_result_page/controller/admin_result_controller.dart @@ -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, + }, + ); +} diff --git a/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart new file mode 100644 index 0000000..6f11dd6 --- /dev/null +++ b/lib/feature/admin_result_page/controller/detail_participant_result_controller.dart @@ -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 = Rx(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); + } + } +} diff --git a/lib/feature/admin_result_page/view/admin_result_page.dart b/lib/feature/admin_result_page/view/admin_result_page.dart new file mode 100644 index 0000000..9ded9a5 --- /dev/null +++ b/lib/feature/admin_result_page/view/admin_result_page.dart @@ -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 { + 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 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; + } +} diff --git a/lib/feature/admin_result_page/view/detail_participant_result_page.dart b/lib/feature/admin_result_page/view/detail_participant_result_page.dart new file mode 100644 index 0000000..0534908 --- /dev/null +++ b/lib/feature/admin_result_page/view/detail_participant_result_page.dart @@ -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 { + 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 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; + } +} diff --git a/lib/feature/detail_quiz/binding/detail_quiz_binding.dart b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart new file mode 100644 index 0000000..30d0af5 --- /dev/null +++ b/lib/feature/detail_quiz/binding/detail_quiz_binding.dart @@ -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()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut( + () => DetailQuizController( + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/detail_quiz/controller/detail_quiz_controller.dart b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart new file mode 100644 index 0000000..ce2eba8 --- /dev/null +++ b/lib/feature/detail_quiz/controller/detail_quiz_controller.dart @@ -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); + } +} diff --git a/lib/feature/detail_quiz/view/detail_quix_view.dart b/lib/feature/detail_quiz/view/detail_quix_view.dart new file mode 100644 index 0000000..07cfd98 --- /dev/null +++ b/lib/feature/detail_quiz/view/detail_quix_view.dart @@ -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 { + 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'); + } + } +} diff --git a/lib/feature/history/binding/detail_history_binding.dart b/lib/feature/history/binding/detail_history_binding.dart new file mode 100644 index 0000000..5478a70 --- /dev/null +++ b/lib/feature/history/binding/detail_history_binding.dart @@ -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()) Get.lazyPut(() => HistoryService()); + Get.lazyPut(() => DetailHistoryController(Get.find())); + } +} diff --git a/lib/feature/history/binding/history_binding.dart b/lib/feature/history/binding/history_binding.dart new file mode 100644 index 0000000..d6f6b81 --- /dev/null +++ b/lib/feature/history/binding/history_binding.dart @@ -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()); + Get.lazyPut( + () => HistoryController( + Get.find(), + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/history/controller/detail_history_controller.dart b/lib/feature/history/controller/detail_history_controller.dart new file mode 100644 index 0000000..122d554 --- /dev/null +++ b/lib/feature/history/controller/detail_history_controller.dart @@ -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? result = await _historyService.getDetailHistory(answerId); + if (result != null) { + if (result.data != null) quizAnswer = result.data!; + } + + isLoading.value = false; + } +} diff --git a/lib/feature/history/controller/history_controller.dart b/lib/feature/history/controller/history_controller.dart new file mode 100644 index 0000000..edc7856 --- /dev/null +++ b/lib/feature/history/controller/history_controller.dart @@ -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 = [].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); +} diff --git a/lib/feature/history/view/component/quiz_item_component.dart b/lib/feature/history/view/component/quiz_item_component.dart new file mode 100644 index 0000000..ca5002d --- /dev/null +++ b/lib/feature/history/view/component/quiz_item_component.dart @@ -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), +// ], +// ); +// } +// } diff --git a/lib/feature/history/view/detail_history_view.dart b/lib/feature/history/view/detail_history_view.dart new file mode 100644 index 0000000..1d8999d --- /dev/null +++ b/lib/feature/history/view/detail_history_view.dart @@ -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 { + 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 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), + ], + ); + } +} diff --git a/lib/feature/history/view/history_view.dart b/lib/feature/history/view/history_view.dart new file mode 100644 index 0000000..d911452 --- /dev/null +++ b/lib/feature/history/view/history_view.dart @@ -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 { + 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), + ), + ], + ), + ], + ); + } +} diff --git a/lib/feature/home/binding/home_binding.dart b/lib/feature/home/binding/home_binding.dart new file mode 100644 index 0000000..040e687 --- /dev/null +++ b/lib/feature/home/binding/home_binding.dart @@ -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()); + Get.lazyPut(() => SubjectService()); + Get.lazyPut( + () => HomeController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/home/controller/home_controller.dart b/lib/feature/home/controller/home_controller.dart new file mode 100644 index 0000000..6f7429d --- /dev/null +++ b/lib/feature/home/controller/home_controller.dart @@ -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 get userName => _userController.userName; + Rx get userImage => _userController.userImage; + + RxList data = [].obs; + + RxList subjects = [].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); + } + } + + 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(); + 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; + } + } +} diff --git a/lib/feature/home/view/component/button_option.dart b/lib/feature/home/view/component/button_option.dart new file mode 100644 index 0000000..acd579e --- /dev/null +++ b/lib/feature/home/view/component/button_option.dart @@ -0,0 +1,118 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class ButtonOption extends StatelessWidget { + final VoidCallback onCreate; + final VoidCallback onCreateRoom; + final VoidCallback onJoinRoom; + + const ButtonOption({ + super.key, + required this.onCreate, + required this.onCreateRoom, + required this.onJoinRoom, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 220, + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 20), + child: Row( + children: [ + Expanded(child: _buildCreateButton(context)), + const SizedBox(width: 12), + Expanded(child: _buildRoomButtons(context)), + ], + ), + ); + } + + Widget _buildCreateButton(BuildContext context) { + return InkWell( + onTap: onCreate, + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: double.infinity, + child: _buildButtonContainer( + label: context.tr("create_quiz"), + gradientColors: [Color(0xFF0052CC), Color(0xFF0367D3)], + icon: Icons.create, + ), + ), + ); + } + + Widget _buildRoomButtons(BuildContext context) { + return Column( + children: [ + Expanded( + child: InkWell( + onTap: onCreateRoom, + borderRadius: BorderRadius.circular(16), + child: _buildButtonContainer( + label: context.tr("create_room"), + gradientColors: [Color(0xFF36B37E), Color(0xFF22C39F)], + icon: Icons.meeting_room, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: InkWell( + onTap: onJoinRoom, + borderRadius: BorderRadius.circular(16), + child: _buildButtonContainer( + label: context.tr("join_room"), + gradientColors: [Color(0xFFFFAB00), Color(0xFFFFC107)], + icon: Icons.group, + ), + ), + ), + ], + ); + } + + Widget _buildButtonContainer({ + required String label, + required List gradientColors, + required IconData icon, + }) { + return Container( + alignment: Alignment.bottomLeft, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: gradientColors.last.withValues(alpha: 0.4), + blurRadius: 6, + offset: const Offset(2, 4), + ), + ], + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Icon(icon, color: Colors.white, size: 24), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/home/view/component/search_component.dart b/lib/feature/home/view/component/search_component.dart new file mode 100644 index 0000000..653560d --- /dev/null +++ b/lib/feature/home/view/component/search_component.dart @@ -0,0 +1,134 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; + +class SearchComponent extends StatelessWidget { + final Function() onSearchTap; + final Function(String, String) onSubjectTap; + final List subject; + + const SearchComponent({ + super.key, + required this.onSubjectTap, + required this.onSearchTap, + required this.subject, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + decoration: BoxDecoration( + color: AppColors.background, // Soft background + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Color(0xFFE1E4E8)), // Light border + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleSection(context), + const SizedBox(height: 12), + _buildCategoryRow(), + const SizedBox(height: 12), + _buildSearchInput(context), + ], + ), + ); + } + + Widget _buildTitleSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.tr("ready_new_challenge"), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + ), + SizedBox(height: 5), + Text( + context.tr("search_or_select_category"), + style: TextStyle( + fontSize: 14, + color: Color(0xFF6B778C), // Soft gray text + ), + ), + ], + ); + } + + Widget _buildCategoryRow() { + return SizedBox( + height: 30, // Set height for horizontal ListView + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: subject.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => onSubjectTap(subject[index].id, subject[index].alias), + child: Padding( + padding: EdgeInsets.only(right: index != subject.length - 1 ? 8.0 : 0), + child: _buildCategoryComponent(subject[index].alias), + ), + ); + }, + ), + ); + } + + Widget _buildCategoryComponent(String category) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Color(0xFFD6E4FF), // Soft blue chip + borderRadius: BorderRadius.circular(20), + ), + child: Text( + category, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF0052CC), // Primary blue + ), + ), + ); + } + + Widget _buildSearchInput(BuildContext context) { + return GestureDetector( + onTap: () => onSearchTap(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Icon(Icons.search, color: Color(0xFF6B778C)), + SizedBox(width: 8), + Text( + context.tr("search_for_quizzes"), + style: TextStyle( + color: Color(0xFF6B778C), + fontSize: 16, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/home/view/component/user_gretings.dart b/lib/feature/home/view/component/user_gretings.dart new file mode 100644 index 0000000..a563230 --- /dev/null +++ b/lib/feature/home/view/component/user_gretings.dart @@ -0,0 +1,77 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class UserGretingsComponent extends StatelessWidget { + final String userName; + final String? userImage; + final int greatingStatus; + + const UserGretingsComponent({ + super.key, + required this.userName, + required this.userImage, + required this.greatingStatus, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (userImage != null) + CircleAvatar( + backgroundImage: NetworkImage(userImage!), + ) + else + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey, + ), + child: Icon( + Icons.person, + color: Colors.white, + size: 30, + ), + ), + SizedBox( + width: 10, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr(_getGreetingKey(greatingStatus)), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + tr("greeting_user", namedArgs: {"user": userName}), + style: TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + Spacer(), + Icon(Icons.notifications), + SizedBox( + width: 10, + ) + ], + ); + } + + String _getGreetingKey(int status) { + switch (status) { + case 1: + return 'greeting_time.morning'; + case 2: + return 'greeting_time.afternoon'; + case 3: + return 'greeting_time.evening'; + case 4: + return 'greeting_time.night'; + default: + return 'greeting_time.morning'; // fallback + } + } +} diff --git a/lib/feature/home/view/home_page.dart b/lib/feature/home/view/home_page.dart new file mode 100644 index 0000000..7032b0d --- /dev/null +++ b/lib/feature/home/view/home_page.dart @@ -0,0 +1,68 @@ +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/enums/listing_type.dart'; +import 'package:quiz_app/feature/home/controller/home_controller.dart'; +import 'package:quiz_app/feature/home/view/component/button_option.dart'; +import 'package:quiz_app/component/widget/recomendation_component.dart'; +import 'package:quiz_app/feature/home/view/component/search_component.dart'; +import 'package:quiz_app/feature/home/view/component/user_gretings.dart'; + +class HomeView extends GetView { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Obx( + () => UserGretingsComponent( + userName: controller.userName.value, + userImage: controller.userImage.value, + greatingStatus: controller.timeStatus.value, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ButtonOption( + onCreate: controller.goToQuizCreation, + onCreateRoom: controller.goToRoomMaker, + onJoinRoom: controller.goToJoinRoom, + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Obx(() => SearchComponent( + onSearchTap: controller.goToSearch, + subject: controller.subjects.toList(), + onSubjectTap: (p0, p1) => controller.goToListingsQuizPage(ListingType.subject, subjectId: p0, subjecName: p1), + )), + const SizedBox(height: 20), + Obx( + () => RecomendationComponent( + title: context.tr("quiz_recommendation"), + datas: controller.data.toList(), + itemOnTap: controller.onRecommendationTap, + allOnTap: () => controller.goToListingsQuizPage(ListingType.recomendation), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/join_room/binding/join_room_binding.dart b/lib/feature/join_room/binding/join_room_binding.dart new file mode 100644 index 0000000..38f74aa --- /dev/null +++ b/lib/feature/join_room/binding/join_room_binding.dart @@ -0,0 +1,20 @@ +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/socket_service.dart'; +import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; + +class JoinRoomBinding extends Bindings { + @override + void dependencies() { + Get.put(SocketService()); + + Get.lazyPut( + () => JoinRoomController( + Get.find(), + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/join_room/controller/join_room_controller.dart b/lib/feature/join_room/controller/join_room_controller.dart new file mode 100644 index 0000000..65d6c42 --- /dev/null +++ b/lib/feature/join_room/controller/join_room_controller.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +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/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +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'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class JoinRoomController extends GetxController { + final SocketService _socketService; + final UserController _userController; + final ConnectionService _connectionService; + + JoinRoomController( + this._socketService, + this._userController, + this._connectionService, + ); + + final TextEditingController codeController = TextEditingController(); + RxBool isLoading = false.obs; + + void joinRoom(BuildContext context) { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + final code = codeController.text.trim(); + + if (code.isEmpty) { + Get.snackbar( + "Error", + "Kode room dan nama harus diisi", + backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.9), + colorText: Colors.white, + ); + return; + } + CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; + _socketService.initSocketConnection(); + + _socketService.joinRoom(sessionCode: code, userId: _userController.userData!.id); + _socketService.errors.listen((error) { + CustomNotification.error(title: "not found", message: "Ruangan tidak ditemukan"); + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + }); + + _socketService.roomMessages.listen((data) { + if (data["type"] == "join") { + final Map dataPayload = data["data"]; + final Map sessionInfoJson = dataPayload["session_info"]; + final Map quizInfoJson = dataPayload["quiz_info"]; + + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + Get.toNamed( + AppRoutes.waitRoomPage, + arguments: WaitingRoomDTO( + isAdmin: false, + data: SessionResponseModel( + sessionId: sessionInfoJson["id"], + sessionCode: sessionInfoJson["session_code"], + ), + sessionInfo: SessionInfo.fromJson(sessionInfoJson), + quizInfo: QuizInfo.fromJson(quizInfoJson), + ), + ); + } + }); + } + + void onGoBack() { + if (!isLoading.value) Get.back(); + } + + @override + void onClose() { + codeController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/join_room/view/join_room_view.dart b/lib/feature/join_room/view/join_room_view.dart new file mode 100644 index 0000000..b37330f --- /dev/null +++ b/lib/feature/join_room/view/join_room_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/feature/join_room/controller/join_room_controller.dart'; + +class JoinRoomView extends GetView { + const JoinRoomView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( + backgroundColor: Colors.white, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black87), + onPressed: () => Get.back(), + ), + ), + body: Container( + color: Colors.white, + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + + // TweenAnimationBuilder( + // duration: const Duration(seconds: 1), + // tween: Tween(begin: 0.0, end: 1.0), + // builder: (context, value, child) { + // return Transform.scale( + // scale: value, + // child: child, + // ); + // }, + // child: Container( + // padding: EdgeInsets.all(22), + // decoration: BoxDecoration( + // color: AppColors.primaryBlue.withValues(alpha: 0.05), + // shape: BoxShape.circle, + // border: Border.all( + // color: AppColors.primaryBlue.withValues(alpha: 0.15), + // width: 2, + // ), + // ), + // child: Icon( + // LucideIcons.trophy, + // size: 70, + // color: AppColors.primaryBlue, + // ), + // ), + // ), + + const SizedBox(height: 30), + + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Text( + context.tr("ready_to_compete"), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 15), + + // Animated Subtitle + TweenAnimationBuilder( + duration: const Duration(milliseconds: 800), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + context.tr("enter_code_to_join"), + style: const TextStyle( + fontSize: 16, + color: Colors.black54, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + ), + + const SizedBox(height: 40), + + TweenAnimationBuilder( + duration: const Duration(milliseconds: 1000), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, 30 * (1 - value)), + child: child, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 30), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.08), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.keySquare, + color: AppColors.primaryBlue, + size: 24, + ), + const SizedBox(width: 12), + Text( + context.tr("enter_room_code"), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 25), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey.shade200, + width: 1, + ), + ), + child: GlobalTextField( + controller: controller.codeController, + hintText: context.tr("room_code_hint"), + textInputType: TextInputType.text, + forceUpperCase: true, + ), + ), + const SizedBox(height: 30), + GlobalButton( + text: context.tr("join_quiz_now"), + onPressed: () => controller.joinRoom(context), + ), + ], + ), + ), + ), + + const SizedBox(height: 30), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/library/binding/library_binding.dart b/lib/feature/library/binding/library_binding.dart new file mode 100644 index 0000000..8446aa5 --- /dev/null +++ b/lib/feature/library/binding/library_binding.dart @@ -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/quiz_service.dart'; +import 'package:quiz_app/feature/library/controller/library_controller.dart'; + +class LibraryBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut(() => LibraryController( + Get.find(), + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/library/controller/library_controller.dart b/lib/feature/library/controller/library_controller.dart new file mode 100644 index 0000000..35c6962 --- /dev/null +++ b/lib/feature/library/controller/library_controller.dart @@ -0,0 +1,69 @@ +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/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; + +class LibraryController extends GetxController { + final QuizService _quizService; + final UserController _userController; + final ConnectionService _connectionService; + + LibraryController( + this._quizService, + this._userController, + this._connectionService, + ); + + RxList quizs = [].obs; + RxBool isLoading = true.obs; + RxString emptyMessage = "".obs; + int currentPage = 1; + + @override + void onInit() { + loadUserQuiz(); + super.onInit(); + } + + void loadUserQuiz() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + try { + isLoading.value = true; + BaseResponseModel>? response = await _quizService.userQuiz(_userController.userData!.id, currentPage); + if (response == null) { + emptyMessage.value = "Kamu belum membuat soal."; + } else { + quizs.assignAll(response.data!); + } + } catch (e) { + emptyMessage.value = "Terjadi kesalahan saat memuat data."; + } finally { + isLoading.value = false; + } + } + + void goToDetail(int index) { + Get.toNamed(AppRoutes.detailQuizPage, arguments: quizs[index].quizId); + } + + String formatDuration(int seconds) { + int minutes = seconds ~/ 60; + return '$minutes menit'; + } + + String formatDate(String dateString) { + try { + // DateTime date = DateTime.parse(dateString); + return "19-04-2025"; // Ini kamu hardcode, pastikan nanti parse bener + } catch (e) { + return '-'; + } + } +} diff --git a/lib/feature/library/view/library_view.dart b/lib/feature/library/view/library_view.dart new file mode 100644 index 0000000..bfb597b --- /dev/null +++ b/lib/feature/library/view/library_view.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.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/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/feature/library/controller/library_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class LibraryView extends GetView { + const LibraryView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background2, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.tr('library_title'), + style: AppTextStyles.title.copyWith(fontSize: 24), + ), + const SizedBox(height: 8), + Text( + context.tr('library_description'), + style: AppTextStyles.subtitle, + ), + const SizedBox(height: 20), + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return ListView.builder( + itemCount: 3, + itemBuilder: (context, index) { + return ContainerSkeleton(); + }, + ); + } + + if (controller.quizs.isEmpty) { + return Center( + child: Text( + context.tr('no_quiz_available'), + style: AppTextStyles.caption, + ), + ); + } + + return ListView.builder( + itemCount: controller.quizs.length, + itemBuilder: (context, index) { + final quiz = controller.quizs[index]; + return InkWell( + onTap: () => controller.goToDetail(index), + child: _buildQuizCard(context, quiz), + ); + }, + ); + }), + ), + ], + ), + ), + ), + ); + } + + Widget _buildQuizCard(BuildContext context, QuizListingModel quiz) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.primaryBlue, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.menu_book_rounded, color: Colors.white), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quiz.title, + style: AppTextStyles.body.copyWith( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + quiz.description, + style: AppTextStyles.caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.calendar_today_rounded, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + controller.formatDate(quiz.date), + style: AppTextStyles.dateTime, + ), + const SizedBox(width: 12), + const Icon(Icons.list, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + context.tr('quiz_count_named', namedArgs: {'total': quiz.totalQuiz.toString()}), + style: AppTextStyles.dateTime, + ), + const SizedBox(width: 12), + const Icon(Icons.access_time, size: 14, color: AppColors.softGrayText), + const SizedBox(width: 4), + Text( + controller.formatDuration(quiz.duration), + style: AppTextStyles.dateTime, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/listing_quiz/binding/listing_quiz_binding.dart b/lib/feature/listing_quiz/binding/listing_quiz_binding.dart new file mode 100644 index 0000000..ae61395 --- /dev/null +++ b/lib/feature/listing_quiz/binding/listing_quiz_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/feature/listing_quiz/controller/listing_quiz_controller.dart'; + +class ListingQuizBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + Get.lazyPut(() => ListingQuizController(Get.find())); + } +} diff --git a/lib/feature/listing_quiz/controller/listing_quiz_controller.dart b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart new file mode 100644 index 0000000..329a5ec --- /dev/null +++ b/lib/feature/listing_quiz/controller/listing_quiz_controller.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +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/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; + +class ListingQuizController extends GetxController { + final QuizService _quizService; + + ListingQuizController(this._quizService); + + RxBool isLoading = false.obs; + RxBool isLoadingMore = false.obs; + RxList quizzes = [].obs; + + final ScrollController scrollController = ScrollController(); + + final int amountQuiz = 8; + int currentPage = 1; + bool hasMore = true; + + RxString appBarTitle = "Quiz Recommendation".obs; + bool isSearchMode = false; + String? currentSubjectId; + + @override + void onInit() { + super.onInit(); + loadData(); + scrollController.addListener(_onScroll); + } + + void loadData() { + final Map data = Get.arguments as Map; + final pageType = data['page'] as ListingType; + + switch (pageType) { + case ListingType.populer: + appBarTitle.value = "Quiz Populer"; + _loadRecommendation(resetPage: true); + break; + case ListingType.recomendation: + appBarTitle.value = "Quiz Recommendation"; + _loadRecommendation(resetPage: true); + break; + case ListingType.subject: + appBarTitle.value = "Quiz ${data["subject_name"]}"; + _loadBySubject(subjectId: data["id"], resetPage: true); + break; + } + } + + void _onScroll() { + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { + if (!isLoadingMore.value && hasMore) { + loadMoreQuiz(); + } + } + } + + Future _loadRecommendation({bool resetPage = false}) async { + isSearchMode = false; + currentSubjectId = null; + if (resetPage) currentPage = 1; + + isLoading.value = true; + + final response = await _quizService.populerQuiz(page: currentPage, amount: amountQuiz); + _handleResponse(response, resetPage: resetPage); + + isLoading.value = false; + } + + Future _loadBySubject({required String subjectId, bool resetPage = false}) async { + isSearchMode = true; + currentSubjectId = subjectId; + if (resetPage) currentPage = 1; + + isLoading.value = true; + + final response = await _quizService.searchQuiz("", currentPage, subjectId: subjectId); + _handleResponse(response, resetPage: resetPage); + + isLoading.value = false; + } + + Future loadMoreQuiz() async { + if (!hasMore) return; + + isLoadingMore.value = true; + currentPage++; + + BaseResponseModel? response; + if (isSearchMode && currentSubjectId != null) { + response = await _quizService.searchQuiz("", currentPage, subjectId: currentSubjectId!); + } else { + response = await _quizService.populerQuiz(page: currentPage, amount: amountQuiz); + } + + _handleResponse(response, resetPage: false); + + isLoadingMore.value = false; + } + + void _handleResponse(BaseResponseModel? response, {required bool resetPage}) { + if (response != null && response.data != null) { + final data = response.data as List; + if (resetPage) { + quizzes.assignAll(data); + } else { + quizzes.addAll(data); + } + hasMore = data.length == amountQuiz; + } + } + + void goToDetailQuiz(String quizId) => Get.toNamed(AppRoutes.detailQuizPage, arguments: quizId); +} diff --git a/lib/feature/listing_quiz/view/listing_quiz_view.dart b/lib/feature/listing_quiz/view/listing_quiz_view.dart new file mode 100644 index 0000000..e4f0b8a --- /dev/null +++ b/lib/feature/listing_quiz/view/listing_quiz_view.dart @@ -0,0 +1,62 @@ +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/quiz_container_component.dart'; +import 'package:quiz_app/feature/listing_quiz/controller/listing_quiz_controller.dart'; + +class ListingsQuizView extends GetView { + const ListingsQuizView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + centerTitle: true, + title: Obx( + () => Text( + controller.appBarTitle.value, + ), + ), + ), + body: Padding( + padding: const EdgeInsets.all(20.0), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.quizzes.isEmpty) { + return Center( + child: Text( + context.tr('no_quiz_available'), + textAlign: TextAlign.center, + ), + ); + } + + return ListView.builder( + controller: controller.scrollController, + itemCount: controller.quizzes.length + 1, // +1 untuk indikator loading + itemBuilder: (context, index) { + if (index == controller.quizzes.length) { + return Obx(() => controller.isLoadingMore.value + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox()); + } + + final quiz = controller.quizzes[index]; + return QuizContainerComponent( + data: quiz, + onTap: controller.goToDetailQuiz, + ); + }, + ); + })), + ); + } +} diff --git a/lib/feature/login/bindings/login_binding.dart b/lib/feature/login/bindings/login_binding.dart new file mode 100644 index 0000000..3e05bcc --- /dev/null +++ b/lib/feature/login/bindings/login_binding.dart @@ -0,0 +1,25 @@ +import 'package:get/get_core/get_core.dart'; +import 'package:get/get_instance/get_instance.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:quiz_app/feature/login/controllers/login_controller.dart'; + +class LoginBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => GoogleAuthService()); + Get.lazyPut(() => AuthService()); + Get.lazyPut( + () => LoginController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/login/controllers/login_controller.dart b/lib/feature/login/controllers/login_controller.dart new file mode 100644 index 0000000..c1cb0f8 --- /dev/null +++ b/lib/feature/login/controllers/login_controller.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/entity/user/user_entity.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/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; + +class LoginController extends GetxController { + final AuthService _authService; + final UserStorageService _userStorageService; + final UserController _userController; + final GoogleAuthService _googleAuthService; + final ConnectionService _connectionService; + + LoginController( + this._authService, + this._userStorageService, + this._userController, + this._googleAuthService, + this._connectionService, + ); + + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + final Rx isButtonEnabled = ButtonType.disabled.obs; + final RxBool isPasswordHidden = true.obs; + final RxBool isLoading = false.obs; + + late Worker _connectionWorker; + + @override + void onInit() { + super.onInit(); + emailController.addListener(validateFields); + passwordController.addListener(validateFields); + + _connectionWorker = ever(_connectionService.isConnected, (value) { + if (!value) { + ConnectionNotification.noInternedConnection(); + } else { + ConnectionNotification.internetConnected(); + } + }); + } + + @override + void onReady() { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + } + super.onReady(); + } + + void checkConnection() async {} + + void validateFields() { + final isEmailNotEmpty = emailController.text.trim().isNotEmpty; + final isPasswordNotEmpty = passwordController.text.trim().isNotEmpty; + isButtonEnabled.value = (isEmailNotEmpty && isPasswordNotEmpty) ? ButtonType.primary : ButtonType.disabled; + } + + void togglePasswordVisibility() { + isPasswordHidden.toggle(); + } + + /// **🔹 Login via Email & Password** + Future loginWithEmail() async { + final email = emailController.text.trim(); + final password = passwordController.text.trim(); + + if (email.isEmpty || password.isEmpty) { + Get.snackbar("Kesalahan", "Email dan kata sandi wajib diisi"); + return; + } + + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + try { + isLoading.value = true; + CustomFloatingLoading.showLoading(Get.overlayContext!); + + final LoginResponseModel response = await _authService.loginWithEmail( + LoginRequestModel(email: email, password: password), + ); + + final userEntity = _convertLoginResponseToUserEntity(response); + + await _userStorageService.saveUser(userEntity); + _userController.setUserFromEntity(userEntity); + _userStorageService.isLogged = true; + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + Get.offAllNamed(AppRoutes.mainPage); + } catch (e, stackTrace) { + logC.e(e, stackTrace: stackTrace); + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + CustomNotification.error(title: "Gagal", message: "Periksa kembali email dan kata sandi Anda"); + } + } + + Future loginWithGoogle() async { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + try { + CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; + final user = await _googleAuthService.signIn(); + if (user == null) { + Get.snackbar("Kesalahan", "Masuk dengan Google dibatalkan"); + + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + return; + } + + final idToken = await user.authentication.then((auth) => auth.idToken); + if (idToken == null || idToken.isEmpty) { + Get.snackbar("Kesalahan", "Tidak menerima ID Token dari Google"); + + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + return; + } + + final LoginResponseModel response = await _authService.loginWithGoogle(idToken); + final userEntity = _convertLoginResponseToUserEntity(response); + + await _userStorageService.saveUser(userEntity); + _userController.setUserFromEntity(userEntity); + _userStorageService.isLogged = true; + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + Get.offAllNamed(AppRoutes.mainPage); + } catch (e, stackTrace) { + logC.e("Google Sign-In Error: $e", stackTrace: stackTrace); + Get.snackbar("Error", "Google sign-in error"); + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + } + } + + void onGoBack() { + if (!isLoading.value) Get.back(); + } + + void goToRegsPage() => Get.toNamed(AppRoutes.registerPage); + + UserEntity _convertLoginResponseToUserEntity(LoginResponseModel response) { + logC.i("user data ${response.toJson()}"); + return UserEntity( + id: response.id ?? '', + name: response.name, + email: response.email, + picUrl: response.picUrl, + locale: response.locale, + birthDate: response.birthDate, + createdAt: response.createdAt, + phone: response.phone, + ); + } + + @override + void onClose() { + _connectionWorker.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/login/view/component/google_button.dart b/lib/feature/login/view/component/google_button.dart new file mode 100644 index 0000000..032c5e1 --- /dev/null +++ b/lib/feature/login/view/component/google_button.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class GoogleButton extends StatelessWidget { + final VoidCallback onPress; + const GoogleButton({super.key, required this.onPress}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onPress, + icon: Image.asset( + 'assets/logo/google_logo.png', + height: 24, + ), + label: const Text("Masuk dengan Google"), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ); + } +} diff --git a/lib/feature/login/view/component/register_text_button.dart b/lib/feature/login/view/component/register_text_button.dart new file mode 100644 index 0000000..3a36284 --- /dev/null +++ b/lib/feature/login/view/component/register_text_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class RegisterTextButton extends StatelessWidget { + final VoidCallback onTap; + const RegisterTextButton({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Belum punya akun? ", + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), + ), + GestureDetector( + onTap: onTap, + child: const Text( + "Daftar", + style: TextStyle( + fontSize: 14, + color: Color.fromARGB(255, 0, 122, 255), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} diff --git a/lib/feature/login/view/login_page.dart b/lib/feature/login/view/login_page.dart new file mode 100644 index 0000000..30633eb --- /dev/null +++ b/lib/feature/login/view/login_page.dart @@ -0,0 +1,91 @@ +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/app_name.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/login/controllers/login_controller.dart'; +import 'package:quiz_app/feature/login/view/component/google_button.dart'; +import 'package:quiz_app/feature/login/view/component/register_text_button.dart'; + +class LoginView extends GetView { + const LoginView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ListView( + children: [ + const SizedBox(height: 40), + const AppName(), + const SizedBox(height: 40), + LabelTextField( + label: context.tr("log_in"), + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF172B4D), + ), + const SizedBox(height: 24), + LabelTextField( + label: context.tr("email"), + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), + GlobalTextField( + controller: controller.emailController, + hintText: context.tr("enter_your_email"), + ), + const SizedBox(height: 20), + LabelTextField( + label: context.tr("password"), + color: Color(0xFF6B778C), + fontSize: 14, + ), + const SizedBox(height: 6), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + hintText: context.tr("enter_your_password"), + ), + ), + const SizedBox(height: 32), + Obx(() => GlobalButton( + onPressed: controller.loginWithEmail, + text: context.tr("sign_in"), + type: controller.isButtonEnabled.value, + )), + const SizedBox(height: 24), + LabelTextField( + label: context.tr("or"), + alignment: Alignment.center, + color: Color(0xFF6B778C), + ), + const SizedBox(height: 24), + GoogleButton( + onPress: controller.loginWithGoogle, + ), + const SizedBox(height: 32), + RegisterTextButton( + onTap: controller.goToRegsPage, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart b/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart new file mode 100644 index 0000000..6559586 --- /dev/null +++ b/lib/feature/monitor_quiz/binding/monitor_quiz_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; + +class MonitorQuizBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MonitorQuizController(Get.find())); + } +} diff --git a/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart new file mode 100644 index 0000000..b0a8cab --- /dev/null +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -0,0 +1,127 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class MonitorQuizController extends GetxController { + final SocketService _socketService; + + MonitorQuizController(this._socketService); + + String sessionCode = ""; + String sessionId = ""; + + RxString currentQuestion = "".obs; + RxList participan = [].obs; + + @override + void onInit() { + loadData(); + registerListener(); + super.onInit(); + } + + void loadData() { + final args = Get.arguments; + sessionCode = args["session_code"] ?? ""; + sessionId = args["session_id"] ?? ""; + + final List userList = (args["list_participan"] as List).map((e) => e as UserModel).toList(); + participan.assignAll( + userList.map( + (user) => ParticipantAnswerPoint( + id: user.id, + name: user.username, + ), + ), + ); + } + + void registerListener() { + _socketService.questionUpdate.listen((data) { + logC.i(data); + currentQuestion.value = data["question"]; + }); + + _socketService.scoreUpdates.listen((data) { + logC.i("📊 Score Update Received: $data"); + + // Ensure data is a valid map + // if (data is! Map) { + // logC.e("Invalid score update format: $data"); + // return; + // } + + // Parse the score data more carefully + final List scoreList = data['scores'] ?? []; + + for (var scoreData in scoreList) { + // Safely extract user ID and score information + final String? userId = scoreData['user_id']; + + if (userId == null) { + logC.w("Skipping score update with missing user ID"); + continue; + } + + // Find the index of the participant + final index = participan.indexWhere((p) => p.id == userId); + + if (index != -1) { + // Participant found, update their scores + final participant = participan[index]; + + // Safely extract correct and incorrect values, default to 0 + final int correct = scoreData['correct'] ?? 0; + final int incorrect = scoreData['incorrect'] ?? 0; + final int totalScore = scoreData['total_score'] ?? 0; + + // Update participant scores + participant.correct.value = correct; + participant.wrong.value = incorrect; + participant.totalScore.value = totalScore; // Assuming you have a totalScore observable + } else { + // Participant not found, add new participant + participan.add( + ParticipantAnswerPoint( + id: userId, + name: "Unknown", // Consider fetching proper name if possible + correct: (scoreData['correct'] ?? 0).obs, + wrong: (scoreData['incorrect'] ?? 0).obs, + totalScore: (scoreData['total_score'] ?? 0).obs, + ), + ); + } + } + + // Sort participants by total score (optional) + participan.sort((a, b) => b.totalScore.value.compareTo(a.totalScore.value)); + + // Notify observers + participan.refresh(); + }); + + _socketService.quizDone.listen((_) { + Get.offAllNamed(AppRoutes.monitorResultMPLPage, arguments: sessionId); + }); + } +} + +class ParticipantAnswerPoint { + final String id; + final String name; + final RxInt correct; + final RxInt wrong; + final RxInt totalScore; + + ParticipantAnswerPoint({ + required this.id, + required this.name, + RxInt? correct, + RxInt? wrong, + RxInt? totalScore, + }) : correct = correct ?? 0.obs, + wrong = wrong ?? 0.obs, + totalScore = totalScore ?? 0.obs; +} diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart new file mode 100644 index 0000000..6ebccb9 --- /dev/null +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:percent_indicator/linear_percent_indicator.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/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; + +class MonitorQuizView extends GetView { + const MonitorQuizView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Monitor Admin"), + Obx(() => _buildCurrentQuestion( + questionText: controller.currentQuestion.value, + )), + const SizedBox(height: 24), + _buildSectionHeader('Daftar Peserta'), + const SizedBox(height: 16), + Expanded( + child: Obx( + () => ListView.separated( + itemCount: controller.participan.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final student = controller.participan[index]; + final totalAnswers = student.correct.value + student.wrong.value; + final progressPercent = totalAnswers > 0 ? student.correct.value / totalAnswers : 0.0; + + return _buildStudentCard( + name: student.name, + totalBenar: student.correct.value, + totalSalah: student.wrong.value, + progressPercent: progressPercent, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + 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 _buildCurrentQuestion({required String questionText}) { + return Card( + elevation: 2, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.accentBlue, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.activity, + color: AppColors.primaryBlue, + size: 18, + ), + const SizedBox(width: 8), + Text( + "PERTANYAAN AKTIF", + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + letterSpacing: 0.8, + ), + ), + ], + ), + Divider(color: AppColors.borderLight, height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.helpCircle, + color: AppColors.darkText, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + questionText, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.darkText, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStudentCard({ + required String name, + required int totalBenar, + required int totalSalah, + required double progressPercent, + }) { + final int totalJawaban = totalBenar + totalSalah; + + return Card( + elevation: 2, + color: Colors.white, + shadowColor: AppColors.shadowPrimary.withValues(alpha: 0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppColors.accentBlue.withValues(alpha: 0.2)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.accentBlue, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : "?", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 4), + Text( + 'Total Jawaban: $totalJawaban', + style: TextStyle( + fontSize: 14, + color: AppColors.softGrayText, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + LinearPercentIndicator( + animation: true, + animationDuration: 600, + lineHeight: 12.0, + percent: progressPercent, + center: Text( + "${(progressPercent * 100).toInt()}%", + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + barRadius: const Radius.circular(8), + progressColor: _getProgressColor(progressPercent), + backgroundColor: AppColors.disabledBackground, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: _buildStatCard( + icon: LucideIcons.checkCircle, + color: const Color(0xFF36B37E), + label: "Benar", + value: totalBenar, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: LucideIcons.xCircle, + color: const Color(0xFFFF5630), + label: "Salah", + value: totalSalah, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required Color color, + required String label, + required int value, + }) { + return Container( + width: 130, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + ), + ), + Text( + value.toString(), + style: TextStyle( + fontSize: 18, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + } + + Color _getProgressColor(double percent) { + if (percent < 0.4) return const Color(0xFFFF5630); // Red + if (percent < 0.7) return const Color(0xFFFF991F); // Orange + return const Color(0xFF36B37E); // Green + } +} diff --git a/lib/feature/navigation/bindings/navigation_binding.dart b/lib/feature/navigation/bindings/navigation_binding.dart new file mode 100644 index 0000000..5d39678 --- /dev/null +++ b/lib/feature/navigation/bindings/navigation_binding.dart @@ -0,0 +1,11 @@ +// feature/navbar/binding/navbar_binding.dart +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; + +class NavbarBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => NavigationController(Get.find())); + } +} diff --git a/lib/feature/navigation/controllers/navigation_controller.dart b/lib/feature/navigation/controllers/navigation_controller.dart new file mode 100644 index 0000000..ffef10a --- /dev/null +++ b/lib/feature/navigation/controllers/navigation_controller.dart @@ -0,0 +1,36 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; + +class NavigationController extends GetxController { + RxInt selectedIndex = 0.obs; + + final ConnectionService _connectionService; + + NavigationController(this._connectionService); + + @override + void onInit() { + super.onInit(); + final args = Get.arguments; + if (args != null && args is int) { + selectedIndex.value = args; + } + } + + @override + void onReady() { + ever(_connectionService.isConnected, (value) { + if (!value) { + ConnectionNotification.noInternedConnection(); + } else { + ConnectionNotification.internetConnected(); + } + }); + super.onReady(); + } + + void changePage(int page) { + selectedIndex.value = page; + } +} diff --git a/lib/feature/navigation/views/navbar_view.dart b/lib/feature/navigation/views/navbar_view.dart new file mode 100644 index 0000000..7551ee5 --- /dev/null +++ b/lib/feature/navigation/views/navbar_view.dart @@ -0,0 +1,68 @@ +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/feature/history/view/history_view.dart'; +import 'package:quiz_app/feature/home/view/home_page.dart'; +import 'package:quiz_app/feature/library/view/library_view.dart'; +import 'package:quiz_app/feature/navigation/controllers/navigation_controller.dart'; +import 'package:quiz_app/feature/profile/view/profile_view.dart'; +import 'package:quiz_app/feature/search/view/search_view.dart'; + +class NavbarView extends GetView { + const NavbarView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Obx(() { + switch (controller.selectedIndex.value) { + case 0: + return const HomeView(); + case 1: + return const SearchView(); + case 2: + return const LibraryView(); + case 3: + return const HistoryView(); + case 4: + return const ProfileView(); + default: + return const HomeView(); + } + }), + bottomNavigationBar: Obx( + () => BottomNavigationBar( + fixedColor: AppColors.primaryBlue, + backgroundColor: AppColors.background2, + type: BottomNavigationBarType.fixed, + currentIndex: controller.selectedIndex.value, + onTap: controller.changePage, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: context.tr('nav_home'), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.search), + label: context.tr('nav_search'), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.menu_book), + label: context.tr('nav_library'), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.history), + label: context.tr('nav_history'), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.person), + label: context.tr('nav_profile'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart b/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart new file mode 100644 index 0000000..5c92b1f --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; + +class PlayQuizMultiplayerBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => PlayQuizMultiplayerController(Get.find(), Get.find())); + } +} diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart new file mode 100644 index 0000000..083973b --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class PlayQuizMultiplayerController extends GetxController { + final SocketService _socketService; + final UserController _userController; + PlayQuizMultiplayerController(this._socketService, this._userController); + + final Rxn currentQuestion = Rxn(); + final currentQuestionIndex = 0.obs; + final selectedAnswer = Rxn(); + final isDone = false.obs; + final Rx buttonType = ButtonType.disabled.obs; + final fillInAnswerController = TextEditingController(); + RxBool isASentAns = false.obs; + + // Timer related variables + final RxInt remainingTime = 0.obs; + Timer? _timer; + + late final String sessionId; + late final bool isAdmin; + + @override + void onInit() { + _loadData(); + _registerListener(); + super.onInit(); + } + + _loadData() { + final args = Get.arguments as Map; + sessionId = args["session_id"]; + isAdmin = args["is_admin"]; + } + + _registerListener() { + fillInAnswerController.addListener(() { + final text = fillInAnswerController.text; + if (text.isNotEmpty) { + buttonType.value = ButtonType.primary; + } else { + buttonType.value = ButtonType.disabled; + } + }); + + _socketService.questionUpdate.listen((data) { + buttonType.value = ButtonType.disabled; + fillInAnswerController.clear(); + isASentAns.value = false; + selectedAnswer.value = null; + + final model = MultiplayerQuestionModel.fromJson(Map.from(data)); + currentQuestion.value = model; + + _startTimer(model.duration); + }); + + _socketService.quizDone.listen((_) { + isDone.value = true; + _cancelTimer(); + }); + } + + // Start timer with the question duration + void _startTimer(int duration) { + // Cancel any existing timer + _cancelTimer(); + + // Set initial remaining time in seconds + remainingTime.value = duration; + + // Create a timer that ticks every second + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (remainingTime.value > 0) { + remainingTime.value--; + } else { + // Time's up - cancel the timer + _cancelTimer(); + + // Auto-submit if the user hasn't already submitted an answer + if (!isASentAns.value) { + submitAnswer(); + } + } + }); + } + + // Cancel the timer + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + void selectOptionAnswer(int choosenIndex) { + selectedAnswer.value = choosenIndex.toString(); + buttonType.value = ButtonType.primary; + } + + void selectTrueFalseAnswer(bool value) { + selectedAnswer.value = value.toString(); + buttonType.value = ButtonType.primary; + } + + void submitAnswer() { + final question = currentQuestion.value!; + final type = question.type; + String? answer; + + if (type == 'fill_the_blank') { + answer = fillInAnswerController.text.trim(); + } else { + answer = selectedAnswer.value; + } + + if (answer != null && answer.isNotEmpty) { + _socketService.sendAnswer( + sessionId: sessionId, + userId: _userController.userData!.id, + questionIndex: question.questionIndex, + answer: answer, + timeSpent: question.duration - remainingTime.value, + ); + isASentAns.value = true; + } + } + + void goToDetailResult() { + Get.offAllNamed(AppRoutes.quizMPLResultPage, arguments: { + "user_id": _userController.userData!.id, + "session_id": sessionId, + "username": _userController.userName.value, + "is_admin": false, + }); + } + + @override + void onClose() { + fillInAnswerController.dispose(); + _cancelTimer(); + super.onClose(); + } +} + +class MultiplayerQuestionModel { + final int questionIndex; + final String question; + final String type; + final int duration; + final List? options; + + MultiplayerQuestionModel({ + required this.questionIndex, + required this.question, + required this.type, + required this.duration, + this.options, + }); + + factory MultiplayerQuestionModel.fromJson(Map json) { + return MultiplayerQuestionModel( + questionIndex: json['index'], + question: json['question'], + type: json['type'], + duration: json['duration'], + options: json['options'] != null ? List.from(json['options']) : null, + ); + } + + Map toJson() { + return { + 'question_index': questionIndex, + 'question': question, + 'type': type, + 'options': options, + }; + } +} diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart new file mode 100644 index 0000000..f685ff3 --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -0,0 +1,648 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; + +class PlayQuizMultiplayerView extends GetView { + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: Obx(() { + if (controller.isDone.value) { + return _buildDoneView(); + } + + if (controller.currentQuestion.value == null) { + return _buildLoadingView(); + } + + return _buildQuestionView(); + }), + ), + ); + } + + Widget _buildLoadingView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 1500), + builder: (context, value, child) { + return Transform.rotate( + angle: value * 6.28, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFF2563EB), + width: 4, + ), + ), + child: const Center( + child: Icon( + Icons.quiz, + color: Color(0xFF2563EB), + size: 30, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 20), + const Text( + "Memuat soal...", + style: TextStyle( + fontSize: 16, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildQuestionView() { + final question = controller.currentQuestion.value!; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomAppBar(question), + const SizedBox(height: 20), + _buildProgressSection(question), + const SizedBox(height: 20), + _buildQuestionCard(question), + const SizedBox(height: 30), + Expanded(child: _buildAnswerSection()), + _buildSubmitButton(), + ], + ), + ), + ); + } + + Widget _buildCustomAppBar(dynamic question) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF2563EB).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + "Soal ${question.questionIndex}/10", + style: const TextStyle( + color: Color(0xFF2563EB), + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Row( + children: [ + Icon( + Icons.people, + size: 20, + color: const Color(0xFF2563EB), + ), + const SizedBox(width: 4), + Text( + "Multiplayer", + style: TextStyle( + color: const Color(0xFF2563EB), + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + Obx(() => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: controller.remainingTime.value <= 10 ? Colors.red.withOpacity(0.1) : const Color(0xFF2563EB).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 16, + color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + ), + const SizedBox(width: 4), + Text( + "${controller.remainingTime.value}s", + style: TextStyle( + color: controller.remainingTime.value <= 10 ? Colors.red : const Color(0xFF2563EB), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + )), + ], + ), + ); + } + + Widget _buildProgressSection(dynamic question) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Progress", + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + "${(question.questionIndex * 10).toInt()}%", + style: const TextStyle( + fontSize: 14, + color: Color(0xFF2563EB), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: question.questionIndex / 10, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF1E40AF)], + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: controller.remainingTime.value / question.duration, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: controller.remainingTime.value > question.duration * 0.3 + ? Colors.green + : controller.remainingTime.value > question.duration * 0.1 + ? Colors.orange + : Colors.red, + ), + ), + ), + )), + ], + ); + } + + Widget _buildQuestionCard(dynamic question) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0.3, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Container( + key: ValueKey(question.question), + padding: const EdgeInsets.all(20), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Text( + question.question, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + height: 1.4, + ), + ), + ), + ); + } + + Widget _buildAnswerSection() { + return Obx(() { + if (controller.isASentAns.value) { + return _buildWaitingView(); + } + + final question = controller.currentQuestion.value!; + + if (question.type == 'option') return _buildOptionQuestion(); + if (question.type == 'fill_the_blank') return _buildFillInBlankQuestion(); + if (question.type == 'true_false') return _buildTrueFalseQuestion(); + + return const SizedBox(); + }); + } + + Widget _buildWaitingView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 2000), + builder: (context, value, child) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.green.withOpacity(0.1), + border: Border.all( + color: Colors.green, + width: 3, + ), + ), + child: Transform.scale( + scale: 0.8 + (value * 0.2), + child: const Icon( + Icons.check_circle, + size: 40, + color: Colors.green, + ), + ), + ); + }, + ), + const SizedBox(height: 24), + const Text( + "Jawaban Terkirim!", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + const SizedBox(height: 8), + Text( + "Menunggu soal selanjutnya...", + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + Container( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.grey[400]!), + ), + ), + ], + ), + ); + } + + Widget _buildOptionQuestion() { + final options = controller.currentQuestion.value!.options; + return Column( + children: List.generate(options!.length, (index) { + return _buildOptionButton(options[index], index); + }), + ); + } + + Widget _buildOptionButton(String option, int index) { + return Obx(() { + final isSelected = controller.selectedAnswer.value == index.toString(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => controller.selectOptionAnswer(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2563EB) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? const Color(0xFF2563EB) : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? Colors.white : Colors.transparent, + border: Border.all( + color: isSelected ? Colors.white : Colors.grey, + width: 2, + ), + ), + child: isSelected ? const Icon(Icons.check, size: 16, color: Color(0xFF2563EB)) : null, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget _buildFillInBlankQuestion() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Masukkan jawaban Anda:", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 16), + GlobalTextField(controller: controller.fillInAnswerController), + ], + ), + ); + } + + Widget _buildTrueFalseQuestion() { + return Column( + children: [ + Row( + children: [ + Expanded( + child: _buildTrueFalseButton( + 'Ya', + true, + Icons.check_circle, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildTrueFalseButton( + 'Tidak', + false, + Icons.cancel, + Colors.red, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTrueFalseButton(String label, bool value, IconData icon, Color color) { + return Obx(() { + final isSelected = controller.selectedAnswer.value == value.toString(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 120, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => controller.selectTrueFalseAnswer(value), + child: Container( + decoration: BoxDecoration( + color: isSelected ? color.withOpacity(0.1) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? color : Colors.grey.shade300, + width: isSelected ? 3 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? color : Colors.grey.shade100, + ), + child: Icon( + icon, + size: 32, + color: isSelected ? Colors.white : Colors.grey, + ), + ), + const SizedBox(height: 12), + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected ? color : Colors.black87, + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget _buildSubmitButton() { + return Obx(() { + if (controller.isASentAns.value) { + return const SizedBox.shrink(); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(top: 20), + child: GlobalButton( + text: "Kirim Jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, + ), + ); + }); + } + + Widget _buildDoneView() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF2563EB).withOpacity(0.1), + border: Border.all( + color: const Color(0xFF2563EB), + width: 4, + ), + ), + child: const Icon( + Icons.emoji_events, + size: 60, + color: Color(0xFF2563EB), + ), + ), + ); + }, + ), + const SizedBox(height: 32), + const Text( + "Kuis Selesai!", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 16), + Text( + "Terima kasih telah berpartisipasi dalam kuis multiplayer.", + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + Container( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: controller.goToDetailResult, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 6, + ), + icon: const Icon(Icons.assessment), + label: const Text( + "Lihat Hasil", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/profile/binding/profile_binding.dart b/lib/feature/profile/binding/profile_binding.dart new file mode 100644 index 0000000..9da327e --- /dev/null +++ b/lib/feature/profile/binding/profile_binding.dart @@ -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/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; + +class ProfileBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => GoogleAuthService()); + if (!Get.isRegistered()) Get.lazyPut(() => UserService()); + Get.lazyPut(() => ProfileController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/profile/binding/update_profile_binding.dart b/lib/feature/profile/binding/update_profile_binding.dart new file mode 100644 index 0000000..a09dbe9 --- /dev/null +++ b/lib/feature/profile/binding/update_profile_binding.dart @@ -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/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; + +class UpdateProfileBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => UserService()); + Get.lazyPut(() => UpdateProfileController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/profile/controller/profile_controller.dart b/lib/feature/profile/controller/profile_controller.dart new file mode 100644 index 0000000..4fd78d8 --- /dev/null +++ b/lib/feature/profile/controller/profile_controller.dart @@ -0,0 +1,190 @@ +import 'dart:ui'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/user/user_stat_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/google_auth_service.dart'; +import 'package:quiz_app/data/services/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ProfileController extends GetxController { + final UserController _userController; + final UserStorageService _userStorageService; + final GoogleAuthService _googleAuthService; + final UserService _userService; + final ConnectionService _connectionService; + + ProfileController( + this._userController, + this._userStorageService, + this._googleAuthService, + this._userService, + this._connectionService, + ); + + // User basic info + Rx get userName => _userController.userName; + Rx get email => _userController.email; + Rx get userImage => _userController.userImage; + Rx data = Rx(null); + + Rx birthDate = "".obs; + Rx phoneNumber = "".obs; + Rx location = "".obs; + Rx joinDate = "".obs; + Rx education = "".obs; + Rx profileImage = null.obs; + + // App settings + Rx notificationsEnabled = true.obs; + Rx darkModeEnabled = false.obs; + Rx soundEffectsEnabled = true.obs; + + // App info + Rx appName = "Quiz App".obs; + Rx appVersion = "1.0.2".obs; + Rx appDescription = "An educational quiz app to test and improve your knowledge across various subjects.".obs; + Rx companyName = "Genso Inc.".obs; + + // URLs for legal pages + final String privacyPolicyUrl = "${APIEndpoint.baseUrl}/privacy-policy"; + final String termsOfServiceUrl = "${APIEndpoint.baseUrl}/terms-of-service"; + final String helpCenterUrl = "${APIEndpoint.baseUrl}/help-center"; + final String supportEmail = "support@quizmaster.com"; + + @override + void onInit() { + super.onInit(); + loadUserStat(); + loadUserProfileData(); + } + + void loadUserProfileData() { + try { + birthDate.value = _userController.userData?.birthDate ?? ""; + phoneNumber.value = _userController.userData?.phone ?? ""; + joinDate.value = _userController.userData?.createdAt ?? ""; + } catch (e, stackTrace) { + logC.e("Failed to load user profile data: $e", stackTrace: stackTrace); + } + } + + void loadUserStat() async { + if (!await _connectionService.isHaveConnection()) { + return; + } + try { + final result = await _userService.getUserStat(_userController.userData!.id); + if (result != null) { + data.value = result.data; + } + } catch (e, stackTrace) { + logC.e("Failed to load user stat: $e", stackTrace: stackTrace); + } + } + + void logout(BuildContext context) async { + final confirm = await AppDialog.showConfirmationDialog( + context, + title: "Keluar dari akun?", + message: "Apakah Anda yakin ingin logout dari akun ini?", + confirmText: "Logout", + ); + + if (confirm == true) { + try { + await _googleAuthService.signOut(); + await _userStorageService.clearUser(); + _userController.clearUser(); + _userStorageService.isLogged = false; + Get.offAllNamed(AppRoutes.loginPage); + } catch (e, stackTrace) { + logC.e("Google Sign-Out Error: $e", stackTrace: stackTrace); + Get.snackbar("Error", "Gagal logout dari Google"); + } + } + } + + void editProfile() async { + final resultUpdate = await Get.toNamed(AppRoutes.updateProfilePage); + + if (resultUpdate == true) { + loadUserProfileData(); + } + } + + void changeLanguage(BuildContext context, String languageCode, String countryCode) async { + final locale = Locale(languageCode, countryCode); + await context.setLocale(locale); + Get.updateLocale(locale); + } + + // Settings methods + void toggleNotifications() { + notificationsEnabled.value = !notificationsEnabled.value; + // In a real app, you would save this preference to storage + } + + void toggleDarkMode() { + darkModeEnabled.value = !darkModeEnabled.value; + // In a real app, you would update the theme and save preference + } + + void toggleSoundEffects() { + soundEffectsEnabled.value = !soundEffectsEnabled.value; + // In a real app, you would save this preference to storage + } + + void clearCache() { + // Implement cache clearing logic + Get.snackbar( + "Success", + "Cache cleared successfully", + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Legal and support methods + void openPrivacyPolicy() async { + await _launchUrl(privacyPolicyUrl); + } + + void openTermsOfService() async { + await _launchUrl(termsOfServiceUrl); + } + + void openHelpCenter() async { + await _launchUrl(helpCenterUrl); + } + + void contactSupport() async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: supportEmail, + query: 'subject=Support Request&body=Hello, I need help with...', + ); + await _launchUrl(emailUri.toString()); + } + + Future _launchUrl(String urlString) async { + try { + final url = Uri.parse(urlString); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $url'); + } + } catch (e) { + Get.snackbar( + "Error", + "Could not open the link", + snackPosition: SnackPosition.BOTTOM, + ); + } + } +} diff --git a/lib/feature/profile/controller/update_profile_controller.dart b/lib/feature/profile/controller/update_profile_controller.dart new file mode 100644 index 0000000..f8beac4 --- /dev/null +++ b/lib/feature/profile/controller/update_profile_controller.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/entity/user/user_entity.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/user_service.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; + +class UpdateProfileController extends GetxController { + final UserController _userController; + final UserStorageService _userStorageService; + final UserService _userService; + final ConnectionService _connectionService; + + UpdateProfileController( + this._userService, + this._userController, + this._userStorageService, + this._connectionService, + ); + + final nameController = TextEditingController(); + final phoneController = TextEditingController(); + final birthDateController = TextEditingController(); + + var selectedLocale = 'en-US'.obs; + + RxBool isLoading = false.obs; + + final Map localeMap = { + 'English': 'en-US', + 'Indonesian': 'id-ID', + 'French': 'fr-FR', + 'Spanish': 'es-ES', + }; + + @override + void onInit() { + final userData = _userController.userData!; + nameController.text = userData.name; + phoneController.text = userData.phone ?? ''; + birthDateController.text = userData.birthDate ?? ''; + super.onInit(); + } + + bool _validateInputs() { + final name = nameController.text.trim(); + final phone = phoneController.text.trim(); + final birthDate = birthDateController.text.trim(); + + if (name.isEmpty || phone.isEmpty || birthDate.isEmpty) { + CustomNotification.error( + title: 'Validation Error', + message: 'All fields must be filled.', + ); + return false; + } + + if (!_isValidDateFormat(birthDate)) { + CustomNotification.error( + title: 'Validation Error', + message: 'birth date must valid.', + ); + return false; + } + return true; + } + + Future saveProfile() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + + if (!_validateInputs()) return; + + try { + CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; + final isSuccessUpdate = await _userService.updateProfileData( + _userController.userData!.id, + nameController.text.trim(), + birthDate: birthDateController.text.trim(), + phone: phoneController.text.trim(), + locale: selectedLocale.value, + ); + + if (isSuccessUpdate) { + final response = await _userService.getUserData(_userController.userData!.id); + + if (response?.data != null) { + final userNew = response!.data!; + final newUser = UserEntity( + id: userNew.id, + email: userNew.email, + name: userNew.name, + birthDate: userNew.birthDate, + locale: userNew.locale, + picUrl: userNew.picUrl, + phone: userNew.phone, + createdAt: userNew.createdAt, + ); + + _userStorageService.saveUser(newUser); + _userController.userData = newUser; + + _userController.email.value = userNew.email; + _userController.userName.value = userNew.name; + _userController.userImage.value = userNew.picUrl; + } + } + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + + Get.back(result: true); + CustomNotification.success(title: "Success", message: "Profile updated successfully"); + } catch (e) { + CustomNotification.success(title: "something wrong", message: "failed to update profile"); + isLoading.value = false; + logC.e(e); + } + } + + bool _isValidDateFormat(String date) { + final regex = RegExp(r'^([0-2][0-9]|(3)[0-1])\-((0[1-9])|(1[0-2]))\-\d{4}$'); + return regex.hasMatch(date); + } + + void onGoBack() { + if (!isLoading.value) Get.back(); + } +} diff --git a/lib/feature/profile/view/components/info_row_card.dart b/lib/feature/profile/view/components/info_row_card.dart new file mode 100644 index 0000000..00fd865 --- /dev/null +++ b/lib/feature/profile/view/components/info_row_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; + +class InfoRow extends StatelessWidget { + const InfoRow({super.key, required this.icon, required this.label, required this.value}); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: AppColors.primaryBlue), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppTextStyles.caption), + Text(value, style: AppTextStyles.body), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/profile/view/components/section_header_card.dart b/lib/feature/profile/view/components/section_header_card.dart new file mode 100644 index 0000000..b91f9e5 --- /dev/null +++ b/lib/feature/profile/view/components/section_header_card.dart @@ -0,0 +1,35 @@ +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'; + +class SectionHeader extends StatelessWidget { + const SectionHeader({super.key, required this.title, required this.icon, this.onEdit}); + + final String title; + final IconData icon; + final VoidCallback? onEdit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: AppColors.primaryBlue), + const SizedBox(width: 8), + Text(title, style: AppTextStyles.subtitle.copyWith(fontWeight: FontWeight.bold)), + ], + ), + if (onEdit != null) + IconButton( + icon: Icon(LucideIcons.edit, color: AppColors.primaryBlue), + onPressed: onEdit, + tooltip: context.tr('edit_profile'), + ), + ], + ); + } +} diff --git a/lib/feature/profile/view/profile_view.dart b/lib/feature/profile/view/profile_view.dart new file mode 100644 index 0000000..3cf95f1 --- /dev/null +++ b/lib/feature/profile/view/profile_view.dart @@ -0,0 +1,271 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; +import 'package:quiz_app/component/app_name.dart'; +import 'package:quiz_app/feature/profile/controller/profile_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/profile/view/components/info_row_card.dart'; +import 'package:quiz_app/feature/profile/view/components/section_header_card.dart'; + +class ProfileView extends GetView { + const ProfileView({super.key}); + + @override + Widget build(BuildContext context) { + const cardRadius = BorderRadius.all(Radius.circular(20)); + + return Scaffold( + backgroundColor: AppColors.background2, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Obx( + () => SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + _avatar(), + // const SizedBox(height: 16), + // _userHeader(), + const SizedBox(height: 24), + _statsCard(cardRadius: cardRadius), + const SizedBox(height: 10), + _profileDetails(cardRadius: cardRadius), + const SizedBox(height: 10), + _settingsSection(context, cardRadius: cardRadius), + const SizedBox(height: 10), + _legalSection(cardRadius: cardRadius), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ), + ); + } + + // -------------------- UTILITY WIDGETS -------------------- // + + Widget _statChip(String label, String value) => Column( + children: [ + Text(value, style: AppTextStyles.statValue), + const SizedBox(height: 4), + Text(label, style: AppTextStyles.caption), + ], + ); + + Widget _settingsTile( + BuildContext context, { + required IconData icon, + required String title, + VoidCallback? onTap, + Color? iconColor, + Color? textColor, + }) { + final primary = iconColor ?? AppColors.primaryBlue; + return ListTile( + leading: Icon(icon, color: primary, size: 22), + title: Text(title, style: AppTextStyles.optionText.copyWith(color: textColor ?? AppColors.darkText)), + trailing: Icon(LucideIcons.chevronRight, color: AppColors.softGrayText, size: 18), + onTap: onTap, + dense: true, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ); + } + + // -------------------- SECTIONS -------------------- // + + Widget _avatar() => Center( + child: CircleAvatar( + radius: 50, + backgroundColor: AppColors.accentBlue, + foregroundImage: controller.userImage.value != null ? NetworkImage(controller.userImage.value!) : null, + child: controller.userImage.value == null ? Icon(LucideIcons.user, color: AppColors.primaryBlue, size: 40) : null, + ), + ); + + // Widget _userHeader() => Column( + // children: [ + // Text(controller.userName.value, style: AppTextStyles.title), + // Text(controller.email.value, style: AppTextStyles.subtitle), + // ], + // ); + + Widget _statsCard({required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _statChip(tr('total_quiz'), controller.data.value?.totalQuiz.toString() ?? '0'), + _statChip(tr('total_solve'), controller.data.value?.totalSolve.toString() ?? '0'), + _statChip(tr('avg_score'), '${controller.data.value?.avgScore ?? 100}%'), + ], + ), + ), + ); + + Widget _profileDetails({required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + child: Column( + children: [ + SectionHeader( + title: tr('personal_info'), + icon: LucideIcons.userCog, + onEdit: controller.editProfile, + ), + const Divider(height: 1), + const SizedBox(height: 20), + InfoRow(icon: LucideIcons.user, label: tr('full_name'), value: controller.userName.value), + InfoRow( + icon: LucideIcons.cake, + label: tr('birth_date'), + value: controller.birthDate.value ?? tr('not_set'), + ), + InfoRow( + icon: LucideIcons.phone, + label: tr('phone'), + value: controller.phoneNumber.value ?? tr('not_set'), + ), + // InfoRow( + // icon: LucideIcons.mapPin, + // label: tr('location'), + // value: controller.location.value ?? tr('not_set'), + // ), + InfoRow( + icon: LucideIcons.calendar, + label: tr('joined'), + value: controller.joinDate.value ?? tr('not_available'), + ), + // InfoRow( + // icon: LucideIcons.graduationCap, + // label: tr('education'), + // value: controller.education.value ?? tr('not_set'), + // ), + ], + ), + ), + ); + + Widget _settingsSection(BuildContext context, {required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), + child: SectionHeader(title: tr('settings'), icon: LucideIcons.settings), + ), + const Divider(height: 1), + _settingsTile(Get.context!, icon: LucideIcons.languages, title: tr('change_language'), onTap: () => _showLanguageDialog(Get.context!)), + _settingsTile(Get.context!, + icon: LucideIcons.logOut, title: tr('logout'), iconColor: Colors.red, textColor: Colors.red, onTap: () => controller.logout(context)), + ], + ), + ), + ); + + Widget _legalSection({required BorderRadius cardRadius}) => Card( + color: Colors.white, + elevation: 1, + shadowColor: AppColors.shadowPrimary, + shape: RoundedRectangleBorder(borderRadius: cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10), + child: SectionHeader(title: tr('legal_and_support'), icon: LucideIcons.shieldQuestion), + ), + const Divider(height: 1), + _settingsTile(Get.context!, icon: LucideIcons.shield, title: tr('privacy_policy'), onTap: controller.openPrivacyPolicy), + _settingsTile(Get.context!, icon: LucideIcons.fileText, title: tr('terms_of_service'), onTap: controller.openTermsOfService), + _settingsTile(Get.context!, icon: LucideIcons.helpCircle, title: tr('help_center'), onTap: controller.openHelpCenter), + _settingsTile(Get.context!, icon: LucideIcons.mail, title: tr('contact_us'), onTap: controller.contactSupport), + _settingsTile(Get.context!, icon: LucideIcons.info, title: tr('about_app'), onTap: () => _showAboutAppDialog(Get.context!)), + ], + ), + ), + ); + + void _showLanguageDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(tr('select_language'), style: AppTextStyles.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('English'), + onTap: () { + controller.changeLanguage(context, 'en', 'US'); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('Bahasa Indonesia'), + onTap: () { + controller.changeLanguage(context, 'id', 'ID'); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: Icon(LucideIcons.languages, color: AppColors.primaryBlue), + title: const Text('Malaysia'), + onTap: () { + controller.changeLanguage(context, 'ms', 'MY'); + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } + + void _showAboutAppDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppName(), + const SizedBox(height: 16), + Text(controller.appName.value, style: AppTextStyles.title), + Text("${tr('version')}: ${controller.appVersion.value}", style: AppTextStyles.caption), + const SizedBox(height: 16), + Text(controller.appDescription.value, textAlign: TextAlign.center, style: AppTextStyles.body), + const SizedBox(height: 16), + Text('© ${DateTime.now().year} ${controller.companyName.value}', style: AppTextStyles.caption), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(tr('close'), style: AppTextStyles.optionText)), + ], + ), + ); + } +} diff --git a/lib/feature/profile/view/update_profile_view.dart b/lib/feature/profile/view/update_profile_view.dart new file mode 100644 index 0000000..291c32e --- /dev/null +++ b/lib/feature/profile/view/update_profile_view.dart @@ -0,0 +1,74 @@ +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/global_dropdown_field.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/profile/controller/update_profile_controller.dart'; + +class UpdateProfilePage extends GetView { + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(), + child: Scaffold( + backgroundColor: AppColors.background2, + appBar: AppBar( + backgroundColor: AppColors.background2, + title: Text('Update Profile'), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + LabelTextField(label: "Name"), + GlobalTextField(controller: controller.nameController), + SizedBox(height: 16), + LabelTextField(label: "Phone"), + GlobalTextField( + controller: controller.phoneController, + hintText: 'Enter your phone number', + ), + SizedBox(height: 16), + LabelTextField(label: "Birth Date"), + GlobalTextField( + controller: controller.birthDateController, + hintText: 'Enter your birth date', + ), + SizedBox(height: 16), + LabelTextField(label: "Locale"), + Obx(() => GlobalDropdownField( + value: controller.selectedLocale.value, + items: controller.localeMap.entries.map>((entry) { + return DropdownMenuItem( + value: entry.value, + child: Text(entry.key), // Display country name + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + controller.selectedLocale.value = newValue; + final parts = newValue.split('-'); + if (parts.length == 2) { + Get.updateLocale(Locale(parts[0], parts[1])); + } else { + Get.updateLocale(Locale(newValue)); + } + } + }, + )), + SizedBox(height: 32), + Center( + child: GlobalButton(text: tr("save_changes"), onPressed: controller.saveProfile), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/quiz_creation/binding/quiz_creation_binding.dart b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart new file mode 100644 index 0000000..64de799 --- /dev/null +++ b/lib/feature/quiz_creation/binding/quiz_creation_binding.dart @@ -0,0 +1,17 @@ +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/quiz_creation/controller/quiz_creation_controller.dart"; + +class QuizCreationBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizService()); + Get.lazyPut( + () => QuizCreationController( + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/quiz_creation/controller/quiz_creation_controller.dart b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart new file mode 100644 index 0000000..c33a4ab --- /dev/null +++ b/lib/feature/quiz_creation/controller/quiz_creation_controller.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/question_type.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/delete_confirmation.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.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/quiestion_data_model.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; + +class QuizCreationController extends GetxController { + final QuizService _quizService; + final ConnectionService _connectionService; + QuizCreationController( + this._quizService, + this._connectionService, + ); + + final TextEditingController inputSentenceTC = TextEditingController(); + final TextEditingController questionTC = TextEditingController(); + final TextEditingController answerTC = TextEditingController(); + final List optionTCList = List.generate(4, (_) => TextEditingController()); + final RxInt selectedOptionIndex = 0.obs; + + RxBool isGenerate = true.obs; + Rx currentQuestionType = QuestionType.fillTheBlank.obs; + RxList quizData = [QuestionData(index: 1, type: QuestionType.fillTheBlank)].obs; + RxInt selectedQuizIndex = 0.obs; + + RxInt currentDuration = 30.obs; + + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + _initializeListeners(); + } + + void _initializeListeners() { + // Listener untuk pertanyaan + questionTC.addListener(() { + if (quizData.isNotEmpty) { + _updateCurrentQuestion(question: questionTC.text); + } + }); + + // Listener untuk jawaban langsung (Fill the Blank atau True/False) + answerTC.addListener(() { + if (quizData.isNotEmpty && currentQuestionType.value != QuestionType.option) { + _updateCurrentQuestion(answer: answerTC.text); + } + }); + + // Listener untuk masing-masing pilihan opsi + for (var i = 0; i < optionTCList.length; i++) { + optionTCList[i].addListener(() { + if (quizData.isNotEmpty && currentQuestionType.value == QuestionType.option) { + _updateCurrentQuestion( + options: List.generate( + optionTCList.length, + (index) => OptionData(index: index, text: optionTCList[index].text), + ), + ); + } + }); + } + + // Listener perubahan tipe soal + ever(currentQuestionType, (type) { + if (quizData.isNotEmpty) { + if (type == QuestionType.option) { + _updateCurrentQuestion(type: type, correctAnswerIndex: 0); + } else { + _updateCurrentQuestion(type: type, correctAnswerIndex: null); + } + } + }); + + // Listener perubahan jawaban benar (untuk pilihan ganda) + ever(selectedOptionIndex, (index) { + if (quizData.isNotEmpty && currentQuestionType.value == QuestionType.option) { + _updateCurrentQuestion(correctAnswerIndex: index); + } + }); + } + + void onCreationTypeChange(bool value) { + isGenerate.value = value; + } + + void onQuestionTypeChange(QuestionType type) { + currentQuestionType.value = type; + } + + void onQuestionAdd() { + quizData.add(QuestionData(index: quizData.length + 1)); + } + + void onSelectedQuizItem(int index) { + selectedQuizIndex.value = index; + final data = quizData[index]; + + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + + currentDuration.value = data.duration; + + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + if (currentQuestionType.value == QuestionType.option) { + for (int i = 0; i < optionTCList.length; i++) { + optionTCList[i].text = data.options?[i].text ?? ""; + } + } else { + cleanInput(); + } + if (data.options != null && data.options!.isNotEmpty) { + for (int i = 0; i < optionTCList.length; i++) { + optionTCList[i].text = data.options!.length > i ? data.options![i].text : ''; + } + selectedOptionIndex.value = data.correctAnswerIndex ?? 0; + } else { + for (var controller in optionTCList) { + controller.clear(); + } + selectedOptionIndex.value = 0; + } + } + + void cleanInput() { + for (final controller in optionTCList) { + controller.clear(); + } + } + + void _updateCurrentQuestion({String? question, String? answer, List? options, int? correctAnswerIndex, QuestionType? type, int? duration}) { + final current = quizData[selectedQuizIndex.value]; + quizData[selectedQuizIndex.value] = current.copyWith( + question: question, + answer: answer, + options: options, + correctAnswerIndex: correctAnswerIndex, + type: type, + duration: duration, + ); + } + + void updateTOFAnswer(bool answer) { + _updateCurrentQuestion(answer: answer.toString()); + } + + void onDurationChange(int? duration) { + currentDuration.value = duration ?? 30; + _updateCurrentQuestion(duration: duration); + } + + void onDone() { + for (final value in quizData) { + if (value.question == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + + if (value.type == QuestionType.option) { + if (value.correctAnswerIndex == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + if (value.options == null || value.options!.length < 4) { + Get.snackbar( + 'Pilihan jawaban kurang dari 4 di soal ${value.index}', + 'Tambahkan pilihan jawaban', + ); + return; + } + } else { + if (value.answer == null) { + Get.snackbar( + 'Field kosong di soal ${value.index}', + 'Hapus jika tidak digunakan', + ); + return; + } + } + } + + Get.toNamed(AppRoutes.quizPreviewPage, arguments: quizData); + } + + void onBack(BuildContext context) { + if (quizData.length <= 1) { + Get.back(); + } else { + AppDialog.showExitConfirmationDialog(context); + } + } + + void showDeleteQuestionDialog(BuildContext context, int index) { + DeleteQuestionDialog.show(context: context, onDelete: () => onQuestionDelete(index)); + } + + void onQuestionDelete(int index) { + if (quizData.length <= 1) { + Get.snackbar('Tidak Bisa Dihapus', 'Minimal harus ada satu soal dalam kuis.'); + return; + } + + quizData.removeAt(index); + + for (int i = 0; i < quizData.length; i++) { + quizData[i] = quizData[i].copyWith(index: i + 1); + } + + if (selectedQuizIndex.value == index) { + selectedQuizIndex.value = 0; + onSelectedQuizItem(0); + } else if (selectedQuizIndex.value > index) { + selectedQuizIndex.value -= 1; + } + } + + void generateQuiz() async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + + if (inputSentenceTC.text.trim().isEmpty) { + CustomNotification.error(title: "Gagal", message: "kalimat atau paragraph tidak boleh kosong"); + return; + } + CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; + + try { + BaseResponseModel> response = await _quizService.createQuizAuto(inputSentenceTC.text); + + if (response.data != null && response.data!.isNotEmpty) { + // Check if we should remove the initial empty question + bool shouldRemoveInitial = quizData.length == 1 && quizData[0].question == null && quizData[0].answer == null; + + if (shouldRemoveInitial) { + quizData.clear(); + } + + // Add new questions + for (final quizItem in response.data!) { + QuestionType type = QuestionType.fillTheBlank; + + if (quizItem.answer.toString().toLowerCase() == 'true' || quizItem.answer.toString().toLowerCase() == 'false') { + type = QuestionType.trueOrFalse; + } + + quizData.add(QuestionData( + index: quizData.length + 1, + question: quizItem.qustion, + answer: quizItem.answer, + type: type, + )); + } + + // Set the selected index to the first newly added question + if (shouldRemoveInitial) { + selectedQuizIndex.value = 0; + } else { + // If we didn't remove initial data, select the first new question + selectedQuizIndex.value = quizData.length - response.data!.length; + } + + // Update UI with the selected question data + if (selectedQuizIndex.value < quizData.length) { + final data = quizData[selectedQuizIndex.value]; + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + currentDuration.value = data.duration; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + } + } + } catch (e) { + logC.e("Error while generating quiz: $e"); + CustomFloatingLoading.hideLoading(); + } finally { + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + isGenerate.value = false; + inputSentenceTC.text = ""; + + if (quizData.isNotEmpty && selectedQuizIndex.value >= quizData.length) { + selectedQuizIndex.value = 0; + } + + if (quizData.isNotEmpty) { + final data = quizData[selectedQuizIndex.value]; + questionTC.text = data.question ?? ""; + answerTC.text = data.answer ?? ""; + currentDuration.value = data.duration; + currentQuestionType.value = data.type ?? QuestionType.fillTheBlank; + } + } + } + + onGoBack(BuildContext context, bool didPop) { + if (!isLoading.value) onBack(context); + } +} diff --git a/lib/feature/quiz_creation/view/component/custom_question_component.dart b/lib/feature/quiz_creation/view/component/custom_question_component.dart new file mode 100644 index 0000000..17fe6da --- /dev/null +++ b/lib/feature/quiz_creation/view/component/custom_question_component.dart @@ -0,0 +1,206 @@ +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/enums/question_type.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/fill_the_blank_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/option_question_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/true_or_false_component.dart'; + +class CustomQuestionComponent extends GetView { + const CustomQuestionComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildNumberPicker(), + const SizedBox(height: 8), + const Text( + "*Tekan dan tahan soal untuk menghapus", + style: TextStyle( + fontSize: 12, + color: AppColors.softGrayText, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 20), + _buildQuizTypeSelector(), + const SizedBox(height: 20), + _questionTypeValue(), + const SizedBox(height: 20), + _buildDurationDropdown(), + const SizedBox(height: 30), + GlobalButton( + text: context.tr('save_all'), + onPressed: controller.onDone, + ) + ], + ); + } + + Widget _questionTypeValue() { + return Obx(() { + switch (controller.currentQuestionType.value) { + case QuestionType.fillTheBlank: + return FillTheBlankComponent( + questionTC: controller.questionTC, + answerTC: controller.answerTC, + ); + case QuestionType.option: + return OptionQuestionComponent(); + case QuestionType.trueOrFalse: + return TrueFalseQuestionComponent(questionTC: controller.questionTC); + } + }); + } + + Widget _buildNumberPicker() { + return Obx( + () => SizedBox( + height: 100, + child: GridView.builder( + scrollDirection: Axis.horizontal, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: controller.quizData.length + 1, + itemBuilder: (context, index) { + final isLast = index == controller.quizData.length; + + return GestureDetector( + onTap: () { + if (isLast) { + controller.onQuestionAdd(); + } else { + controller.onSelectedQuizItem(index); + } + }, + onLongPress: () { + if (!isLast) { + controller.showDeleteQuestionDialog(context, index); + } + }, + child: Obx(() { + bool isSelected = controller.selectedQuizIndex.value == index; + return Container( + width: 60, + height: 60, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primaryBlue : AppColors.borderLight, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + blurRadius: 4, + offset: const Offset(2, 2), + ), + ], + ), + child: isLast + ? const Icon(Icons.add, color: AppColors.darkText) + : Text( + '${controller.quizData[index].index}', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: isSelected ? Colors.white : AppColors.darkText, + ), + ), + ); + }), + ); + }, + ), + ), + ); + } + + Widget _buildQuizTypeSelector() { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + _buildQuizTypeButton( + 'Fill the Blanks', + type: QuestionType.fillTheBlank, + ), + _buildQuizTypeButton( + 'Option', + type: QuestionType.option, + ), + _buildQuizTypeButton( + 'True / False', + type: QuestionType.trueOrFalse, + ), + ], + ), + ); + } + + Widget _buildQuizTypeButton(String label, {required QuestionType type}) { + return Expanded( + child: Obx(() { + final bool isSelected = controller.currentQuestionType.value == type; + return GestureDetector( + onTap: () => controller.onQuestionTypeChange(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : AppColors.softGrayText, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + ); + }), + ); + } + + Widget _buildDurationDropdown() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Obx(() => DropdownButtonFormField( + value: controller.currentDuration.value, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), + style: const TextStyle(color: AppColors.darkText, fontWeight: FontWeight.w500), + items: const [ + DropdownMenuItem(value: 10, child: Text('10 detik')), + DropdownMenuItem(value: 20, child: Text('20 detik')), + DropdownMenuItem(value: 30, child: Text('30 detik')), + DropdownMenuItem(value: 60, child: Text('1 menit')), + ], + onChanged: controller.onDurationChange)), + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart b/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart new file mode 100644 index 0000000..ab411ea --- /dev/null +++ b/lib/feature/quiz_creation/view/component/fill_the_blank_component.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; + +class FillTheBlankComponent extends StatelessWidget { + final TextEditingController questionTC; + final TextEditingController answerTC; + + const FillTheBlankComponent({super.key, required this.questionTC, required this.answerTC}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + LabelTextField(label: "Jawaban"), + GlobalTextField( + controller: answerTC, + hintText: "Tulis Jawaban", + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/generate_component.dart b/lib/feature/quiz_creation/view/component/generate_component.dart new file mode 100644 index 0000000..04b8b2f --- /dev/null +++ b/lib/feature/quiz_creation/view/component/generate_component.dart @@ -0,0 +1,37 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; + +class GenerateComponent extends GetView { + const GenerateComponent({super.key}); + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Masukkan paragraf untuk dijadikan soal", + style: TextStyle( + fontSize: 14, + color: Color(0xFF6B778C), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + GlobalTextField( + hintText: "Tulis kalimat atau paragraf panjang, dan kami akan mengubahnya menjadi soal secara otomatis", + controller: controller.inputSentenceTC, + limitTextLine: 15, + ), + const SizedBox(height: 16), + GlobalButton( + text: context.tr('auto_generate_quiz'), + onPressed: controller.generateQuiz, + ) + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/option_question_component.dart b/lib/feature/quiz_creation/view/component/option_question_component.dart new file mode 100644 index 0000000..0a95424 --- /dev/null +++ b/lib/feature/quiz_creation/view/component/option_question_component.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; // Pastikan import controllermu + +class OptionQuestionComponent extends GetView { + const OptionQuestionComponent({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Pertanyaan + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: controller.questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + + // Pilihan A, B, C, D + ...List.generate(controller.optionTCList.length, (index) { + return Column( + children: [ + LabelTextField(label: "Pilihan ${String.fromCharCode(65 + index)}"), + GlobalTextField( + controller: controller.optionTCList[index], + hintText: "Tulis Pilihan ${String.fromCharCode(65 + index)}", + ), + const SizedBox(height: 10), + ], + ); + }), + + const SizedBox(height: 10), + + // Jawaban Benar Dropdown + LabelTextField(label: "Jawaban Benar"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Obx( + () => DropdownButtonHideUnderline( + child: DropdownButton( + value: controller.selectedOptionIndex.value, + isExpanded: true, + items: List.generate(controller.optionTCList.length, (index) { + final optionLabel = String.fromCharCode(65 + index); // 'A', 'B', 'C', etc. + return DropdownMenuItem( + value: index, + child: Text(optionLabel), + ); + }), + onChanged: (value) { + if (value != null) { + controller.selectedOptionIndex.value = value; + } + }, + ), + ), + ), + ), + + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/feature/quiz_creation/view/component/true_or_false_component.dart b/lib/feature/quiz_creation/view/component/true_or_false_component.dart new file mode 100644 index 0000000..79f7170 --- /dev/null +++ b/lib/feature/quiz_creation/view/component/true_or_false_component.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; // Ganti path sesuai projekmu + +class TrueFalseQuestionComponent extends GetView { + final TextEditingController questionTC; + + const TrueFalseQuestionComponent({ + super.key, + required this.questionTC, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Pertanyaan + LabelTextField(label: "Pertanyaan"), + GlobalTextField( + controller: questionTC, + limitTextLine: 3, + hintText: "Tulis Pertanyaan", + ), + const SizedBox(height: 15), + + // Jawaban Benar Dropdown + LabelTextField(label: "Jawaban Benar"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Obx( + () => DropdownButtonHideUnderline( + child: DropdownButton( + value: _getCurrentAnswer(), + hint: const Text('Pilih Jawaban Benar'), + isExpanded: true, + items: const [ + DropdownMenuItem( + value: true, + child: Text('True'), + ), + DropdownMenuItem( + value: false, + child: Text('False'), + ), + ], + onChanged: (value) { + if (value != null) { + controller.updateTOFAnswer(value); + } + }, + ), + ), + ), + ), + + const SizedBox(height: 20), + ], + ); + } + + bool? _getCurrentAnswer() { + // Ambil answer dari controller dan parsing ke bool + final currentAnswer = controller.quizData[controller.selectedQuizIndex.value].answer; + if (currentAnswer == "true") return true; + if (currentAnswer == "false") return false; + return null; + } +} diff --git a/lib/feature/quiz_creation/view/quiz_creation_view.dart b/lib/feature/quiz_creation/view/quiz_creation_view.dart new file mode 100644 index 0000000..b687139 --- /dev/null +++ b/lib/feature/quiz_creation/view/quiz_creation_view.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/feature/quiz_creation/controller/quiz_creation_controller.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/custom_question_component.dart'; +import 'package:quiz_app/feature/quiz_creation/view/component/generate_component.dart'; + +class QuizCreationView extends GetView { + const QuizCreationView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onGoBack(context, didPop), + child: Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: Text( + context.tr('create_quiz_title'), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, color: AppColors.darkText), + onPressed: () => controller.onBack(context), + ), + centerTitle: true, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildModeSelector(context), + const SizedBox(height: 20), + Obx(() => controller.isGenerate.value ? const GenerateComponent() : const CustomQuestionComponent()), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildModeSelector(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + _buildModeButton(context.tr('mode_generate'), controller.isGenerate, true), + _buildModeButton(context.tr('mode_manual'), controller.isGenerate, false), + ], + ), + ); + } + + Widget _buildModeButton(String label, RxBool isSelected, bool base) { + return Expanded( + child: InkWell( + onTap: () => controller.onCreationTypeChange(base), + child: Obx(() { + final selected = isSelected.value == base; + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: selected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: base + ? const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ) + : const BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: selected ? Colors.white : AppColors.softGrayText, + fontWeight: FontWeight.w600, + ), + ), + ); + }), + ), + ); + } +} diff --git a/lib/feature/quiz_play/binding/quiz_play_binding.dart b/lib/feature/quiz_play/binding/quiz_play_binding.dart new file mode 100644 index 0000000..d58c70e --- /dev/null +++ b/lib/feature/quiz_play/binding/quiz_play_binding.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/answer_service.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizPlayBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AnswerService()); + Get.lazyPut(() => QuizPlayController( + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/quiz_play/controller/quiz_play_controller.dart b/lib/feature/quiz_play/controller/quiz_play_controller.dart new file mode 100644 index 0000000..57155d4 --- /dev/null +++ b/lib/feature/quiz_play/controller/quiz_play_controller.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/component/notification/pop_up_confirmation.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/answer/answer_model.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; +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'; +import 'package:quiz_app/data/services/answer_service.dart'; + +class QuizPlayController extends GetxController { + final AnswerService _answerService; + final UserController _userController; + + QuizPlayController(this._answerService, this._userController); + + late final QuizData quizData; + + // State & UI + final currentIndex = 0.obs; + final timeLeft = 0.obs; + final isStarting = true.obs; + final isAnswerSelected = false.obs; + final prepareDuration = 3.obs; + + // Answer-related + final selectedAnswer = ''.obs; + final idxOptionSelected = (-1).obs; + final choosenAnswerTOF = 0.obs; + final List answeredQuestions = []; + + // Input controller + final answerTextController = TextEditingController(); + + Timer? _timer; + + BaseQuestionModel get currentQuestion => quizData.questionListings[currentIndex.value]; + + @override + void onInit() { + super.onInit(); + quizData = Get.arguments as QuizData; + _startCountdown(); + + // Listener untuk fill in the blank + answerTextController.addListener(() { + isAnswerSelected.value = answerTextController.text.trim().isNotEmpty; + }); + + // Listener untuk true/false + ever(choosenAnswerTOF, (value) { + if (value != 0) { + isAnswerSelected.value = true; + } + }); + } + + void _startCountdown() async { + isStarting.value = false; + for (int i = 3; i > 0; i--) { + prepareDuration.value = i; + await Future.delayed(const Duration(seconds: 1)); + } + _startTimer(); + isStarting.value = true; + } + + void _startTimer() { + timeLeft.value = currentQuestion.duration; + _timer?.cancel(); + + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (timeLeft.value > 0) { + timeLeft.value--; + } else { + _submitAnswerIfNeeded(); + _nextQuestion(); + } + }); + + isAnswerSelected.value = false; + } + + void selectAnswerOption(int selectedIndex) { + selectedAnswer.value = selectedIndex.toString(); + idxOptionSelected.value = selectedIndex; + isAnswerSelected.value = true; + } + + void onChooseTOF(bool value) { + choosenAnswerTOF.value = value ? 1 : 2; + selectedAnswer.value = value.toString(); + } + + void _submitAnswerIfNeeded() { + final question = currentQuestion; + dynamic userAnswer = ''; + + if (question is FillInTheBlankQuestion) { + userAnswer = answerTextController.text.toString(); + } else { + userAnswer = selectedAnswer.value; + } + + dynamic correctAnswer; + if (question is FillInTheBlankQuestion) { + correctAnswer = question.targetAnswer.toLowerCase(); + userAnswer = userAnswer.toString().toLowerCase(); + } else if (question is TrueFalseQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = userAnswer == 'true' || userAnswer == true; + } else if (question is OptionQuestion) { + correctAnswer = question.targetAnswer; + userAnswer = int.tryParse(userAnswer.toString()); + } + + final isCorrect = userAnswer == correctAnswer; + + answeredQuestions.add(AnsweredQuestion( + index: currentIndex.value, + questionIndex: question.index, + selectedAnswer: userAnswer, + isCorrect: isCorrect, + duration: question.duration - timeLeft.value, + )); + } + + void nextQuestion() { + _submitAnswerIfNeeded(); + _nextQuestion(); + } + + void _nextQuestion() { + _timer?.cancel(); + + if (currentIndex.value < quizData.questionListings.length - 1) { + currentIndex.value++; + _resetAnswerState(); + _startTimer(); + } else { + _finishQuiz(); + } + } + + void _resetAnswerState() { + answerTextController.clear(); + selectedAnswer.value = ''; + choosenAnswerTOF.value = 0; + idxOptionSelected.value = -1; + isAnswerSelected.value = false; + } + + void _finishQuiz() async { + _timer?.cancel(); + + AppDialog.showMessage(Get.context!, "Yeay semua soal selesai"); + + // Kirim data ke server + try { + await _answerService.submitQuizAnswers({ + "session_id": "", // Sesuaikan logika session ID + "quiz_id": quizData.id, + "user_id": _userController.userData!.id, // Asumsikan ini ada di model `QuizData` + "answered_at": DateTime.now().toIso8601String(), + "answers": answeredQuestions.map((answered) { + return AnswerModel( + questionIndex: answered.questionIndex, + answer: answered.selectedAnswer, + isCorrect: answered.isCorrect, + timeSpent: answered.duration.toDouble(), + ); + }).toList(), + }); + } catch (e) { + AppDialog.showMessage(Get.context!, "Gagal mengirim jawaban: $e"); + return; + } + + await Future.delayed(const Duration(seconds: 2)); + + Get.offAllNamed( + AppRoutes.resultQuizPage, + arguments: { + "quiz_data": quizData, + "answer_data": answeredQuestions, + }, + ); + } + + @override + void onClose() { + _timer?.cancel(); + answerTextController.dispose(); + super.onClose(); + } +} + +class AnsweredQuestion { + final int index; + final int questionIndex; + final dynamic selectedAnswer; + final bool isCorrect; + final int duration; + + AnsweredQuestion({ + required this.index, + required this.questionIndex, + required this.selectedAnswer, + required this.isCorrect, + required this.duration, + }); + + factory AnsweredQuestion.fromJson(Map json) { + return AnsweredQuestion( + index: json['index'], + questionIndex: json['question_index'], + selectedAnswer: json['selectedAnswer'], + isCorrect: json['isCorrect'], + duration: json['duration'], + ); + } + + Map toJson() => { + 'index': index, + 'question_index': questionIndex, + 'selectedAnswer': selectedAnswer, + 'isCorrect': isCorrect, + 'duration': duration, + }; +} diff --git a/lib/feature/quiz_play/view/quiz_play_view.dart b/lib/feature/quiz_play/view/quiz_play_view.dart new file mode 100644 index 0000000..a08be3d --- /dev/null +++ b/lib/feature/quiz_play/view/quiz_play_view.dart @@ -0,0 +1,554 @@ +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_text_field.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'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizPlayView extends GetView { + const QuizPlayView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + if (!controller.isStarting.value) { + return _buildCountdownScreen(context); + } + + return ListView( + children: [ + _buildCustomAppBar(context), + const SizedBox(height: 20), + _buildProgressBar(), + const SizedBox(height: 20), + _buildQuestionIndicator(context), + const SizedBox(height: 12), + _buildQuestionText(), + const SizedBox(height: 30), + _buildAnswerSection(context), + _buildNextButton(context), + ], + ); + }), + ), + ), + ), + ); + } + + Widget _buildCountdownScreen(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + builder: (context, value, child) { + return Transform.scale( + scale: 0.5 + (value * 0.5), + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryBlue.withValues(alpha: 0.1), + border: Border.all( + color: AppColors.primaryBlue, + width: 4, + ), + ), + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Obx(() => Text( + controller.prepareDuration.toString(), + key: ValueKey(controller.prepareDuration.value), + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + )), + ), + ), + ), + ); + }, + ), + const SizedBox(height: 32), + Text( + context.tr('get_ready'), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 16), + Text( + context.tr('quiz_starting_soon'), + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + Widget _buildCustomAppBar(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 48), // Placeholder for balance + Text( + context.tr('quiz_play_title'), + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 16, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 4), + Obx(() => Text( + '${controller.timeLeft.value}s', + style: TextStyle( + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + )), + ], + ), + ), + ], + ), + ); + } + + Widget _buildProgressBar() { + final question = controller.currentQuestion; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + '${((controller.currentIndex.value + 1) / controller.quizData.questionListings.length * 100).toInt()}%', + style: TextStyle( + fontSize: 14, + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: (controller.currentIndex.value + 1) / controller.quizData.questionListings.length, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.7)], + ), + ), + ), + ), + ), + const SizedBox(height: 12), + // Time progress bar + Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: Colors.grey[300], + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: controller.timeLeft.value / question.duration, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: controller.timeLeft.value > question.duration * 0.3 + ? Colors.green + : controller.timeLeft.value > question.duration * 0.1 + ? Colors.orange + : Colors.red, + ), + ), + ), + )), + ], + ); + } + + Widget _buildQuestionIndicator(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Container( + key: ValueKey(controller.currentIndex.value), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + context.tr( + 'question_indicator', + namedArgs: { + 'current': (controller.currentIndex.value + 1).toString(), + 'total': controller.quizData.questionListings.length.toString(), + }, + ), + style: TextStyle( + fontSize: 14, + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + Widget _buildQuestionText() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0.3, 0), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Container( + key: ValueKey(controller.currentQuestion.question), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Text( + controller.currentQuestion.question, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + height: 1.4, + ), + ), + ), + ); + } + + Widget _buildAnswerSection(BuildContext context) { + final question = controller.currentQuestion; + + if (question is OptionQuestion) { + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: 100, + maxHeight: 300, + ), + child: AnimatedList( + initialItemCount: question.options.length, + itemBuilder: (context, index, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Interval(index * 0.1, 1.0, curve: Curves.easeOut), + )), + child: _buildOptionButton(question.options[index], index), + ); + }, + ), + ); + } else if (question is TrueFalseQuestion) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: _buildTrueFalseButton( + context.tr('yes'), + true, + controller.choosenAnswerTOF, + Icons.check_circle, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildTrueFalseButton( + context.tr('no'), + false, + controller.choosenAnswerTOF, + Icons.cancel, + Colors.red, + ), + ), + ], + ), + ], + ); + } else { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: GlobalTextField(controller: controller.answerTextController), + ); + } + } + + Widget _buildOptionButton(String option, int index) { + return Obx(() { + final isSelected = controller.idxOptionSelected.value == index; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + width: double.infinity, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + controller.selectAnswerOption(index); + // Add haptic feedback + // HapticFeedback.lightImpact(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.primaryBlue : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? Colors.white : Colors.transparent, + border: Border.all( + color: isSelected ? Colors.white : Colors.grey, + width: 2, + ), + ), + child: isSelected ? const Icon(Icons.check, size: 16, color: AppColors.primaryBlue) : null, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget _buildTrueFalseButton(String label, bool value, RxInt choosenAnswer, IconData icon, Color color) { + return Obx(() { + final isSelected = (choosenAnswer.value == (value ? 1 : 2)); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 120, + child: Material( + elevation: isSelected ? 8 : 2, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => controller.onChooseTOF(value), + child: Container( + decoration: BoxDecoration( + color: isSelected ? color.withValues(alpha: 0.1) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? color : Colors.grey.shade300, + width: isSelected ? 3 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? color : Colors.grey.shade100, + ), + child: Icon( + icon, + size: 32, + color: isSelected ? Colors.white : Colors.grey, + ), + ), + const SizedBox(height: 12), + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected ? color : Colors.black87, + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget _buildNextButton(BuildContext context) { + return Obx(() { + final isEnabled = controller.isAnswerSelected.value; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(top: 20), + child: Material( + elevation: isEnabled ? 6 : 2, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: isEnabled ? controller.nextQuestion : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + height: 56, + decoration: BoxDecoration( + gradient: isEnabled + ? LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], + ) + : null, + color: !isEnabled ? Colors.grey.shade300 : null, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + context.tr('next'), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isEnabled ? Colors.white : Colors.grey, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward, + color: isEnabled ? Colors.white : Colors.grey, + ), + ], + ), + ), + ), + ), + ), + ); + }); + } +} diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart new file mode 100644 index 0000000..a5a6d9b --- /dev/null +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -0,0 +1,20 @@ +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/quiz_preview/controller/quiz_preview_controller.dart'; + +class QuizPreviewBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizService()); + Get.lazyPut(() => SubjectService()); + Get.lazyPut(() => QuizPreviewController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart new file mode 100644 index 0000000..e4871be --- /dev/null +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/const/enums/question_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/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/models/quiz/question_create_request.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/models/quiz/quiestion_data_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'; + +class QuizPreviewController extends GetxController { + final TextEditingController titleController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + + final QuizService _quizService; + final UserController _userController; + final SubjectService _subjectService; + final ConnectionService _connectionService; + + QuizPreviewController( + this._quizService, + this._userController, + this._subjectService, + this._connectionService, + ); + + RxBool isPublic = false.obs; + + RxBool isLoading = false.obs; + + late final List data; + + RxList subjects = [].obs; + + RxInt subjectIndex = 0.obs; + + String subjectId = ""; + + @override + void onInit() { + super.onInit(); + loadData(); + loadSubjectData(); + } + + void loadData() { + if (Get.arguments is List) { + data = Get.arguments as List; + } else { + data = []; + Get.snackbar('Error', 'Data soal tidak ditemukan'); + } + } + + void loadSubjectData() async { + try { + final response = await _subjectService.getSubject(); + subjects.assignAll(response.data!); + + if (subjects.isNotEmpty) { + subjectId = subjects[0].id; + } + } catch (e) { + logC.e(e); + } + } + + Future onSaveQuiz() async { + try { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + if (isLoading.value) return; + + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + + if (title.isEmpty || description.isEmpty) { + CustomNotification.error( + title: 'Error', + message: 'Judul dan deskripsi tidak boleh kosong!', + ); + return; + } + + if (data.length < 10) { + CustomNotification.error( + title: 'Error', + message: 'Jumlah soal harus 10 atau lebih', + ); + + return; + } + + isLoading.value = true; + CustomFloatingLoading.showLoading(Get.overlayContext!); + + final now = DateTime.now(); + final String formattedDate = "${now.day.toString().padLeft(2, '0')}-${now.month.toString().padLeft(2, '0')}-${now.year}"; + + final quizRequest = QuizCreateRequestModel( + title: title, + description: description, + isPublic: isPublic.value, + date: formattedDate, + totalQuiz: data.length, + limitDuration: data.length * 30, + authorId: _userController.userData!.id, + subjectId: subjectId, + questionListings: _mapQuestionsToListings(data), + ); + final success = await _quizService.createQuiz(quizRequest); + + if (success) { + CustomNotification.success( + title: 'Sukses', + message: 'Kuis berhasil disimpan!', + ); + + CustomFloatingLoading.hideLoading(); + Get.offAllNamed(AppRoutes.mainPage, arguments: 2); + } + } catch (e) { + CustomFloatingLoading.hideLoading(); + logC.e(e); + } finally { + isLoading.value = false; + } + } + + List _mapQuestionsToListings(List questions) { + return questions.asMap().entries.map((entry) { + final index = entry.key; + final q = entry.value; + + String typeString; + dynamic answer; + List? option; + + switch (q.type) { + case QuestionType.fillTheBlank: + typeString = 'fill_the_blank'; + answer = q.answer ?? ""; + break; + case QuestionType.option: + typeString = 'option'; + answer = q.correctAnswerIndex; + option = q.options?.map((o) => o.text).toList(); + break; + case QuestionType.trueOrFalse: + typeString = 'true_false'; + answer = q.answer!.contains("true"); + break; + default: + typeString = 'fill_the_blank'; + answer = q.answer ?? ""; + } + + return QuestionListing( + index: index, + question: q.question ?? '', + targetAnswer: answer, + duration: q.duration, + type: typeString, + options: option, + ); + }).toList(); + } + + void onSubjectTap(String id, int index) { + subjectId = id; + subjectIndex.value = index; + } + + void onBack() { + if (!isLoading.value) Get.back(); + } + + @override + void onClose() { + titleController.dispose(); + descriptionController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart new file mode 100644 index 0000000..123cd9f --- /dev/null +++ b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/data/models/subject/subject_model.dart'; + +class SubjectDropdownComponent extends StatelessWidget { + final List data; + final void Function(String id, int index) onItemTap; + final int selectedIndex; + + const SubjectDropdownComponent({ + super.key, + required this.data, + required this.onItemTap, + required this.selectedIndex, + }); + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: selectedIndex >= 0 && selectedIndex < data.length ? data[selectedIndex].id : null, + items: data.asMap().entries.map((entry) { + // final index = entry.key; + final subject = entry.value; + return DropdownMenuItem( + value: subject.id, + child: Text('${subject.alias} - ${subject.name}'), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + final index = data.indexWhere((e) => e.id == value); + if (index != -1) { + onItemTap(value, index); + } + } + }, + dropdownColor: Colors.white, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.borderLight), + ), + ), + ); + } +} diff --git a/lib/feature/quiz_preview/view/quiz_preview.dart b/lib/feature/quiz_preview/view/quiz_preview.dart new file mode 100644 index 0000000..f5cdf4f --- /dev/null +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/component/widget/question_container_widget.dart'; +import 'package:quiz_app/feature/quiz_preview/controller/quiz_preview_controller.dart'; +import 'package:quiz_app/feature/quiz_preview/view/component/subject_dropdown_component.dart'; + +class QuizPreviewPage extends GetView { + const QuizPreviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) => controller.onBack(), + child: Scaffold( + backgroundColor: AppColors.background, + appBar: _buildAppBar(context), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: _buildContent(context), + ), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: AppColors.background, + elevation: 0, + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + title: Text( + context.tr('quiz_preview_title'), + style: const TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LabelTextField(label: context.tr("quiz_title_label")), + GlobalTextField(controller: controller.titleController), + const SizedBox(height: 20), + LabelTextField(label: context.tr("quiz_description_label")), + GlobalTextField(controller: controller.descriptionController), + const SizedBox(height: 20), + LabelTextField(label: context.tr("quiz_subject_label")), + Obx(() => SubjectDropdownComponent( + data: controller.subjects.toList(), + onItemTap: controller.onSubjectTap, + selectedIndex: controller.subjectIndex.value, + )), + const SizedBox(height: 20), + _buildPublicCheckbox(context), + const SizedBox(height: 30), + const Divider(thickness: 1.2, color: AppColors.borderLight), + const SizedBox(height: 20), + _buildQuestionContent(), + const SizedBox(height: 30), + GlobalButton( + onPressed: controller.onSaveQuiz, + text: context.tr("save_quiz"), + ), + ], + ), + ); + } + + Widget _buildQuestionContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: controller.data.map((question) { + return QuestionContainerWidget(question: question); + }).toList(), + ); + } + + Widget _buildPublicCheckbox(BuildContext context) { + return Obx(() => GestureDetector( + onTap: controller.isPublic.toggle, + child: Row( + children: [ + Checkbox( + value: controller.isPublic.value, + activeColor: AppColors.primaryBlue, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + side: const BorderSide(color: AppColors.primaryBlue, width: 2), + onChanged: (val) => controller.isPublic.value = val ?? false, + ), + const SizedBox(width: 8), + Text( + context.tr("make_quiz_public"), + style: const TextStyle( + fontSize: 16, + color: AppColors.darkText, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + )); + } +} diff --git a/lib/feature/quiz_result/binding/quiz_result_binding.dart b/lib/feature/quiz_result/binding/quiz_result_binding.dart new file mode 100644 index 0000000..6a1be4c --- /dev/null +++ b/lib/feature/quiz_result/binding/quiz_result_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; + +class QuizResultBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QuizResultController()); + } +} diff --git a/lib/feature/quiz_result/controller/quiz_result_controller.dart b/lib/feature/quiz_result/controller/quiz_result_controller.dart new file mode 100644 index 0000000..c436df3 --- /dev/null +++ b/lib/feature/quiz_result/controller/quiz_result_controller.dart @@ -0,0 +1,50 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/data/models/quiz/library_quiz_model.dart'; +import 'package:quiz_app/data/models/quiz/question/base_qustion_model.dart'; +import 'package:quiz_app/feature/quiz_play/controller/quiz_play_controller.dart'; + +class QuizResultController extends GetxController { + late final QuizData question; + late final List questions; + late final List answers; + + RxInt correctAnswers = 0.obs; + RxInt totalQuestions = 0.obs; + RxDouble scorePercentage = 0.0.obs; + + @override + void onInit() { + super.onInit(); + loadData(); + calculateResult(); + } + + void loadData() { + final args = Get.arguments; + + question = args['quiz_data'] as QuizData; + answers = args["answer_data"] as List; + + questions = question.questionListings; + totalQuestions.value = questions.length; + } + + void calculateResult() { + int correct = answers.where((a) => a.isCorrect).length; + correctAnswers.value = correct; + if (totalQuestions.value > 0) { + scorePercentage.value = (correctAnswers.value / totalQuestions.value) * 100; + } + } + + String getResultMessage() { + double value = scorePercentage.value; + String formatted = value % 1 == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + return "Nilai kamu $formatted"; + } + + void onPopInvoke(bool isPop, dynamic value) { + Get.offNamed(AppRoutes.mainPage, arguments: 3); + } +} diff --git a/lib/feature/quiz_result/view/quiz_result_view.dart b/lib/feature/quiz_result/view/quiz_result_view.dart new file mode 100644 index 0000000..bbd02d4 --- /dev/null +++ b/lib/feature/quiz_result/view/quiz_result_view.dart @@ -0,0 +1,130 @@ +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/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'; +import 'package:quiz_app/feature/quiz_result/controller/quiz_result_controller.dart'; +import 'package:quiz_app/component/widget/quiz_item_wa_component.dart'; + +class QuizResultView extends GetView { + const QuizResultView({super.key}); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: controller.onPopInvoke, + child: Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAppBar(context), + const SizedBox(height: 16), + _buildScoreSummary(), + const SizedBox(height: 16), + _buildQuizList(), + ], + )), + ), + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return Row( + children: [ + IconButton( + icon: const Icon(LucideIcons.arrowLeft, color: Colors.black), + onPressed: () => controller.onPopInvoke(true, null), + ), + const SizedBox(width: 8), + const Text( + 'Hasil Kuis', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + ), + ], + ); + } + + Widget _buildScoreSummary() { + final score = controller.scorePercentage.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Skor Kamu: ${controller.correctAnswers}/${controller.totalQuestions}", + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: score / 100, + minHeight: 10, + backgroundColor: AppColors.borderLight, + valueColor: const AlwaysStoppedAnimation(AppColors.primaryBlue), + ), + const SizedBox(height: 8), + Text( + controller.getResultMessage(), + style: TextStyle( + fontSize: 16, + color: score >= 80 ? Colors.green : Colors.red, + ), + ), + ], + ); + } + + Widget _buildQuizList() { + return Expanded( + child: ListView.builder( + itemCount: controller.questions.length, + itemBuilder: (context, index) { + final answer = controller.answers[index]; + final question = controller.questions.firstWhere( + (q) => q.index == answer.questionIndex, + orElse: () => throw Exception("Question not found"), + ); + + final parsed = _parseAnswer(question, answer.selectedAnswer); + + return QuizItemWAComponent( + index: index + 1, + isCorrect: answer.isCorrect, + question: question.question, + targetAnswer: parsed.targetAnswer, + userAnswer: parsed.userAnswer, + timeSpent: answer.duration.toDouble(), + type: question.type, + options: parsed.options, + ); + }, + ), + ); + } + + /// Helper class for parsed answer details + ({dynamic userAnswer, dynamic targetAnswer, List options}) _parseAnswer(dynamic question, dynamic selectedAnswer) { + switch (question.type) { + case 'fill_the_blank': + final q = question as FillInTheBlankQuestion; + return (userAnswer: selectedAnswer.toString(), targetAnswer: q.targetAnswer, options: []); + case 'option': + final q = question as OptionQuestion; + final parsedAnswer = int.tryParse(selectedAnswer.toString()) ?? -1; + return (userAnswer: parsedAnswer, targetAnswer: q.targetAnswer, options: q.options); + case 'true_false': + final q = question as TrueFalseQuestion; + final boolAnswer = selectedAnswer is bool ? selectedAnswer : selectedAnswer.toString().toLowerCase() == 'true'; + return (userAnswer: boolAnswer, targetAnswer: q.targetAnswer, options: []); + default: + throw Exception("Unknown question type: ${question.type}"); + } + } +} diff --git a/lib/feature/register/binding/register_binding.dart b/lib/feature/register/binding/register_binding.dart new file mode 100644 index 0000000..ab49d20 --- /dev/null +++ b/lib/feature/register/binding/register_binding.dart @@ -0,0 +1,17 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; +import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +class RegisterBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AuthService()); + Get.lazyPut( + () => RegisterController( + Get.find(), + Get.find(), + ), + ); + } +} diff --git a/lib/feature/register/controller/register_controller.dart b/lib/feature/register/controller/register_controller.dart new file mode 100644 index 0000000..b31810a --- /dev/null +++ b/lib/feature/register/controller/register_controller.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/helper/connection_check.dart'; +import 'package:quiz_app/core/utils/custom_floating_loading.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/models/register/register_request.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; +import 'package:quiz_app/data/services/connection_service.dart'; + +class RegisterController extends GetxController { + final AuthService _authService; + final ConnectionService _connectionService; + + RegisterController( + this._authService, + this._connectionService, + ); + + final TextEditingController nameController = TextEditingController(); + final TextEditingController bDateController = TextEditingController(); + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + final TextEditingController confirmPasswordController = TextEditingController(); + final TextEditingController phoneController = TextEditingController(); + + var isPasswordHidden = true.obs; + var isConfirmPasswordHidden = true.obs; + + RxBool isLoading = false.obs; + + @override + void onReady() { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + } + super.onReady(); + } + + void togglePasswordVisibility() { + isPasswordHidden.value = !isPasswordHidden.value; + } + + void toggleConfirmPasswordVisibility() { + isConfirmPasswordHidden.value = !isConfirmPasswordHidden.value; + } + + Future onRegister() async { + if (!_connectionService.isCurrentlyConnected) { + ConnectionNotification.noInternedConnection(); + return; + } + + String email = emailController.text.trim(); + String name = nameController.text.trim(); + String birthDate = bDateController.text.trim(); + String password = passwordController.text.trim(); + String confirmPassword = confirmPasswordController.text.trim(); + String phone = phoneController.text.trim(); + + if (email.isEmpty || password.isEmpty || confirmPassword.isEmpty || name.isEmpty || birthDate.isEmpty) { + CustomNotification.error(title: "Kesalahan", message: "Semua data harus diisi"); + return; + } + + if (!_isValidEmail(email)) { + CustomNotification.error(title: "Kesalahan", message: "Format email tidak valid"); + return; + } + + if (!_isValidDateFormat(birthDate)) { + CustomNotification.error(title: "Kesalahan", message: "Format tanggal tidak valid. Gunakan format seperti ini 12-09-2003"); + return; + } + + if (password != confirmPassword) { + CustomNotification.error(title: "Kesalahan", message: "Kata sandi tidak cocok"); + return; + } + + if (phone.isNotEmpty && (phone.length < 10 || phone.length > 13)) { + CustomNotification.error(title: "Kesalahan", message: "Nomor telepon harus terdiri dari 10 hingga 13 digit"); + return; + } + + try { + CustomFloatingLoading.showLoading(Get.overlayContext!); + isLoading.value = true; + await _authService.register( + RegisterRequestModel( + email: email, + password: password, + name: name, + birthDate: birthDate, + phone: phone, + ), + ); + + Get.back(); + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + CustomNotification.success(title: "Pendaftaran Berhasil", message: "Akun berhasil dibuat"); + } catch (e) { + CustomFloatingLoading.hideLoading(); + isLoading.value = false; + + String errorMessage = e.toString().replaceFirst("Exception: ", ""); + + CustomNotification.error(title: "Pendaftaran gagal", message: errorMessage); + } + } + + bool _isValidDateFormat(String date) { + final regex = RegExp(r'^([0-2][0-9]|(3)[0-1])\-((0[1-9])|(1[0-2]))\-\d{4}$'); + return regex.hasMatch(date); + } + + bool _isValidEmail(String email) { + final regex = RegExp(r"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"); + return regex.hasMatch(email); + } +} diff --git a/lib/feature/register/view/register_page.dart b/lib/feature/register/view/register_page.dart new file mode 100644 index 0000000..a749110 --- /dev/null +++ b/lib/feature/register/view/register_page.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; +import 'package:quiz_app/component/app_name.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/component/global_text_field.dart'; +import 'package:quiz_app/component/label_text_field.dart'; +import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +class RegisterView extends GetView { + const RegisterView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + buildBackHeader(), + const SizedBox(height: 30), + AppName(), + const SizedBox(height: 40), + LabelTextField( + label: context.tr('register_title'), + fontSize: 24, + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('full_name')), + GlobalTextField(controller: controller.nameController), + const SizedBox(height: 10), + LabelTextField(label: context.tr('email')), + GlobalTextField(controller: controller.emailController), + const SizedBox(height: 10), + LabelTextField(label: context.tr('birth_date')), + GlobalTextField( + controller: controller.bDateController, + hintText: "12-08-2001", + ), + LabelTextField(label: context.tr('phone_optional')), + GlobalTextField( + controller: controller.phoneController, + hintText: "085708570857", + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('password')), + Obx( + () => GlobalTextField( + controller: controller.passwordController, + isPassword: true, + obscureText: controller.isPasswordHidden.value, + onToggleVisibility: controller.togglePasswordVisibility, + ), + ), + const SizedBox(height: 10), + LabelTextField(label: context.tr('verify_password')), + Obx( + () => GlobalTextField( + controller: controller.confirmPasswordController, + isPassword: true, + obscureText: controller.isConfirmPasswordHidden.value, + onToggleVisibility: controller.toggleConfirmPasswordVisibility, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + GlobalButton( + onPressed: controller.onRegister, + text: context.tr('register_button'), + ), + ], + ), + ), + ), + ); + } + + Widget buildBackHeader({String title = ""}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart new file mode 100644 index 0000000..73af401 --- /dev/null +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -0,0 +1,23 @@ +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/session_service.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; + +class RoomMakerBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => SessionService()); + Get.lazyPut(() => QuizService()); + Get.put(SocketService()); + Get.lazyPut(() => RoomMakerController( + Get.find(), + Get.find(), + Get.find(), + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart new file mode 100644 index 0000000..a073204 --- /dev/null +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +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/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; +import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; +import 'package:quiz_app/data/models/session/session_info_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/services/connection_service.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/session_service.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class RoomMakerController extends GetxController { + final SessionService _sessionService; + final UserController _userController; + final SocketService _socketService; + final QuizService _quizService; + final ConnectionService _connectionService; + + RoomMakerController( + this._sessionService, + this._userController, + this._socketService, + this._quizService, + this._connectionService, + ); + + final selectedQuiz = Rxn(); + RxBool isOnwQuiz = true.obs; + + final TextEditingController nameTC = TextEditingController(); + final TextEditingController maxPlayerTC = TextEditingController(); + + final ScrollController scrollController = ScrollController(); + final availableQuizzes = [].obs; + + int currentPage = 1; + bool isLoading = false; + bool hasMoreData = true; + + @override + void onInit() { + loadQuiz(reset: true); + scrollController.addListener(_scrollListener); + super.onInit(); + } + + Future loadQuiz({bool reset = false}) async { + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + if (isLoading) return; + + isLoading = true; + + if (reset) { + currentPage = 1; + hasMoreData = true; + } + + BaseResponseModel>? response; + + if (isOnwQuiz.value) { + response = await _quizService.userQuiz(_userController.userData!.id, currentPage); + } else { + response = await _quizService.populerQuiz(page: currentPage, amount: 5); + } + + if (response != null) { + if (reset) { + availableQuizzes.assignAll(response.data!); + } else { + availableQuizzes.addAll(response.data!); + } + + if (response.data == null || response.data!.isEmpty) { + hasMoreData = false; + } else { + currentPage++; + } + } else { + hasMoreData = false; + } + + isLoading = false; + } + + void _scrollListener() { + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 100) { + if (hasMoreData && !isLoading) { + loadQuiz(); + } + } + } + + void onCreateRoom() async { + if (nameTC.text.trim().isEmpty || maxPlayerTC.text.trim().isEmpty || selectedQuiz.value == null) { + CustomNotification.error(title: "Gagal", message: "Nama room, maksimal pemain dan kuis harus dipilih."); + return; + } + + if (int.tryParse(maxPlayerTC.text) == null) { + CustomNotification.error( + title: "Input tidak valid", + message: "Jumlah pemain harus berupa angka tanpa karakter huruf atau simbol.", + ); + return; + } + + if (!await _connectionService.isHaveConnection()) { + ConnectionNotification.noInternedConnection(); + return; + } + + final quiz = selectedQuiz.value!; + + final response = await _sessionService.createSession( + SessionRequestModel( + quizId: quiz.quizId, + hostId: _userController.userData!.id, + roomName: nameTC.text, + limitParticipan: int.parse(maxPlayerTC.text), + ), + ); + + if (response != null) { + _socketService.initSocketConnection(); + _socketService.joinRoom( + sessionCode: response.data!.sessionCode, + userId: _userController.userData!.id, + ); + + _socketService.roomMessages.listen((data) { + if (data["type"] == "join") { + final Map dataPayload = data["data"]; + final Map sessionInfoJson = dataPayload["session_info"]; + final Map quizInfoJson = dataPayload["quiz_info"]; + + Get.toNamed( + AppRoutes.waitRoomPage, + arguments: WaitingRoomDTO( + isAdmin: true, + data: SessionResponseModel( + sessionId: sessionInfoJson["id"], + sessionCode: sessionInfoJson["session_code"], + ), + sessionInfo: SessionInfo.fromJson(sessionInfoJson), + quizInfo: QuizInfo.fromJson(quizInfoJson), + ), + ); + } + }); + } + } + + void onQuizSourceChange(bool base) async { + isOnwQuiz.value = base; + await loadQuiz(reset: true); + } + + void onQuizChoosen(String quizId) { + final selected = availableQuizzes.firstWhere((e) => e.quizId == quizId); + selectedQuiz.value = selected; + } +} diff --git a/lib/feature/room_maker/view/room_maker_view.dart b/lib/feature/room_maker/view/room_maker_view.dart new file mode 100644 index 0000000..fe5eaea --- /dev/null +++ b/lib/feature/room_maker/view/room_maker_view.dart @@ -0,0 +1,633 @@ +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_text_field.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; + +class RoomMakerView extends GetView { + const RoomMakerView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + body: SafeArea( + child: Column( + children: [ + _buildCustomAppBar(context), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRoomSettingsCard(), + const SizedBox(height: 20), + _buildQuizMetaCard(), + const SizedBox(height: 20), + _buildModeSelector(), + const SizedBox(height: 20), + _buildQuizListSection(), + const SizedBox(height: 80), + ], + ), + ), + ), + ], + ), + ), + floatingActionButton: _buildCreateRoomButton(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + } + + Widget _buildCustomAppBar(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Buat Room Quiz", + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + Text( + "Siapkan room untuk bermain bersama", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildRoomSettingsCard() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.settings, + color: AppColors.primaryBlue, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + "Pengaturan Room", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 20), + _buildInputSection( + "Nama Room", + "Masukkan nama room", + Icons.meeting_room, + controller.nameTC, + ), + const SizedBox(height: 16), + _buildInputSection( + "Maksimal Pemain", + "Berapa banyak pemain yang bisa bergabung?", + Icons.group, + controller.maxPlayerTC, + isNumber: true, + ), + ], + ), + ); + } + + Widget _buildInputSection(String label, String hint, IconData icon, TextEditingController textController, {bool isNumber = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + hint, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 12), + GlobalTextField( + controller: textController, + textInputType: isNumber ? TextInputType.number : TextInputType.text, + ), + ], + ); + } + + Widget _buildQuizMetaCard() { + return Obx(() { + final quiz = controller.selectedQuiz.value; + + return AnimatedContainer( + duration: const Duration(milliseconds: 400), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: quiz == null ? Colors.grey[50] : Colors.white, + borderRadius: BorderRadius.circular(16), + border: quiz == null ? Border.all(color: Colors.grey[300]!, style: BorderStyle.solid, width: 2) : null, + boxShadow: quiz == null + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: quiz == null ? _buildNoQuizSelected() : _buildSelectedQuizInfo(quiz), + ); + }); + } + + Widget _buildNoQuizSelected() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[200], + shape: BoxShape.circle, + ), + child: Icon( + Icons.quiz_outlined, + size: 32, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 12), + Text( + "Pilih kuis untuk dimainkan", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + Text( + "Scroll ke bawah untuk memilih kuis", + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Widget _buildSelectedQuizInfo(dynamic quiz) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Kuis Terpilih", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + Text( + "Siap untuk dimainkan!", + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primaryBlue.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + _buildMetaRow("Judul", quiz.title), + _buildMetaRow("Deskripsi", quiz.description), + _buildMetaRow("Jumlah Soal", "${quiz.totalQuiz} soal"), + _buildMetaRow("Durasi", "${quiz.duration ~/ 60} menit"), + ], + ), + ), + ], + ); + } + + Widget _buildMetaRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + ], + ), + ); + } + + Widget _buildModeSelector() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.source, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + const Text( + "Sumber Kuis", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildModeButton('Kuisku', controller.isOnwQuiz, true, Icons.person), + _buildModeButton('Rekomendasi', controller.isOnwQuiz, false, Icons.recommend), + ], + ), + ), + ], + ); + } + + Widget _buildModeButton(String label, RxBool isSelected, bool base, IconData icon) { + return Expanded( + child: Obx(() { + final selected = isSelected.value == base; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: base + ? const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ) + : const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + onTap: () => controller.onQuizSourceChange(base), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: selected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: base + ? const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ) + : const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 20, + color: selected ? Colors.white : Colors.grey[600], + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey[600], + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ); + }), + ); + } + + Widget _buildQuizListSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.list, + size: 20, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + const Text( + "Pilih Kuis", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: 400, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Obx(() => controller.availableQuizzes.isEmpty && !controller.isLoading + ? _buildEmptyQuizList() + : ListView.builder( + controller: controller.scrollController, + padding: const EdgeInsets.all(16), + itemCount: controller.availableQuizzes.length + (controller.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index < controller.availableQuizzes.length) { + return AnimatedContainer( + duration: Duration(milliseconds: 200 + (index * 50)), + margin: const EdgeInsets.only(bottom: 12), + child: QuizContainerComponent( + data: controller.availableQuizzes[index], + onTap: controller.onQuizChoosen, + ), + ); + } else { + return _buildLoadingIndicator(); + } + }, + )), + ), + ], + ); + } + + Widget _buildEmptyQuizList() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey[100], + shape: BoxShape.circle, + ), + child: Icon( + Icons.quiz_outlined, + size: 48, + color: Colors.grey[400], + ), + ), + const SizedBox(height: 16), + Text( + "Belum ada kuis tersedia", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + "Coba ganti sumber kuis atau buat kuis baru", + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildLoadingIndicator() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primaryBlue), + ), + ), + const SizedBox(width: 12), + Text( + "Memuat kuis lainnya...", + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + ); + } + + Widget _buildCreateRoomButton() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: MediaQuery.of(Get.context!).size.width - 32, + height: 56, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: controller.onCreateRoom, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.primaryBlue, AppColors.primaryBlue.withValues(alpha: 0.8)], + ), + color: Colors.grey[300], + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 12), + Text( + "Buat Room Sekarang", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/search/binding/search_binding.dart b/lib/feature/search/binding/search_binding.dart new file mode 100644 index 0000000..3383d06 --- /dev/null +++ b/lib/feature/search/binding/search_binding.dart @@ -0,0 +1,20 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; +import 'package:quiz_app/feature/search/controller/search_controller.dart'; + +class SearchBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) { + Get.lazyPut(() => QuizService()); + } + + if (!Get.isRegistered()) Get.lazyPut(() => SubjectService()); + + Get.lazyPut(() => SearchQuizController( + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/search/controller/search_controller.dart b/lib/feature/search/controller/search_controller.dart new file mode 100644 index 0000000..c278014 --- /dev/null +++ b/lib/feature/search/controller/search_controller.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +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/utils/logger.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/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; + +class SearchQuizController extends GetxController { + final QuizService _quizService; + final SubjectService _subjectService; + + SearchQuizController( + this._quizService, + this._subjectService, + ); + + final searchController = TextEditingController(); + final searchText = ''.obs; + + RxList recommendationQData = [].obs; + RxList populerQData = [].obs; + RxList searchQData = [].obs; + + RxList subjects = [].obs; + + @override + void onInit() { + _getRecommendation(); + _getPopuler(); + loadSubjectData(); + super.onInit(); + searchController.addListener(() { + searchText.value = searchController.text; + }); + debounce( + searchText, + (value) => getSearchData(value), + time: Duration(seconds: 1), + ); + } + + void _getPopuler() async { + BaseResponseModel? response = await _quizService.populerQuiz(); + if (response != null) { + populerQData.assignAll(response.data as List); + } + } + + void _getRecommendation() async { + BaseResponseModel? response = await _quizService.recommendationQuiz(); + if (response != null) { + recommendationQData.assignAll(response.data as List); + } + } + + void getSearchData(String keyword) async { + searchQData.clear(); + BaseResponseModel? response = await _quizService.searchQuiz(keyword, 1); + if (response != null) { + searchQData.assignAll(response.data); + } + } + + void goToDetailPage(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 loadSubjectData() async { + try { + final response = await _subjectService.getSubject(); + subjects.assignAll(response.data!); + } catch (e) { + logC.e("Failed to load subjects: $e"); + } + } + + @override + void onClose() { + searchController.dispose(); + super.onClose(); + } +} diff --git a/lib/feature/search/view/search_view.dart b/lib/feature/search/view/search_view.dart new file mode 100644 index 0000000..c12778f --- /dev/null +++ b/lib/feature/search/view/search_view.dart @@ -0,0 +1,125 @@ +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/enums/listing_type.dart'; +import 'package:quiz_app/component/quiz_container_component.dart'; +import 'package:quiz_app/component/widget/recomendation_component.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/feature/search/controller/search_controller.dart'; + +class SearchView extends GetView { + const SearchView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background2, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Obx(() { + final isSearching = controller.searchText.isNotEmpty; + return ListView( + children: [ + _buildSearchBar(), + const SizedBox(height: 20), + if (isSearching) ...[ + Obx(() => _buildCategoryFilter(controller.subjects.toList())), + const SizedBox(height: 20), + ...controller.searchQData.map( + (e) => QuizContainerComponent(data: e, onTap: controller.goToDetailPage), + ) + ] else ...[ + _buildRecommendationSection( + context.tr('quiz_recommendation'), + controller.recommendationQData, + () => controller.goToListingsQuizPage(ListingType.recomendation), + ), + _buildRecommendationSection( + context.tr('quiz_popular'), + controller.populerQData, + () => controller.goToListingsQuizPage(ListingType.populer), + ), + ], + ], + ); + }), + ), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: TextField( + controller: controller.searchController, + decoration: const InputDecoration( + hintText: 'Cari quiz...', + border: InputBorder.none, + icon: Icon(Icons.search), + ), + ), + ); + } + + Widget _buildCategoryFilter(List data) { + return Wrap( + spacing: 6, + runSpacing: 1, + children: data.map((cat) { + return InkWell( + onTap: () => controller.goToListingsQuizPage( + ListingType.subject, + subjectId: cat.id, + subjecName: cat.alias, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFD6E4FF), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + cat.alias, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF0052CC), + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildRecommendationSection( + String title, + RxList data, + VoidCallback onAllTap, + ) { + return Obx( + () => RecomendationComponent( + title: title, + datas: data.toList(), + itemOnTap: controller.goToDetailPage, + allOnTap: onAllTap, + ), + ); + } +} diff --git a/lib/feature/splash_screen/presentation/splash_screen_page.dart b/lib/feature/splash_screen/presentation/splash_screen_page.dart new file mode 100644 index 0000000..e1d9101 --- /dev/null +++ b/lib/feature/splash_screen/presentation/splash_screen_page.dart @@ -0,0 +1,39 @@ +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/routes/app_pages.dart'; +import 'package:quiz_app/component/app_name.dart'; +import 'package:quiz_app/data/services/user_storage_service.dart'; + +class SplashScreenView extends StatelessWidget { + const SplashScreenView({super.key}); + + void _navigate() async { + final storageService = Get.find(); + final isLoggedIn = await storageService.isLoggedIn(); + storageService.isLogged = isLoggedIn; + + await Future.delayed(const Duration(seconds: 2)); + + if (isLoggedIn) { + Get.offNamed(AppRoutes.mainPage); + } else { + Get.offNamed(AppRoutes.loginPage); + } + } + + @override + Widget build(BuildContext context) { + // Jalankan navigasi setelah frame pertama selesai dirender + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigate(); + }); + + return const Scaffold( + backgroundColor: AppColors.background, + body: Center( + child: AppName(), + ), + ); + } +} diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart new file mode 100644 index 0000000..1fb5fbc --- /dev/null +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; +import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; + +class WaitingRoomBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered()) Get.put(SocketService()); + Get.lazyPut(() => WaitingRoomController( + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart new file mode 100644 index 0000000..b18ace9 --- /dev/null +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/app/routes/app_pages.dart'; +import 'package:quiz_app/core/utils/custom_notification.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/dto/waiting_room_dto.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class WaitingRoomController extends GetxController { + final SocketService _socketService; + final UserController _userController; + WaitingRoomController(this._socketService, this._userController); + + final sessionCode = ''.obs; + final roomName = "".obs; + String sessionId = ''; + final quizMeta = Rx(null); + final joinedUsers = [].obs; + final isAdmin = true.obs; + + final quizQuestions = >[].obs; + final isQuizStarted = false.obs; + + SessionResponseModel? roomData; + @override + void onInit() { + super.onInit(); + _loadInitialData(); + _registerSocketListeners(); + } + + void _loadInitialData() { + final data = Get.arguments as WaitingRoomDTO; + + roomData = data.data; + isAdmin.value = data.isAdmin; + + sessionCode.value = roomData!.sessionCode; + sessionId = roomData!.sessionId; + + quizMeta.value = data.quizInfo; + roomName.value = data.sessionInfo.roomName; + + joinedUsers.assignAll(data.sessionInfo.participants); + } + + void _registerSocketListeners() { + _socketService.roomMessages.listen((data) { + if (data["type"] == "participan_join" || data["type"] == "participan_leave") { + joinedUsers.clear(); + + final dataPayload = data["data"]; + final participants = dataPayload["participants"] as List?; + + if (participants != null && participants.isNotEmpty) { + final users = participants.map((e) => UserModel.fromJson(e as Map)).toList(); + + joinedUsers.addAll(users); + + if (data["type"] == "participan_join") { + CustomNotification.success( + title: "Participant Joined", + message: data["message"] ?? "A participant has joined the room.", + ); + } else if (data["type"] == "participan_leave") { + CustomNotification.warning( + title: "Participant Left", + message: data["message"] ?? "A participant has left the room.", + ); + } + } + } + + if (data["type"] == "leave") { + CustomNotification.warning( + title: "Participant Leave", + message: "Participant left the room", + ); + // joinedUsers.removeWhere((e) => e.id == userId); + } + }); + + _socketService.quizStarted.listen((_) { + isQuizStarted.value = true; + Get.snackbar("Info", "Kuis telah dimulai"); + if (!isAdmin.value) { + Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { + "session_id": sessionId, + "is_admin": isAdmin.value, + }); + } + }); + } + + void copySessionCode(BuildContext context) { + Clipboard.setData(ClipboardData(text: sessionCode.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Session code disalin!')), + ); + } + + void addUser(UserModel user) { + joinedUsers.add(user); + } + + void startQuiz() { + _socketService.startQuiz(sessionId: sessionId); + Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { + "session_id": sessionId, + "is_admin": isAdmin.value, + "list_participan": joinedUsers.toList(), + }); + } + + void leaveRoom() async { + _socketService.leaveRoom( + sessionId: roomData!.sessionId, + userId: _userController.userData!.id, + username: _userController.userName.value, + ); + Get.offAllNamed(AppRoutes.mainPage); + + await Future.delayed(Duration(seconds: 2)); + _socketService.dispose(); + } +} diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart new file mode 100644 index 0000000..7eaa603 --- /dev/null +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:quiz_app/app/const/text/string_extension.dart'; +import 'package:quiz_app/app/const/text/text_style.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/models/quiz/quiz_info_model.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class WaitingRoomView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text(tr("waiting_room.title"), style: AppTextStyles.title), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + final session = controller.sessionCode.value; + final quiz = controller.quizMeta.value; + final users = controller.joinedUsers; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + Center( + child: Obx(() => Text( + controller.roomName.value.toTitleCase(), + style: AppTextStyles.title, + )), + ), + const SizedBox(height: 20), + _buildQuizMeta(quiz!), + const SizedBox(height: 20), + _buildSessionCode(context, session), + const SizedBox(height: 20), + Text( + tr("waiting_room.participants_joined"), + style: AppTextStyles.subtitle.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 10), + Expanded(child: Obx(() => _buildUserList(users.toList()))), + const SizedBox(height: 16), + controller.isAdmin.value + ? GlobalButton( + text: tr("start_quiz"), + onPressed: controller.startQuiz, + ) + : GlobalButton( + text: tr("waiting_room.leave_room"), + onPressed: controller.leaveRoom, + baseColor: const Color.fromARGB(255, 204, 14, 0), + ) + ], + ); + }), + ), + ); + } + + Widget _buildSessionCode(BuildContext context, String code) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.accentBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.primaryBlue), + ), + child: Row( + children: [ + Text(tr("waiting_room.session_code"), style: AppTextStyles.statValue), + const SizedBox(width: 4), + SelectableText(code, style: AppTextStyles.body.copyWith(fontSize: 16)), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy), + tooltip: tr("waiting_room.copy_code"), + onPressed: () => controller.copySessionCode(context), + ), + ], + ), + ); + } + + Widget _buildQuizMeta(QuizInfo quiz) { + return Container( + padding: const EdgeInsets.all(16), + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.background, + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr("waiting_room.quiz_info"), style: AppTextStyles.subtitle.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text("${tr("waiting_room.quiz_title")}: ${quiz.title}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_description")}: ${quiz.description}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_total_question")}: ${quiz.totalQuiz}", style: AppTextStyles.body), + Text("${tr("waiting_room.quiz_duration")}: ${quiz.limitDuration ~/ 60} min", style: AppTextStyles.body), + ], + ), + ); + } + + Widget _buildUserList(List users) { + return ListView.separated( + itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final user = users[index]; + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + CircleAvatar(child: Text(user.username[0].toUpperCase())), + const SizedBox(width: 12), + Text(user.username, style: AppTextStyles.body.copyWith(fontSize: 16)), + ], + ), + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8e94089..35ae197 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,125 +1,41 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:quiz_app/app/app.dart'; +import 'package:quiz_app/core/utils/logger.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; void main() { - runApp(const MyApp()); -} + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); -class MyApp extends StatelessWidget { - const MyApp({super.key}); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, + WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); + + WakelockPlus.enable(); + + runApp( + EasyLocalization( + supportedLocales: [ + Locale('en', 'US'), + Locale('id', 'ID'), + Locale('ms', 'MY'), + ], + path: 'assets/translations', + fallbackLocale: Locale('id', 'ID'), + startLocale: Locale('id', 'ID'), + useOnlyLangCode: false, + child: MyApp(), ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } + }, (e, stackTrace) { + logC.e("issue message $e || $stackTrace"); + }); } diff --git a/pubspec.lock b/pubspec.lock index 9999eda..a43198a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -41,6 +49,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +81,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" + url: "https://pub.dev" + source: hosted + version: "3.0.7+1" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" fake_async: dependency: transitive description: @@ -57,11 +129,35 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_lints: dependency: "direct dev" description: @@ -70,11 +166,109 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get: + dependency: "direct main" + description: + name: get + sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + url: "https://pub.dev" + source: hosted + version: "4.6.6" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "55580f436822d64c8ff9a77e37d61f5fb1e6c7ec9d632a43ee324e2a05c3c6c9" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: fad6ddc80c427b0bba705f2116204ce1173e09cf299f85e053d57a55e5b2dd56 + url: "https://pub.dev" + source: hosted + version: "6.2.2" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "7af72e5502c313865c729223b60e8ae7bce0a1011b250c24edcf30d3d7032748" + url: "https://pub.dev" + source: hosted + version: "6.1.35" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "8468465516a6fdc283ffbbb06ec03a860ee34e9ff84b0454074978705b42379b" + url: "https://pub.dev" + source: hosted + version: "5.8.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "1f6e5787d7a120cc0359ddf315c92309069171306242e181c09472d1b00a2971" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: ada595df6c30cead48e66b1f3a050edf0c5cf2ba60c185d69690e08adcc6281b + url: "https://pub.dev" + source: hosted + version: "0.12.4+3" + http: + dependency: transitive + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: @@ -107,6 +301,30 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" matcher: dependency: transitive description: @@ -131,6 +349,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" path: dependency: transitive description: @@ -139,11 +389,171 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" + url: "https://pub.dev" + source: hosted + version: "4.2.5" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d + url: "https://pub.dev" + source: hosted + version: "3.1.2" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: @@ -192,6 +602,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_math: dependency: transitive description: @@ -208,6 +690,54 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 + url: "https://pub.dev" + source: hosted + version: "1.3.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5d797d3..6e079a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: quiz_app description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -31,13 +31,29 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + get: ^4.6.6 + + logger: ^2.5.0 + + google_sign_in: ^6.2.2 + flutter_dotenv: ^5.2.1 + dio: ^5.8.0+1 + shared_preferences: ^2.5.3 + lucide_icons: ^0.257.0 + google_fonts: ^6.1.0 + socket_io_client: ^3.1.2 + easy_localization: ^3.0.7+1 + percent_indicator: ^4.2.5 + connectivity_plus: ^6.1.4 + url_launcher: ^6.3.1 + wakelock_plus: ^1.3.2 + shimmer: ^3.0.0 dev_dependencies: flutter_test: sdk: flutter + mocktail: ^1.0.4 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -51,14 +67,16 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: + assets: + - assets/ + - assets/logo/ + - assets/translations/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/test/controller/login_controller_test.dart b/test/controller/login_controller_test.dart new file mode 100644 index 0000000..55874cf --- /dev/null +++ b/test/controller/login_controller_test.dart @@ -0,0 +1,135 @@ +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:get/get.dart'; +// import 'package:mockito/mockito.dart'; +// import 'package:quiz_app/app/routes/app_pages.dart'; +// import 'package:quiz_app/component/global_button.dart'; +// import 'package:quiz_app/data/controllers/user_controller.dart'; +// import 'package:quiz_app/data/entity/user/user_entity.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/services/auth_service.dart'; +// import 'package:quiz_app/data/services/google_auth_service.dart'; +// import 'package:quiz_app/data/services/user_storage_service.dart'; +// import 'package:quiz_app/feature/login/controllers/login_controller.dart'; + +// class MockAuthService extends Mock implements AuthService {} + +// class MockUserStorageService extends Mock implements UserStorageService {} + +// class MockUserController extends Mock implements UserController {} + +// class MockGoogleAuthService extends Mock implements GoogleAuthService {} + +// void main() { +// late LoginController loginController; +// late MockAuthService mockAuthService; +// late MockUserStorageService mockUserStorageService; +// late MockUserController mockUserController; +// late MockGoogleAuthService mockGoogleAuthService; + +// setUp(() { +// mockAuthService = MockAuthService(); +// mockUserStorageService = MockUserStorageService(); +// mockUserController = MockUserController(); +// mockGoogleAuthService = MockGoogleAuthService(); + +// loginController = LoginController( +// mockAuthService, +// mockUserStorageService, +// mockUserController, +// mockGoogleAuthService, +// ); +// }); + +// test('Initial state should have button disabled and password hidden', () { +// expect(loginController.isButtonEnabled.value, ButtonType.disabled); +// expect(loginController.isPasswordHidden.value, true); +// }); + +// test('Button should enable when both fields are not empty', () { +// loginController.emailController.text = 'test@example.com'; +// loginController.passwordController.text = 'password123'; + +// loginController.validateFields(); + +// expect(loginController.isButtonEnabled.value, ButtonType.primary); +// }); + +// test('Button should disable if email or password is empty', () { +// loginController.emailController.text = ''; +// loginController.passwordController.text = 'password123'; + +// loginController.validateFields(); + +// expect(loginController.isButtonEnabled.value, ButtonType.disabled); +// }); + +// test('Toggle password visibility works', () { +// final initial = loginController.isPasswordHidden.value; +// loginController.togglePasswordVisibility(); +// expect(loginController.isPasswordHidden.value, !initial); +// }); + +// test('Successful email login navigates to main page', () async { +// final response = LoginResponseModel(id: '1', name: 'John', email: 'john@example.com'); + +// when(mockAuthService.loginWithEmail(any)).thenAnswer((_) async => response); +// when(mockUserStorageService.saveUser(any)).thenAnswer((_) async => {}); +// when(mockUserStorageService.isLogged = true).thenReturn(true); + +// loginController.emailController.text = 'john@example.com'; +// loginController.passwordController.text = 'password123'; + +// await loginController.loginWithEmail(); + +// verify(mockAuthService.loginWithEmail(any)).called(1); +// verify(mockUserStorageService.saveUser(any)).called(1); +// verify(mockUserController.setUserFromEntity(any)).called(1); +// }); + +// test('Login with empty fields should show error', () async { +// loginController.emailController.text = ''; +// loginController.passwordController.text = ''; + +// await loginController.loginWithEmail(); + +// expect(loginController.isLoading.value, false); +// }); + +// test('Google login canceled should show error', () async { +// when(mockGoogleAuthService.signIn()).thenAnswer((_) async => null); + +// await loginController.loginWithGoogle(); + +// verifyNever(mockAuthService.loginWithGoogle(any)); +// }); + +// test('Successful Google login navigates to main page', () async { +// final fakeGoogleUser = FakeGoogleUser(); +// final response = LoginResponseModel(id: '1', name: 'John', email: 'john@example.com'); + +// when(mockGoogleAuthService.signIn()).thenAnswer((_) async => fakeGoogleUser); +// when(fakeGoogleUser.authentication).thenAnswer((_) async => FakeGoogleAuth()); +// when(mockAuthService.loginWithGoogle(any)).thenAnswer((_) async => response); +// when(mockUserStorageService.saveUser(any)).thenAnswer((_) async => {}); +// when(mockUserStorageService.isLogged = true).thenReturn(true); + +// await loginController.loginWithGoogle(); + +// verify(mockAuthService.loginWithGoogle(any)).called(1); +// verify(mockUserStorageService.saveUser(any)).called(1); +// verify(mockUserController.setUserFromEntity(any)).called(1); +// }); +// } + +// /// Fakes for Google Sign-In +// class FakeGoogleUser extends Mock { +// @override +// Future get authentication async => FakeGoogleAuth(); +// } + +// class FakeGoogleAuth extends Mock { +// @override +// Future get idToken async => 'fake_id_token'; +// } diff --git a/test/controller/register_controller_test.dart b/test/controller/register_controller_test.dart new file mode 100644 index 0000000..6ef2ef3 --- /dev/null +++ b/test/controller/register_controller_test.dart @@ -0,0 +1,115 @@ +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:mockito/mockito.dart'; +// import 'package:quiz_app/data/models/register/register_request.dart'; +// import 'package:quiz_app/data/services/auth_service.dart'; +// import 'package:quiz_app/feature/register/controller/register_controller.dart'; + +// // Mock class for AuthService +// class MockAuthService extends Mock implements AuthService {} + +// void main() { +// late RegisterController registerController; +// late MockAuthService mockAuthService; + +// setUp(() { +// mockAuthService = MockAuthService(); +// registerController = RegisterController(mockAuthService); +// }); + +// test('Initial state should have password and confirm password hidden', () { +// expect(registerController.isPasswordHidden.value, true); +// expect(registerController.isConfirmPasswordHidden.value, true); +// }); + +// test('Toggle password visibility works correctly', () { +// final initial = registerController.isPasswordHidden.value; +// registerController.togglePasswordVisibility(); +// expect(registerController.isPasswordHidden.value, !initial); +// }); + +// test('Toggle confirm password visibility works correctly', () { +// final initial = registerController.isConfirmPasswordHidden.value; +// registerController.toggleConfirmPasswordVisibility(); +// expect(registerController.isConfirmPasswordHidden.value, !initial); +// }); + +// test('Invalid email format should show error', () async { +// registerController.emailController.text = 'invalid-email'; +// registerController.nameController.text = 'John Doe'; +// registerController.bDateController.text = '12-12-2000'; +// registerController.passwordController.text = 'password123'; +// registerController.confirmPasswordController.text = 'password123'; + +// await registerController.onRegister(); + + + +// // Verify no call to register method due to invalid email +// verifyNever(() => mockAuthService.register(any())); +// }); + +// // test('Invalid date format should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12/12/2000'; // Invalid format +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Passwords do not match should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password456'; + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Phone number invalid length should show error', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; +// // registerController.phoneController.text = '123'; // Too short + +// // await registerController.onRegister(); + +// // verifyNever(mockAuthService.register(any)); +// // }); + +// // test('Successful registration calls AuthService and navigates back', () async { +// // registerController.emailController.text = 'john@example.com'; +// // registerController.nameController.text = 'John Doe'; +// // registerController.bDateController.text = '12-12-2000'; +// // registerController.passwordController.text = 'password123'; +// // registerController.confirmPasswordController.text = 'password123'; +// // registerController.phoneController.text = '081234567890'; + +// // when(mockAuthService.register(any)).thenAnswer((_) async => {}); + +// // await registerController.onRegister(); + +// // verify(mockAuthService.register(any)).called(1); +// // }); + +// // test('_isValidDateFormat should return correct results', () { +// // expect(registerController._isValidDateFormat('12-12-2000'), true); +// // expect(registerController._isValidDateFormat('31-02-2000'), true); // Format correct, logic not checked +// // expect(registerController._isValidDateFormat('12/12/2000'), false); +// // expect(registerController._isValidDateFormat('2000-12-12'), false); +// // }); + +// // test('_isValidEmail should return correct results', () { +// // expect(registerController._isValidEmail('test@example.com'), true); +// // expect(registerController._isValidEmail('test.example.com'), false); +// // expect(registerController._isValidEmail('test@com'), false); +// // }); +// } diff --git a/test/service/answer_service_test.dart b/test/service/answer_service_test.dart new file mode 100644 index 0000000..da20384 --- /dev/null +++ b/test/service/answer_service_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.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/services/answer_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late AnswerService answerService; + + setUp(() { + mockDio = MockDio(); + answerService = AnswerService(); + answerService.dio = mockDio; + }); + + group('AnswerService Tests', () { + test('submitQuizAnswers - Success', () async { + final payload = {'question_id': 'q1', 'answer': 'A'}; + + when(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).thenAnswer((_) async => Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: APIEndpoint.quizAnswer), + )); + + final result = await answerService.submitQuizAnswers(payload); + + expect(result, isA()); + expect(result?.message, 'success'); + + verify(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).called(1); + }); + + test('submitQuizAnswers - Failure', () async { + final payload = {'question_id': 'q1', 'answer': 'A'}; + + when(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.quizAnswer), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + final result = await answerService.submitQuizAnswers(payload); + + expect(result, isNull); + verify(() => mockDio.post(APIEndpoint.quizAnswer, data: payload)).called(1); + }); + + test('getAnswerSession - Success', () async { + final sessionId = '682a26b3bedac6c20a215452'; + final userId = '680f0e63180b5c19b3751d42'; + final responseData = { + "message": "Successfully retrieved the answer", + "data": { + "id": "682a26e6bedac6c20a215453", + "session_id": "682a26b3bedac6c20a215452", + "quiz_id": "682a120f18339f4cc31318e4", + "user_id": "680f0e63180b5c19b3751d42", + "answered_at": "2025-05-19 01:28:22", + "answers": [ + { + "index": 1, + "question": "Siapakah ketua Wali Songo yang juga dikenal sebagai Sunan Gresik?", + "target_answer": "Maulana Malik Ibrahim", + "duration": 30, + "type": "fill_the_blank", + "options": null, + "answer": "maulana Malik ibrahim", + "is_correct": true, + "time_spent": 8.0 + } + ], + "total_score": 100, + "total_correct": 1 + }, + "meta": null + }; + + when(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.quizAnswerSession), + )); + + final result = await answerService.getAnswerSession(sessionId, userId); + + expect(result, isNotNull); + expect(result?.data?.sessionId, sessionId); + expect(result?.data?.userId, userId); + expect(result?.data?.totalScore, 100); + + verify(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).called(1); + }); + + test('getAnswerSession - Failure', () async { + final sessionId = ''; + final userId = ''; + + when(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.quizAnswerSession), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + final result = await answerService.getAnswerSession(sessionId, userId); + + expect(result, isNull); + + verify(() => mockDio.post(APIEndpoint.quizAnswerSession, data: { + "session_id": sessionId, + "user_id": userId, + })).called(1); + }); + }); +} diff --git a/test/service/auth_service_test.dart b/test/service/auth_service_test.dart new file mode 100644 index 0000000..a4ed0d4 --- /dev/null +++ b/test/service/auth_service_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/login/login_request_model.dart'; +import 'package:quiz_app/data/models/register/register_request.dart'; +import 'package:quiz_app/data/services/auth_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late AuthService authService; + + setUp(() { + mockDio = MockDio(); + authService = AuthService(); + authService.dio = mockDio; + }); + + group('AuthService with Mocktail (Using Real Data)', () { + test('Register Success', () async { + final request = RegisterRequestModel( + email: 'danakhdan@gmail.com', + password: '123456', + name: 'Akhdan Robbani', + birthDate: '12-07-2002', + ); + + when(() => mockDio.post(APIEndpoint.register, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 200, + data: {}, + requestOptions: RequestOptions(path: APIEndpoint.register), + )); + + final result = await authService.register(request); + + expect(result, true); + verify(() => mockDio.post(APIEndpoint.register, data: request.toJson())).called(1); + }); + + test('Register Failure', () async { + final request = RegisterRequestModel( + email: 'danakhdan@gmail.com', + password: '123456', + name: 'Akhdan Robbani', + birthDate: '12-07-2002', + ); + + when(() => mockDio.post(APIEndpoint.register, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 400, + data: {'message': 'Registration failed'}, + requestOptions: RequestOptions(path: APIEndpoint.register), + )); + + expect(() => authService.register(request), throwsException); + verify(() => mockDio.post(APIEndpoint.register, data: request.toJson())).called(1); + }); + + test('Login With Email Success', () async { + final request = LoginRequestModel(email: 'danakhdan@gmail.com', password: '123456'); + final responseData = { + 'message': 'Login success', + 'data': { + 'id': 'lkasjd93093j4oi234n1234', + 'email': 'danakhdan@gmail.com', + 'name': 'Akhdan Robbani', + 'birth_date': '2002-08-13 07:00:00', + 'pic_url': 'https://example.com/akhdan.png', + 'phone': '081234567890', + 'locale': 'id-ID', + }, + 'meta': null, + }; + + when(() => mockDio.post(APIEndpoint.login, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.login), + )); + + final result = await authService.loginWithEmail(request); + + expect(result.name, 'Akhdan Robbani'); + expect(result.email, 'danakhdan@gmail.com'); + expect(result.birthDate, DateTime.parse('2002-08-13 07:00:00')); + expect(result.phone, '081234567890'); + expect(result.locale, 'id-ID'); + verify(() => mockDio.post(APIEndpoint.login, data: request.toJson())).called(1); + }); + + test('Login With Email Failure - Invalid Credentials', () async { + final request = LoginRequestModel(email: 'danakhdan@gmail.com', password: 'wrongpassword'); + + when(() => mockDio.post(APIEndpoint.login, data: request.toJson())).thenAnswer((_) async => Response( + statusCode: 401, + data: {'message': 'Invalid credentials'}, + requestOptions: RequestOptions(path: APIEndpoint.login), + )); + + expect(() => authService.loginWithEmail(request), throwsException); + verify(() => mockDio.post(APIEndpoint.login, data: request.toJson())).called(1); + }); + + test('Login With Google Success', () async { + final idToken = 'valid_google_token'; + final responseData = { + 'message': 'Login success', + 'data': { + 'id': '680e5a6d2f480bd75db17a09', + 'email': 'danakhdan@gmail.com', + 'name': 'Akhdan Robbani', + 'birth_date': '2002-08-13 07:00:00', + 'pic_url': null, + 'phone': '081234567890', + 'locale': 'id-ID' + }, + 'meta': null + }; + + when(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.loginGoogle), + )); + + final result = await authService.loginWithGoogle(idToken); + + expect(result.name, 'Akhdan Robbani'); + expect(result.email, 'danakhdan@gmail.com'); + expect(result.birthDate, DateTime.parse('2002-08-13 07:00:00')); + expect(result.phone, '081234567890'); + expect(result.locale, 'id-ID'); + verify(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).called(1); + }); + + test('Login With Google Failure - Invalid Token', () async { + final idToken = 'invalid_google_token'; + + when(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).thenAnswer((_) async => Response( + statusCode: 401, + data: {'message': 'Invalid Google token'}, + requestOptions: RequestOptions(path: APIEndpoint.loginGoogle), + )); + + expect(() => authService.loginWithGoogle(idToken), throwsException); + verify(() => mockDio.post(APIEndpoint.loginGoogle, data: {"token_id": idToken})).called(1); + }); + }); +} diff --git a/test/service/quiz_service_test.dart b/test/service/quiz_service_test.dart new file mode 100644 index 0000000..139bbd6 --- /dev/null +++ b/test/service/quiz_service_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/data/models/quiz/question_listings_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; +import 'package:quiz_app/data/services/quiz_service.dart'; +import 'package:quiz_app/data/models/quiz/question_create_request.dart'; + +class MockDio extends Mock implements Dio {} + +class MockApiClient extends Mock implements ApiClient {} + +void main() { + late MockDio mockDio; + late QuizService quizService; + + setUp(() { + mockDio = MockDio(); + quizService = QuizService(); + quizService.dio = mockDio; + }); + + group('createQuiz', () { + final request = QuizCreateRequestModel( + title: 'Test Quiz', + description: 'A sample quiz description', + isPublic: true, + date: '2025-05-19', + totalQuiz: 1, + limitDuration: 60, + authorId: 'author_123', + subjectId: 'subject_456', + questionListings: [ + QuestionListing( + index: 1, + question: 'Sample question?', + targetAnswer: 'Sample Answer', + duration: 30, + type: 'multiple_choice', + ) + ], + ); + + test('returns true when status code is 201', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 201, + ), + ); + + final result = await quizService.createQuiz(request); + expect(result, true); + }); + + test('throws Exception on non-201 response', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 400, + ), + ); + + expect(() => quizService.createQuiz(request), throwsException); + }); + + test('throws Exception on Dio error', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenThrow(Exception('Network Error')); + + expect(() => quizService.createQuiz(request), throwsException); + }); + }); + + group('createQuizAuto', () { + const sentence = "This is a test sentence."; + final mockResponseData = { + 'message': "succes create quiz automatic", + 'data': [ + {'qustion': 'What is this?', 'answer': 'A test.'}, + ] + }; + + test('returns BaseResponseModel when status code is 200', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 200, + data: mockResponseData, + ), + ); + + final result = await quizService.createQuizAuto(sentence); + expect(result.data, isA>()); + expect(result.data!.first.qustion, 'What is this?'); + }); + + test('throws Exception on non-200 response', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ), + ); + + expect(() => quizService.createQuizAuto(sentence), throwsException); + }); + + test('throws Exception on Dio error', () async { + when(() => mockDio.post(any(), data: any(named: 'data'))).thenThrow(Exception('Network Error')); + + expect(() => quizService.createQuizAuto(sentence), throwsException); + }); + }); +} diff --git a/test/service/subject_service_test_mock.dart b/test/service/subject_service_test_mock.dart new file mode 100644 index 0000000..a909c9e --- /dev/null +++ b/test/service/subject_service_test_mock.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late SubjectService subjectService; + + setUp(() { + mockDio = MockDio(); + subjectService = SubjectService(); + subjectService.dio = mockDio; + }); + + group('SubjectService Tests', () { + test('getSubject - Success', () async { + final responseData = { + 'message': 'Subjects fetched successfully', + 'data': [ + { + 'id': 'subject1', + 'name': 'Mathematics', + }, + { + 'id': 'subject2', + 'name': 'Science', + }, + ], + 'meta': null, + }; + + when(() => mockDio.get(APIEndpoint.subject)).thenAnswer((_) async => Response( + statusCode: 200, + data: responseData, + requestOptions: RequestOptions(path: APIEndpoint.subject), + )); + + final result = await subjectService.getSubject(); + + expect(result.data!.length, 2); + expect(result.data![0].id, 'subject1'); + expect(result.data![0].name, 'Mathematics'); + expect(result.data![1].id, 'subject2'); + expect(result.data![1].name, 'Science'); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + + test('getSubject 400 validation issue', () async { + when(() => mockDio.get(APIEndpoint.subject)).thenAnswer((_) async => Response( + statusCode: 400, + data: {'message': 'Bad Request'}, + requestOptions: RequestOptions(path: APIEndpoint.subject), + )); + + expect( + () => subjectService.getSubject(), + throwsA(isA()), + ); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + + test('getSubject (Network Error)', () async { + when(() => mockDio.get(APIEndpoint.subject)).thenThrow(DioException( + requestOptions: RequestOptions(path: APIEndpoint.subject), + error: 'Network Error', + type: DioExceptionType.connectionError, + )); + + expect( + () => subjectService.getSubject(), + throwsA(isA()), + ); + + verify(() => mockDio.get(APIEndpoint.subject)).called(1); + }); + }); +} diff --git a/test/test_helper/auth_service_test.mock.dart b/test/test_helper/auth_service_test.mock.dart new file mode 100644 index 0000000..af25380 --- /dev/null +++ b/test/test_helper/auth_service_test.mock.dart @@ -0,0 +1,8 @@ +// // Create this in a separate file, e.g., auth_service_test.mocks.dart + +// import 'package:dio/dio.dart'; +// import 'package:mockito/annotations.dart'; +// import 'package:quiz_app/data/providers/dio_client.dart'; + +// @GenerateMocks([Dio, ApiClient]) +// void main() {} diff --git a/test/widget_test.dart b/test/widget_test.dart index 536c270..efbbeab 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,30 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// // This is a basic Flutter widget test. +// // +// // To perform an interaction with a widget in your test, use the WidgetTester +// // utility in the flutter_test package. For example, you can send tap and scroll +// // gestures. You can also use WidgetTester to find child widgets in the widget +// // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; -import 'package:quiz_app/main.dart'; +// import 'package:quiz_app/main.dart'; -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); +// void main() { +// testWidgets('Counter increments smoke test', (WidgetTester tester) async { +// // Build our app and trigger a frame. +// await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); +// // Verify that our counter starts at 0. +// expect(find.text('0'), findsOneWidget); +// expect(find.text('1'), findsNothing); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); +// // Tap the '+' icon and trigger a frame. +// await tester.tap(find.byIcon(Icons.add)); +// await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} +// // Verify that our counter has incremented. +// expect(find.text('0'), findsNothing); +// expect(find.text('1'), findsOneWidget); +// }); +// }