feat: server-side search with debounce for professionals list

- getProfessionals() now accepts optional search param sent to ?search=
- updateCity only triggers the initial load (city no longer used as filter)
- Search field debounces 450ms before firing API call
- TextEditingController added so clear button also resets the field
- Client-side filtering removed (API now handles it and returns top 7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-12 20:18:25 -05:00
co-authored by Claude Sonnet 4.6
parent 9da159f287
commit e0982995e6
2 changed files with 32 additions and 16 deletions
+5 -7
View File
@@ -5,25 +5,23 @@ import 'package:prosapp_web_app/services/api_service.dart';
class ProfessionalsProvider extends ChangeNotifier {
List<UsuarioProfesional> professionals = [];
bool isLoading = true;
String? _city;
bool _initialized = false;
final _api = ApiService.instance;
void updateCity(String? city) {
// Always load on first auth event; afterwards only reload when city changes.
if (!_initialized || _city != city) {
// Only used to trigger the initial load when auth becomes available.
if (!_initialized) {
_initialized = true;
_city = city;
getProfessionals();
}
}
getProfessionals() async {
Future<void> getProfessionals({String? search}) async {
isLoading = true;
notifyListeners();
try {
final path = (_city != null && _city!.isNotEmpty)
? '/professionals?city=${Uri.encodeComponent(_city!)}'
final path = (search != null && search.trim().isNotEmpty)
? '/professionals?search=${Uri.encodeComponent(search.trim())}'
: '/professionals';
final res = await _api.get(path);
final list = (res is Map ? res['data'] : res) as List;
+27 -9
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:prosapp_web_app/models/usuario_profesional.dart';
import 'package:prosapp_web_app/providers/professionals_provider.dart';
@@ -14,19 +15,35 @@ class ProfessionalsView extends StatefulWidget {
class _ProfessionalsViewState extends State<ProfessionalsView> {
String _search = '';
Timer? _debounce;
final _controller = TextEditingController();
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _search = value);
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
context.read<ProfessionalsProvider>().getProfessionals(search: value);
});
}
void _clearSearch() {
_controller.clear();
_onSearchChanged('');
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ProfessionalsProvider>();
final isDark = context.watch<ThemeProvider>().isDark;
final filtered = provider.professionals.where((p) {
if (_search.isEmpty) return true;
final q = _search.toLowerCase();
return p.user.name.toLowerCase().contains(q) ||
p.professionalInfo.profession.toLowerCase().contains(q) ||
(p.user.city ?? '').toLowerCase().contains(q);
}).toList();
final filtered = provider.professionals;
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
@@ -39,7 +56,8 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 14),
child: TextField(
onChanged: (v) => setState(() => _search = v),
controller: _controller,
onChanged: _onSearchChanged,
style: TextStyle(
color: isDark ? Colors.white : const Color(0xFF111827),
fontSize: 14,
@@ -52,7 +70,7 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
suffixIcon: _search.isNotEmpty
? IconButton(
icon: Icon(Icons.close, size: 18, color: hintColor),
onPressed: () => setState(() => _search = ''),
onPressed: _clearSearch,
)
: null,
filled: true,