From 80e6704becc80a2b2a31f206a48f129f6b960aa4 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Mon, 5 May 2025 00:09:53 +0700 Subject: [PATCH] fix: limitation on the quiz creation --- lib/core/endpoint/api_endpoint.dart | 2 + lib/core/utils/custom_notification.dart | 63 ++++++++ .../models/quiz/question_create_request.dart | 3 + lib/data/models/subject/subject_model.dart | 31 ++++ lib/data/services/subject_service.dart | 39 +++++ .../binding/quiz_preview_binding.dart | 8 +- .../controller/quiz_preview_controller.dart | 51 ++++++- .../component/subject_dropdown_component.dart | 52 +++++++ .../quiz_preview/view/quiz_preview.dart | 142 +++++++++--------- 9 files changed, 318 insertions(+), 73 deletions(-) create mode 100644 lib/core/utils/custom_notification.dart create mode 100644 lib/data/models/subject/subject_model.dart create mode 100644 lib/data/services/subject_service.dart create mode 100644 lib/feature/quiz_preview/view/component/subject_dropdown_component.dart diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index b76756a..7b66832 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -13,4 +13,6 @@ class APIEndpoint { static const String historyQuiz = "/history"; static const String detailHistoryQuiz = "/history/detail"; + + static const String subject = "/subject"; } 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/data/models/quiz/question_create_request.dart b/lib/data/models/quiz/question_create_request.dart index d5d0e34..a6466ba 100644 --- a/lib/data/models/quiz/question_create_request.dart +++ b/lib/data/models/quiz/question_create_request.dart @@ -8,6 +8,7 @@ class QuizCreateRequestModel { final int totalQuiz; final int limitDuration; final String authorId; + final String subjectId; final List questionListings; QuizCreateRequestModel({ @@ -18,6 +19,7 @@ class QuizCreateRequestModel { required this.totalQuiz, required this.limitDuration, required this.authorId, + required this.subjectId, required this.questionListings, }); @@ -30,6 +32,7 @@ class QuizCreateRequestModel { 'total_quiz': totalQuiz, 'limit_duration': limitDuration, 'author_id': authorId, + "subject_id": subjectId, 'question_listings': questionListings.map((e) => e.toJson()).toList(), }; } 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/services/subject_service.dart b/lib/data/services/subject_service.dart new file mode 100644 index 0000000..6cd95ef --- /dev/null +++ b/lib/data/services/subject_service.dart @@ -0,0 +1,39 @@ +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/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 { + try { + 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 { + return null; + } + } catch (e) { + logC.e("Quiz creation error: $e"); + return null; + } + } +} diff --git a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart index 7d3c4d7..efbc228 100644 --- a/lib/feature/quiz_preview/binding/quiz_preview_binding.dart +++ b/lib/feature/quiz_preview/binding/quiz_preview_binding.dart @@ -1,12 +1,18 @@ import 'package:get/get.dart'; import 'package:quiz_app/data/controllers/user_controller.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(() => QuizPreviewController(Get.find(), Get.find())); + Get.lazyPut(() => SubjectService()); + Get.lazyPut(() => QuizPreviewController( + 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 index 66d0da7..c555aef 100644 --- a/lib/feature/quiz_preview/controller/quiz_preview_controller.dart +++ b/lib/feature/quiz_preview/controller/quiz_preview_controller.dart @@ -2,12 +2,16 @@ 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/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/base/base_model.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/quiz_service.dart'; +import 'package:quiz_app/data/services/subject_service.dart'; class QuizPreviewController extends GetxController { final TextEditingController titleController = TextEditingController(); @@ -15,17 +19,29 @@ class QuizPreviewController extends GetxController { final QuizService _quizService; final UserController _userController; + final SubjectService _subjectService; - QuizPreviewController(this._quizService, this._userController); + QuizPreviewController( + this._quizService, + this._userController, + this._subjectService, + ); RxBool isPublic = false.obs; late final List data; + RxList subjects = [].obs; + + RxInt subjectIndex = 0.obs; + + String subjectId = ""; + @override void onInit() { super.onInit(); loadData(); + loadSubjectData(); } void loadData() { @@ -37,12 +53,31 @@ class QuizPreviewController extends GetxController { } } + void loadSubjectData() async { + BaseResponseModel>? respnse = await _subjectService.getSubject(); + if (respnse != null) { + subjects.assignAll(respnse.data!); + subjectId = subjects[0].id; + } + } + Future onSaveQuiz() async { final title = titleController.text.trim(); final description = descriptionController.text.trim(); if (title.isEmpty || description.isEmpty) { - Get.snackbar('Error', 'Judul dan deskripsi tidak boleh kosong!'); + 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; } @@ -58,12 +93,17 @@ class QuizPreviewController extends GetxController { totalQuiz: data.length, limitDuration: data.length * 30, authorId: _userController.userData!.id, + subjectId: subjectId, questionListings: _mapQuestionsToListings(data), ); final success = await _quizService.createQuiz(quizRequest); if (success) { - Get.snackbar('Sukses', 'Kuis berhasil disimpan!'); + CustomNotification.success( + title: 'Sukses', + message: 'Kuis berhasil disimpan!', + ); + Get.offAllNamed(AppRoutes.mainPage, arguments: 2); } } catch (e) { @@ -110,6 +150,11 @@ class QuizPreviewController extends GetxController { }).toList(); } + void onSubjectTap(String id, int index) { + subjectId = id; + subjectIndex.value = index; + } + @override void onClose() { titleController.dispose(); 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..8e982f9 --- /dev/null +++ b/lib/feature/quiz_preview/view/component/subject_dropdown_component.dart @@ -0,0 +1,52 @@ +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); + } + } + }, + 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 index b50401c..8b5209c 100644 --- a/lib/feature/quiz_preview/view/quiz_preview.dart +++ b/lib/feature/quiz_preview/view/quiz_preview.dart @@ -6,6 +6,7 @@ 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}); @@ -14,50 +15,65 @@ class QuizPreviewPage extends GetView { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - elevation: 0, - title: const Text( - 'Preview Quiz', - style: TextStyle( - color: AppColors.darkText, - fontWeight: FontWeight.bold, - ), - ), - centerTitle: true, - iconTheme: const IconThemeData(color: AppColors.darkText), - ), + appBar: _buildAppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LabelTextField(label: "Judul"), - GlobalTextField(controller: controller.titleController), - const SizedBox(height: 20), - LabelTextField(label: "Deskripsi Singkat"), - GlobalTextField(controller: controller.descriptionController), - const SizedBox(height: 20), - _buildPublicCheckbox(), // Ganti ke sini - 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: "Simpan Kuis", - ), - ], - ), - ), + child: _buildContent(), ), ), ); } + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: AppColors.background, + elevation: 0, + centerTitle: true, + iconTheme: const IconThemeData(color: AppColors.darkText), + title: const Text( + 'Preview Quiz', + style: TextStyle( + color: AppColors.darkText, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContent() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LabelTextField(label: "Judul"), + GlobalTextField(controller: controller.titleController), + const SizedBox(height: 20), + const LabelTextField(label: "Deskripsi Singkat"), + GlobalTextField(controller: controller.descriptionController), + const SizedBox(height: 20), + const LabelTextField(label: "Mata Pelajaran"), + Obx(() => SubjectDropdownComponent( + data: controller.subjects.toList(), + onItemTap: controller.onSubjectTap, + selectedIndex: controller.subjectIndex.value, + )), + const SizedBox(height: 20), + _buildPublicCheckbox(), + 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: "Simpan Kuis", + ), + ], + ), + ); + } + Widget _buildQuestionContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -68,40 +84,28 @@ class QuizPreviewPage extends GetView { } Widget _buildPublicCheckbox() { - return Obx( - () => GestureDetector( - onTap: () { - controller.isPublic.toggle(); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - value: controller.isPublic.value, - activeColor: AppColors.primaryBlue, // Pakai warna biru utama - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + 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, ), - side: const BorderSide( - color: AppColors.primaryBlue, // Pinggirannya juga biru - width: 2, + const SizedBox(width: 8), + const Text( + "Buat Kuis Public", + style: TextStyle( + fontSize: 16, + color: AppColors.darkText, + fontWeight: FontWeight.w500, + ), ), - onChanged: (value) { - controller.isPublic.value = value ?? false; - }, - ), - const SizedBox(width: 8), - const Text( - "Buat Kuis Public", - style: TextStyle( - fontSize: 16, - color: AppColors.darkText, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); + ], + ), + )); } }