feat: waiting room

This commit is contained in:
akhdanre 2025-05-07 00:32:38 +07:00
parent 481bfbe228
commit 49e33d00a9
18 changed files with 454 additions and 13 deletions

View File

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

View File

@ -19,4 +19,5 @@ abstract class AppRoutes {
static const detailHistoryPage = "/history/detail";
static const roomPage = "/room/quiz";
static const waitRoomPage = "/room/quiz/waiting";
}

View File

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

View File

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

View File

@ -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<String, dynamic> json) {
return SessionRequestModel(
quizId: json['quiz_id'],
hostId: json['host_id'],
limitParticipan: json['limit_participan'],
);
}
Map<String, dynamic> toJson() {
return {
'quiz_id': quizId,
'host_id': hostId,
'limit_participan': limitParticipan,
};
}
}

View File

@ -0,0 +1,20 @@
class SessionResponseModel {
final String sessionId;
final String sessionCode;
SessionResponseModel({required this.sessionId, required this.sessionCode});
factory SessionResponseModel.fromJson(Map<String, dynamic> json) {
return SessionResponseModel(
sessionId: json['session_id'],
sessionCode: json['session_code'],
);
}
Map<String, dynamic> toJson() {
return {
'session_id': sessionId,
'session_code': sessionCode,
};
}
}

View File

@ -0,0 +1,9 @@
class UserModel {
final String id;
final String name;
UserModel({
required this.id,
required this.name,
});
}

View File

@ -8,7 +8,7 @@ class ApiClient extends GetxService {
Future<ApiClient> init() async {
dio = Dio(BaseOptions(
baseUrl: APIEndpoint.baseUrl,
baseUrl: APIEndpoint.api,
connectTimeout: const Duration(minutes: 3),
receiveTimeout: const Duration(minutes: 10),
headers: {

View File

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

View File

@ -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<ApiClient>().dio;
super.onInit();
}
Future<BaseResponseModel<SessionResponseModel>?> 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;
}
}
}

View File

@ -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<Map<String, dynamic>>.broadcast();
final _chatMessageController = StreamController<Map<String, dynamic>>.broadcast();
final _errorController = StreamController<String>.broadcast();
Stream<Map<String, dynamic>> get roomMessages => _roomMessageController.stream;
Stream<Map<String, dynamic>> get chatMessages => _chatMessageController.stream;
Stream<String> 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<String, dynamic>.from(data));
});
socket.on('receive_message', (data) {
print('Message from ${data['from']}: ${data['message']}');
_chatMessageController.add(Map<String, dynamic>.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();
}
}

View File

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

View File

@ -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<QuizListingModel>();
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() {}
}

View File

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

View File

@ -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<QuizListingModel?>(null);
final joinedUsers = <UserModel>[].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}");
}
}

View File

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

View File

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

View File

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