feat: play quiz multiplayer done

This commit is contained in:
akhdanre 2025-05-07 12:32:33 +07:00
parent 7e126e24a6
commit edb7ab0fdf
8 changed files with 290 additions and 19 deletions

View File

@ -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/listing_quiz/view/listing_quiz_view.dart';
import 'package:quiz_app/feature/login/bindings/login_binding.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/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/bindings/navigation_binding.dart';
import 'package:quiz_app/feature/navigation/views/navbar_view.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/profile_binding.dart';
import 'package:quiz_app/feature/quiz_creation/binding/quiz_creation_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'; import 'package:quiz_app/feature/quiz_creation/view/quiz_creation_view.dart';
@ -120,6 +123,16 @@ class AppPages {
name: AppRoutes.joinRoomPage, name: AppRoutes.joinRoomPage,
page: () => JoinRoomView(), page: () => JoinRoomView(),
binding: JoinRoomBinding(), binding: JoinRoomBinding(),
) ),
GetPage(
name: AppRoutes.monitorQuizMPLPage,
page: () => MonitorQuizView(),
// binding: JoinRoomBinding(),
),
GetPage(
name: AppRoutes.playQuizMPLPage,
page: () => PlayQuizMultiplayerView(),
binding: PlayQuizMultiplayerBinding(),
),
]; ];
} }

View File

