feat: done implement admin result page

This commit is contained in:
akhdanre 2025-05-19 00:44:42 +07:00
parent b8c7d62c8c
commit 871ec13c31
10 changed files with 468 additions and 281 deletions

View File

@ -1,5 +1,7 @@
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/view/admin_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';
@ -142,9 +144,10 @@ class AppPages {
page: () => UpdateProfilePage(),
binding: UpdateProfileBinding(),
),
// GetPage(
// name: AppRoutes.monitorResultMPLPage,
// page: () => AdminResultPage(),
// )
GetPage(
name: AppRoutes.monitorResultMPLPage,
page: () => AdminResultPage(),
binding: AdminResultBinding(),
)
];
}

View File

@ -23,6 +23,8 @@ class APIEndpoint {
static const String session = "/session";
static const String sessionHistory = "/history/session";
static const String userData = "/user";
static const String userUpdate = "/user/update";
}

View File

@ -0,0 +1,87 @@
class Participant {
final String id;
final String name;
final int score;
Participant({
required this.id,
required this.name,
required this.score,
});
factory Participant.fromJson(Map<String, dynamic> json) {
return Participant(
id: json['id'],
name: json['name'],
score: json['score'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'score': score,
};
}
}
class SessionHistory {
final String id;
final String sessionCode;
final String quizId;
final String hostId;
final DateTime createdAt;
final DateTime? startedAt;
final DateTime? endedAt;
final bool isActive;
final int participantLimit;
final List<Participant> participants;
final int currentQuestionIndex;
SessionHistory({
required this.id,
required this.sessionCode,
required this.quizId,
required this.hostId,
required this.createdAt,
this.startedAt,
this.endedAt,
required this.isActive,
required this.participantLimit,
required this.participants,
required this.currentQuestionIndex,
});
factory SessionHistory.fromJson(Map<String, dynamic> json) {
return SessionHistory(
id: json['id'],
sessionCode: json['session_code'],
quizId: json['quiz_id'],
hostId: json['host_id'],
createdAt: DateTime.parse(json['created_at']),
startedAt: json['started_at'] != null ? DateTime.parse(json['started_at']) : null,
endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null,
isActive: json['is_active'],
participantLimit: json['participan_limit'], // Typo di JSON, harusnya "participant_limit"
participants: (json['participants'] as List).map((p) => Participant.fromJson(p)).toList(),
currentQuestionIndex: json['current_question_index'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'session_code': sessionCode,
'quiz_id': quizId,
'host_id': hostId,
'created_at': createdAt.toIso8601String(),
'started_at': startedAt?.toIso8601String(),
'ended_at': endedAt?.toIso8601String(),
'is_active': isActive,
'participan_limit': participantLimit, // Tetap gunakan sesuai field JSON yang ada
'participants': participants.map((p) => p.toJson()).toList(),
'current_question_index': currentQuestionIndex,
};
}
}

View File

@ -5,6 +5,7 @@ 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/detail_quiz_history.dart';
import 'package:quiz_app/data/models/history/quiz_history.dart';
import 'package:quiz_app/data/models/history/session_history.dart';
import 'package:quiz_app/data/providers/dio_client.dart';
class HistoryService extends GetxService {
@ -45,4 +46,20 @@ class HistoryService extends GetxService {
return null;
}
}
Future<BaseResponseModel<SessionHistory>?> getSessionHistory(String sessionId) async {
try {
final result = await _dio.get("${APIEndpoint.sessionHistory}/$sessionId");
final parsedResponse = BaseResponseModel<SessionHistory>.fromJson(
result.data,
(data) => SessionHistory.fromJson(data),
);
return parsedResponse;
} catch (e, stacktrace) {
logC.e(e, stackTrace: stacktrace);
return null;
}
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import 'package:quiz_app/data/services/history_service.dart';
import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.dart';
class AdminResultBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => HistoryService());
Get.lazyPut(() => AdminResultController(Get.find<HistoryService>()));
}
}

View File

@ -0,0 +1,32 @@
import 'package:get/get.dart';
import 'package:quiz_app/data/models/history/session_history.dart';
import 'package:quiz_app/data/services/history_service.dart';
class AdminResultController extends GetxController {
final HistoryService _historyService;
AdminResultController(this._historyService);
SessionHistory? sessionHistory;
RxBool isLoading = false.obs;
@override
void onInit() {
loadData();
super.onInit();
}
void loadData() async {
isLoading.value = true;
final sessionId = Get.arguments as String;
final result = await _historyService.getSessionHistory(sessionId);
if (result != null) {
sessionHistory = result.data!;
print(sessionHistory!.toJson());
}
isLoading.value = false;
}
}

