import 'package:ethica/screens/adminloginpage.da...
Creato il: 2 dicembre 2025
Risposto usando GPT-5.1 Thinking da Chat01
Creato il: 2 dicembre 2025
Risposto usando GPT-5.1 Thinking da Chat01
import 'package:ethica/screens/adminloginpage.dart';
import 'package:ethica/utils/session_manager.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../utils/database/db_helper.dart';
import 'dart:async';
// ---------------------- SORT ENUM ----------------------
enum SortOption { nameAsc, nameDesc, dateAsc, dateDesc }
class AdminDashboard extends StatefulWidget {
const AdminDashboard({super.key});
@override
State<AdminDashboard> createState() => _AdminDashboardState();
}
class _AdminDashboardState extends State<AdminDashboard> with SingleTickerProviderStateMixin {
String _searchQuery = '';
late AnimationController _controller;
bool _isDeleting = false;
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
SortOption _currentSort = SortOption.nameAsc; // ✅ default sort
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
}
@override
void dispose() {
_controller.dispose();
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
// ---------------------- DATE HELPER ----------------------
DateTime _extractDate(dynamic date) {
if (date is Timestamp) return date.toDate();
if (date is DateTime) return date;
if (date is String) return DateTime.tryParse(date) ?? DateTime(1970);
if (date is int) return DateTime.fromMillisecondsSinceEpoch(date);
return DateTime(1970);
}
// ---------------------- ARCHIVE USER ----------------------
Future<void> _archiveUser(String userName) async {
final users = await DBHelper.getUsers();
int? userId;
textfor (var u in users) { if ((u['name'] ?? '').toString() == userName) { userId = u['id'] as int?; break; } } final confirm = await showDialog<bool>( context: context, builder: (_) => AlertDialog( title: const Text("Archive User"), content: Text("Archive user \"$userName\"? This will move cloud results to archive, delete their results, and reset their progress."), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("Archive", style: TextStyle(color: Colors.red))), ], ), ); if (confirm != true) return; setState(() => _isDeleting = true); try { if (userId != null) { await DBHelper.archiveUserFull(userId: userId, userName: userName); } else { final firestore = FirebaseFirestore.instance; final snapshot = await firestore.collection('results').where('user_name', isEqualTo: userName).get(); final archivedData = snapshot.docs.map((d) { final m = d.data(); m['firestore_id'] = d.id; return m; }).toList(); await firestore.collection('archived_users').doc(userName).set({ 'user_name': userName, 'archived_date': DateTime.now().toIso8601String(), 'results': archivedData, }); for (var doc in snapshot.docs) { await doc.reference.delete(); } await DBHelper.sendArchiveNotification(userName: userName); } ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("User $userName archived. Reset notification sent."))); } catch (e) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error archiving user: $e"))); } finally { setState(() => _isDeleting = false); }
}
// ---------------------- ARCHIVED RECORDS DIALOG ----------------------
Future<void> _showArchivedRecordsDialog() async {
final firestore = FirebaseFirestore.instance;
final snapshot = await firestore.collection('archived_users').get();
textfinal grouped = <String, Map<String, dynamic>>{}; for (var doc in snapshot.docs) { grouped[doc.id] = doc.data(); } String searchQuery = ''; final TextEditingController searchController = TextEditingController(); SortOption localSort = SortOption.nameAsc; await showDialog( context: context, builder: (_) => StatefulBuilder( builder: (context, setState) { final screenHeight = MediaQuery.of(context).size.height; final screenWidth = MediaQuery.of(context).size.width; final filteredEntries = grouped.entries.where((entry) { return entry.key.toLowerCase().contains(searchQuery.toLowerCase()); }).toList(); // ✅ Apply sorting filteredEntries.sort((a, b) { switch (localSort) { case SortOption.nameAsc: return a.key.compareTo(b.key); case SortOption.nameDesc: return b.key.compareTo(a.key); case SortOption.dateAsc: return _extractDate(a.value['archived_date']).compareTo(_extractDate(b.value['archived_date'])); case SortOption.dateDesc: return _extractDate(b.value['archived_date']).compareTo(_extractDate(a.value['archived_date'])); } }); //expansion tile return Dialog( child: SizedBox( width: double.maxFinite, height: screenHeight * 0.7, child: Column( children: [ Padding( padding: EdgeInsets.all(screenHeight * 0.02), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton.icon( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back, color: Colors.deepPurple), label: Text( "Umalis", style: TextStyle( color: Colors.deepPurple, fontSize: screenHeight * 0.035, ), ), ), Text("Mga Naka-Arkibo", style: GoogleFonts.poppins(fontSize: screenHeight * 0.045, fontWeight: FontWeight.w700)), Row( children: [ DropdownButton<SortOption>( value: localSort, onChanged: (value) { if (value != null) setState(() => localSort = value); }, items: const [ DropdownMenuItem(value: SortOption.nameAsc, child: Text("Pangalan A-Z ↑")), DropdownMenuItem(value: SortOption.nameDesc, child: Text("Pangalan Z-A ↓")), DropdownMenuItem(value: SortOption.dateAsc, child: Text("Petsa Pinakabago ↑")), DropdownMenuItem(value: SortOption.dateDesc, child: Text("Petsa Pinaka luma ↓")), ], ), SizedBox(width: screenWidth * 0.02), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.015)), ), onPressed: () async { final confirm = await showDialog<bool>( context: context, builder: (_) => AlertDialog( title: const Text("Pag tanggal ng LAHAT ng mga arkibo"), content: const Text("Sigurado kabang tatanggalin lahat ng mga naka arkibo?"), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Hindi")), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("Tanggalin Lahat", style: TextStyle(color: Colors.red))), ], ), ); if (confirm == true) { final batch = firestore.batch(); for (var entry in grouped.entries) { batch.delete(firestore.collection('archived_users').doc(entry.key)); } await batch.commit(); setState(() => grouped.clear()); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("All archived users deleted"))); } }, icon: const Icon(Icons.delete_forever, color: Colors.white), label: const Text("Tanggalin lahat", style: TextStyle(color: Colors.white)), ), ], ), ], ), ), Padding( padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.04), child: TextField( controller: searchController, decoration: InputDecoration( hintText: "Hanapin ang mga Naka Arkibo...", prefixIcon: const Icon(Icons.search, color: Colors.deepPurple), filled: true, fillColor: Colors.white, border: OutlineInputBorder( borderRadius: BorderRadius.circular(screenHeight * 0.02), borderSide: BorderSide.none, ), ), onChanged: (v) => setState(() => searchQuery = v.trim()), ), ), SizedBox(height: screenHeight * 0.01), Expanded( child: ListView( children: filteredEntries.map((entry) { final userName = entry.key; final userData = entry.value; final results = List<Map<String, dynamic>>.from(userData['results'] ?? []); return Card( margin: EdgeInsets.symmetric(vertical: screenHeight * 0.01, horizontal: screenWidth * 0.02), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.02)), child: ExpansionTile( title: Text( userName, style: GoogleFonts.poppins( fontWeight: FontWeight.w600, fontSize: screenHeight * 0.045, color: Colors.black87, ), ), children: results.map((data) { final type = data['type'] ?? 'quiz'; final scoreDisplay = type == 'minigame' ? "Score: ${data['score']}" : "Score: ${data['score']}/${data['total']}"; final typeLabel = type == 'minigame' ? " 🎮" : " 📝"; return ListTile( title: Text( "${data['chapter'] ?? 'Unknown Chapter'}$typeLabel", style: TextStyle(fontSize: screenHeight * 0.045), ), subtitle: Text( scoreDisplay, style: TextStyle(fontSize: screenHeight * 0.0), ), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( _formatDate(data['date']), style: TextStyle(color: Colors.black54, fontSize: screenHeight * 0.030), ), Text( _formatTime(data['date']), style: TextStyle(color: Colors.black45, fontSize: screenHeight * 0.030), ), ], ), ); }).toList(), ), ); }).toList(), ), ), ], ), ), ); }, ), );
}
// ---------------------- DATE / TIME FORMATTING ----------------------
String _formatDate(dynamic date) {
try {
if (date == null) return '';
DateTime? dt;
textif (date is Timestamp) dt = date.toDate(); if (date is DateTime) dt = date; if (date is String) dt = DateTime.tryParse(date); if (date is int) dt = DateTime.fromMillisecondsSinceEpoch(date); if (dt == null) return ''; dt = dt.toLocal(); return "${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}"; } catch (_) { return ''; }
}
String _formatTime(dynamic date) {
try {
if (date == null) return '';
DateTime? dt;
textif (date is Timestamp) dt = date.toDate(); if (date is DateTime) dt = date; if (date is String) dt = DateTime.tryParse(date); if (date is int) dt = DateTime.fromMillisecondsSinceEpoch(date); if (dt == null) return ''; dt = dt.toLocal(); return "${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}"; } catch (_) { return ''; }
}
@override
Widget build(BuildContext context) {
final firestore = FirebaseFirestore.instance;
final screenHeight = MediaQuery.of(context).size.height;
final screenWidth = MediaQuery.of(context).size.width;
textreturn Scaffold( backgroundColor: const Color(0xFFF4F5F7), body: SafeArea( child: _isDeleting ? const Center(child: CircularProgressIndicator(color: Colors.deepPurple)) : StreamBuilder<QuerySnapshot>( stream: firestore.collection('results').snapshots(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator(color: Colors.deepPurple)); } if (!snapshot.hasData || snapshot.data!.docs.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text("No data yet. Scores will appear soon!"), backgroundColor: Colors.deepPurple, duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, margin: EdgeInsets.all(screenHeight * 0.02), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.015)), ), ); }); } _controller.reset(); _controller.forward(); final docs = snapshot.data?.docs.map((e) => e.data() as Map<String, dynamic>).toList() ?? []; final Map<String, List<Map<String, dynamic>>> grouped = {}; for (var data in docs) { final name = (data['user_name'] ?? 'Unknown').toString(); grouped.putIfAbsent(name, () => []).add(data); } final filteredKeys = _searchQuery.isEmpty ? grouped.keys.toList() // 👉 show all users when query is empty : grouped.keys.where((k) => k.toLowerCase().contains(_searchQuery)).toList(); // ✅ Apply sorting to dashboard filteredKeys.sort((a, b) { switch (_currentSort) { case SortOption.nameAsc: return a.compareTo(b); case SortOption.nameDesc: return b.compareTo(a); case SortOption.dateAsc: return _extractDate(grouped[a]!.first['date']).compareTo(_extractDate(grouped[b]!.first['date'])); case SortOption.dateDesc: return _extractDate(grouped[b]!.first['date']).compareTo(_extractDate(grouped[a]!.first['date'])); } }); final totalUsers = grouped.keys.length; final totalRecords = docs.length; return SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Padding( padding: EdgeInsets.all(screenHeight * 0.02), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header row Row( children: [ TextButton.icon( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back, color: Colors.deepPurple), label: Text("Back", style: TextStyle(color: Colors.deepPurple, fontSize: screenHeight * 0.045)), ), SizedBox(width: screenWidth * 0.05), Text( "ADMINISTRATURANG PISARA", style: GoogleFonts.poppins( fontSize: screenHeight * 0.050, fontWeight: FontWeight.w700, color: Colors.black87, ), ), const Spacer(), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Colors.deepPurple, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.015)), ), onPressed: _showArchivedRecordsDialog, icon: const Icon(Icons.archive, color: Colors.white), label: Text("Mga\nArkibo", style: TextStyle(color: Colors.white, fontSize: screenHeight * 0.035)), ), SizedBox(width: screenWidth * 0.02), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.015)), ), onPressed: () async { final confirm = await showDialog<bool>( context: context, builder: (_) => AlertDialog( title: Text("Lumabas", style: TextStyle(fontSize: screenHeight * 0.045)), content: Text("Sigurado ka bang nais mong lumabas?", style: TextStyle(fontSize: screenHeight * 0.040)), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Hindi")), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("Lumabas", style: TextStyle(color: Colors.red))), ], ), ); if (confirm == true) { await SessionManager.logoutAdmin(); Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const AdminLoginPage()), ); } }, icon: const Icon(Icons.logout, color: Colors.white), label: Text("Lumabas", style: TextStyle(color: Colors.white, fontSize: screenHeight * 0.045)), ), ], ), SizedBox(height: screenHeight * 0.02), // Search + Sort Wrap Wrap( spacing: screenWidth * 0.02, runSpacing: screenHeight * 0.01, children: [ SizedBox( width: screenWidth * 0.5, child: TextField( controller: _searchController, decoration: InputDecoration( hintText: "Search users...", hintStyle: TextStyle(fontSize: screenHeight * 0.045), prefixIcon: const Icon(Icons.search, color: Colors.deepPurple), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, color: Colors.grey), onPressed: () { _searchController.clear(); setState(() => _searchQuery = ''); }, ) : null, filled: true, fillColor: Colors.white, border: OutlineInputBorder( borderRadius: BorderRadius.circular(screenHeight * 0.02), borderSide: BorderSide.none, ), ), style: TextStyle(fontSize: screenHeight * 0.018), onSubmitted: (v) { setState(() => _searchQuery = v.toLowerCase().trim()); }, ), ), Row( mainAxisSize: MainAxisSize.min, children: [ Text( "Ayusin ayon sa:", style: TextStyle( fontWeight: FontWeight.w600, color: Colors.black87, fontSize: screenHeight * 0.050, ), ), SizedBox(width: screenWidth * 0.02), DropdownButton<SortOption>( value: _currentSort, onChanged: (value) { if (value != null) setState(() => _currentSort = value); }, items: const [ DropdownMenuItem(value: SortOption.nameAsc, child: Text("Pangalan A-Z ↑")), DropdownMenuItem(value: SortOption.nameDesc, child: Text("Pangalan Z-A ↓")), DropdownMenuItem(value: SortOption.dateAsc, child: Text("Petsa Pinakabago ↑")), DropdownMenuItem(value: SortOption.dateDesc, child: Text("Petsa Pinaka luma ↓")), ], ), ], ), ], ), SizedBox(height: screenHeight * 0.02), // Summary cards Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildSummaryCard("👤 Kabuuang Gumagamit", totalUsers.toString(), screenHeight), _buildSummaryCard("🧾 Kabuuang Tala ", totalRecords.toString(), screenHeight), ], ), SizedBox(height: screenHeight * 0.015), // User list ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: filteredKeys.length, itemBuilder: (context, index) { final userName = filteredKeys[index]; final userDocs = grouped[userName]!; return FadeTransition( opacity: CurvedAnimation( parent: _controller, curve: Interval(index / filteredKeys.length, 1.0, curve: Curves.easeIn), ), child: Card( color: Colors.white, margin: EdgeInsets.symmetric(vertical: screenHeight * 0.01, horizontal: screenWidth * 0.01), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.02)), elevation: 2, child: ExpansionTile( iconColor: Colors.deepPurple, collapsedIconColor: Colors.deepPurple, title: Row( children: [ const Icon(Icons.folder, color: Colors.deepPurple), SizedBox(width: screenWidth * 0.02), Expanded( child: Text( userName, style: GoogleFonts.poppins( fontWeight: FontWeight.w600, fontSize: screenHeight * 0.040, color: Colors.black87, ), ), ), IconButton( icon: const Icon(Icons.archive, color: Colors.deepOrange), onPressed: () => _archiveUser(userName), tooltip: "Archive user (reset progress)", ), ], ), children: userDocs.map((data) { final type = data['type'] ?? 'quiz'; final isMinigame = type == 'minigame'; final scoreDisplay = isMinigame ? "Score: ${data['score']}" : "Score: ${data['score']}/${data['total']}"; final typeLabel = isMinigame ? " 🎮" : " 📝"; return ListTile( title: Text( "${data['chapter'] ?? 'Unknown Chapter'}$typeLabel", style: TextStyle(fontSize: screenHeight * 0.035), ), subtitle: Text( scoreDisplay, style: TextStyle(fontSize: screenHeight * 0.035), ), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( _formatDate(data['date']), style: TextStyle(color: Colors.black54, fontSize: screenHeight * 0.035), ), Text( _formatTime(data['date']), style: TextStyle(color: Colors.black45, fontSize: screenHeight * 0.035), ), ], ), ); }).toList(), ), ), ); }, ), ], ), ), ); }, ), ), );
}
// Summary card helper
Widget _buildSummaryCard(String title, String value, double screenHeight) {
return Card(
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(screenHeight * 0.02)),
elevation: 2,
child: Padding(
padding: EdgeInsets.symmetric(vertical: screenHeight * 0.02, horizontal: screenHeight * 0.30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontWeight: FontWeight.w600, fontSize: screenHeight * 0.040)),//.018
SizedBox(height: screenHeight * 0.01),
Text(value, style: TextStyle(fontSize: screenHeight * 0.045, fontWeight: FontWeight.bold, color: Colors.deepPurple)),
],
),
),
);
}
}
SizedBox(
width: screenWidth * 0.5,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Search users...",
hintStyle: TextStyle(fontSize: screenHeight * 0.045),
prefixIcon: const Icon(Icons.search, color: Colors.deepPurple),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(screenHeight * 0.02),
borderSide: BorderSide.none,
),
),
style: TextStyle(fontSize: screenHeight * 0.018),
textonSubmitted: (v) { setState(() => _searchQuery = v.toLowerCase().trim()); }, ), ),
Why isn’t my search engine functioning properly? When I tap the search bar, it automatically starts searching even though I haven’t typed anything yet or the keyboard hasn’t appeared. Just pressing the search field makes it search right away even if I haven’t entered anything. What could be the possible problem?
why is my
You’re not crazy 🙂 — the behavior you’re seeing actually matches what your code is doing, even if it’s not what you meant.
There are two main issues:
This line is the big one:
dartfinal filteredKeys = _searchQuery.isEmpty ? grouped.keys.toList() // 👉 show all users when query is empty : grouped.keys.where((k) => k.toLowerCase().contains(_searchQuery)).toList();
_searchQuery starts as an empty string ''.That can feel like “it searched even though I didn’t type anything”, but in reality you’ve said:
“If query is empty, return all results.”
If you want nothing to be searched until the user types something, change it to:
dartfinal filteredKeys = _searchQuery.trim().isEmpty ? <String>[] // or maybe grouped.keys.toList() if you want “no filter” : grouped.keys .where((k) => k.toLowerCase().contains(_searchQuery)) .toList();
_searchQuery instead of the TextField textRight now:
dartonSubmitted: (v) { setState(() => _searchQuery = v.toLowerCase().trim()); },
So:
_searchQuery, not by _searchController.text._searchQuery only updates when:
That means:
_searchQuery still holds the last value from the previous search._searchQuery may still be "juan" or "test".A simple, clean fix: ditch _searchQuery altogether and just use the controller’s text as the source of truth.
_searchQuery field from the state (or ignore it).build, read the text from the controller:dartfinal filter = _searchController.text.toLowerCase().trim(); final filteredKeys = filter.isEmpty ? grouped.keys.toList() // or [] if you want no results until user types : grouped.keys .where((k) => k.toLowerCase().contains(filter)) .toList();
dartTextField( controller: _searchController, ... onChanged: (v) { setState(() { // no need to store v, we read from _searchController in build }); }, // onSubmitted is now optional, you can just unfocus: onSubmitted: (_) => FocusScope.of(context).unfocus(), )
Now:
[] for filteredKeys when empty, then nothing will “search” until the user actually types._searchQuery that doesn’t always match what’s in the TextField.