feat: adding monitor quiz logic

This commit is contained in:
akhdanre 2025-05-13 02:24:38 +07:00
parent 5f54ca6c8c
commit e060f32593
9 changed files with 375 additions and 98 deletions

View File

@ -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,

View File

@ -7,3 +7,5 @@ class UserModel {
required this.name,
});
}

View File

@ -8,12 +8,23 @@ class SocketService {
final _roomMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _chatMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _questionUpdateController = StreamController<Map<String, dynamic>>.broadcast();
final _quizStartedController = StreamController<void>.broadcast();
final _answerSubmittedController = StreamController<Map<String, dynamic>>.broadcast();
final _scoreUpdateController = StreamController<Map<String, dynamic>>.broadcast();
final _quizDoneController = StreamController<void>.broadcast();
final _roomClosedController = StreamController<String>.broadcast();
final _errorController = StreamController<String>.broadcast();
// Public streams
Stream<Map<String, dynamic>> get roomMessages => _roomMessageController.stream;
Stream<Map<String, dynamic>> get questionUpdate => _questionUpdateController.stream;
Stream<Map<String, dynamic>> get chatMessages => _chatMessageController.stream;
Stream<void> get quizStarted => _quizStartedController.stream;
Stream<Map<String, dynamic>> get answerSubmitted => _answerSubmittedController.stream;
Stream<Map<String, dynamic>> get scoreUpdates => _scoreUpdateController.stream;
Stream<void> get quizDone => _quizDoneController.stream;
Stream<String> get roomClosed => _roomClosedController.stream;
Stream<String> 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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.from(data));
});
socket.on('answer_submitted', (data) {
logC.i('✅ Answer Submitted: $data');
_answerSubmittedController.add(Map<String, dynamic>.from(data));
});
socket.on('score_update', (data) {
logC.i('📊 Score Update: $data');
_scoreUpdateController.add(Map<String, dynamic>.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();
}
}

View File

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

View File

@ -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<ParticipantAnswerPoint> participan = <ParticipantAnswerPoint>[].obs;
@override
void onInit() {
loadData();
registerListener();
super.onInit();
}
void loadData() {
final args = Get.arguments;
sessionCode = args["session_code"] ?? "";
sessionId = args["session_id"] ?? "";
final List<UserModel> userList = (args["list_participan"] as List<dynamic>).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<String, dynamic> scoreMap = Map<String, dynamic>.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;
}

View File

@ -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<MonitorQuizController> {
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),
),
],
),
],
),
),
],
),
);
}
}

View File

@ -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 = <MultiplayerQuestionModel>[].obs;
// final questions = <MultiplayerQuestionModel>[].obs;
final Rxn<MultiplayerQuestionModel> currentQuestion = Rxn<MultiplayerQuestionModel>();
final currentQuestionIndex = 0.obs;
final selectedAnswer = Rxn<String>();
final isDone = false.obs;
final Rx<ButtonType> 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<String, dynamic>;
sessionCode = args["session_code"];
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();
_socketService.socket.on("quiz_question", (data) {
final model = MultiplayerQuestionModel.fromJson(Map<String, dynamic>.from(data));
currentQuestion.value = model;
questions.add(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;
}
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<String> options;
final int duration;
final List<String>? options;
MultiplayerQuestionModel({
required this.questionIndex,
required this.question,
required this.type,
required this.options,
required this.duration,
this.options,
});
factory MultiplayerQuestionModel.fromJson(Map<String, dynamic> json) {
return MultiplayerQuestionModel(
questionIndex: json['question_index'],
questionIndex: json['index'],
question: json['question'],
type: json['type'],
options: List<String>.from(json['options']),
duration: json['duration'],
options: json['options'] != null ? List<String>.from(json['options']) : null,
);
}

View File

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

View File

@ -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 {