View File

@ -1,263 +1,246 @@
// import 'package:flutter/material.dart';
// import 'package:lucide_icons/lucide_icons.dart';
// import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart';
import 'package:get/get_state_manager/src/simple/get_view.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/data/models/history/session_history.dart';
import 'package:quiz_app/feature/admin_result_page/controller/admin_result_controller.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/detail_participant_result_page.dart';
class AdminResultPage extends GetView<AdminResultController> {
const AdminResultPage({Key? key}) : super(key: key);
// class AdminResultPage extends StatelessWidget {
// const AdminResultPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// backgroundColor: AppColors.background,
// body: SafeArea(
// child: Padding(
// padding: const EdgeInsets.all(16.0),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// _buildSectionHeader("Hasil Akhir Kuis"),
// _buildSummaryCard(),
// const SizedBox(height: 20),
// _buildSectionHeader('Peringkat Peserta'),
// const SizedBox(height: 14),
// Expanded(
// child: ListView.separated(
// itemCount: dummyParticipants.length,
// separatorBuilder: (context, index) => const SizedBox(height: 10),
// itemBuilder: (context, index) {
// final participant = dummyParticipants[index];
// return _buildParticipantResultCard(
// context,
// participant,
// position: index + 1,
// );
// },
// ),
// ),
// ],
// ),
// ),
// ),
// );
// }
if (controller.sessionHistory == null) {
return const Center(child: Text("Data tidak ditemukan."));
}
// Widget _buildSectionHeader(String title) {
// return Container(
// padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
// decoration: BoxDecoration(
// borderRadius: BorderRadius.circular(8),
// ),
// child: Text(title, style: AppTextStyles.title),
// );
// }
final participants = controller.sessionHistory!.participants;
// Widget _buildSummaryCard() {
// // Hitung nilai rata-rata
// final avgScore = dummyParticipants.map((p) => p.scorePercent).reduce((a, b) => a + b) / dummyParticipants.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader("Hasil Akhir Kuis"),
const SizedBox(height: 20),
_buildSummaryCard(participants),
const SizedBox(height: 20),
_buildSectionHeader('Peringkat Peserta'),
const SizedBox(height: 14),
Expanded(
child: ListView.separated(
itemCount: participants.length,
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final participant = participants[index];
return _buildParticipantResultCard(
participant,
position: index + 1,
);
},
),
),
],
);
}),
),
),
);
}
// // Hitung jumlah peserta yang lulus (>= 60%)
// final passCount = dummyParticipants.where((p) => p.scorePercent >= 60).length;
Widget _buildSectionHeader(String title) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: Text(title, style: AppTextStyles.title),
);
}
// return Card(
// elevation: 2,
// color: Colors.white,
// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(16),
// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)),
// ),
// child: Padding(
// padding: const EdgeInsets.all(20),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Row(
// children: [
// Icon(
// LucideIcons.clipboardCheck,
// color: AppColors.primaryBlue,
// size: 20,
// ),
// const SizedBox(width: 8),
// Text(
// "RINGKASAN KUIS",
// style: TextStyle(
// fontSize: 13,
// fontWeight: FontWeight.bold,
// color: AppColors.primaryBlue,
// letterSpacing: 0.8,
// ),
// ),
// ],
// ),
// Divider(color: AppColors.borderLight, height: 20),
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceAround,
// children: [
// _buildSummaryItem(
// icon: LucideIcons.users,
// value: "${dummyParticipants.length}",
// label: "Total Peserta",
// ),
// _buildSummaryItem(
// icon: LucideIcons.percent,
// value: "${avgScore.toStringAsFixed(1)}%",
// label: "Rata-Rata Nilai",
// valueColor: _getScoreColor(avgScore),
// ),
// _buildSummaryItem(
// icon: LucideIcons.award,
// value: "$passCount/${dummyParticipants.length}",
// label: "Peserta Lulus",
// valueColor: AppColors.scoreGood,
// ),
// ],
// ),
// ],
// ),
// ),
// );
// }
Widget _buildSummaryCard(List<Participant> participants) {
final avgScore = participants.isNotEmpty ? participants.map((p) => p.score).reduce((a, b) => a + b) / participants.length : 0.0;
// Widget _buildSummaryItem({
// required IconData icon,
// required String value,
// required String label,
// Color? valueColor,
// }) {
// return Column(
// children: [
// Icon(icon, color: AppColors.softGrayText, size: 22),
// const SizedBox(height: 8),
// Text(
// value,
// style: TextStyle(
// fontSize: 18,
// fontWeight: FontWeight.bold,
// color: valueColor ?? AppColors.darkText,
// ),
// ),
// const SizedBox(height: 4),
// Text(
// label,
// style: AppTextStyles.caption,
// ),
// ],
// );
// }
final passCount = participants.where((p) => p.score >= 60).length;
// Widget _buildParticipantResultCard(BuildContext context, ParticipantResult participant, {required int position}) {
// return InkWell(
// onTap: () {
// // Navigasi ke halaman detail saat kartu ditekan
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => ParticipantDetailPage(participant: participant),
// ),
// );
// },
// child: Card(
// elevation: 2,
// color: Colors.white,
// shadowColor: AppColors.shadowPrimary.withValues(alpha:0.2),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(16),
// side: BorderSide(color: AppColors.accentBlue.withValues(alpha:0.2)),
// ),
// child: Padding(
// padding: const EdgeInsets.all(20),
// child: Row(
// children: [
// // Position indicator
// Container(
// width: 36,
// height: 36,
// decoration: BoxDecoration(
// shape: BoxShape.circle,
// color: _getPositionColor(position),
// ),
// child: Center(
// child: Text(
// position.toString(),
// style: const TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: Colors.white,
// ),
// ),
// ),
// ),
// const SizedBox(width: 16),
// // Participant info
// Expanded(
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// participant.name,
// style: AppTextStyles.subtitle,
// ),
// const SizedBox(height: 4),
// Text(
// "Benar: ${participant.correctAnswers}/${participant.totalQuestions}",
// style: AppTextStyles.caption,
// ),
// ],
// ),
// ),
// // Score
// Container(
// width: 60,
// height: 60,
// decoration: BoxDecoration(
// shape: BoxShape.circle,
// color: _getScoreColor(participant.scorePercent).withValues(alpha:0.1),
// border: Border.all(
// color: _getScoreColor(participant.scorePercent),
// width: 2,
// ),
// ),
// child: Center(
// child: Text(
// "${participant.scorePercent.toInt()}%",
// style: TextStyle(
// fontSize: 16,
// fontWeight: FontWeight.bold,
// color: _getScoreColor(participant.scorePercent),
// ),
// ),
// ),
// ),
// const SizedBox(width: 12),
// // Arrow indicator
// Icon(
// LucideIcons.chevronRight,
// color: AppColors.softGrayText,
// size: 20,
// ),
// ],
// ),
// ),
// ),
// );
// }
return Card(
elevation: 2,
color: Colors.white,
shadowColor: AppColors.shadowPrimary.withOpacity(0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.assignment_turned_in, color: AppColors.primaryBlue, size: 20),
const SizedBox(width: 8),
Text(
"RINGKASAN KUIS",
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
letterSpacing: 0.8,
),
),
],
),
const Divider(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildSummaryItem(
icon: Icons.group,
value: "${participants.length}",
label: "Total Peserta",
),
_buildSummaryItem(
icon: Icons.percent,
value: "${avgScore.toStringAsFixed(1)}%",
label: "Rata-Rata Nilai",
valueColor: _getScoreColor(avgScore),
),
_buildSummaryItem(
icon: Icons.emoji_events,
value: "$passCount/${participants.length}",
label: "Peserta Lulus",
valueColor: AppColors.scoreGood,
),
],
),
],
),
),
);
}
// Color _getScoreColor(double score) {
// if (score >= 80) return AppColors.scoreExcellent;
// if (score >= 70) return AppColors.scoreGood;
// if (score >= 60) return AppColors.scoreAverage;
// return AppColors.scorePoor;
// }
Widget _buildSummaryItem({
required IconData icon,
required String value,
required String label,
Color? valueColor,
}) {
return Column(
children: [
Icon(icon, color: AppColors.softGrayText, size: 22),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: valueColor ?? AppColors.darkText,
),
),
const SizedBox(height: 4),
Text(
label,
style: AppTextStyles.caption,
),
],
);
}
// Color _getPositionColor(int position) {
// if (position == 1) return const Color(0xFFFFD700); // Gold
// if (position == 2) return const Color(0xFFC0C0C0); // Silver
// if (position == 3) return const Color(0xFFCD7F32); // Bronze
// return AppColors.softGrayText;
// }
// }
Widget _buildParticipantResultCard(Participant participant, {required int position}) {
final scorePercent = participant.score.toDouble();
return Card(
elevation: 2,
color: Colors.white,
shadowColor: AppColors.shadowPrimary.withOpacity(0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.accentBlue.withOpacity(0.2)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getPositionColor(position),
),
child: Center(
child: Text(
position.toString(),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(participant.name, style: AppTextStyles.subtitle),
const SizedBox(height: 4),
Text("Skor: ${participant.score}", style: AppTextStyles.caption),
],
),
),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getScoreColor(scorePercent).withOpacity(0.1),
border: Border.all(
color: _getScoreColor(scorePercent),
width: 2,
),
),
child: Center(
child: Text(
"${participant.score}%",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getScoreColor(scorePercent),
),
),
),
),
const SizedBox(width: 12),
const Icon(Icons.chevron_right, color: AppColors.softGrayText, size: 20),
],
),
),
);
}
Color _getScoreColor(double score) {
if (score >= 80) return AppColors.scoreExcellent;
if (score >= 70) return AppColors.scoreGood;
if (score >= 60) return AppColors.scoreAverage;
return AppColors.scorePoor;
}
Color _getPositionColor(int position) {
if (position == 1) return const Color(0xFFFFD700); // Gold
if (position == 2) return const Color(0xFFC0C0C0); // Silver
if (position == 3) return const Color(0xFFCD7F32); // Bronze
return AppColors.softGrayText;
}
}

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart';
import 'package:quiz_app/app/routes/app_pages.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';
@ -100,6 +101,10 @@ class MonitorQuizController extends GetxController {
// Notify observers
participan.refresh();
});
_socketService.quizDone.listen((_) {
Get.offAllNamed(AppRoutes.monitorResultMPLPage, arguments: sessionId);
});
}
}

