From 4a42f4a9e1a11bc4953b2da1ac63c09b46311f1a Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:41:45 -0500 Subject: [PATCH] fix: setup_city solo permite ciudades del backend, mensaje si no disponible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solo se puede seleccionar de la lista de ciudades registradas - GPS detecta ciudad → si está en la lista: preselecciona y muestra banner verde - GPS detecta ciudad → si NO está en lista: banner amarillo con mensaje de no disponibilidad - No hay campo de texto libre, solo selección de lista - Botón muestra "Continuar con [Ciudad]" solo cuando hay selección Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/views/setup_city_view.dart | 345 +++++++++++++----------------- 1 file changed, 144 insertions(+), 201 deletions(-) diff --git a/lib/ui/views/setup_city_view.dart b/lib/ui/views/setup_city_view.dart index daa1ec2..5127cc7 100644 --- a/lib/ui/views/setup_city_view.dart +++ b/lib/ui/views/setup_city_view.dart @@ -18,12 +18,10 @@ class SetupCityView extends StatefulWidget { } class _SetupCityViewState extends State { - final _searchCtrl = TextEditingController(); - - List _cities = []; // lista del backend - List _filtered = []; // filtradas por búsqueda - String? _selected; // ciudad seleccionada - String? _detected; // ciudad detectada por GPS + List _cities = []; + String? _selected; + String? _detected; // nombre detectado por GPS + bool _detectedInList = false; // si la ciudad GPS está en la lista bool _loadingCities = true; bool _detectingGps = true; bool _saving = false; @@ -33,35 +31,16 @@ class _SetupCityViewState extends State { super.initState(); _loadCities(); _detectCity(); - _searchCtrl.addListener(_onSearch); - } - - @override - void dispose() { - _searchCtrl.removeListener(_onSearch); - _searchCtrl.dispose(); - super.dispose(); - } - - void _onSearch() { - final q = _searchCtrl.text.toLowerCase(); - setState(() { - _filtered = q.isEmpty - ? List.from(_cities) - : _cities.where((c) => c.toLowerCase().contains(q)).toList(); - }); } Future _loadCities() async { try { final data = await ApiService.instance.get('/locations/cities/all'); - final list = (data as List).map((c) => (c['name'] as String)).toList(); + final list = (data as List).map((c) => c['name'] as String).toList(); if (mounted) { setState(() { _cities = list; - _filtered = List.from(list); _loadingCities = false; - // Si ya detectamos ciudad antes de que cargara la lista, preseleccionar if (_detected != null) _tryPreselect(_detected!); }); } @@ -77,7 +56,8 @@ class _SetupCityViewState extends State { if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } - if (perm == LocationPermission.deniedForever || perm == LocationPermission.denied) { + if (perm == LocationPermission.deniedForever || + perm == LocationPermission.denied) { if (mounted) setState(() => _detectingGps = false); return; } @@ -116,37 +96,38 @@ class _SetupCityViewState extends State { } } - // Busca la mejor coincidencia de la ciudad detectada en la lista del backend void _tryPreselect(String detectedCity) { final normalized = detectedCity.toLowerCase().trim(); - // Coincidencia exacta primero String? match = _cities.firstWhere( (c) => c.toLowerCase() == normalized, orElse: () => '', ); if (match!.isEmpty) { - // Coincidencia parcial match = _cities.firstWhere( - (c) => c.toLowerCase().contains(normalized) || - normalized.contains(c.toLowerCase()), + (c) => + c.toLowerCase().contains(normalized) || + normalized.contains(c.toLowerCase()), orElse: () => '', ); } - if (match.isNotEmpty && mounted) { + if (match.isNotEmpty) { setState(() { _selected = match; - _searchCtrl.text = match!; - _filtered = [match!, ..._cities.where((c) => c != match).toList()]; + _detectedInList = true; + }); + } else { + setState(() { + _detectedInList = false; + _selected = null; }); } } Future _save() async { - final city = _selected ?? _searchCtrl.text.trim(); - if (city.isEmpty) return; + if (_selected == null) return; setState(() => _saving = true); try { - await context.read().updateCity(city); + await context.read().updateCity(_selected!); if (mounted) NavigationService.replaceTo(Flurorouter.dashboardRoute); } catch (_) { if (mounted) setState(() => _saving = false); @@ -162,22 +143,24 @@ class _SetupCityViewState extends State { final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB); final textPrimary = isDark ? Colors.white : const Color(0xFF111827); final textSec = isDark ? const Color(0xFF94A3B8) : const Color(0xFF6B7280); - final inputFill = isDark ? const Color(0xFF0F172A) : const Color(0xFFF9FAFB); + + // Ciudad GPS detectada pero no está en la lista + final bool unavailable = + _detected != null && !_detectingGps && !_loadingCities && !_detectedInList; return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), + constraints: const BoxConstraints(maxWidth: 440), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // ── Header ───────────────────────────────────────────────────── + // ── Header ────────────────────────────────────────────────────── Column( children: [ Container( - width: 64, - height: 64, + width: 64, height: 64, decoration: BoxDecoration( color: const Color(0xFF42A4EF).withOpacity(0.12), shape: BoxShape.circle, @@ -185,222 +168,135 @@ class _SetupCityViewState extends State { child: const Icon(Icons.location_city_rounded, color: Color(0xFF42A4EF), size: 32), ), - const SizedBox(height: 16), + const SizedBox(height: 14), Text('¿En qué ciudad estás?', style: TextStyle( - fontSize: 22, + fontSize: 21, fontWeight: FontWeight.bold, color: textPrimary), textAlign: TextAlign.center), - const SizedBox(height: 6), - Text( - 'Así te mostramos profesionales cerca de ti.', + const SizedBox(height: 5), + Text('Selecciona una de las ciudades disponibles.', style: TextStyle(fontSize: 13, color: textSec), textAlign: TextAlign.center), ], ), - const SizedBox(height: 20), + const SizedBox(height: 18), - // ── Ciudad detectada por GPS ──────────────────────────────────── + // ── Banner GPS ────────────────────────────────────────────────── if (_detectingGps) - Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), - decoration: BoxDecoration( - color: const Color(0xFF42A4EF).withOpacity(0.07), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.2)), - ), - child: Row( - children: [ - const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Color(0xFF42A4EF)), - ), - const SizedBox(width: 10), - Text('Detectando tu ubicación...', - style: TextStyle(fontSize: 12, color: textSec)), - ], - ), + _Banner( + color: const Color(0xFF42A4EF), + icon: null, + loading: true, + text: 'Detectando tu ubicación...', ) - else if (_detected != null) - Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), - decoration: BoxDecoration( - color: const Color(0xFF10B981).withOpacity(0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: const Color(0xFF10B981).withOpacity(0.3)), - ), - child: Row( - children: [ - const Icon(Icons.my_location, - size: 16, color: Color(0xFF10B981)), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Detectado: $_detected', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF10B981), - fontWeight: FontWeight.w500), - ), - ), - TextButton( - onPressed: _detectCity, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap), - child: const Text('Reintentar', - style: TextStyle( - fontSize: 11, color: Color(0xFF42A4EF))), - ), - ], - ), + else if (_detected != null && _detectedInList) + _Banner( + color: const Color(0xFF10B981), + icon: Icons.my_location, + text: 'Tu ubicación: $_detected — disponible ✓', ) - else - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(Icons.my_location, size: 14), - label: const Text('Detectar GPS', style: TextStyle(fontSize: 12)), - onPressed: _detectCity, - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF42A4EF), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4)), - ), + else if (unavailable) + _Banner( + color: const Color(0xFFF59E0B), + icon: Icons.location_off_outlined, + text: + 'El servicio aún no está disponible en "$_detected". Selecciona una ciudad disponible.', + multiline: true, ), - const SizedBox(height: 12), + if (_detectingGps || _detected != null) const SizedBox(height: 12), - // ── Buscador ──────────────────────────────────────────────────── - TextField( - controller: _searchCtrl, - style: TextStyle(color: textPrimary, fontSize: 14), - decoration: InputDecoration( - hintText: 'Buscar ciudad...', - hintStyle: TextStyle(color: textSec, fontSize: 13), - prefixIcon: const Icon(Icons.search, - color: Color(0xFF42A4EF), size: 20), - suffixIcon: _searchCtrl.text.isNotEmpty - ? IconButton( - icon: Icon(Icons.close, size: 16, color: textSec), - onPressed: () { - _searchCtrl.clear(); - setState(() { - _selected = null; - _filtered = List.from(_cities); - }); - }, - ) - : null, - filled: true, - fillColor: inputFill, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: border)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: border)), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF42A4EF), width: 2)), - contentPadding: - const EdgeInsets.symmetric(vertical: 13, horizontal: 4), - ), - onChanged: (_) => setState(() => _selected = null), - ), - - const SizedBox(height: 8), - - // ── Lista de ciudades ─────────────────────────────────────────── + // ── Lista de ciudades ──────────────────────────────────────────── Expanded( child: _loadingCities ? const Center(child: CircularProgressIndicator()) - : _filtered.isEmpty + : _cities.isEmpty ? Center( - child: Text('Sin resultados', - style: TextStyle(color: textSec, fontSize: 13)), + child: Text('No hay ciudades disponibles.', + style: TextStyle(color: textSec)), ) : Container( decoration: BoxDecoration( color: cardBg, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all(color: border), ), child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), child: ListView.separated( - itemCount: _filtered.length, + itemCount: _cities.length, separatorBuilder: (_, __) => Divider(height: 1, color: border), itemBuilder: (_, i) { - final city = _filtered[i]; + final city = _cities[i]; final isSelected = city == _selected; + final isGps = city == _detected; return InkWell( - onTap: () => setState(() { - _selected = city; - _searchCtrl.text = city; - }), - child: Container( + onTap: () => + setState(() => _selected = city), + child: AnimatedContainer( + duration: + const Duration(milliseconds: 150), color: isSelected ? const Color(0xFF42A4EF) .withOpacity(0.10) : Colors.transparent, padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 13), + horizontal: 18, vertical: 16), child: Row( children: [ Icon( Icons.location_on_outlined, - size: 16, + size: 18, color: isSelected ? const Color(0xFF42A4EF) : textSec, ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Text( city, style: TextStyle( - fontSize: 14, - color: isSelected - ? const Color(0xFF42A4EF) - : textPrimary, + fontSize: 15, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? const Color(0xFF42A4EF) + : textPrimary, ), ), ), - if (isSelected) - const Icon(Icons.check_circle, - size: 18, - color: Color(0xFF42A4EF)), - if (city == _detected && - !isSelected) + if (isGps && !isSelected) Container( + margin: const EdgeInsets.only( + right: 8), padding: const EdgeInsets.symmetric( - horizontal: 6, + horizontal: 7, vertical: 2), decoration: BoxDecoration( color: const Color(0xFF10B981) - .withOpacity(0.1), + .withOpacity(0.12), borderRadius: BorderRadius.circular(4), ), child: const Text('GPS', style: TextStyle( - fontSize: 9, + fontSize: 10, color: Color(0xFF10B981), fontWeight: - FontWeight.w600)), + FontWeight.bold)), ), + if (isSelected) + const Icon( + Icons.check_circle_rounded, + size: 20, + color: Color(0xFF42A4EF)), ], ), ), @@ -413,7 +309,7 @@ class _SetupCityViewState extends State { const SizedBox(height: 14), - // ── Botón guardar ─────────────────────────────────────────────── + // ── Botón guardar ──────────────────────────────────────────────── SizedBox( height: 48, child: ElevatedButton( @@ -421,38 +317,33 @@ class _SetupCityViewState extends State { backgroundColor: const Color(0xFF42A4EF), foregroundColor: Colors.white, disabledBackgroundColor: - const Color(0xFF42A4EF).withOpacity(0.4), + const Color(0xFF42A4EF).withOpacity(0.35), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), elevation: 0, ), - onPressed: (_selected != null || - _searchCtrl.text.trim().isNotEmpty) && - !_saving - ? _save - : null, + onPressed: _selected != null && !_saving ? _save : null, child: _saving ? const SizedBox( - width: 20, - height: 20, + width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : Text( _selected != null - ? 'Guardar: $_selected' - : 'Guardar ciudad', + ? 'Continuar con $_selected' + : 'Selecciona una ciudad', style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600)), + fontSize: 14, fontWeight: FontWeight.w600), + ), ), ), const SizedBox(height: 8), - // Omitir TextButton( onPressed: _skip, child: Text('Omitir por ahora', - style: TextStyle(color: textSec, fontSize: 13)), + style: TextStyle(color: textSec, fontSize: 12)), ), ], ), @@ -461,3 +352,55 @@ class _SetupCityViewState extends State { ); } } + +class _Banner extends StatelessWidget { + final Color color; + final IconData? icon; + final String text; + final bool loading; + final bool multiline; + + const _Banner({ + required this.color, + required this.text, + this.icon, + this.loading = false, + this.multiline = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row( + crossAxisAlignment: multiline + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (loading) + SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: color), + ) + else if (icon != null) + Icon(icon, size: 16, color: color), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w500, + height: 1.4)), + ), + ], + ), + ); + } +}