From edb7ab0fdfe54128f964274e3d332dab7b0f7b08 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 12:32:33 +0700 Subject: [PATCH] feat: play quiz multiplayer done --- lib/app/routes/app_pages.dart | 15 ++- lib/app/routes/app_routes.dart | 3 + lib/data/services/socket_service.dart | 59 ++++++++++-- .../monitor_quiz/view/monitor_quiz_view.dart | 10 ++ .../play_quiz_multiplayer_binding.dart | 11 +++ .../controller/play_quiz_controller.dart | 91 +++++++++++++++++++ .../view/play_quiz_multiplayer.dart | 79 ++++++++++++++++ .../controller/waiting_room_controller.dart | 41 +++++++-- 8 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 lib/feature/monitor_quiz/view/monitor_quiz_view.dart create mode 100644 lib/feature/play_quiz_multiplayer/binding/play_quiz_multiplayer_binding.dart create mode 100644 lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart create mode 100644 lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 1537c4c..8344c77 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -14,8 +14,11 @@ 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/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/quiz_creation/binding/quiz_creation_binding.dart'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart'; @@ -120,6 +123,16 @@ class AppPages { name: AppRoutes.joinRoomPage, page: () => JoinRoomView(), binding: JoinRoomBinding(), - ) + ), + GetPage( + name: AppRoutes.monitorQuizMPLPage, + page: () => MonitorQuizView(), + // binding: JoinRoomBinding(), + ), + GetPage( + name: AppRoutes.playQuizMPLPage, + page: () => PlayQuizMultiplayerView(), + binding: PlayQuizMultiplayerBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 14a39ee..3274125 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -21,4 +21,7 @@ abstract class AppRoutes { 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"; } diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart index 4edd78c..b2b8d27 100644 --- a/lib/data/services/socket_service.dart +++ b/lib/data/services/socket_service.dart @@ -1,5 +1,6 @@ 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 { @@ -7,47 +8,51 @@ class SocketService { final _roomMessageController = StreamController>.broadcast(); final _chatMessageController = StreamController>.broadcast(); + final _quizStartedController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); Stream> get roomMessages => _roomMessageController.stream; Stream> get chatMessages => _chatMessageController.stream; + Stream get quizStarted => _quizStartedController.stream; Stream get errors => _errorController.stream; void initSocketConnection() { socket = io.io( APIEndpoint.baseUrl, - io.OptionBuilder() - .setTransports(['websocket']) // WebSocket mode - .disableAutoConnect() - .build(), + io.OptionBuilder().setTransports(['websocket']).disableAutoConnect().build(), ); socket.connect(); socket.onConnect((_) { - print('Connected: ${socket.id}'); + logC.i('Connected: ${socket.id}'); }); socket.onDisconnect((_) { - print('Disconnected'); + logC.i('Disconnected'); }); socket.on('connection_response', (data) { - print('Connection response: $data'); + logC.i('Connection response: $data'); }); socket.on('room_message', (data) { - print('Room Message: $data'); + logC.i('Room Message: $data'); _roomMessageController.add(Map.from(data)); }); socket.on('receive_message', (data) { - print('Message from ${data['from']}: ${data['message']}'); + logC.i('Message from ${data['from']}: ${data['message']}'); _chatMessageController.add(Map.from(data)); }); + socket.on('quiz_started', (_) { + logC.i('Quiz has started!'); + _quizStartedController.add(null); + }); + socket.on('error', (data) { - print('Socket error: $data'); + logC.i('Socket error: $data'); _errorController.add(data.toString()); }); } @@ -78,10 +83,44 @@ 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, + required int questionIndex, + required dynamic answer, + }) { + socket.emit('submit_answer', { + 'session_id': sessionId, + 'user_id': userId, + 'question_index': questionIndex, + 'answer': answer, + }); + } + + /// Emit when user finishes the quiz + void doneQuiz({ + required String sessionId, + required String userId, + }) { + socket.emit('quiz_done', { + 'session_id': sessionId, + 'user_id': userId, + }); + } + void dispose() { socket.dispose(); _roomMessageController.close(); _chatMessageController.close(); + _quizStartedController.close(); _errorController.close(); } } 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..9409ae9 --- /dev/null +++ b/lib/feature/monitor_quiz/view/monitor_quiz_view.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class MonitorQuizView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Text("monitor quiz admin"), + ); + } +} 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..a8356d9 --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart @@ -0,0 +1,91 @@ +import 'package:get/get.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 questions = [].obs; + final Rxn currentQuestion = Rxn(); + final currentQuestionIndex = 0.obs; + final selectedAnswer = Rxn(); + final isDone = false.obs; + + late final String sessionCode; + late final bool isAdmin; + + @override + void onInit() { + super.onInit(); + + 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); + }); + + _socketService.socket.on("quiz_done", (_) { + isDone.value = true; + }); + } + + void submitAnswer() { + final question = questions[currentQuestionIndex.value]; + final answer = selectedAnswer.value; + + if (answer != null) { + _socketService.sendAnswer( + sessionId: sessionCode, + userId: _userController.userData!.id, + questionIndex: question.questionIndex, + answer: answer, + ); + } + + if (currentQuestionIndex.value < questions.length - 1) { + currentQuestionIndex.value++; + selectedAnswer.value = null; + } else { + isDone.value = true; + _socketService.doneQuiz( + sessionId: sessionCode, + userId: _userController.userData!.id, + ); + } + } +} + +class MultiplayerQuestionModel { + final int questionIndex; + final String question; + final List options; + + MultiplayerQuestionModel({ + required this.questionIndex, + required this.question, + required this.options, + }); + + factory MultiplayerQuestionModel.fromJson(Map json) { + return MultiplayerQuestionModel( + questionIndex: json['question_index'], + question: json['question'], + options: List.from(json['options']), + ); + } + + Map toJson() { + return { + 'question_index': questionIndex, + 'question': question, + '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..8ee2ad7 --- /dev/null +++ b/lib/feature/play_quiz_multiplayer/view/play_quiz_multiplayer.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/feature/play_quiz_multiplayer/controller/play_quiz_controller.dart'; + +class PlayQuizMultiplayerView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Obx(() { + if (controller.questions.isEmpty) { + return const Text("Loading..."); + } + return Text("Soal ${controller.currentQuestionIndex.value + 1}/${controller.questions.length}"); + }), + ), + body: Obx(() { + if (controller.isDone.value) { + return _buildDoneView(); + } + + if (controller.questions.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return _buildQuestionView(); + }), + ); + } + + Widget _buildQuestionView() { + return Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.currentQuestion.value!.question, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + ...controller.currentQuestion.value!.options.map((option) => RadioListTile( + title: Text(option), + value: option, + groupValue: controller.selectedAnswer.value, + onChanged: (value) => controller.selectedAnswer.value = value, + )), + const Spacer(), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: controller.selectedAnswer.value == null ? null : controller.submitAnswer, + child: const Text("Kirim Jawaban"), + ), + ), + ], + ), + ); + } + + Widget _buildDoneView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Kuis telah selesai!", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () {}, + child: const Text("Lihat Hasil"), + ), + ], + ), + ); + } +} diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart index d023666..e177e4f 100644 --- a/lib/feature/waiting_room/controller/waiting_room_controller.dart +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -1,6 +1,7 @@ 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/data/controllers/user_controller.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; @@ -16,25 +17,24 @@ class WaitingRoomController extends GetxController { final sessionCode = ''.obs; final quizMeta = Rx(null); final joinedUsers = [].obs; - final isAdmin = true.obs; + final quizQuestions = >[].obs; + final isQuizStarted = false.obs; + @override void onInit() { super.onInit(); - _loadDummyData(); + _loadInitialData(); + _registerSocketListeners(); } - void _loadDummyData() { + void _loadInitialData() { final data = Get.arguments as WaitingRoomDTO; SessionResponseModel? roomData = data.data; isAdmin.value = data.isAdmin; - _socketService.roomMessages.listen((data) { - final user = data["data"]; - joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); - }); sessionCode.value = roomData.sessionCode; quizMeta.value = QuizListingModel( @@ -49,6 +49,31 @@ class WaitingRoomController extends GetxController { ); } + void _registerSocketListeners() { + _socketService.roomMessages.listen((data) { + final user = data["data"]; + if (user != null) { + joinedUsers.assign(UserModel(id: user['user_id'], name: user['username'])); + } + }); + + _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 { + Get.offAllNamed(AppRoutes.playQuizMPLPage, arguments: { + "session_code": sessionCode.value, + "is_admin": isAdmin.value, + }); + } + }); + } + void copySessionCode(BuildContext context) { Clipboard.setData(ClipboardData(text: sessionCode.value)); ScaffoldMessenger.of(context).showSnackBar( @@ -61,6 +86,6 @@ class WaitingRoomController extends GetxController { } void startQuiz() { - print("Mulai kuis dengan session: ${sessionCode.value}"); + _socketService.startQuiz(sessionCode: sessionCode.value); } }