View File

@ -26,26 +26,68 @@ class RoomMakerController extends GetxController {
this._quizService,
);
// final roomName = ''.obs;
final selectedQuiz = Rxn<QuizListingModel>();
RxBool isOnwQuiz = true.obs;
final TextEditingController nameTC = TextEditingController();
final TextEditingController maxPlayerTC = TextEditingController();
final ScrollController scrollController = ScrollController();
final availableQuizzes = <QuizListingModel>[].obs;
int currentPage = 1;
bool isLoading = false;
bool hasMoreData = true;
@override
void onInit() {
loadQuiz();
loadQuiz(reset: true);
scrollController.addListener(_scrollListener);
super.onInit();
}
loadQuiz() async {
BaseResponseModel<List<QuizListingModel>>? response = await _quizService.userQuiz(_userController.userData!.id, 1);
Future<void> loadQuiz({bool reset = false}) async {
if (isLoading) return;
isLoading = true;
if (reset) {
currentPage = 1;
hasMoreData = true;
}
BaseResponseModel<List<QuizListingModel>>? response;
if (isOnwQuiz.value) {
response = await _quizService.userQuiz(_userController.userData!.id, currentPage);
} else {
response = await _quizService.recomendationQuiz(page: currentPage, amount: 5);
}
if (response != null) {
if (reset) {
availableQuizzes.assignAll(response.data!);
} else {
availableQuizzes.addAll(response.data!);
}
if (response.data == null || response.data!.isEmpty) {
hasMoreData = false;
} else {
currentPage++;
}
} else {
hasMoreData = false;
}
isLoading = false;
}
void _scrollListener() {
if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 100) {
if (hasMoreData && !isLoading) {
loadQuiz();
}
}
}
@ -67,7 +109,10 @@ class RoomMakerController extends GetxController {
if (response != null) {
_socketService.initSocketConnection();
_socketService.joinRoom(sessionCode: response.data!.sessionCode, userId: _userController.userData!.id);
_socketService.joinRoom(
sessionCode: response.data!.sessionCode,
userId: _userController.userData!.id,
);
_socketService.roomMessages.listen((data) {
if (data["type"] == "join") {
@ -94,17 +139,7 @@ class RoomMakerController extends GetxController {
void onQuizSourceChange(bool base) async {
isOnwQuiz.value = base;
if (base) {
BaseResponseModel<List<QuizListingModel>>? response = await _quizService.userQuiz(_userController.userData!.id, 1);
if (response != null) {
availableQuizzes.assignAll(response.data!);
}
return;
}
BaseResponseModel<List<QuizListingModel>>? response = await _quizService.recomendationQuiz(page: 1, amount: 4);
if (response != null) {
availableQuizzes.assignAll(response.data!);
}
await loadQuiz(reset: true);
}
void onQuizChoosen(String quizId) {

View File

@ -44,13 +44,25 @@ class RoomMakerView extends GetView<RoomMakerController> {
Expanded(
child: Container(
child: Obx(() => ListView.builder(
itemCount: controller.availableQuizzes.length,
controller: controller.scrollController,
itemCount: controller.availableQuizzes.length + (controller.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index < controller.availableQuizzes.length) {
return QuizContainerComponent(
data: controller.availableQuizzes[index],
onTap: controller.onQuizChoosen,
);
})),
} else {
// Loading Indicator di Bawah
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
},
)),
),
),
SizedBox(