feat: done final result on the multiplayer quiz

This commit is contained in:
akhdanre 2025-05-19 01:42:15 +07:00
parent 871ec13c31
commit abe21031ec
10 changed files with 521 additions and 281 deletions

View File

@ -1,7 +1,9 @@
import 'package:get/get_navigation/src/routes/get_route.dart';
import 'package:quiz_app/app/middleware/auth_middleware.dart';
import 'package:quiz_app/feature/admin_result_page/bindings/admin_result_binding.dart';
import 'package:quiz_app/feature/admin_result_page/bindings/detail_participant_result_binding.dart';
import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart';
import 'package:quiz_app/feature/admin_result_page/view/detail_participant_result_page.dart';
import 'package:quiz_app/feature/history/binding/detail_history_binding.dart';
import 'package:quiz_app/feature/history/binding/history_binding.dart';
import 'package:quiz_app/feature/history/view/detail_history_view.dart';
@ -148,6 +150,11 @@ class AppPages {
name: AppRoutes.monitorResultMPLPage,
page: () => AdminResultPage(),
binding: AdminResultBinding(),
),
GetPage(
name: AppRoutes.quizMPLResultPage,
page: () => ParticipantDetailPage(),
binding: DetailParticipantResultBinding(),
)
];
}

View File

@ -27,4 +27,6 @@ abstract class AppRoutes {
static const monitorResultMPLPage = "/room/quiz/monitor/result";
static const updateProfilePage = "/profile/update";
static const quizMPLResultPage = "/room/quiz/result";
}

View File

