From 49e33d00a918971f639af7cb69751915ab9399a1 Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 7 May 2025 00:32:38 +0700 Subject: [PATCH] feat: waiting room --- lib/app/routes/app_pages.dart | 7 ++ lib/app/routes/app_routes.dart | 1 + lib/core/endpoint/api_endpoint.dart | 5 +- lib/data/dto/waiting_room_dto.dart | 8 ++ .../models/session/session_request_model.dart | 27 +++++ .../session/session_response_model.dart | 20 +++ lib/data/models/user/user_model.dart | 9 ++ lib/data/providers/dio_client.dart | 2 +- lib/data/services/answer_service.dart | 1 + lib/data/services/session_service.dart | 38 ++++++ lib/data/services/socket_service.dart | 87 +++++++++++++ .../binding/room_maker_binding.dart | 5 +- .../controller/room_maker_controller.dart | 34 ++++-- .../binding/waiting_room_binding.dart | 15 +++ .../controller/waiting_room_controller.dart | 69 +++++++++++ .../waiting_room/view/waiting_room_view.dart | 114 ++++++++++++++++++ pubspec.lock | 24 ++++ pubspec.yaml | 1 + 18 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 lib/data/dto/waiting_room_dto.dart create mode 100644 lib/data/models/session/session_request_model.dart create mode 100644 lib/data/models/session/session_response_model.dart create mode 100644 lib/data/models/user/user_model.dart create mode 100644 lib/data/services/session_service.dart create mode 100644 lib/data/services/socket_service.dart create mode 100644 lib/feature/waiting_room/binding/waiting_room_binding.dart create mode 100644 lib/feature/waiting_room/controller/waiting_room_controller.dart create mode 100644 lib/feature/waiting_room/view/waiting_room_view.dart diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 59eead7..e815171 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -29,6 +29,8 @@ import 'package:quiz_app/feature/room_maker/binding/room_maker_binding.dart'; import 'package:quiz_app/feature/room_maker/view/room_maker_view.dart'; import 'package:quiz_app/feature/search/binding/search_binding.dart'; import 'package:quiz_app/feature/splash_screen/presentation/splash_screen_page.dart'; +import 'package:quiz_app/feature/waiting_room/binding/waiting_room_binding.dart'; +import 'package:quiz_app/feature/waiting_room/view/waiting_room_view.dart'; part 'app_routes.dart'; @@ -106,6 +108,11 @@ class AppPages { name: AppRoutes.roomPage, page: () => RoomMakerView(), binding: RoomMakerBinding(), + ), + GetPage( + name: AppRoutes.waitRoomPage, + page: () => WaitingRoomView(), + binding: WaitingRoomBinding(), ) ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index 6fa2357..bdb3b2b 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -19,4 +19,5 @@ abstract class AppRoutes { static const detailHistoryPage = "/history/detail"; static const roomPage = "/room/quiz"; + static const waitRoomPage = "/room/quiz/waiting"; } diff --git a/lib/core/endpoint/api_endpoint.dart b/lib/core/endpoint/api_endpoint.dart index f010423..4611a3d 100644 --- a/lib/core/endpoint/api_endpoint.dart +++ b/lib/core/endpoint/api_endpoint.dart @@ -1,5 +1,6 @@ class APIEndpoint { - static const String baseUrl = "http://192.168.1.9:5000/api"; + static const String baseUrl = "http://192.168.1.9:5000"; + static const String api = "$baseUrl/api"; static const String login = "/login"; static const String loginGoogle = "/login/google"; @@ -17,4 +18,6 @@ class APIEndpoint { static const String detailHistoryQuiz = "/history/detail"; static const String subject = "/subject"; + + static const String session = "/session"; } diff --git a/lib/data/dto/waiting_room_dto.dart b/lib/data/dto/waiting_room_dto.dart new file mode 100644 index 0000000..f72541f --- /dev/null +++ b/lib/data/dto/waiting_room_dto.dart @@ -0,0 +1,8 @@ +import 'package:quiz_app/data/models/session/session_response_model.dart'; + +class WaitingRoomDTO { + final bool isAdmin; + final SessionResponseModel data; + + WaitingRoomDTO(this.isAdmin, this.data); +} diff --git a/lib/data/models/session/session_request_model.dart b/lib/data/models/session/session_request_model.dart new file mode 100644 index 0000000..6eced2f --- /dev/null +++ b/lib/data/models/session/session_request_model.dart @@ -0,0 +1,27 @@ +class SessionRequestModel { + final String quizId; + final String hostId; + final int limitParticipan; + + SessionRequestModel({ + required this.quizId, + required this.hostId, + required this.limitParticipan, + }); + + factory SessionRequestModel.fromJson(Map json) { + return SessionRequestModel( + quizId: json['quiz_id'], + hostId: json['host_id'], + limitParticipan: json['limit_participan'], + ); + } + + Map toJson() { + return { + 'quiz_id': quizId, + 'host_id': hostId, + 'limit_participan': limitParticipan, + }; + } +} diff --git a/lib/data/models/session/session_response_model.dart b/lib/data/models/session/session_response_model.dart new file mode 100644 index 0000000..e9a58e3 --- /dev/null +++ b/lib/data/models/session/session_response_model.dart @@ -0,0 +1,20 @@ +class SessionResponseModel { + final String sessionId; + final String sessionCode; + + SessionResponseModel({required this.sessionId, required this.sessionCode}); + + factory SessionResponseModel.fromJson(Map json) { + return SessionResponseModel( + sessionId: json['session_id'], + sessionCode: json['session_code'], + ); + } + + Map toJson() { + return { + 'session_id': sessionId, + 'session_code': sessionCode, + }; + } +} diff --git a/lib/data/models/user/user_model.dart b/lib/data/models/user/user_model.dart new file mode 100644 index 0000000..eb21881 --- /dev/null +++ b/lib/data/models/user/user_model.dart @@ -0,0 +1,9 @@ +class UserModel { + final String id; + final String name; + + UserModel({ + required this.id, + required this.name, + }); +} diff --git a/lib/data/providers/dio_client.dart b/lib/data/providers/dio_client.dart index 11795fc..456597d 100644 --- a/lib/data/providers/dio_client.dart +++ b/lib/data/providers/dio_client.dart @@ -8,7 +8,7 @@ class ApiClient extends GetxService { Future init() async { dio = Dio(BaseOptions( - baseUrl: APIEndpoint.baseUrl, + baseUrl: APIEndpoint.api, connectTimeout: const Duration(minutes: 3), receiveTimeout: const Duration(minutes: 10), headers: { diff --git a/lib/data/services/answer_service.dart b/lib/data/services/answer_service.dart index e97a9b0..cc377cd 100644 --- a/lib/data/services/answer_service.dart +++ b/lib/data/services/answer_service.dart @@ -20,6 +20,7 @@ class AnswerService extends GetxService { APIEndpoint.quizAnswer, data: payload, ); + return BaseResponseModel(message: "success"); } on DioException catch (e) { logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}'); return null; diff --git a/lib/data/services/session_service.dart b/lib/data/services/session_service.dart new file mode 100644 index 0000000..5e19015 --- /dev/null +++ b/lib/data/services/session_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:quiz_app/data/models/base/base_model.dart'; +import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/providers/dio_client.dart'; + +class SessionService extends GetxService { + late final Dio _dio; + + @override + void onInit() { + _dio = Get.find().dio; + super.onInit(); + } + + Future?> createSession(SessionRequestModel data) async { + try { + final response = await _dio.post(APIEndpoint.session, data: { + 'quiz_id': data.quizId, + 'host_id': data.hostId, + 'limit_participan': data.limitParticipan, + }); + if (response.statusCode != 201) { + return null; + } + + return BaseResponseModel.fromJson(response.data, (e) => SessionResponseModel.fromJson(e)); + } on DioException catch (e) { + print('Error creating session: ${e.response?.data ?? e.message}'); + return null; + } catch (e) { + print('Unexpected error: $e'); + return null; + } + } +} diff --git a/lib/data/services/socket_service.dart b/lib/data/services/socket_service.dart new file mode 100644 index 0000000..4edd78c --- /dev/null +++ b/lib/data/services/socket_service.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:quiz_app/core/endpoint/api_endpoint.dart'; +import 'package:socket_io_client/socket_io_client.dart' as io; + +class SocketService { + late io.Socket socket; + + final _roomMessageController = StreamController>.broadcast(); + final _chatMessageController = StreamController>.broadcast(); + final _errorController = StreamController.broadcast(); + + Stream> get roomMessages => _roomMessageController.stream; + Stream> get chatMessages => _chatMessageController.stream; + Stream get errors => _errorController.stream; + + void initSocketConnection() { + socket = io.io( + APIEndpoint.baseUrl, + io.OptionBuilder() + .setTransports(['websocket']) // WebSocket mode + .disableAutoConnect() + .build(), + ); + + socket.connect(); + + socket.onConnect((_) { + print('Connected: ${socket.id}'); + }); + + socket.onDisconnect((_) { + print('Disconnected'); + }); + + socket.on('connection_response', (data) { + print('Connection response: $data'); + }); + + socket.on('room_message', (data) { + print('Room Message: $data'); + _roomMessageController.add(Map.from(data)); + }); + + socket.on('receive_message', (data) { + print('Message from ${data['from']}: ${data['message']}'); + _chatMessageController.add(Map.from(data)); + }); + + socket.on('error', (data) { + print('Socket error: $data'); + _errorController.add(data.toString()); + }); + } + + void joinRoom({required String sessionCode, required String userId}) { + socket.emit('join_room', { + 'session_code': sessionCode, + 'user_id': userId, + }); + } + + void leaveRoom({required String sessionId, String username = "anonymous"}) { + socket.emit('leave_room', { + 'session_id': sessionId, + 'username': username, + }); + } + + void sendMessage({ + required String sessionId, + required String message, + String username = "anonymous", + }) { + socket.emit('send_message', { + 'session_id': sessionId, + 'message': message, + 'username': username, + }); + } + + void dispose() { + socket.dispose(); + _roomMessageController.close(); + _chatMessageController.close(); + _errorController.close(); + } +} diff --git a/lib/feature/room_maker/binding/room_maker_binding.dart b/lib/feature/room_maker/binding/room_maker_binding.dart index 2e5157a..f2483c4 100644 --- a/lib/feature/room_maker/binding/room_maker_binding.dart +++ b/lib/feature/room_maker/binding/room_maker_binding.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; +import 'package:quiz_app/data/controllers/user_controller.dart'; +import 'package:quiz_app/data/services/session_service.dart'; import 'package:quiz_app/feature/room_maker/controller/room_maker_controller.dart'; class RoomMakerBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => RoomMakerController()); + Get.lazyPut(() => SessionService()); + Get.lazyPut(() => RoomMakerController(Get.find(), Get.find())); } } diff --git a/lib/feature/room_maker/controller/room_maker_controller.dart b/lib/feature/room_maker/controller/room_maker_controller.dart index 23d58ed..d77406e 100644 --- a/lib/feature/room_maker/controller/room_maker_controller.dart +++ b/lib/feature/room_maker/controller/room_maker_controller.dart @@ -1,9 +1,19 @@ import 'package:flutter/material.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'; +import 'package:quiz_app/data/models/session/session_request_model.dart'; +import 'package:quiz_app/data/services/session_service.dart'; class RoomMakerController extends GetxController { - final roomName = ''.obs; + final SessionService _sessionService; + final UserController _userController; + + RoomMakerController(this._sessionService, this._userController); + + // final roomName = ''.obs; final selectedQuiz = Rxn(); RxBool isOnwQuiz = true.obs; @@ -34,18 +44,24 @@ class RoomMakerController extends GetxController { ), ].obs; - void createRoom() { - if (roomName.value.trim().isEmpty || selectedQuiz.value == null) { + void onCreateRoom() async { + print("room ${nameTC.text} || ${selectedQuiz.value}"); + if (nameTC.text.trim().isEmpty || selectedQuiz.value == null) { Get.snackbar("Gagal", "Nama room dan kuis harus dipilih."); return; } final quiz = selectedQuiz.value!; - print("Membuat room:"); - print("- Nama: ${roomName.value}"); - print("- Quiz: ${quiz.title}"); - print("- Durasi: ${quiz.duration} detik"); - print("- Jumlah Soal: ${quiz.totalQuiz}"); + + final response = await _sessionService.createSession( + SessionRequestModel( + quizId: quiz.quizId, + hostId: _userController.userData!.id, + limitParticipan: int.parse(maxPlayerTC.text), + ), + ); + + if (response != null) Get.toNamed(AppRoutes.waitRoomPage, arguments: WaitingRoomDTO(true, response.data!)); } void onQuizSourceChange(bool base) { @@ -56,6 +72,4 @@ class RoomMakerController extends GetxController { final selected = availableQuizzes.firstWhere((e) => e.quizId == quizId); selectedQuiz.value = selected; } - - void onCreateRoom() {} } diff --git a/lib/feature/waiting_room/binding/waiting_room_binding.dart b/lib/feature/waiting_room/binding/waiting_room_binding.dart new file mode 100644 index 0000000..d26c263 --- /dev/null +++ b/lib/feature/waiting_room/binding/waiting_room_binding.dart @@ -0,0 +1,15 @@ +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/waiting_room/controller/waiting_room_controller.dart'; + +class WaitingRoomBinding extends Bindings { + @override + void dependencies() { + Get.put(SocketService()); + Get.lazyPut(() => WaitingRoomController( + Get.find(), + Get.find(), + )); + } +} diff --git a/lib/feature/waiting_room/controller/waiting_room_controller.dart b/lib/feature/waiting_room/controller/waiting_room_controller.dart new file mode 100644 index 0000000..bdb9698 --- /dev/null +++ b/lib/feature/waiting_room/controller/waiting_room_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.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'; +import 'package:quiz_app/data/models/session/session_response_model.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/data/services/socket_service.dart'; + +class WaitingRoomController extends GetxController { + final SocketService _socketService; + final UserController _userController; + WaitingRoomController(this._socketService, this._userController); + + final sessionCode = ''.obs; + final quizMeta = Rx(null); + final joinedUsers = [].obs; + + final isAdmin = true.obs; + + @override + void onInit() { + super.onInit(); + _loadDummyData(); + } + + void _loadDummyData() { + final data = Get.arguments as WaitingRoomDTO; + + SessionResponseModel? roomData = data.data; + isAdmin.value = data.isAdmin; + + _socketService.initSocketConnection(); + _socketService.joinRoom(sessionCode: roomData.sessionCode, userId: _userController.userData!.id); + + _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( + quizId: "q123", + authorId: "a123", + authorName: "Admin", + title: "Uji Coba Kuis", + description: "Kuis untuk testing", + date: DateTime.now().toIso8601String(), + totalQuiz: 5, + duration: 900, + ); + } + + void copySessionCode(BuildContext context) { + Clipboard.setData(ClipboardData(text: sessionCode.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Session code disalin!')), + ); + } + + void addUser(UserModel user) { + joinedUsers.add(user); + } + + void startQuiz() { + print("Mulai kuis dengan session: ${sessionCode.value}"); + } +} diff --git a/lib/feature/waiting_room/view/waiting_room_view.dart b/lib/feature/waiting_room/view/waiting_room_view.dart new file mode 100644 index 0000000..e7f66e8 --- /dev/null +++ b/lib/feature/waiting_room/view/waiting_room_view.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:quiz_app/component/global_button.dart'; +import 'package:quiz_app/data/models/user/user_model.dart'; +import 'package:quiz_app/feature/waiting_room/controller/waiting_room_controller.dart'; +import 'package:quiz_app/app/const/colors/app_colors.dart'; + +class WaitingRoomView extends GetView { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text("Waiting Room")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + final session = controller.sessionCode.value; + final quiz = controller.quizMeta.value; + final users = controller.joinedUsers; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildQuizMeta(quiz), + const SizedBox(height: 20), + _buildSessionCode(context, session), + const SizedBox(height: 20), + const Text("Peserta yang Bergabung:", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + Expanded(child: Obx(() => _buildUserList(users.toList()))), + const SizedBox(height: 16), + if (controller.isAdmin.value) + GlobalButton( + text: "Mulai Kuis", + onPressed: controller.startQuiz, + ), + ], + ); + }), + ), + ); + } + + Widget _buildSessionCode(BuildContext context, String code) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.primaryBlue), + ), + child: Row( + children: [ + const Text("Session Code: ", style: TextStyle(fontWeight: FontWeight.bold)), + SelectableText(code, style: const TextStyle(fontSize: 16)), + const Spacer(), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Salin Kode', + onPressed: () => controller.copySessionCode(context), + ), + ], + ), + ); + } + + Widget _buildQuizMeta(dynamic quiz) { + if (quiz == null) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.background, + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Informasi Kuis:", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text("Judul: ${quiz.title}"), + Text("Deskripsi: ${quiz.description}"), + Text("Jumlah Soal: ${quiz.totalQuiz}"), + Text("Durasi: ${quiz.duration ~/ 60} menit"), + ], + ), + ); + } + + Widget _buildUserList(List users) { + return ListView.separated( + itemCount: users.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final user = users[index]; + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + CircleAvatar(child: Text(user.name[0])), + const SizedBox(width: 12), + Text(user.name, style: const TextStyle(fontSize: 16)), + ], + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 942d1fc..f41ffd9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -248,6 +248,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" lucide_icons: dependency: "direct main" description: @@ -413,6 +421,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d + url: "https://pub.dev" + source: hosted + version: "3.1.2" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cad9a05..f304327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: shared_preferences: ^2.5.3 lucide_icons: ^0.257.0 google_fonts: ^6.1.0 + socket_io_client: ^3.1.2 dev_dependencies: flutter_test: