diff --git a/lib/ui/views/setup_city_view.dart b/lib/ui/views/setup_city_view.dart index b541a2c..daa1ec2 100644 --- a/lib/ui/views/setup_city_view.dart +++ b/lib/ui/views/setup_city_view.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:prosapp_web_app/providers/auth_provider.dart'; +import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/router/router.dart'; +import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:provider/provider.dart'; @@ -16,34 +18,67 @@ class SetupCityView extends StatefulWidget { } class _SetupCityViewState extends State { - final _controller = TextEditingController(); - bool _loading = false; - bool _detecting = true; - String? _error; + final _searchCtrl = TextEditingController(); + + List _cities = []; // lista del backend + List _filtered = []; // filtradas por búsqueda + String? _selected; // ciudad seleccionada + String? _detected; // ciudad detectada por GPS + bool _loadingCities = true; + bool _detectingGps = true; + bool _saving = false; @override void initState() { super.initState(); + _loadCities(); _detectCity(); + _searchCtrl.addListener(_onSearch); } @override void dispose() { - _controller.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(); + 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!); + }); + } + } catch (_) { + if (mounted) setState(() => _loadingCities = false); + } + } + Future _detectCity() async { - setState(() => _detecting = true); + setState(() => _detectingGps = true); try { - // Pedir permiso de ubicación LocationPermission perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } - if (perm == LocationPermission.deniedForever || - perm == LocationPermission.denied) { - setState(() => _detecting = false); + if (perm == LocationPermission.deniedForever || perm == LocationPermission.denied) { + if (mounted) setState(() => _detectingGps = false); return; } @@ -54,7 +89,6 @@ class _SetupCityViewState extends State { ), ); - // Reverse geocode con Nominatim (sin API key) final url = Uri.parse( 'https://nominatim.openstreetmap.org/reverse' '?format=json&lat=${pos.latitude}&lon=${pos.longitude}&zoom=10&addressdetails=1', @@ -69,29 +103,53 @@ class _SetupCityViewState extends State { address?['town'] as String? ?? address?['municipality'] as String? ?? address?['county'] as String?; - if (city != null && city.isNotEmpty) { - _controller.text = city; + + if (city != null && city.isNotEmpty && mounted) { + setState(() => _detected = city); + if (!_loadingCities) _tryPreselect(city); } } } catch (_) { - // GPS no disponible, el usuario ingresa manualmente + // GPS no disponible } finally { - if (mounted) setState(() => _detecting = false); + if (mounted) setState(() => _detectingGps = false); + } + } + + // 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()), + orElse: () => '', + ); + } + if (match.isNotEmpty && mounted) { + setState(() { + _selected = match; + _searchCtrl.text = match!; + _filtered = [match!, ..._cities.where((c) => c != match).toList()]; + }); } } Future _save() async { - final city = _controller.text.trim(); - if (city.isEmpty) { - setState(() => _error = 'Ingresa tu ciudad'); - return; - } - setState(() { _loading = true; _error = null; }); + final city = _selected ?? _searchCtrl.text.trim(); + if (city.isEmpty) return; + setState(() => _saving = true); try { await context.read().updateCity(city); - NavigationService.replaceTo(Flurorouter.dashboardRoute); + if (mounted) NavigationService.replaceTo(Flurorouter.dashboardRoute); } catch (_) { - setState(() => _loading = false); + if (mounted) setState(() => _saving = false); } } @@ -99,144 +157,296 @@ class _SetupCityViewState extends State { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final textSec = isDark ? Colors.white54 : Colors.grey[600]; + final isDark = context.watch().isDark; + final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; + 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); return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), child: Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Ícono - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: const Color(0xFF42A4EF).withOpacity(0.12), - shape: BoxShape.circle, - ), - child: const Icon(Icons.location_city_outlined, - color: Color(0xFF42A4EF), size: 34), + // ── Header ───────────────────────────────────────────────────── + Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF42A4EF).withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.location_city_rounded, + color: Color(0xFF42A4EF), size: 32), + ), + const SizedBox(height: 16), + Text('¿En qué ciudad estás?', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: textPrimary), + textAlign: TextAlign.center), + const SizedBox(height: 6), + Text( + 'Así te mostramos profesionales cerca de ti.', + style: TextStyle(fontSize: 13, color: textSec), + textAlign: TextAlign.center), + ], ), const SizedBox(height: 20), - const Text('¿En qué ciudad estás?', - style: - TextStyle(fontSize: 22, fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - const SizedBox(height: 8), - Text( - 'Así podemos mostrarte profesionales cerca de ti.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: textSec), - ), - const SizedBox(height: 28), - - if (_detecting) - Padding( - padding: const EdgeInsets.only(bottom: 16), + // ── Ciudad detectada por 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( - mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Color(0xFF42A4EF))), + width: 14, height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Color(0xFF42A4EF)), + ), const SizedBox(width: 10), Text('Detectando tu ubicación...', - style: - TextStyle(fontSize: 13, color: textSec)), + style: TextStyle(fontSize: 12, color: textSec)), ], ), + ) + 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 + 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)), + ), ), - // Campo de ciudad + const SizedBox(height: 12), + + // ── Buscador ──────────────────────────────────────────────────── TextField( - controller: _controller, - textCapitalization: TextCapitalization.words, - onSubmitted: (_) => _save(), + controller: _searchCtrl, + style: TextStyle(color: textPrimary, fontSize: 14), decoration: InputDecoration( - labelText: 'Ciudad', - hintText: 'Ej: Bucaramanga', - prefixIcon: const Icon(Icons.location_on_outlined, - color: Color(0xFF42A4EF)), - errorText: _error, - filled: true, - fillColor: isDark - ? const Color(0xFF1E293B) - : const Color(0xFFF5F8FF), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: isDark - ? const Color(0xFF334155) - : const Color(0xFFE0E7FF), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF42A4EF), width: 2), - ), - suffixIcon: _detecting - ? const Padding( - padding: EdgeInsets.all(14), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Color(0xFF42A4EF)), - ), + 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); + }); + }, ) - : IconButton( - icon: const Icon(Icons.my_location, - color: Color(0xFF42A4EF)), - tooltip: 'Detectar de nuevo', - onPressed: _detectCity, - ), + : 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: 20), + const SizedBox(height: 8), - // Botón guardar + // ── Lista de ciudades ─────────────────────────────────────────── + Expanded( + child: _loadingCities + ? const Center(child: CircularProgressIndicator()) + : _filtered.isEmpty + ? Center( + child: Text('Sin resultados', + style: TextStyle(color: textSec, fontSize: 13)), + ) + : Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ListView.separated( + itemCount: _filtered.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: border), + itemBuilder: (_, i) { + final city = _filtered[i]; + final isSelected = city == _selected; + return InkWell( + onTap: () => setState(() { + _selected = city; + _searchCtrl.text = city; + }), + child: Container( + color: isSelected + ? const Color(0xFF42A4EF) + .withOpacity(0.10) + : Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 13), + child: Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 16, + color: isSelected + ? const Color(0xFF42A4EF) + : textSec, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + city, + style: TextStyle( + fontSize: 14, + color: isSelected + ? const Color(0xFF42A4EF) + : textPrimary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (isSelected) + const Icon(Icons.check_circle, + size: 18, + color: Color(0xFF42A4EF)), + if (city == _detected && + !isSelected) + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF10B981) + .withOpacity(0.1), + borderRadius: + BorderRadius.circular(4), + ), + child: const Text('GPS', + style: TextStyle( + fontSize: 9, + color: + Color(0xFF10B981), + fontWeight: + FontWeight.w600)), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + + const SizedBox(height: 14), + + // ── Botón guardar ─────────────────────────────────────────────── SizedBox( - width: double.infinity, + height: 48, child: ElevatedButton( - onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF42A4EF), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), + disabledBackgroundColor: + const Color(0xFF42A4EF).withOpacity(0.4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), elevation: 0, ), - child: _loading + onPressed: (_selected != null || + _searchCtrl.text.trim().isNotEmpty) && + !_saving + ? _save + : null, + child: _saving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( - color: Colors.white, strokeWidth: 2)) - : const Text('Guardar ciudad', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600)), + strokeWidth: 2, color: Colors.white)) + : Text( + _selected != null + ? 'Guardar: $_selected' + : 'Guardar ciudad', + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), // Omitir TextButton(