@ -11,6 +11,7 @@ class APIEndpoint {
static const String quiz = "/quiz";
static const String quizGenerate = "/quiz/ai";
static const String quizAnswer = "/quiz/answer";
static const String quizAnswerSession = "/quiz/answer/session";
static const String userQuiz = "/quiz/user";
static const String quizRecomendation = "/quiz/recomendation";

View File

@ -0,0 +1,78 @@
class QuestionAnswer {
final int index;
final String question;
final dynamic targetAnswer;
final int duration;
final String type;
final List<String>? options;
final String answer;
final bool isCorrect;
final double timeSpent;
QuestionAnswer({
required this.index,
required this.question,
required this.targetAnswer,
required this.duration,
required this.type,
required this.options,
required this.answer,
required this.isCorrect,
required this.timeSpent,
});
factory QuestionAnswer.fromJson(Map<String, dynamic> json) {
return QuestionAnswer(
index: json['index'],
question: json['question'],
targetAnswer: json['target_answer'],
duration: json['duration'],
type: json['type'],
options: json['options'] != null ? List<String>.from(json['options']) : null,
answer: json['answer'],
isCorrect: json['is_correct'],
timeSpent: (json['time_spent'] as num).toDouble(),
);
}
}
class ParticipantResult {
final String id;
final String sessionId;
final String quizId;
final String userId;
final String answeredAt;
final List<QuestionAnswer> answers;
final int totalScore;
final int totalCorrect;
ParticipantResult({
required this.id,
required this.sessionId,
required this.quizId,
required this.userId,
required this.answeredAt,
required this.answers,
required this.totalScore,
required this.totalCorrect,
});
factory ParticipantResult.fromJson(Map<String, dynamic> json) {
return ParticipantResult(
id: json['id'],
sessionId: json['session_id'],
quizId: json['quiz_id'],
userId: json['user_id'],
answeredAt: json['answered_at'],
answers: (json['answers'] as List).map((e) => QuestionAnswer.fromJson(e)).toList(),
totalScore: json['total_score'],
totalCorrect: json['total_correct'],
);
}
double get scorePercent => (totalCorrect / answers.length) * 100;
int get totalQuestions => answers.length;
String get name => "User $userId";
}

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:quiz_app/core/endpoint/api_endpoint.dart';
import 'package:quiz_app/core/utils/logger.dart';
import 'package:quiz_app/data/models/base/base_model.dart';
import 'package:quiz_app/data/models/history/participant_history_result.dart';
import 'package:quiz_app/data/providers/dio_client.dart';
class AnswerService extends GetxService {
@ -26,4 +27,22 @@ class AnswerService extends GetxService {
return null;
}
}
Future<BaseResponseModel<ParticipantResult>?> getAnswerSession(String sessionId, String userId) async {
try {
final response = await _dio.post(APIEndpoint.quizAnswerSession, data: {
"session_id": sessionId,
"user_id": userId,
});
final parsedResponse = BaseResponseModel<ParticipantResult>.fromJson(
response.data,
(data) => ParticipantResult.fromJson(data),
);
return parsedResponse;
} on DioException catch (e) {
logC.e('Gagal mengirim jawaban: ${e.response?.data['message'] ?? e.message}');
return null;
}
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import 'package:quiz_app/data/services/answer_service.dart';
import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart';
class DetailParticipantResultBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => AnswerService());
Get.lazyPut(() => ParticipantResultController(Get.find<AnswerService>()));
}
}

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart';
import 'package:quiz_app/app/routes/app_pages.dart';
import 'package:quiz_app/data/models/history/session_history.dart';
import 'package:quiz_app/data/services/history_service.dart';
@ -10,6 +11,8 @@ class AdminResultController extends GetxController {
SessionHistory? sessionHistory;
RxBool isLoading = false.obs;
String sessionId = "";
@override
void onInit() {
loadData();
@ -19,7 +22,7 @@ class AdminResultController extends GetxController {
void loadData() async {
isLoading.value = true;
final sessionId = Get.arguments as String;
sessionId = Get.arguments as String;
final result = await _historyService.getSessionHistory(sessionId);
if (result != null) {
@ -29,4 +32,9 @@ class AdminResultController extends GetxController {
isLoading.value = false;
}
void goToDetailParticipants(String userId, String username) => Get.toNamed(
AppRoutes.quizMPLResultPage,
arguments: {"user_id": userId, "session_id": sessionId, "username": username},
);
}

View File

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:quiz_app/data/models/history/participant_history_result.dart';
import 'package:quiz_app/data/services/answer_service.dart';
class ParticipantResultController extends GetxController {
final AnswerService _answerService;
ParticipantResultController(this._answerService);
final Rx<ParticipantResult?> participantResult = Rx<ParticipantResult?>(null);
final RxBool isLoading = false.obs;
RxString participantName = "".obs;
@override
void onInit() {
loadData();
super.onInit();
}
void loadData() async {
isLoading.value = true;
final args = Get.arguments;
participantName.value = args["username"];
final response = await _answerService.getAnswerSession(args["session_id"], args["user_id"]);
if (response != null) {
participantResult.value = response.data;
}
isLoading.value = false;
}
double calculateScorePercent() {
if (participantResult.value == null) return 0;
return participantResult.value!.scorePercent;
}
int getTotalCorrect() {
return participantResult.value?.totalCorrect ?? 0;
}
int getTotalQuestions() {
return participantResult.value?.totalQuestions ?? 0;
}
}

View File

@ -167,6 +167,8 @@ class AdminResultPage extends GetView<AdminResultController> {
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)),
),
child: InkWell(
onTap: () => controller.goToDetailParticipants(participant.id, participant.name),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
@ -227,6 +229,7 @@ class AdminResultPage extends GetView<AdminResultController> {
],
),
),
),
);
}

View File

