diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 8344c77..ea622f2 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -14,6 +14,7 @@ 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'; @@ -127,7 +128,7 @@ class AppPages { GetPage( name: AppRoutes.monitorQuizMPLPage, page: () => MonitorQuizView(), - // binding: JoinRoomBinding(), + binding: MonitorQuizBinding(), ), GetPage( name: AppRoutes.playQuizMPLPage, diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart index eb21881..6ece638 100644 --- a/lib/data/models/user/user_model.dart +++ b/lib/data/models/user/user_model.dart @@ -7,3 +7,5 @@ class UserModel { required this.name, }); } + + diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index 3def272..7f39b17 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -8,12 +8,23 @@ class SocketService { 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() { @@ -25,48 +36,67 @@ class SocketService { socket.connect(); socket.onConnect((_) { - logC.i('Connected: ${socket.id}'); + logC.i('✅ Connected: ${socket.id}'); }); socket.onDisconnect((_) { - logC.i('Disconnected'); + logC.i('❌ Disconnected'); }); socket.on('connection_response', (data) { - logC.i('Connection response: $data'); + logC.i('🟢 Connection response: $data'); }); socket.on('room_message', (data) { - logC.i('Room Message: $data'); + logC.i('📥 Room Message: $data'); _roomMessageController.add(Map.from(data)); }); socket.on('receive_message', (data) { - logC.i('Message from ${data['from']}: ${data['message']}'); + logC.i('💬 Chat from ${data['from']}: ${data['message']}'); _chatMessageController.add(Map.from(data)); }); socket.on('quiz_started', (_) { - logC.i('Quiz has 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.i('Socket error: $data'); + logC.e('⚠️ Socket Error: $data'); _errorController.add(data.toString()); }); } void joinRoom({required String sessionCode, required String userId}) { - var data = { + socket.emit('join_room', { 'session_code': sessionCode, 'user_id': userId, - }; - print(data); - socket.emit( - 'join_room', - data, - ); + }); } void leaveRoom({required String sessionId, required String userId, String username = "anonymous"}) { @@ -89,14 +119,12 @@ class SocketService { }); } - /// Emit when admin starts the quiz void startQuiz({required String sessionCode}) { socket.emit('start_quiz', { 'session_code': sessionCode, }); } - /// Emit user's answer during quiz void sendAnswer({ required String sessionId, required String userId, @@ -111,12 +139,8 @@ class SocketService { }); } - /// Emit when user finishes the quiz - void doneQuiz({ - required String sessionId, - required String userId, - }) { - socket.emit('quiz_done', { + void endSession({required String sessionId, required String userId}) { + socket.emit('end_session', { 'session_id': sessionId, 'user_id': userId, }); @@ -127,6 +151,10 @@ class SocketService { _roomMessageController.close(); _chatMessageController.close(); _quizStartedController.close(); + _answerSubmittedController.close(); + _scoreUpdateController.close(); + _quizDoneController.close(); + _roomClosedController.close(); _errorController.close(); } } 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..53fba55 --- /dev/null +++ b/lib/feature/monitor_quiz/controller/monitor_quiz_controller.dart @@ -0,0 +1,94 @@ +import 'package:get/get.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.name, + ), + ), + ); + } + + void registerListener() { + _socketService.questionUpdate.listen((data) { + logC.i(data); + currentQuestion.value = data["question"]; + }); + + _socketService.scoreUpdates.listen((data) { + logC.i("📊 Score Update Received: $data"); + + final Map scoreMap = Map.from(data); + + scoreMap.forEach((userId, scoreData) { + final index = participan.indexWhere((p) => p.id == userId); + + if (index != -1) { + // Participant found, update their scores + final participant = participan[index]; + final correct = scoreData["correct"] ?? 0; + final incorrect = scoreData["incorrect"] ?? 0; + + participant.correct.value = correct; + participant.wrong.value = incorrect; + } else { + // Participant not found, optionally add new participant + participan.add( + ParticipantAnswerPoint( + id: userId, + name: "Unknown", // Or fetch proper name if available + correct: (scoreData["correct"] ?? 0).obs, + wrong: (scoreData["incorrect"] ?? 0).obs, + ), + ); + } + }); + + // Notify observers if needed (optional) + participan.refresh(); + }); + } +} + +class ParticipantAnswerPoint { + final String id; + final String name; + final RxInt correct; + final RxInt wrong; + + ParticipantAnswerPoint({ + required this.id, + required this.name, + RxInt? correct, + RxInt? wrong, + }) : correct = correct ?? 0.obs, + wrong = wrong ?? 0.obs; +} diff --git a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart index 9409ae9..1c09fb7 100644 --- a/lib/feature/monitor_quiz/view/monitor_quiz_view.dart +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -1,10 +1,164 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:quiz_app/feature/monitor_quiz/controller/monitor_quiz_controller.dart'; + +class MonitorQuizView extends GetView { + const MonitorQuizView({super.key}); -class MonitorQuizView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Text("monitor quiz admin"), + appBar: AppBar(title: const Text('Monitor Quiz')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx( + () => _buildCurrentQuestion(questionText: controller.currentQuestion.value), + ), + const SizedBox(height: 24), + const Text( + 'Daftar Peserta:', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Obx( + () => ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.participan.length, + itemBuilder: (context, index) => _buildUserRow( + name: controller.participan[index].name, + totalBenar: controller.participan[index].correct.value, + totalSalah: controller.participan[index].wrong.value, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurrentQuestion({required String questionText}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "pertanyaan sekarang", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + SizedBox( + height: 10, + ), + Row( + children: [ + const Icon( + LucideIcons.helpCircle, + color: Colors.blue, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + questionText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildUserRow({ + required String name, + required int totalBenar, + required int totalSalah, + }) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.shade300, + ), + child: const Icon( + Icons.person, + size: 30, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + LucideIcons.checkCircle, + color: Colors.green, + size: 18, + ), + const SizedBox(width: 4), + Text( + 'Benar: $totalBenar', + style: const TextStyle(color: Colors.green), + ), + const SizedBox(width: 16), + const Icon( + LucideIcons.xCircle, + color: Colors.red, + size: 18, + ), + const SizedBox(width: 4), + Text( + 'Salah: $totalSalah', + style: const TextStyle(color: Colors.red), + ), + ], + ), + ], + ), + ), + ], + ), ); } } diff --git a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart index 204ab4b..e5587f4 100644 --- a/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.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'; @@ -9,59 +10,71 @@ class PlayQuizMultiplayerController extends GetxController { PlayQuizMultiplayerController(this._socketService, this._userController); - final questions = [].obs; + // final questions = [].obs; final Rxn currentQuestion = Rxn(); final currentQuestionIndex = 0.obs; final selectedAnswer = Rxn(); final isDone = false.obs; + final Rx buttonType = ButtonType.disabled.obs; + final fillInAnswerController = TextEditingController(); + bool? selectedTOFAns; late final String sessionCode; late final bool isAdmin; @override void onInit() { + _loadData(); + _registerListener(); super.onInit(); + } + _loadData() { final args = Get.arguments as Map; sessionCode = args["session_code"]; isAdmin = args["is_admin"]; - - _socketService.socket.on("quiz_question", (data) { - final model = MultiplayerQuestionModel.fromJson(Map.from(data)); - currentQuestion.value = model; - questions.add(model); - fillInAnswerController.clear(); // reset tiap soal baru - }); - - _socketService.socket.on("quiz_done", (_) { - isDone.value = true; - }); } - bool isAnswerSelected() { - final type = currentQuestion.value?.type; - if (type == 'fill_in_the_blank') { - return fillInAnswerController.text.trim().isNotEmpty; - } - return selectedAnswer.value != null; + _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(); + + final model = MultiplayerQuestionModel.fromJson(Map.from(data)); + currentQuestion.value = model; + // questions.add(model); + fillInAnswerController.clear(); // reset tiap soal baru + }); } void selectOptionAnswer(String option) { selectedAnswer.value = option; + buttonType.value = ButtonType.primary; } void selectTrueFalseAnswer(bool value) { selectedAnswer.value = value.toString(); + buttonType.value = ButtonType.primary; } void submitAnswer() { - final question = questions[currentQuestionIndex.value]; + final question = currentQuestion.value!; final type = question.type; String? answer; - if (type == 'fill_in_the_blank') { + if (type == 'fill_the_blank') { answer = fillInAnswerController.text.trim(); } else { answer = selectedAnswer.value; @@ -75,18 +88,6 @@ class PlayQuizMultiplayerController extends GetxController { answer: answer, ); } - - if (currentQuestionIndex.value < questions.length - 1) { - currentQuestionIndex.value++; - selectedAnswer.value = null; - fillInAnswerController.clear(); - } else { - isDone.value = true; - _socketService.doneQuiz( - sessionId: sessionCode, - userId: _userController.userData!.id, - ); - } } @override @@ -100,21 +101,24 @@ class MultiplayerQuestionModel { final int questionIndex; final String question; final String type; // 'option', 'true_false', 'fill_in_the_blank' - final List options; + final int duration; + final List? options; MultiplayerQuestionModel({ required this.questionIndex, required this.question, required this.type, - required this.options, + required this.duration, + this.options, }); factory MultiplayerQuestionModel.fromJson(Map json) { return MultiplayerQuestionModel( - questionIndex: json['question_index'], + questionIndex: json['index'], question: json['question'], type: json['type'], - options: List.from(json['options']), + duration: json['duration'], + options: json['options'] != null ? List.from(json['options']) : null, ); } diff --git a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart index c4761fd..c97c9c8 100644 --- a/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -1,5 +1,6 @@ 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'; @@ -12,25 +13,17 @@ class PlayQuizMultiplayerView extends GetView { backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, - title: Obx(() { - if (controller.questions.isEmpty) { - return const Text( - "Loading...", - style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ); - } - return Text( - "Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}", - style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), - ); - }), + title: Text( + "Soal ${(controller.currentQuestion.value?.questionIndex ?? 0) + 1}/10", + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), ), body: Obx(() { if (controller.isDone.value) { return _buildDoneView(); } - if (controller.questions.isEmpty) { + if (controller.currentQuestion.value == null) { return const Center(child: CircularProgressIndicator()); } @@ -52,25 +45,16 @@ class PlayQuizMultiplayerView extends GetView { ), const SizedBox(height: 20), if (question.type == 'option') _buildOptionQuestion(), - if (question.type == 'fill_in_the_blank') _buildFillInBlankQuestion(), + if (question.type == 'fill_the_blank') _buildFillInBlankQuestion(), if (question.type == 'true_false') _buildTrueFalseQuestion(), const Spacer(), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: controller.isAnswerSelected() ? controller.submitAnswer : null, - style: ElevatedButton.styleFrom( - backgroundColor: controller.isAnswerSelected() ? const Color(0xFF2563EB) : Colors.grey, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: const Text( - "Kirim Jawaban", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + Obx( + () => GlobalButton( + text: "Kirim jawaban", + onPressed: controller.submitAnswer, + type: controller.buttonType.value, ), - ), + ) ], ), ); @@ -79,7 +63,7 @@ class PlayQuizMultiplayerView extends GetView { Widget _buildOptionQuestion() { final options = controller.currentQuestion.value!.options; return Column( - children: List.generate(options.length, (index) { + children: List.generate(options!.length, (index) { final option = options[index]; final isSelected = controller.selectedAnswer.value == option; @@ -121,7 +105,7 @@ class PlayQuizMultiplayerView extends GetView { } Widget _buildTrueFalseButton(String label, bool value) { - final isSelected = controller.selectedAnswer.value == value.toString(); + final isSelected = controller.selectedTOFAns = value; return Container( margin: const EdgeInsets.only(bottom: 12), diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index 72bac11..f483d77 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -71,12 +71,7 @@ class WaitingRoomController extends GetxController { _socketService.quizStarted.listen((_) { isQuizStarted.value = true; Get.snackbar("Info", "Kuis telah dimulai"); - if (isAdmin.value) { - Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { - "session_code": sessionCode.value, - "is_admin": isAdmin.value, - }); - } else { + if (!isAdmin.value) { Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { "session_code": sessionCode.value, "is_admin": isAdmin.value, @@ -98,6 +93,11 @@ class WaitingRoomController extends GetxController { void startQuiz() { _socketService.startQuiz(sessionCode: sessionCode.value); + Get.offAllNamed(AppRoutes.monitorQuizMPLPage, arguments: { + "session_code": sessionCode.value, + "is_admin": isAdmin.value, + "list_participan": joinedUsers.toList(), + }); } void leaveRoom() async {