@ -21,4 +21,7 @@ abstract class AppRoutes {
static const roomPage = "/room/quiz"; static const roomPage = "/room/quiz";
static const joinRoomPage = "/room/quiz/join"; static const joinRoomPage = "/room/quiz/join";
static const waitRoomPage = "/room/quiz/waiting"; static const waitRoomPage = "/room/quiz/waiting";
static const playQuizMPLPage = "/room/quiz/play";
static const monitorQuizMPLPage = "/room/quiz/monitor";
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:quiz_app/core/endpoint/api_endpoint.dart'; 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; import 'package:socket_io_client/socket_io_client.dart' as io;
class SocketService { class SocketService {
@ -7,47 +8,51 @@ class SocketService {
final _roomMessageController = StreamController<Map<String, dynamic>>.broadcast(); final _roomMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _chatMessageController = StreamController<Map<String, dynamic>>.broadcast(); final _chatMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _quizStartedController = StreamController<void>.broadcast();
final _errorController = StreamController<String>.broadcast(); final _errorController = StreamController<String>.broadcast();
Stream<Map<String, dynamic>> get roomMessages => _roomMessageController.stream; Stream<Map<String, dynamic>> get roomMessages => _roomMessageController.stream;
Stream<Map<String, dynamic>> get chatMessages => _chatMessageController.stream; Stream<Map<String, dynamic>> get chatMessages => _chatMessageController.stream;
Stream<void> get quizStarted => _quizStartedController.stream;
Stream<String> get errors => _errorController.stream; Stream<String> get errors => _errorController.stream;
void initSocketConnection() { void initSocketConnection() {
socket = io.io( socket = io.io(
APIEndpoint.baseUrl, APIEndpoint.baseUrl,
io.OptionBuilder() io.OptionBuilder().setTransports(['websocket']).disableAutoConnect().build(),
.setTransports(['websocket']) // WebSocket mode
.disableAutoConnect()
.build(),
); );
socket.connect(); socket.connect();
socket.onConnect((_) { socket.onConnect((_) {
print('Connected: ${socket.id}'); logC.i('Connected: ${socket.id}');
}); });
socket.onDisconnect((_) { socket.onDisconnect((_) {
print('Disconnected'); logC.i('Disconnected');
}); });
socket.on('connection_response', (data) { socket.on('connection_response', (data) {
print('Connection response: $data'); logC.i('Connection response: $data');
}); });
socket.on('room_message', (data) { socket.on('room_message', (data) {
print('Room Message: $data'); logC.i('Room Message: $data');
_roomMessageController.add(Map<String, dynamic>.from(data)); _roomMessageController.add(Map<String, dynamic>.from(data));
}); });
socket.on('receive_message', (data) { socket.on('receive_message', (data) {
print('Message from ${data['from']}: ${data['message']}'); logC.i('Message from ${data['from']}: ${data['message']}');
_chatMessageController.add(Map<String, dynamic>.from(data)); _chatMessageController.add(Map<String, dynamic>.from(data));
}); });
socket.on('quiz_started', (_) {
logC.i('Quiz has started!');
_quizStartedController.add(null);
});
socket.on('error', (data) { socket.on('error', (data) {
print('Socket error: $data'); logC.i('Socket error: $data');
_errorController.add(data.toString()); _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() { void dispose() {
socket.dispose(); socket.dispose();
_roomMessageController.close(); _roomMessageController.close();
_chatMessageController.close(); _chatMessageController.close();
_quizStartedController.close();
_errorController.close(); _errorController.close();
} }
} }

View File

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

View File

@ -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<SocketService>(), Get.find<UserController>()));
}
}

View File

@ -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 = <MultiplayerQuestionModel>[].obs;
final Rxn<MultiplayerQuestionModel> currentQuestion = Rxn<MultiplayerQuestionModel>();
final currentQuestionIndex = 0.obs;
final selectedAnswer = Rxn<String>();
final isDone = false.obs;
late final String sessionCode;
late final bool isAdmin;
@override
void onInit() {
super.onInit();
final args = Get.arguments as Map<String, dynamic>;
sessionCode = args["session_code"];
isAdmin = args["is_admin"];
_socketService.socket.on("quiz_question", (data) {
final model = MultiplayerQuestionModel.fromJson(Map<String, dynamic>.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<String> options;
MultiplayerQuestionModel({
required this.questionIndex,
required this.question,
required this.options,
});
factory MultiplayerQuestionModel.fromJson(Map<String, dynamic> json) {
return MultiplayerQuestionModel(
questionIndex: json['question_index'],
question: json['question'],
options: List<String>.from(json['options']),
);
}
Map<String, dynamic> toJson() {
return {
'question_index': questionIndex,
'question': question,
'options': options,
};
}
}

View File

@ -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<PlayQuizMultiplayerController> {
@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<String>(
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"),
),
],
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.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/controllers/user_controller.dart';
import 'package:quiz_app/data/dto/waiting_room_dto.dart'; import 'package:quiz_app/data/dto/waiting_room_dto.dart';
import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart'; import 'package:quiz_app/data/models/quiz/quiz_listing_model.dart';
@ -16,25 +17,24 @@ class WaitingRoomController extends GetxController {
final sessionCode = ''.obs; final sessionCode = ''.obs;
final quizMeta = Rx<QuizListingModel?>(null); final quizMeta = Rx<QuizListingModel?>(null);
final joinedUsers = <UserModel>[].obs; final joinedUsers = <UserModel>[].obs;
final isAdmin = true.obs; final isAdmin = true.obs;
final quizQuestions = <Map<String, dynamic>>[].obs;
final isQuizStarted = false.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_loadDummyData(); _loadInitialData();
_registerSocketListeners();
} }
void _loadDummyData() { void _loadInitialData() {
final data = Get.arguments as WaitingRoomDTO; final data = Get.arguments as WaitingRoomDTO;
SessionResponseModel? roomData = data.data; SessionResponseModel? roomData = data.data;
isAdmin.value = data.isAdmin; 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; sessionCode.value = roomData.sessionCode;
quizMeta.value = QuizListingModel( 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) { void copySessionCode(BuildContext context) {
Clipboard.setData(ClipboardData(text: sessionCode.value)); Clipboard.setData(ClipboardData(text: sessionCode.value));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -61,6 +86,6 @@ class WaitingRoomController extends GetxController {
} }
void startQuiz() { void startQuiz() {
print("Mulai kuis dengan session: ${sessionCode.value}"); _socketService.startQuiz(sessionCode: sessionCode.value);
} }
} }