import 'package:ethica/screens/adminloginpage.da...

作成日: 2025年12月2日

使用モデル GPT-5.1 Thinking by 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;

text
for (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();

text
final 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;

text
if (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;

text
if (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;

text
return 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),

text
onSubmitted: (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

思考

Clarifying search behavior issue

It looks like with an empty _searchQuery, all users are displayed, but if non-empty, a filter applies. The problem might be that after clearing the field, the search seems to auto-trigger—likely due to residual values in _searchQuery. They seem to expect an immediate reset, but the previous query might still linger.

Investigating auto-search issue

It seems like tapping the search bar triggers something, even when nothing is typed. Looking at the code, it could be that the StreamBuilder rebuilds when focus shifts or when _searchQuery changes. The filter might be using an empty string, which would include all results. So, it's a focus/ rebuild, not an actual search.

Investigating automatic search behavior

It seems they might want the search to show nothing until there’s a valid query. However, the code is set to show all when _searchQuery is empty, which is intentional. It's possible a previous search query is lingering in the _searchController.text, but the filter uses _searchQuery, and this only updates on submission.

Investigating search engine issue

The user is asking why their search starts automatically when they tap the search bar without typing. It seems like the onSubmitted event could be triggering unexpectedly, possibly updating _searchQuery to an empty string and displaying all results. The keyboard hasn't appeared, so this might involve something else, like hidden focus-related settings.

Clarifying search behavior expectations

It seems the user might want search results to only appear after they press the search button, not as soon as they tap the search bar. Their current logic is displaying all results before anything is typed because _searchQuery starts empty. To fix this, they'd need to ensure results appear only once they start typing or hit "search."

Exploring search bar behavior assumptions

It looks like the user wants the search results to show only after typing, but currently, all user cards are visible when the dialog first appears. The issue could stem from the onSubmitted trigger or _searchQuery updating unexpectedly. The search logic might be firing early due to focus changes or event misinterpretation.

Clarifying search behavior confusion

It seems like the user expects search results only after typing, but it’s showing earlier results from a previous query when the search bar is tapped. This could be due to the _searchController retaining a past search value. They may have expected it not to trigger with a tapped field, but instead, it's reflecting the earlier query's results.

Examining search behavior and possible causes

It seems like the user expects real-time search updates as they type, using onChanged and Timer for debouncing. However, I think the issue might be that the search triggers immediately when the input field gains focus, likely because of how _searchQuery changes with onSubmitted. Searching only should occur once text is typed.

Suggesting potential search behavior fix

It seems the issue arises because _searchQuery starts empty, triggering a filtered list on widget build. This is by design. To make sure no results show until typing starts, I recommend updating the condition to only show filtered results once _searchQuery is non-empty. Tapping the search field itself doesn’t trigger a search.

Clarifying search behavior

The issue seems to be with filtering logic. Since _searchQuery starts as empty, it shows all results immediately. To prevent this, updating the condition to only filter after typing would help. Also, they might prefer dynamic search to trigger on onChanged instead of waiting for onSubmitted.

Explaining automatic search behavior

The root of the issue is likely that the empty _searchQuery returns all results by design, which makes it seem like a search is happening immediately. To prevent this, adjusting the logic to not show results until typed (e.g., with .trim().isEmpty) would stop it from triggering too soon. Additionally, they might be confused by the behavior of onSubmitted.

Explaining search behavior and handling fixed query logic

The issue likely arises because they treat an empty search query as a wildcard, causing the filter to return all results initially. When they tap the search field, no new search occurs since _searchQuery is unchanged. I’d suggest adjusting logic to handle empty queries properly and recommend using _debounce to improve responsiveness.

Clarifying search behavior and suggesting improvements

The issue is that when the search field is tapped but no new text is entered, the filter logic still runs because _searchQuery remains stale from the previous input. To fix this, consider adding a reset for _searchQuery when the field is cleared, or use _debounce for better real-time updates.

Thought for 7m 52s

回答

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:


1. You’re treating an empty search as “show everything”

This line is the big one:

dart
final 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 ''.
  • When it’s empty, you explicitly tell the code to show all users.
  • So from the very first build, and also when you tap the search field with empty text, your “search result” is already “everything”.

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:

dart
final filteredKeys = _searchQuery.trim().isEmpty ? <String>[] // or maybe grouped.keys.toList() if you want “no filter” : grouped.keys .where((k) => k.toLowerCase().contains(_searchQuery)) .toList();

2. You’re using a separate _searchQuery instead of the TextField text

Right now:

dart
onSubmitted: (v) { setState(() => _searchQuery = v.toLowerCase().trim()); },

So:

  • The list is filtered by _searchQuery, not by _searchController.text.
  • _searchQuery only updates when:
    • You press the keyboard’s “search/done” (onSubmitted), or
    • You press the clear (X) icon.

That means:

  • You can click into the field and see “search happening” even if you didn’t type this time, because _searchQuery still holds the last value from the previous search.
  • The TextField can look empty, but _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.

Fix version

  1. Remove the _searchQuery field from the state (or ignore it).
  2. In build, read the text from the controller:
dart
final 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();
  1. Make the TextField rebuild the list as you type:
dart
TextField( 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:

  • Tapping the field alone doesn’t change the filter.
  • The list always reflects exactly what’s in the TextField.
  • If the field is empty and you choose [] for filteredKeys when empty, then nothing will “search” until the user actually types.

TL;DR

  • The “auto-search” feeling comes from:
    • Empty query = “show everything” in your filter logic.
    • Using a separate _searchQuery that doesn’t always match what’s in the TextField.
  • Use the controller’s text directly in the filter, and decide what you want to happen when it’s empty (show all vs show nothing).

このQ&Aを共有