TIF_NGANJUK_E41210753/lib/app/views/dashboard_teacher.dart

3654 lines
129 KiB
Dart

import 'package:flutter/material.dart';
import 'package:forward_chaining_man_app/app/controllers/developer_controller.dart';
import 'package:forward_chaining_man_app/app/views/developer/page/page_developer_viewer.dart';
import 'package:forward_chaining_man_app/app/views/page_intro.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fl_chart/fl_chart.dart';
class TeacherDashboardController extends GetxController {
// ========== INSTANCE & DEPENDENCIES ==========
final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
// ========== STATE VARIABLES ==========
// Loading state
var isLoading = true.obs;
// Developer mode
final RxBool isDeveloperMode = developerMode.obs;
// User data
var teacherName = ''.obs;
// School data
var schoolId = ''.obs;
// Results data
var studentResults = <DocumentSnapshot>[].obs;
var filteredResults = <DocumentSnapshot>[].obs;
// Filter states
var selectedCategoryFilter = 'all'.obs;
var selectedDateFilter = 'all_time'.obs;
var selectedFilter = 'all'.obs;
var selectedClass = 'Semua Kelas'.obs;
var availableClasses = <String>['Semua Kelas'].obs;
// Custom date filter
var selectedStartDate = DateTime.now().obs;
var selectedEndDate = DateTime.now().obs;
var isCustomDateSelected = false.obs;
// Statistics
var stats = {
'totalStudents': 0,
'careerRecommendations': 0,
'studyRecommendations': 0,
}.obs;
// Variabel untuk pagination
var currentPage = 0.obs;
var itemsPerPage = 10.obs; // Jumlah item per halaman
var totalPages = 0.obs;
var paginatedResults = <DocumentSnapshot>[].obs;
// Fungsi untuk mengatur jumlah item per halaman
void setItemsPerPage(int count) {
itemsPerPage.value = count;
updatePagination();
}
// Fungsi untuk pindah ke halaman tertentu
void goToPage(int page) {
if (page >= 0 && page < totalPages.value) {
currentPage.value = page;
updatePagination();
}
}
// Fungsi untuk halaman selanjutnya
void nextPage() {
if (currentPage.value < totalPages.value - 1) {
currentPage.value++;
updatePagination();
}
}
// Fungsi untuk halaman sebelumnya
void previousPage() {
if (currentPage.value > 0) {
currentPage.value--;
updatePagination();
}
}
void updatePagination() {
if (filteredResults.isEmpty) {
paginatedResults.clear();
totalPages.value = 0;
return;
}
totalPages.value = (filteredResults.length / itemsPerPage.value).ceil();
// Pastikan halaman saat ini valid
if (currentPage.value >= totalPages.value) {
currentPage.value = totalPages.value - 1;
}
int startIndex = currentPage.value * itemsPerPage.value;
int endIndex = startIndex + itemsPerPage.value;
// Pastikan endIndex tidak melebihi panjang array
if (endIndex > filteredResults.length) {
endIndex = filteredResults.length;
}
// Update data yang akan ditampilkan
paginatedResults.value = filteredResults.sublist(startIndex, endIndex);
}
// Cache untuk student class mapping - user ID to class mapping
final Map<String, String> _studentClassCache = {};
// Cache untuk student school mapping - user ID to school ID mapping
final Map<String, String> _studentSchoolCache = {};
// ========== LIFECYCLE METHODS ==========
@override
void onInit() {
super.onInit();
loadTeacherData();
loadStudentResults();
loadAvailableClasses();
}
// ========== DATA LOADING METHODS ==========
Future<void> loadTeacherData() async {
try {
final User? currentUser = _auth.currentUser;
if (currentUser != null) {
// Tetap menggunakan koleksi teachers seperti yang diminta
final teacherDoc =
await _firestore.collection('teachers').doc(currentUser.uid).get();
if (teacherDoc.exists) {
teacherName.value = teacherDoc.data()?['name'] ?? 'Guru';
// Ambil schoolId dari data guru jika ada
schoolId.value = teacherDoc.data()?['schoolId'] ?? '';
}
}
} catch (e) {
print('Error loading teacher data: $e');
}
}
Future<void> loadStudentResults() async {
try {
isLoading.value = true;
// Get the teacher's school ID
final prefs = await SharedPreferences.getInstance();
final teacherSchoolId = prefs.getString('school_id') ?? schoolId.value;
if (teacherSchoolId.isEmpty) {
// If no school ID, handle the error
Get.snackbar(
'Error',
'Tidak dapat menentukan sekolah Anda',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
snackPosition: SnackPosition.BOTTOM,
);
isLoading.value = false;
return;
}
// Preload student class cache
await _preloadStudentClassCache();
// Jika filter kelas sudah dipilih, gunakan query where
QuerySnapshot querySnapshot;
if (selectedClass.value != 'Semua Kelas') {
// Dapatkan semua ID siswa dengan kelas yang dipilih
List<String> studentIds =
await _getStudentIdsByClass(selectedClass.value);
if (studentIds.isEmpty) {
// Jika tidak ada siswa dengan kelas ini
studentResults.value = [];
filteredResults.value = [];
stats['totalStudents'] = 0;
stats['careerRecommendations'] = 0;
stats['studyRecommendations'] = 0;
isLoading.value = false;
return;
}
// Batasi ukuran chunk karena Firestore hanya mendukung hingga 10 where-in dalam satu query
List<DocumentSnapshot> allResults = [];
for (int i = 0; i < studentIds.length; i += 10) {
int end = (i + 10 < studentIds.length) ? i + 10 : studentIds.length;
List<String> chunk = studentIds.sublist(i, end);
// Query from the school-specific recommendation_history subcollection
QuerySnapshot chunkResult = await _firestore
.collection('schools')
.doc(teacherSchoolId)
.collection('recommendation_history')
.where('userId', whereIn: chunk)
.orderBy('timestamp', descending: true)
.get();
allResults.addAll(chunkResult.docs);
}
// Sort the combined results by timestamp
allResults.sort((a, b) {
Timestamp aTimestamp =
(a.data() as Map<String, dynamic>)['timestamp'] as Timestamp;
Timestamp bTimestamp =
(b.data() as Map<String, dynamic>)['timestamp'] as Timestamp;
return bTimestamp.compareTo(aTimestamp);
});
studentResults.value = allResults;
} else {
// Terapkan pagination jika perlu - query from school-specific collection
querySnapshot = await _firestore
.collection('schools')
.doc(teacherSchoolId)
.collection('recommendation_history')
.orderBy('timestamp', descending: true)
.limit(100) // Batasi ke 100 hasil terakhir untuk mencegah lag
.get();
studentResults.value = querySnapshot.docs;
}
// Apply category and date filters
_applyFiltersWithoutClass();
// Update stats
stats['totalStudents'] = studentResults.length;
stats['careerRecommendations'] = studentResults.where((doc) {
final data = doc.data() as Map<String, dynamic>;
return data['isKerja'] == true;
}).length;
stats['studyRecommendations'] = studentResults.where((doc) {
final data = doc.data() as Map<String, dynamic>;
return data['isKerja'] == false;
}).length;
} catch (e) {
print('Error loading student results: $e');
Get.snackbar(
'Error',
'Gagal memuat data siswa: ${e.toString()}',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
snackPosition: SnackPosition.BOTTOM,
);
} finally {
isLoading.value = false;
}
}
// Metode baru untuk memuat class cache - diperbarui untuk struktur firestore baru
Future<void> _preloadStudentClassCache() async {
try {
if (_studentClassCache.isEmpty) {
// Dapatkan shared preferences untuk school ID
final prefs = await SharedPreferences.getInstance();
final teacherSchoolId = prefs.getString('school_id') ?? schoolId.value;
if (teacherSchoolId.isEmpty) {
// Jika tidak ada schoolId, coba ambil dari semua sekolah
final QuerySnapshot schoolsSnapshot =
await _firestore.collection('schools').get();
for (var schoolDoc in schoolsSnapshot.docs) {
// Ambil semua siswa dari setiap sekolah
final QuerySnapshot studentsSnapshot =
await schoolDoc.reference.collection('students').get();
for (var doc in studentsSnapshot.docs) {
final data = doc.data() as Map<String, dynamic>;
if (data.containsKey('class') && data['class'] != null) {
_studentClassCache[doc.id] = data['class'].toString();
_studentSchoolCache[doc.id] = schoolDoc.id;
}
}
}
} else {
// Jika ada schoolId, ambil hanya dari sekolah tersebut
final QuerySnapshot studentsSnapshot = await _firestore
.collection('schools')
.doc(teacherSchoolId)
.collection('students')
.get();
for (var doc in studentsSnapshot.docs) {
final data = doc.data() as Map<String, dynamic>;
if (data.containsKey('class') && data['class'] != null) {
_studentClassCache[doc.id] = data['class'].toString();
_studentSchoolCache[doc.id] = teacherSchoolId;
}
}
}
}
} catch (e) {
print('Error preloading student class cache: $e');
}
}
// Metode untuk mendapatkan student IDs berdasarkan kelas - diperbarui untuk struktur firestore baru
Future<List<String>> _getStudentIdsByClass(String className) async {
List<String> studentIds = [];
try {
// Gunakan cache yang sudah kita buat
if (_studentClassCache.isNotEmpty) {
_studentClassCache.forEach((userId, userClass) {
if (userClass == className) {
studentIds.add(userId);
}
});
} else {
// Jika cache kosong, kita perlu query database
// Dapatkan shared preferences untuk school ID
final prefs = await SharedPreferences.getInstance();
final teacherSchoolId = prefs.getString('school_id') ?? schoolId.value;
if (teacherSchoolId.isEmpty) {
// Jika tidak ada schoolId, cari di semua sekolah
final QuerySnapshot schoolsSnapshot =
await _firestore.collection('schools').get();
for (var schoolDoc in schoolsSnapshot.docs) {
final QuerySnapshot studentsSnapshot = await schoolDoc.reference
.collection('students')
.where('class', isEqualTo: className)
.get();
for (var doc in studentsSnapshot.docs) {
studentIds.add(doc.id);
// Update cache
_studentClassCache[doc.id] = className;
_studentSchoolCache[doc.id] = schoolDoc.id;
}
}
} else {
// Jika ada schoolId, ambil hanya dari sekolah tersebut
final QuerySnapshot studentsSnapshot = await _firestore
.collection('schools')
.doc(teacherSchoolId)
.collection('students')
.where('class', isEqualTo: className)
.get();
studentIds = studentsSnapshot.docs.map((doc) => doc.id).toList();
// Update cache
for (var doc in studentsSnapshot.docs) {
_studentClassCache[doc.id] = className;
_studentSchoolCache[doc.id] = teacherSchoolId;
}
}
}
} catch (e) {
print('Error getting student IDs by class: $e');
}
return studentIds;
}
Future<void> loadAvailableClasses() async {
try {
if (_studentClassCache.isNotEmpty) {
// Gunakan data dari cache yang sudah ada
final Set<String> classes = {'Semua Kelas'};
classes.addAll(_studentClassCache.values);
availableClasses.value = classes.toList()..sort();
} else {
// Dapatkan shared preferences untuk school ID
final prefs = await SharedPreferences.getInstance();
final teacherSchoolId = prefs.getString('school_id') ?? schoolId.value;
// Extract unique class values
final Set<String> classes = {'Semua Kelas'};
if (teacherSchoolId.isEmpty) {
// Jika tidak ada schoolId, ambil dari semua sekolah
final QuerySnapshot schoolsSnapshot =
await _firestore.collection('schools').get();
for (var schoolDoc in schoolsSnapshot.docs) {
final QuerySnapshot studentsSnapshot =
await schoolDoc.reference.collection('students').get();
for (var doc in studentsSnapshot.docs) {
final data = doc.data() as Map<String, dynamic>;
if (data.containsKey('class') && data['class'] != null) {
classes.add(data['class'].toString());
// Update cache sambil kita ambil data
_studentClassCache[doc.id] = data['class'].toString();
_studentSchoolCache[doc.id] = schoolDoc.id;
}
}
}
} else {
// Jika ada schoolId, ambil hanya dari sekolah tersebut
final QuerySnapshot studentsSnapshot = await _firestore
.collection('schools')
.doc(teacherSchoolId)
.collection('students')
.get();
for (var doc in studentsSnapshot.docs) {
final data = doc.data() as Map<String, dynamic>;
if (data.containsKey('class') && data['class'] != null) {
classes.add(data['class'].toString());
// Update cache sambil kita ambil data
_studentClassCache[doc.id] = data['class'].toString();
_studentSchoolCache[doc.id] = teacherSchoolId;
}
}
}
availableClasses.value = classes.toList()..sort();
}
} catch (e) {
print('Error loading available classes: $e');
}
}
// ========== FILTERING METHODS ==========
// Metode dioptimalkan untuk filter tanpa perlu loading ulang data kelas
void _applyFiltersWithoutClass() {
List<DocumentSnapshot> result = studentResults;
// Apply category filter
if (selectedCategoryFilter.value != 'all') {
bool isKerja = selectedCategoryFilter.value == 'career';
result = result.where((doc) {
final data = doc.data() as Map<String, dynamic>;
return data['isKerja'] == isKerja;
}).toList();
}
// Apply date filter
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final firstDayOfMonth = DateTime(now.year, now.month, 1);
if (isCustomDateSelected.value) {
final start = DateTime(selectedStartDate.value.year,
selectedStartDate.value.month, selectedStartDate.value.day);
final end = DateTime(selectedEndDate.value.year,
selectedEndDate.value.month, selectedEndDate.value.day, 23, 59, 59);
result = result.where((doc) {
final data = doc.data() as Map<String, dynamic>;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date.isAfter(start.subtract(const Duration(seconds: 1))) &&
date.isBefore(end.add(const Duration(seconds: 1)));
}).toList();
} else if (selectedDateFilter.value == 'today') {
result = result.where((doc) {
final data = doc.data() as Map<String, dynamic>;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date.isAfter(today.subtract(const Duration(seconds: 1)));
}).toList();
} else if (selectedDateFilter.value == 'this_month') {
result = result.where((doc) {
final data = doc.data() as Map<String, dynamic>;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date
.isAfter(firstDayOfMonth.subtract(const Duration(seconds: 1)));
}).toList();
}
filteredResults.value = result;
updatePagination();
}
// Master filter application - sekarang hanya memicu reload jika filter kelas berubah
Future<void> applyAllFilters() async {
// Jika filter kelas berubah, kita perlu memuat ulang data
if (selectedClass.value != 'Semua Kelas') {
await loadStudentResults();
} else {
_applyFiltersWithoutClass();
}
}
// Filter triggers
void filterByCategory(String category) {
selectedCategoryFilter.value = category;
_applyFiltersWithoutClass();
}
// Filter by date (all_time, today, this_month)
void filterByDate(String dateFilter) {
selectedDateFilter.value = dateFilter;
_applyFiltersWithoutClass();
}
// Filter by class - ini memerlukan reload karena mengubah query dasar
void filterByClass(String classFilter) {
selectedClass.value = classFilter;
loadStudentResults(); // Reload data dengan query baru
}
// Custom date filter methods
void setCustomDateRange(DateTime start, DateTime end) {
selectedStartDate.value = start;
selectedEndDate.value = end;
isCustomDateSelected.value = true;
selectedDateFilter.value = 'custom';
_applyFiltersWithoutClass();
}
void resetCustomDateFilter() {
isCustomDateSelected.value = false;
}
// Legacy filter - tetap dipertahankan untuk kompatibilitas
void filterResults(String filter) {
selectedFilter.value = filter;
switch (filter) {
case 'career':
filteredResults.value =
studentResults.where((doc) => doc['isKerja'] == true).toList();
break;
case 'study':
filteredResults.value =
studentResults.where((doc) => doc['isKerja'] == false).toList();
break;
case 'all':
default:
filteredResults.value = studentResults;
break;
}
updatePagination();
}
// ========== LEGACY FILTER METHODS - DIPERTAHANKAN UNTUK KOMPATIBILITAS ==========
List<DocumentSnapshot> _applyCategoryFilter(
List<DocumentSnapshot> docs, String filter) {
switch (filter) {
case 'career':
return docs.where((doc) {
final data = doc.data() as Map<String, dynamic>;
return data['isKerja'] == true;
}).toList();
case 'study':
return docs.where((doc) {
final data = doc.data() as Map<String, dynamic>;
return data['isKerja'] == false;
}).toList();
case 'all':
default:
return docs;
}
}
List<DocumentSnapshot> _applyDateFilter(
List<DocumentSnapshot> docs, String filter) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final firstDayOfMonth = DateTime(now.year, now.month, 1);
switch (filter) {
case 'today':
return docs.where((doc) {
final data = doc.data() as Map<String, dynamic>;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date.isAfter(today.subtract(const Duration(seconds: 1)));
}).toList();
case 'this_month':
return docs.where((doc) {
final data = doc.data() as Map<String, dynamic>;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date
.isAfter(firstDayOfMonth.subtract(const Duration(seconds: 1)));
}).toList();
case 'all_time':
default:
return docs;
}
}
List<DocumentSnapshot> _applyCustomDateFilter(
List<DocumentSnapshot> docs, DateTime startDate, DateTime endDate) {
final start = DateTime(startDate.year, startDate.month, startDate.day);
final end = DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59);
return docs.where((doc) {
final data = doc.data() as Map;
if (data['timestamp'] == null) return false;
final timestamp = data['timestamp'] as Timestamp;
final date = timestamp.toDate();
return date.isAfter(start.subtract(const Duration(seconds: 1))) &&
date.isBefore(end.add(const Duration(seconds: 1)));
}).toList();
}
// Optimasi: pakai cache untuk mencegah query ke database untuk setiap dokumen - diperbarui untuk struktur baru
Future<List<DocumentSnapshot>> _applyClassFilter(
List<DocumentSnapshot> docs, String filter) async {
if (filter == 'Semua Kelas') {
return docs;
}
List<DocumentSnapshot> filteredDocs = [];
for (var doc in docs) {
final data = doc.data() as Map<String, dynamic>;
final userId = data['userId'];
if (userId == null) continue;
// Gunakan cache terlebih dahulu
if (_studentClassCache.containsKey(userId)) {
if (_studentClassCache[userId] == filter) {
filteredDocs.add(doc);
}
continue;
}
// Jika tidak ada di cache, coba query database
try {
// Dapatkan schoolId dari cache atau shared preferences
String? studentSchoolId = _studentSchoolCache[userId];
if (studentSchoolId == null) {
// Jika tidak ada di cache, coba cari di semua sekolah
bool found = false;
final schoolsSnapshot = await _firestore.collection('schools').get();
for (var schoolDoc in schoolsSnapshot.docs) {
final studentDoc = await schoolDoc.reference
.collection('students')
.doc(userId)
.get();
if (studentDoc.exists) {
final studentData = studentDoc.data() as Map<String, dynamic>;
final studentClass = studentData['class'];
// Update cache
_studentClassCache[userId] = studentClass ?? '';
_studentSchoolCache[userId] = schoolDoc.id;
if (studentClass == filter) {
filteredDocs.add(doc);
}
found = true;
break;
}
}
if (!found) {
// Siswa tidak ditemukan di database
continue;
}
} else {
// Jika ada schoolId di cache, gunakan untuk query langsung
final studentDoc = await _firestore
.collection('schools')
.doc(studentSchoolId)
.collection('students')
.doc(userId)
.get();
if (!studentDoc.exists) continue;
final studentData = studentDoc.data() as Map<String, dynamic>;
final studentClass = studentData['class'];
// Update cache
_studentClassCache[userId] = studentClass ?? '';
if (studentClass == filter) {
filteredDocs.add(doc);
}
}
} catch (e) {
print('Error fetching student class: $e');
}
}
return filteredDocs;
}
// ========== UTILITY METHODS ==========
String formatTimestamp(dynamic timestamp) {
if (timestamp == null) return 'Tanggal tidak tersedia';
try {
DateTime dateTime = (timestamp as Timestamp).toDate();
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
} catch (e) {
return 'Format tanggal error';
}
}
void toggleDeveloperMode(bool value) {
isDeveloperMode.value = value;
developerMode = value;
}
// ========== CHART DATA METHODS ==========
List<PieChartSectionData> getPieChartData() {
return [
PieChartSectionData(
value: stats['careerRecommendations']!.toDouble(),
title: 'Karir',
color: Colors.deepPurple.shade700,
radius: 60,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
value: stats['studyRecommendations']!.toDouble(),
title: 'Kuliah',
color: Colors.indigo.shade900,
radius: 60,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
];
}
// ========== NAVIGATION METHODS ==========
void viewStudentDetail(DocumentSnapshot doc) {
Get.to(() => StudentResultDetailPage(document: doc));
}
Future<void> signOut() async {
try {
await _auth.signOut();
// Clear shared preferences
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
// Navigate to login selection page
Get.off(IntroPage());
} catch (e) {
print('Error signing out: $e');
Get.snackbar(
'Error',
'Gagal keluar: ${e.toString()}',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
snackPosition: SnackPosition.BOTTOM,
);
}
}
}
class TeacherDashboardPage extends StatelessWidget {
const TeacherDashboardPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Get.put(TeacherDashboardController());
final theme = Theme.of(context);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.blue.shade800,
Colors.indigo.shade900,
],
),
),
child: SafeArea(
child: Column(
children: [
_buildAppBar(controller),
// Developer Mode Card with improved visual cues
Expanded(
child: Container(
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(30),
topRight: Radius.circular(30),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(30),
topRight: Radius.circular(30),
),
child: Obx(() => controller.isLoading.value
? _buildLoadingView()
: _buildDashboardContent(controller, theme)),
),
),
),
],
),
),
),
);
}
Widget _buildAppBar(TeacherDashboardController controller) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
padding: const EdgeInsets.all(8),
child: Icon(
Icons.psychology,
size: 30,
color: Colors.blue.shade700,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'EduGuide',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Obx(() => Text(
'Selamat datang, ${controller.teacherName.value}',
style: const TextStyle(
fontSize: 14,
color: Colors.white70,
),
)),
],
),
),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
onPressed: () {
// Refresh data
controller.loadStudentResults();
},
icon: const Icon(Icons.refresh, color: Colors.white),
tooltip: 'Refresh Data',
),
),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
onPressed: () {
// Show sign out confirmation
Get.dialog(
AlertDialog(
title: const Text('Konfirmasi'),
content: const Text('Apakah Anda yakin ingin keluar?'),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey.shade700),
),
),
ElevatedButton(
onPressed: () {
Get.back();
controller.signOut();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
),
child: const Text('Keluar',
style: TextStyle(color: Colors.white)),
),
],
),
);
},
icon: const Icon(Icons.logout, color: Colors.white),
tooltip: 'Keluar',
),
),
],
),
);
}
Widget _buildLoadingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue.shade700),
),
const SizedBox(height: 16),
Text(
'Memuat data...',
style: TextStyle(
fontSize: 16,
color: Colors.blue.shade700,
),
),
],
),
);
}
Widget _buildDashboardContent(
TeacherDashboardController controller, ThemeData theme) {
return RefreshIndicator(
onRefresh: controller.loadStudentResults,
color: Colors.blue.shade700,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Row(
children: [
Icon(Icons.analytics,
size: 18, color: Colors.blue.shade800),
const SizedBox(width: 8),
Text(
'Akses Data Aplikasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
],
),
),
const SizedBox(height: 12),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.grey.shade300,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.indigo.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.code,
size: 20,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode Developer',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.indigo.shade800,
),
),
const SizedBox(height: 2),
const Text(
'Akses data & model AI',
style: TextStyle(
fontSize: 12,
color: Colors.black54,
),
),
],
),
),
],
),
const SizedBox(height: 12),
const Text(
'Aktifkan mode pengembang untuk melihat data dan validasi model forward chaining yang digunakan dalam sistem rekomendasi.',
style: TextStyle(
fontSize: 13,
color: Colors.black54,
height: 1.4,
),
),
const SizedBox(height: 12),
Obx(() => Container(
decoration: BoxDecoration(
color: controller.isDeveloperMode.value
? Colors.indigo.shade50
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
controller.isDeveloperMode.value
? 'Mode Pengembang Aktif'
: 'Mode Pengembang Nonaktif',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: controller.isDeveloperMode.value
? Colors.indigo.shade800
: Colors.black54,
),
),
value: controller.isDeveloperMode.value,
onChanged: (value) =>
controller.toggleDeveloperMode(value),
activeColor: Colors.indigo,
activeTrackColor: Colors.indigo.shade300,
inactiveThumbColor: Colors.grey.shade400,
inactiveTrackColor: Colors.grey.shade300,
secondary: Icon(
controller.isDeveloperMode.value
? Icons.visibility
: Icons.visibility_off,
color: controller.isDeveloperMode.value
? Colors.indigo
: Colors.grey.shade500,
size: 20,
),
dense: true,
),
)),
const SizedBox(height: 12),
// Developer mode button with attention-grabbing styling
Obx(() => controller.isDeveloperMode.value
? Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.orange.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton(
onPressed: () =>
Get.to(() => const DevDataViewerPage()),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 16),
minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.data_array, size: 22),
SizedBox(width: 12),
Text(
'Data Viewer',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
),
)
: const SizedBox.shrink()),
],
),
),
),
],
),
const SizedBox(height: 12),
_buildStatisticsCards(controller),
const SizedBox(height: 24),
_buildFilterButtons(controller),
const SizedBox(height: 24),
_buildStudentList(controller),
],
),
);
}
Widget _buildStatisticsCards(TeacherDashboardController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header dengan padding yang konsisten
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Row(
children: [
Icon(Icons.analytics, size: 18, color: Colors.blue.shade800),
const SizedBox(width: 8),
Text(
'Statistik Hasil Kuisioner',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
],
),
),
const SizedBox(height: 12),
// Row untuk Chart dan Stat Cards
LayoutBuilder(
builder: (context, constraints) {
// Tentukan ukuran chart optimal berdasarkan lebar yang tersedia
final isWideScreen = constraints.maxWidth > 600;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart Container - flex yang lebih besar pada layar lebar
Expanded(
flex: isWideScreen ? 3 : 2,
child: Container(
height: 210, // Sedikit ditambah untuk menampung legend
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chart title
Row(
children: [
Icon(Icons.pie_chart,
size: 14, color: Colors.blue.shade800),
const SizedBox(width: 6),
Expanded(
child: Text(
'Distribusi Jenis Rekomendasi',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
// Chart area
Expanded(
child: Obx(() {
final hasData =
controller.stats['totalStudents']! > 0;
return hasData
? PieChartWidget(controller: controller)
: const EmptyChartMessage();
}),
),
// Legend - hanya ditampilkan jika ada data
Obx(() {
final hasData =
controller.stats['totalStudents']! > 0;
return hasData
? Padding(
padding:
const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem(
'Karir', Colors.blue.shade700),
const SizedBox(width: 16),
_buildLegendItem(
'Kuliah', Colors.indigo.shade800),
],
),
)
: const SizedBox.shrink();
}),
],
),
),
),
const SizedBox(width: 12),
// Stat Cards Container
Expanded(
flex:
isWideScreen ? 2 : 3, // Sesuaikan flex berdasarkan layar
child: Container(
height:
210, // Tinggi yang sama dengan chart untuk konsistensi
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Stat card untuk Total Siswa
Expanded(
child: _buildStatCard(
title: 'Total Siswa',
value: controller.stats['totalStudents'].toString(),
icon: Icons.people,
color: Colors.blue.shade800,
),
),
const SizedBox(height: 8),
// Stat card untuk Rekomendasi Karir
Expanded(
child: _buildStatCard(
title: 'Rekomendasi Karir',
value: controller.stats['careerRecommendations']
.toString(),
icon: Icons.work,
color: Colors.blue.shade700,
),
),
const SizedBox(height: 8),
// Stat card untuk Rekomendasi Kuliah
Expanded(
child: _buildStatCard(
title: 'Rekomendasi Kuliah',
value: controller.stats['studyRecommendations']
.toString(),
icon: Icons.school,
color: Colors.indigo.shade800,
),
),
],
),
),
),
],
);
},
),
],
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade700,
),
),
],
);
}
Widget _buildStatCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 16,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _buildFilterButtons(TeacherDashboardController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.filter_list, size: 20, color: Colors.blue.shade800),
const SizedBox(width: 8),
Text(
'Daftar Hasil Kuisioner Siswa',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
],
),
const SizedBox(height: 16),
// Category Filters
Text(
'Filter Kategori:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildFilterButton(
label: 'Semua',
value: 'all',
controller: controller,
icon: Icons.list_alt,
filterType: 'category',
),
const SizedBox(width: 12),
_buildFilterButton(
label: 'Karir',
value: 'career',
controller: controller,
icon: Icons.work,
filterType: 'category',
),
const SizedBox(width: 12),
_buildFilterButton(
label: 'Kuliah',
value: 'study',
controller: controller,
icon: Icons.school,
filterType: 'category',
),
],
),
const SizedBox(height: 20),
// Date Filters
Text(
'Filter Waktu:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildFilterButton(
label: 'Semua Waktu',
value: 'all_time',
controller: controller,
icon: Icons.av_timer,
filterType: 'date',
),
const SizedBox(width: 12),
_buildFilterButton(
label: 'Hari Ini',
value: 'today',
controller: controller,
icon: Icons.today,
filterType: 'date',
),
const SizedBox(width: 12),
_buildFilterButton(
label: 'Bulan Ini',
value: 'this_month',
controller: controller,
icon: Icons.date_range,
filterType: 'date',
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildDatePickerButton(controller),
),
],
),
Obx(() => controller.isCustomDateSelected.value
? Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blue.shade200),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.date_range,
size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text(
'${controller.selectedStartDate.value.day}/${controller.selectedStartDate.value.month}/${controller.selectedStartDate.value.year} - '
'${controller.selectedEndDate.value.day}/${controller.selectedEndDate.value.month}/${controller.selectedEndDate.value.year}',
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade800,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
InkWell(
onTap: () {
controller.resetCustomDateFilter();
controller.filterByDate('all_time');
},
child: Icon(Icons.close,
size: 16, color: Colors.red.shade700),
),
],
),
),
)
: const SizedBox.shrink()),
const SizedBox(height: 20),
// Class Filter DropDown
Text(
'Filter Kelas:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.blue.shade200, width: 1),
),
child: Obx(() => DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: controller.selectedClass.value,
isExpanded: true,
icon: Icon(Icons.keyboard_arrow_down,
color: Colors.blue.shade700),
elevation: 16,
style: TextStyle(color: Colors.blue.shade700),
onChanged: (String? newValue) {
controller.filterByClass(newValue!);
},
items: controller.availableClasses
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
)),
),
],
);
}
Widget _buildDatePickerButton(TeacherDashboardController controller) {
return ElevatedButton(
onPressed: () async {
// Tampilkan date range picker
final DateTimeRange? picked = await showDateRangePicker(
context: Get.context!,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: controller.selectedStartDate.value,
end: controller.selectedEndDate.value,
),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: Colors.blue.shade700,
onPrimary: Colors.white,
onSurface: Colors.black,
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: Colors.blue.shade700,
),
),
),
child: child!,
);
},
);
if (picked != null) {
controller.setCustomDateRange(picked.start, picked.end);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: controller.isCustomDateSelected.value
? Colors.blue.shade700
: Colors.white,
foregroundColor: controller.isCustomDateSelected.value
? Colors.white
: Colors.blue.shade700,
elevation: controller.isCustomDateSelected.value ? 4 : 1,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(
color: controller.isCustomDateSelected.value
? Colors.transparent
: Colors.blue.shade200,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.calendar_month, size: 16),
const SizedBox(width: 8),
const Text(
'Pilih Tanggal',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildFilterButton({
required String label,
required String value,
required TeacherDashboardController controller,
required IconData icon,
required String filterType,
}) {
return Obx(() {
final isSelected = filterType == 'category'
? controller.selectedCategoryFilter.value == value
: controller.selectedDateFilter.value == value;
// Jika filter tanggal kustom aktif, nonaktifkan tombol filter tanggal lainnya
final isDateDisabled = filterType == 'date' &&
controller.isCustomDateSelected.value &&
value != 'custom';
return Expanded(
child: ElevatedButton(
onPressed: isDateDisabled
? null
: () {
if (filterType == 'category') {
controller.filterByCategory(value);
} else {
controller.resetCustomDateFilter();
controller.filterByDate(value);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? Colors.blue.shade700 : Colors.white,
foregroundColor: isSelected ? Colors.white : Colors.blue.shade700,
disabledBackgroundColor: Colors.grey.shade200,
disabledForegroundColor: Colors.grey.shade600,
elevation: isSelected ? 4 : 1,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(
color: isSelected || isDateDisabled
? Colors.transparent
: Colors.blue.shade200,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
});
}
Widget _buildStudentList(TeacherDashboardController controller) {
return Obx(() {
if (controller.filteredResults.isEmpty) {
return Container(
height: 200,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 48,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Belum ada data hasil kuisioner',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
// Panggil updatePagination jika belum dipanggil
if (controller.paginatedResults.isEmpty) {
controller.updatePagination();
}
return Column(
children: [
// Informasi pagination
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Menampilkan ${controller.paginatedResults.length} dari ${controller.filteredResults.length} hasil',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
// Dropdown untuk mengatur jumlah item per halaman
Row(
children: [
Text(
'Tampilkan: ',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: controller.itemsPerPage.value,
items: [5, 10, 20, 50].map((int value) {
return DropdownMenuItem<int>(
value: value,
child: Text('$value'),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
controller.setItemsPerPage(newValue);
}
},
),
),
),
],
),
],
),
),
// Daftar siswa
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.paginatedResults.length,
itemBuilder: (context, index) {
final doc = controller.paginatedResults[index];
final data = doc.data() as Map<String, dynamic>;
final isKerja = data['isKerja'] ?? false;
final recommendations = List<Map<String, dynamic>>.from(
data['recommendations'] ?? []);
// Get top recommendation
String topRecommendation = 'Tidak ada';
if (recommendations.isNotEmpty) {
topRecommendation = recommendations[0]['title'] ?? 'Tidak ada';
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: InkWell(
onTap: () => controller.viewStudentDetail(doc),
borderRadius: BorderRadius.circular(15),
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isKerja
? Colors.blue.shade50
: Colors.indigo.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isKerja ? Icons.work : Icons.school,
color: isKerja
? Colors.blue.shade400
: Colors.indigo.shade400,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['userName'] ??
data['userEmail'] ??
'Siswa',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
controller
.formatTimestamp(data['timestamp']),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isKerja
? Colors.blue.shade100
: Colors.indigo.shade100,
borderRadius: BorderRadius.circular(20),
),
child: Text(
isKerja ? 'Karir' : 'Kuliah',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isKerja
? Colors.blue.shade700
: Colors.indigo.shade700,
),
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rekomendasi Utama',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
topRecommendation,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () =>
controller.viewStudentDetail(doc),
icon: const Icon(Icons.visibility),
color: Colors.blue.shade700,
tooltip: 'Lihat Detail',
),
),
],
),
],
),
),
),
);
},
),
// Kontrol pagination yang diperbaiki
if (controller.totalPages.value > 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Tombol Previous
IconButton(
onPressed: controller.currentPage.value > 0
? () => controller.previousPage()
: null,
icon: const Icon(Icons.chevron_left),
color: Colors.blue.shade700,
disabledColor: Colors.grey.shade400,
),
// Indikator halaman dengan smart pagination
Expanded(
child: Center(
child: _buildSmartPagination(controller),
),
),
// Tombol Next
IconButton(
onPressed: controller.currentPage.value <
controller.totalPages.value - 1
? () => controller.nextPage()
: null,
icon: const Icon(Icons.chevron_right),
color: Colors.blue.shade700,
disabledColor: Colors.grey.shade400,
),
],
),
),
],
);
});
}
// Fungsi untuk membuat tombol halaman dengan pendekatan smart pagination
Widget _buildSmartPagination(TeacherDashboardController controller) {
// Variabel yang dibutuhkan
final int currentPage = controller.currentPage.value;
final int totalPages = controller.totalPages.value;
// Fungsi untuk membuat tombol halaman
Widget pageButton(int index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
child: ElevatedButton(
onPressed: () => controller.goToPage(index),
style: ElevatedButton.styleFrom(
backgroundColor:
currentPage == index ? Colors.blue.shade700 : Colors.white,
foregroundColor:
currentPage == index ? Colors.white : Colors.blue.shade700,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: currentPage == index
? Colors.transparent
: Colors.blue.shade200,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
minimumSize: const Size(32, 32),
),
child: Text('${index + 1}'),
),
);
}
// Widget untuk ellipsis
Widget ellipsis() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'...',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
),
),
);
}
// Buat list untuk tombol-tombol yang akan ditampilkan
List<Widget> paginationItems = [];
// Jika total halaman <= 7, tampilkan semua halaman
if (totalPages <= 7) {
for (int i = 0; i < totalPages; i++) {
paginationItems.add(pageButton(i));
}
}
// Jika halaman lebih dari 7, gunakan smart pagination
else {
// Selalu tampilkan halaman pertama
paginationItems.add(pageButton(0));
// Logika untuk menentukan halaman mana yang ditampilkan
if (currentPage < 3) {
// Dekat dengan awal: tampilkan halaman 0-4 kemudian ellipsis dan halaman terakhir
for (int i = 1; i <= 3; i++) {
paginationItems.add(pageButton(i));
}
paginationItems.add(ellipsis());
paginationItems.add(pageButton(totalPages - 1));
} else if (currentPage >= totalPages - 3) {
// Dekat dengan akhir: tampilkan halaman pertama, ellipsis, dan 4 halaman terakhir
paginationItems.add(ellipsis());
for (int i = totalPages - 4; i < totalPages; i++) {
paginationItems.add(pageButton(i));
}
} else {
// Di tengah: tampilkan halaman pertama, ellipsis, halaman saat ini dan tetangganya, ellipsis, halaman terakhir
paginationItems.add(ellipsis());
for (int i = currentPage - 1; i <= currentPage + 1; i++) {
paginationItems.add(pageButton(i));
}
paginationItems.add(ellipsis());
paginationItems.add(pageButton(totalPages - 1));
}
}
// Tampilkan dalam SingleChildScrollView untuk mencegah overflow
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: paginationItems,
),
);
}
}
class PieChartWidget extends StatelessWidget {
final TeacherDashboardController controller;
const PieChartWidget({
Key? key,
required this.controller,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Hitung ukuran chart berdasarkan constraints
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Center(
child: SizedBox(
width: size * 0.9, // Sedikit lebih kecil dari ruang yang tersedia
height: size * 0.9,
child: PieChart(
PieChartData(
centerSpaceRadius: size * 0.15,
sectionsSpace: 1,
borderData: FlBorderData(show: false),
centerSpaceColor: Colors.transparent,
sections: controller.getPieChartData().map((section) {
return section.copyWith(
radius: size * 0.35,
showTitle: false,
titlePositionPercentageOffset: 0,
);
}).toList(),
),
swapAnimationDuration: const Duration(milliseconds: 800),
),
),
);
},
);
}
}
// Widget untuk menampilkan pesan saat tidak ada data
class EmptyChartMessage extends StatelessWidget {
const EmptyChartMessage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bar_chart_outlined,
size: 36,
color: Colors.grey.shade300,
),
const SizedBox(height: 4),
Text(
'Belum ada data',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
),
);
}
}
// Widget untuk item legenda yang disederhanakan dan konsisten
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
],
);
}
class StudentResultDetailPage extends StatefulWidget {
final DocumentSnapshot document;
const StudentResultDetailPage({
Key? key,
required this.document,
}) : super(key: key);
@override
State<StudentResultDetailPage> createState() =>
_StudentResultDetailPageState();
}
class _StudentResultDetailPageState extends State<StudentResultDetailPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final GlobalKey _headerKey = GlobalKey();
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
// Delay to allow build to complete before animations start
Future.delayed(Duration.zero, () {
_startInitialAnimations();
});
}
void _startInitialAnimations() {
// Animation logic for initial page load
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final data = widget.document.data() as Map<String, dynamic>;
final isKerja = data['isKerja'] ?? false;
final userName = data['userName'] ?? data['userEmail'] ?? 'Siswa';
final recommendations =
List<Map<String, dynamic>>.from(data['recommendations'] ?? []);
final userAnswers =
List<Map<String, dynamic>>.from(data['userAnswers'] ?? []);
Map<String, dynamic> workingMemory = {};
if (data['workingMemory'] != null) {
if (data['workingMemory'] is Map) {
workingMemory = Map<String, dynamic>.from(data['workingMemory']);
} else if (data['workingMemory'] is List) {
// Convert list to map with indices as keys
final list = List.from(data['workingMemory']);
for (int i = 0; i < list.length; i++) {
workingMemory['item_$i'] = list[i];
}
}
}
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
pinned: true,
expandedHeight: 180.0,
backgroundColor: Colors.blue.shade800,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Get.back(),
),
actions: [
IconButton(
icon: const Icon(Icons.share, color: Colors.white),
onPressed: () {
Get.snackbar(
'Info',
'Fitur berbagi hasil sedang dalam pengembangan',
backgroundColor: Colors.blue.shade50,
colorText: Colors.blue.shade700,
snackPosition: SnackPosition.BOTTOM,
animationDuration: const Duration(milliseconds: 800),
duration: const Duration(seconds: 3),
);
},
),
],
flexibleSpace: FlexibleSpaceBar(
title: Text(
'Detail Hasil $userName',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.white),
),
background: Hero(
tag: 'student_header_${widget.document.id}',
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.blue.shade800,
Colors.indigo.shade900,
],
),
),
child: Stack(
children: [
Positioned(
right: -50,
bottom: -50,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 1500),
curve: Curves.elasticOut,
builder: (context, value, child) {
return Opacity(
opacity: 0.2 * value,
child: Transform.scale(
scale: value,
child: Icon(
isKerja ? Icons.work : Icons.school,
size: 200,
color: Colors.white,
),
),
);
},
),
),
Positioned(
top: 70,
left: 20,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 800),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(-20 * (1 - value), 0),
child: Row(
children: [
CircleAvatar(
backgroundColor:
Colors.white.withOpacity(0.9),
radius: 30,
child: Icon(
Icons.person,
color: Colors.blue.shade700,
size: 30,
),
),
const SizedBox(width: 15),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
data['userName'] ?? 'Siswa',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
Text(
data['userEmail'] ?? '',
style: TextStyle(
color:
Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
),
],
),
),
);
},
),
),
],
),
),
),
),
),
SliverPersistentHeader(
delegate: _SliverAppBarDelegate(
TabBar(
controller: _tabController,
tabs: [
Tab(icon: Icon(Icons.person), text: 'Profil'),
Tab(icon: Icon(Icons.recommend), text: 'Rekomendasi'),
Tab(icon: Icon(Icons.question_answer), text: 'Jawaban'),
Tab(icon: Icon(Icons.memory), text: 'Working Memory'),
],
labelColor: Colors.blue.shade700,
unselectedLabelColor: Colors.grey.shade600,
indicatorColor: Colors.blue.shade700,
indicatorWeight: 3,
),
),
pinned: true,
),
];
},
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.grey.shade50,
Colors.blue.shade50,
],
stops: const [0.7, 1.0],
),
),
child: TabBarView(
controller: _tabController,
children: [
_buildProfileTab(data),
_buildRecommendationsTab(recommendations, isKerja),
_buildAnswersTab(userAnswers),
_buildWorkingMemoryTab(workingMemory),
],
),
),
),
);
}
Widget _buildProfileTab(Map<String, dynamic> data) {
final timestamp = data['timestamp'] is Timestamp
? (data['timestamp'] as Timestamp).toDate()
: DateTime.now();
final formattedDate = DateFormat('dd MMMM yyyy, HH:mm').format(timestamp);
final isKerja = data['isKerja'] ?? false;
final answeredQuestions = data['answeredQuestions'] ?? 0;
final totalQuestions = data['totalQuestions'] ?? 0;
final completionPercentage = totalQuestions > 0
? (answeredQuestions / totalQuestions * 100).round()
: 0;
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Informasi Siswa', Icons.info),
const SizedBox(height: 12),
_buildStudentInfoCard(data),
const SizedBox(height: 24),
_buildSectionHeader('Progres Pengerjaan', Icons.insights),
const SizedBox(height: 12),
_buildProgressCard(
completionPercentage, answeredQuestions, totalQuestions),
const SizedBox(height: 24),
_buildSectionHeader('Statistik', Icons.bar_chart),
const SizedBox(height: 12),
_buildStatisticsCard(data),
],
),
);
}
Widget _buildProgressCard(int percentage, int answered, int total) {
return Card(
elevation: 4,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: percentage / 100),
duration: const Duration(milliseconds: 1500),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Column(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox(
height: 120,
width: 120,
child: CircularProgressIndicator(
value: value,
strokeWidth: 12,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue.shade500,
),
),
),
Column(
children: [
Text(
'${(value * 100).toInt()}%',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
Text(
'Selesai',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildProgressStat(
'Terjawab',
answered.toString(),
Icons.check_circle,
Colors.green,
),
_buildProgressStat(
'Total',
total.toString(),
Icons.assignment,
Colors.blue,
),
],
),
],
);
},
),
],
),
),
);
}
Widget _buildProgressStat(
String label, String value, IconData icon, MaterialColor color) {
return Column(
children: [
Icon(
icon,
color: color.shade400,
size: 28,
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color.shade700,
),
),
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
);
}
Widget _buildStatisticsCard(Map<String, dynamic> data) {
// Sample data for demonstration
final recommendations =
List<Map<String, dynamic>>.from(data['recommendations'] ?? []);
final scores =
recommendations.map((r) => (r['score'] ?? 0) as num).toList();
return Card(
elevation: 4,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distribusi Skor Rekomendasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: scores.isEmpty
? Center(
child: Text(
'Tidak ada data rekomendasi',
style: TextStyle(
color: Colors.grey.shade600,
),
),
)
: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: 100,
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (double value, TitleMeta meta) {
final index = value.toInt();
if (index >= 0 &&
index < recommendations.length) {
return Text(
'R${index + 1}',
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 12,
),
);
}
return const SizedBox();
},
reservedSize: 22,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (double value, TitleMeta meta) {
if (value == 0 || value == 50 || value == 100) {
return Text(
'${value.toInt()}%',
style: const TextStyle(
color: Colors.black,
fontSize: 10,
),
);
}
return const SizedBox();
},
interval: 25,
reservedSize: 30,
),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(
show: false,
),
barGroups: scores.asMap().entries.map((entry) {
final index = entry.key;
final value = entry.value;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: value.toDouble(),
gradient: LinearGradient(
colors: [
Colors.blue.shade300,
Colors.blue.shade700
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
width: 20,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
],
);
}).toList(),
gridData: FlGridData(
show: true,
horizontalInterval: 25,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
),
swapAnimationDuration: const Duration(milliseconds: 800),
),
),
],
),
),
);
}
Widget _buildStudentInfoCard(Map<String, dynamic> data) {
final timestamp = data['timestamp'] is Timestamp
? (data['timestamp'] as Timestamp).toDate()
: DateTime.now();
final formattedDate = DateFormat('dd MMMM yyyy, HH:mm').format(timestamp);
final isKerja = data['isKerja'] ?? false;
return Card(
elevation: 4,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.shade50,
radius: 24,
child: Icon(
Icons.person,
color: Colors.blue.shade700,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['userName'] ?? 'Siswa',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
data['userEmail'] ?? '',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
Row(
children: [
_buildInfoItem(
icon: Icons.calendar_today,
label: 'Tanggal',
value: formattedDate,
),
_buildInfoItem(
icon: isKerja ? Icons.work : Icons.school,
label: 'Jenis Rekomendasi',
value: isKerja ? 'Karir' : 'Kuliah',
),
],
),
const SizedBox(height: 12),
Row(
children: [
_buildInfoItem(
icon: Icons.question_answer,
label: 'Total Pertanyaan',
value: data['totalQuestions']?.toString() ?? '0',
),
_buildInfoItem(
icon: Icons.check_circle,
label: 'Pertanyaan Dijawab',
value: data['answeredQuestions']?.toString() ?? '0',
),
],
),
],
),
),
);
}
Widget _buildRecommendationsTab(
List<Map<String, dynamic>> recommendations, bool isKerja) {
return recommendations.isEmpty
? _buildEmptyCenteredMessage(
'Tidak ada rekomendasi yang tersedia',
Icons.search_off,
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: recommendations.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
'Hasil Rekomendasi',
isKerja ? Icons.work : Icons.school,
),
const SizedBox(height: 16),
],
);
}
final recommendationIndex = index - 1;
final recommendation = recommendations[recommendationIndex];
return _buildRecommendationCard(
recommendation, recommendationIndex, isKerja);
},
);
}
Widget _buildRecommendationCard(
Map<String, dynamic> recommendation, int index, bool isKerja) {
final score = recommendation['score']?.toString() ?? '0';
final careers = List<String>.from(recommendation['careers'] ?? []);
final majors = List<String>.from(recommendation['majors'] ?? []);
final rules = List<String>.from(recommendation['rules'] ?? []);
final title = recommendation['title'] ?? 'Rekomendasi ${index + 1}';
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300 + (index * 150)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 4,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: ExpansionTile(
initiallyExpanded: index == 0, // First one expanded by default
tilePadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
leading: Hero(
tag: 'recommendation_$index',
child: CircleAvatar(
backgroundColor: Colors.blue.shade50,
radius: 20,
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
),
),
),
),
title: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0.0, end: double.parse(score) / 100),
duration: const Duration(milliseconds: 1000),
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Skor: ${(value * 100).toInt()}%',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: value,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
value < 0.3
? Colors.red.shade400
: value < 0.7
? Colors.orange.shade400
: Colors.green.shade400,
),
minHeight: 5,
borderRadius: BorderRadius.circular(10),
),
],
);
},
),
],
),
children: [
const Divider(),
const SizedBox(height: 16),
if (isKerja && careers.isNotEmpty) ...[
_buildSectionTitle('Karir yang Direkomendasikan'),
const SizedBox(height: 8),
_buildChipList(careers),
const SizedBox(height: 16),
],
if (!isKerja && majors.isNotEmpty) ...[
_buildSectionTitle('Jurusan yang Direkomendasikan'),
const SizedBox(height: 8),
_buildChipList(majors),
const SizedBox(height: 16),
],
if (rules.isNotEmpty) ...[
_buildSectionTitle('Aturan yang Terpenuhi'),
const SizedBox(height: 8),
_buildRulesList(rules),
],
],
),
),
),
);
},
);
}
Widget _buildAnswersTab(List<Map<String, dynamic>> userAnswers) {
return userAnswers.isEmpty
? _buildEmptyCenteredMessage(
'Tidak ada jawaban yang tersedia',
Icons.question_mark,
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: userAnswers.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Jawaban Siswa', Icons.question_answer),
const SizedBox(height: 16),
],
);
}
final answerIndex = index - 1;
return _buildAnswerCard(userAnswers[answerIndex], answerIndex);
},
);
}
Widget _buildAnswerCard(Map<String, dynamic> answer, int index) {
final questionText = answer['question'] ?? 'Pertanyaan tidak tersedia';
final userAnswer = answer['answer'] ?? 'Tidak dijawab';
final programName = answer['programName'] ?? '';
final bobot = answer['bobot']?.toString() ?? '0';
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300 + (index * 100)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(30 * (1 - value), 0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 3,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.blue.shade100.withOpacity(0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
questionText,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: userAnswer == 'Ya'
? Colors.green.shade50
: userAnswer == 'Tidak'
? Colors.red.shade50
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: userAnswer == 'Ya'
? Colors.green.shade200
: userAnswer == 'Tidak'
? Colors.red.shade200
: Colors.grey.shade300,
),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Text(
'Jawaban: $userAnswer',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: userAnswer == 'Ya'
? Colors.green.shade700
: userAnswer == 'Tidak'
? Colors.red.shade700
: Colors.grey.shade700,
),
),
),
),
],
),
if (programName.isNotEmpty || bobot != '0') ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
if (programName.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Text(
programName,
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade700,
),
),
),
if (bobot != '0')
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Text(
'Bobot: $bobot',
style: TextStyle(
fontSize: 12,
color: Colors.amber.shade800,
),
),
),
],
),
],
],
),
),
],
),
),
),
),
);
},
);
}
Widget _buildWorkingMemoryTab(Map<String, dynamic> workingMemory) {
return workingMemory.isEmpty
? _buildEmptyCenteredMessage(
'Working memory tidak tersedia',
Icons.memory_outlined,
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: workingMemory.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
'Working Memory',
Icons.memory,
tooltip:
'Data memori kerja yang digunakan saat proses inferensi',
),
const SizedBox(height: 16),
Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
flex: 3,
child: Text(
'Variabel',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
),
Expanded(
flex: 2,
child: Text(
'Nilai',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
),
],
),
),
),
const SizedBox(height: 8),
],
);
}
final memoryIndex = index - 1;
final entry = workingMemory.entries.elementAt(memoryIndex);
return _buildWorkingMemoryItem(
entry.key, entry.value, memoryIndex);
},
);
}
Widget _buildWorkingMemoryItem(String key, dynamic value, int index) {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300 + (index * 50)),
curve: Curves.easeOutCubic,
builder: (context, animValue, child) {
return Opacity(
opacity: animValue,
child: Transform.translate(
offset: Offset(0, 20 * (1 - animValue)),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 2,
shadowColor: Colors.blue.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
flex: 3,
child: Text(
key,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
flex: 2,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: value is bool && value == true
? Colors.green.shade50
: value is bool && value == false
? Colors.red.shade50
: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Text(
value.toString(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: value is bool && value == true
? Colors.green.shade700
: value is bool && value == false
? Colors.red.shade700
: Colors.blue.shade700,
),
),
),
),
],
),
),
),
),
);
},
);
}
Widget _buildEmptyCenteredMessage(String message, IconData icon) {
return Center(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 800),
curve: Curves.elasticOut,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.scale(
scale: 0.8 + (0.2 * value),
child: Container(
width: 250,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.blue.shade100.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 60,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
),
),
);
},
),
);
}
Widget _buildInfoItem({
required IconData icon,
required String label,
required String value,
}) {
return Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 16,
color: Colors.blue.shade400,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
Widget _buildSectionHeader(String title, IconData icon, {String? tooltip}) {
return Row(
children: [
Icon(
icon,
color: Colors.blue.shade700,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
if (tooltip != null) ...[
const SizedBox(width: 8),
Tooltip(
message: tooltip,
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.grey.shade600,
),
),
],
],
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
);
}
Widget _buildChipList(List<String> items) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 600 + (index * 50)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.scale(
scale: 0.8 + (0.2 * value),
child: Chip(
label: Text(
item,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
backgroundColor: Colors.blue.shade50,
labelStyle: TextStyle(color: Colors.blue.shade700),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
elevation: 1,
shadowColor: Colors.blue.shade100,
),
),
);
},
);
}).toList(),
);
}
Widget _buildRulesList(List<String> rules) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rules.asMap().entries.map((entry) {
final index = entry.key;
final rule = entry.value;
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300 + (index * 100)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(20 * (1 - value), 0),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
size: 16,
color: Colors.green.shade600,
),
const SizedBox(width: 8),
Expanded(
child: Text(
rule,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
),
);
},
);
}).toList(),
);
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar tabBar;
_SliverAppBarDelegate(this.tabBar);
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
color: Colors.grey.shade200,
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.05),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: tabBar,
);
}
@override
double get maxExtent => tabBar.preferredSize.height;
@override
double get minExtent => tabBar.preferredSize.height;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}