@ -1,238 +1,302 @@
// // Halaman detail untuk peserta
// import 'package:flutter/material.dart';
// import 'package:lucide_icons/lucide_icons.dart';
// import 'package:quiz_app/app/const/colors/app_colors.dart';
// import 'package:quiz_app/app/const/text/text_style.dart';
// import 'package:quiz_app/feature/admin_result_page/view/admin_result_page.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:quiz_app/app/const/colors/app_colors.dart';
import 'package:quiz_app/data/models/history/participant_history_result.dart';
import 'package:quiz_app/feature/admin_result_page/controller/detail_participant_result_controller.dart';
// class ParticipantDetailPage extends StatelessWidget {
// final ParticipantResult participant;
class ParticipantDetailPage extends GetView<ParticipantResultController> {
const ParticipantDetailPage({
Key? key,
}) : super(key: key);
// const ParticipantDetailPage({
// Key? key,
// required this.participant,
// }) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('Detail Peserta'),
backgroundColor: Colors.white,
foregroundColor: AppColors.darkText,
elevation: 0,
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Get.back(),
),
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// backgroundColor: AppColors.background,
// appBar: AppBar(
// title: Text('Detail ${participant.name}'),
// backgroundColor: Colors.white,
// foregroundColor: AppColors.darkText,
// elevation: 0,
// leading: IconButton(
// icon: const Icon(LucideIcons.arrowLeft),
// onPressed: () => Navigator.pop(context),
// ),
// ),
// body: Column(
// children: [
// _buildParticipantHeader(),
// Expanded(
// child: ListView.builder(
// padding: const EdgeInsets.all(16),
// itemCount: participant.answers.length,
// itemBuilder: (context, index) {
// return _buildAnswerCard(participant.answers[index], index + 1);
// },
// ),
// ),
// ],
// ),
// );
// }
final participant = controller.participantResult.value;
if (participant == null) {
return const Center(child: Text('Data peserta tidak tersedia.'));
}
// Widget _buildParticipantHeader() {
// return Container(
// width: double.infinity,
// padding: const EdgeInsets.all(16),
// decoration: BoxDecoration(
// color: Colors.white,
// border: Border(
// bottom: BorderSide(
// color: AppColors.borderLight,
// width: 1,
// ),
// ),
// ),
// child: Row(
// children: [
// CircleAvatar(
// radius: 26,
// backgroundColor: AppColors.accentBlue,
// child: Text(
// participant.name.isNotEmpty ? participant.name[0].toUpperCase() : "?",
// style: TextStyle(
// fontSize: 22,
// fontWeight: FontWeight.bold,
// color: AppColors.primaryBlue,
// ),
// ),
// ),
// const SizedBox(width: 16),
// Expanded(
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// participant.name,
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: AppColors.darkText,
// ),
// ),
// const SizedBox(height: 4),
// Text(
// "Jumlah Soal: ${participant.totalQuestions}",
// style: TextStyle(
// fontSize: 14,
// color: AppColors.softGrayText,
// ),
// ),
// ],
// ),
// ),
// Container(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// decoration: BoxDecoration(
// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1),
// borderRadius: BorderRadius.circular(16),
// border: Border.all(
// color: _getScoreColor(participant.scorePercent),
// ),
// ),
// child: Row(
// children: [
// Icon(
// LucideIcons.percent,
// size: 16,
// color: _getScoreColor(participant.scorePercent),
// ),
// const SizedBox(width: 6),
// Text(
// "${participant.scorePercent.toInt()}%",
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: _getScoreColor(participant.scorePercent),
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// );
// }
return Column(
children: [
_buildParticipantHeader(participant),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: participant.answers.length,
itemBuilder: (context, index) {
return _buildAnswerCard(participant.answers[index], index + 1);
},
),
),
],
);
}),
);
}
// Widget _buildAnswerCard(QuestionAnswer answer, int number) {
// return Container(
// width: double.infinity,
// margin: const EdgeInsets.only(bottom: 20),
// padding: const EdgeInsets.all(16),
// // decoration: _containerDecoration,
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Row(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Container(
// width: 28,
// height: 28,
// decoration: BoxDecoration(
// shape: BoxShape.circle,
// color: answer.isCorrect ? AppColors.primaryBlue.withValues(alpha:0.1) : AppColors.scorePoor.withValues(alpha:0.1),
// ),
// child: Center(
// child: Text(
// number.toString(),
// style: TextStyle(
// fontSize: 13,
// fontWeight: FontWeight.bold,
// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
// ),
// ),
// ),
// ),
// const SizedBox(width: 12),
// Expanded(
// child: Text(
// 'Soal $number: ${answer.question}',
// style: const TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: AppColors.darkText,
// ),
// ),
// ),
// Icon(
// answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle,
// color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
// size: 20,
// ),
// ],
// ),
// const SizedBox(height: 12),
// Divider(color: AppColors.borderLight),
// const SizedBox(height: 12),
// _buildAnswerRow(
// label: "Jawaban Siswa:",
// answer: answer.userAnswer,
// isCorrect: answer.isCorrect,
// ),
// if (!answer.isCorrect) ...[
// const SizedBox(height: 10),
// _buildAnswerRow(
// label: "Jawaban Benar:",
// answer: answer.correctAnswer,
// isCorrect: true,
// ),
// ],
// ],
// ),
// );
// }
Widget _buildParticipantHeader(ParticipantResult participant) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
color: AppColors.borderLight,
width: 1,
),
),
),
child: Row(
children: [
CircleAvatar(
radius: 26,
backgroundColor: AppColors.accentBlue,
child: Text(
controller.participantName.value[0].toUpperCase(),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.participantName.value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.darkText,
),
),
const SizedBox(height: 4),
Text(
"Jumlah Soal: ${participant.totalQuestions}",
style: const TextStyle(
fontSize: 14,
color: AppColors.softGrayText,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getScoreColor(participant.scorePercent).withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _getScoreColor(participant.scorePercent),
),
),
child: Row(
children: [
Icon(
LucideIcons.percent,
size: 16,
color: _getScoreColor(participant.scorePercent),
),
const SizedBox(width: 6),
Text(
"${participant.scorePercent.toInt()}%",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getScoreColor(participant.scorePercent),
),
),
],
),
),
],
),
);
}
// Widget _buildAnswerRow({
// required String label,
// required String answer,
// required bool isCorrect,
// }) {
// return Row(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// SizedBox(
// width: 110,
// child: Text(
// label,
// style: TextStyle(
// fontSize: 14,
// fontWeight: FontWeight.w500,
// color: AppColors.softGrayText,
// ),
// ),
// ),
// Expanded(
// child: Text(
// answer,
// style: TextStyle(
// fontSize: 15,
// fontWeight: FontWeight.w600,
// color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
// ),
// ),
// ),
// ],
// );
// }
Widget _buildAnswerCard(QuestionAnswer answer, int number) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: answer.isCorrect ? AppColors.primaryBlue.withOpacity(0.1) : AppColors.scorePoor.withOpacity(0.1),
),
child: Center(
child: Text(
number.toString(),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Soal $number: ${answer.question}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.darkText,
),
),
),
Icon(
answer.isCorrect ? LucideIcons.checkCircle : LucideIcons.xCircle,
color: answer.isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
size: 20,
),
],
),
const SizedBox(height: 12),
Divider(color: AppColors.borderLight),
const SizedBox(height: 12),
_buildAnswerRow(
label: "Tipe Soal:",
answer: answer.type,
isCorrect: true,
),
_buildAnswerRow(
label: "Waktu Diberikan:",
answer: "${answer.duration} detik",
isCorrect: true,
),
_buildAnswerRow(
label: "Waktu Dihabiskan:",
answer: "${answer.timeSpent} detik",
isCorrect: true,
),
_buildAnswerRow(
label: "Jawaban Siswa:",
answer: answer.answer,
isCorrect: answer.isCorrect,
),
if (!answer.isCorrect) ...[
const SizedBox(height: 10),
_buildAnswerRow(
label: "Jawaban Benar:",
answer: answer.targetAnswer.toString(),
isCorrect: true,
),
],
if (answer.options != null) ...[
const SizedBox(height: 10),
_buildOptions(answer.options!),
],
],
),
);
}
// Color _getScoreColor(double score) {
// if (score >= 70) return AppColors.scoreGood;
// if (score >= 60) return AppColors.scoreAverage;
// return AppColors.scorePoor;
// }
// }
Widget _buildOptions(List<String> options) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Pilihan Jawaban:",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.softGrayText,
),
),
const SizedBox(height: 6),
...options.map((opt) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
"- $opt",
style: const TextStyle(
fontSize: 15,
color: AppColors.darkText,
),
),
)),
],
);
}
Widget _buildAnswerRow({
required String label,
required String answer,
required bool isCorrect,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.softGrayText,
),
),
),
Expanded(
child: Text(
answer,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isCorrect ? AppColors.primaryBlue : AppColors.scorePoor,
),
),
),
],
);
}
Color _getScoreColor(double score) {
if (score >= 70) return AppColors.scoreGood;
if (score >= 60) return AppColors.scoreAverage;
return AppColors.scorePoor;
}
}