From 0711d30f07fa14c2b65bf458e2931221657ac010 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:34:51 -0500 Subject: [PATCH] =?UTF-8?q?feat:=206=20mejoras=20simult=C3=A1neas=20en=20p?= =?UTF-8?q?rosappweb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/index.html: lang=es (evita traductor) + cache busting con timestamp en flutter_bootstrap.js - profesional.dart: rate → número, location_preferences → string para coincidir con backend DTO - location_preferences.dart: funciones locationPrefsToString/locationPrefsFromValue - auth_provider.dart: _navigateAfterAuth redirige a setup-city si user.city vacío, método updateCity y linkEmailWithOtp - setup_city_view.dart: nueva vista con GPS + Nominatim para detectar ciudad, campo editable, omitir - email_view.dart: rediseño completo con flujo OTP (paso 1: email+contraseña → paso 2: código recibido en correo) - router + dashboard_handlers: ruta /dashboard/setup-city Co-Authored-By: Claude Sonnet 4.6 --- lib/models/location_preferences.dart | 20 + lib/models/profesional.dart | 8 +- lib/providers/auth_provider.dart | 24 ++ lib/router/dashboard_handlers.dart | 12 + lib/router/router.dart | 6 + lib/ui/views/email_view.dart | 530 ++++++++++++++++++++------- lib/ui/views/setup_city_view.dart | 253 +++++++++++++ web/index.html | 11 +- 8 files changed, 716 insertions(+), 148 deletions(-) create mode 100644 lib/ui/views/setup_city_view.dart diff --git a/lib/models/location_preferences.dart b/lib/models/location_preferences.dart index 7329985..1fafb7f 100644 --- a/lib/models/location_preferences.dart +++ b/lib/models/location_preferences.dart @@ -9,3 +9,23 @@ enum LocationPreferences { office, delivery, both } LocationPreferences intToEnum(int value) { return LocationPreferences.values[value]; } + +String locationPrefsToString(LocationPreferences p) { + switch (p) { + case LocationPreferences.office: return 'office'; + case LocationPreferences.delivery: return 'delivery'; + case LocationPreferences.both: return 'both'; + } +} + +LocationPreferences locationPrefsFromValue(dynamic v) { + if (v is int) return intToEnum(v); + if (v is String) { + switch (v) { + case 'delivery': return LocationPreferences.delivery; + case 'both': return LocationPreferences.both; + default: return LocationPreferences.office; + } + } + return LocationPreferences.office; +} diff --git a/lib/models/profesional.dart b/lib/models/profesional.dart index 1b76d71..7182b13 100644 --- a/lib/models/profesional.dart +++ b/lib/models/profesional.dart @@ -113,8 +113,8 @@ class Profesional { aditionalAddress: (doc['aditional_address'] as String?) ?? '', profession: (doc['profession'] as String?) ?? '', ratePreferences: (doc['rate_preferences'] as bool?) ?? false, - rate: (doc['rate'] as String?) ?? '', - locationPreferences: intToEnum((doc['location_preferences'] as int?) ?? 0), + rate: (doc['rate'] as num?)?.toString() ?? '', + locationPreferences: locationPrefsFromValue(doc['location_preferences']), bannerPicture: (doc['banner_picture'] as String?) ?? '', identificationPicture: (doc['identification_picture'] as String?) ?? '', certificatePicture: (doc['certificate_picture'] as String?) ?? '', @@ -183,8 +183,8 @@ class Profesional { 'aditional_address': aditionalAddress, 'profession': profession, 'rate_preferences': ratePreferences, - 'rate': rate, - 'location_preferences': enumToInt(locationPreferences), + 'rate': double.tryParse(rate) ?? 0.0, + 'location_preferences': locationPrefsToString(locationPreferences), 'banner_picture': bannerPicture, 'identification_picture': identificationPicture, 'certificate_picture': certificatePicture, diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index bdfe268..9449347 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -24,6 +24,8 @@ class AuthProvider extends ChangeNotifier { void _navigateAfterAuth() { if (user?.isPhoneVerified == false) { NavigationService.replaceTo(Flurorouter.phoneLoginRoute); + } else if (user?.city == null || user!.city!.isEmpty) { + NavigationService.replaceTo(Flurorouter.setupCityRoute); } else { NavigationService.replaceTo(Flurorouter.dashboardRoute); } @@ -107,6 +109,17 @@ class AuthProvider extends ChangeNotifier { } } + Future updateCity(String city) async { + try { + await _api.patch('/users/me', {'city': city.trim()}); + user = await _fetchMe(); + notifyListeners(); + } catch (_) { + NotificationsService.showSnackBarError('Error al guardar la ciudad'); + rethrow; + } + } + Future verifyPhoneNumberForLink(String phoneNumber) async { try { await _api.post('/auth/send-otp', {'phone': phoneNumber}); @@ -141,6 +154,17 @@ class AuthProvider extends ChangeNotifier { } } + Future linkEmailWithOtp(String email, String password, String code) async { + await _api.post('/auth/link-email-otp', { + 'email': email, + 'password': password, + 'code': code, + }); + user = await _fetchMe(); + notifyListeners(); + NotificationsService.showSnackbar('Correo vinculado exitosamente'); + } + Future isAuthenticated() async { final token = await _api.getToken(); if (token == null) { diff --git a/lib/router/dashboard_handlers.dart b/lib/router/dashboard_handlers.dart index 26a6804..3917ab7 100644 --- a/lib/router/dashboard_handlers.dart +++ b/lib/router/dashboard_handlers.dart @@ -22,6 +22,7 @@ import 'package:prosapp_web_app/ui/views/services_history_view.dart'; import 'package:prosapp_web_app/ui/views/services_requests_view.dart'; import 'package:prosapp_web_app/ui/views/services_view.dart'; import 'package:prosapp_web_app/ui/views/setup_name_view.dart'; +import 'package:prosapp_web_app/ui/views/setup_city_view.dart'; import 'package:prosapp_web_app/ui/views/support_view.dart'; import 'package:provider/provider.dart'; @@ -52,6 +53,17 @@ class DashboardHandlers { }, ); + static Handler setupCity = Handler( + handlerFunc: (context, params) { + final authProvider = Provider.of(context!); + if (authProvider.authStatus == AuthStatus.authenticated) { + return const SetupCityView(); + } else { + return const LoginView(); + } + }, + ); + static Handler support = Handler( handlerFunc: (context, params) { final authProvider = Provider.of(context!); diff --git a/lib/router/router.dart b/lib/router/router.dart index a940ef2..b01b678 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -19,6 +19,7 @@ class Flurorouter { // Onboarding static String setupNameRoute = "/dashboard/setup-name"; + static String setupCityRoute = "/dashboard/setup-city"; // Users static String profileRoute = "/dashboard/profile"; @@ -77,6 +78,11 @@ class Flurorouter { handler: DashboardHandlers.setupName, transitionType: TransitionType.none, ); + router.define( + setupCityRoute, + handler: DashboardHandlers.setupCity, + transitionType: TransitionType.none, + ); // Dashboard Routes router.define( diff --git a/lib/ui/views/email_view.dart b/lib/ui/views/email_view.dart index 320a0d8..8a86839 100644 --- a/lib/ui/views/email_view.dart +++ b/lib/ui/views/email_view.dart @@ -1,155 +1,401 @@ import 'package:flutter/material.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; -import 'package:prosapp_web_app/providers/email_form_provider.dart'; -import 'package:prosapp_web_app/providers/phone_form_provider.dart'; -import 'package:prosapp_web_app/ui/buttons/custom_outlined_button.dart'; -import 'package:prosapp_web_app/ui/cards/white_card.dart'; -import 'package:prosapp_web_app/ui/inputs/custom_inputs.dart'; +import 'package:prosapp_web_app/providers/theme_provider.dart'; +import 'package:prosapp_web_app/services/api_service.dart'; +import 'package:prosapp_web_app/services/notifications_service.dart'; +import 'package:prosapp_web_app/router/router.dart'; +import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:provider/provider.dart'; -class EmailView extends StatelessWidget { +class EmailView extends StatefulWidget { const EmailView({super.key}); + @override + State createState() => _EmailViewState(); +} + +class _EmailViewState extends State { + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + final _otpCtrl = TextEditingController(); + + bool _step2 = false; // false = ingresar email+pass, true = ingresar OTP + bool _loading = false; + bool _obscurePass = true; + bool _obscureConfirm = true; + String? _emailError; + String? _passError; + String? _confirmError; + String? _otpError; + + final _emailRegex = + RegExp(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"); + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + _confirmCtrl.dispose(); + _otpCtrl.dispose(); + super.dispose(); + } + + bool _validateStep1() { + String? eErr, pErr, cErr; + if (!_emailRegex.hasMatch(_emailCtrl.text.trim())) { + eErr = 'Ingresa un email válido'; + } + if (_passCtrl.text.length < 6) { + pErr = 'Mínimo 6 caracteres'; + } else if (!_passCtrl.text.contains(RegExp(r'[0-9]'))) { + pErr = 'Debe contener al menos un número'; + } + if (_confirmCtrl.text != _passCtrl.text) { + cErr = 'Las contraseñas no coinciden'; + } + setState(() { _emailError = eErr; _passError = pErr; _confirmError = cErr; }); + return eErr == null && pErr == null && cErr == null; + } + + Future _sendOtp() async { + if (!_validateStep1()) return; + setState(() => _loading = true); + try { + final api = ApiService.instance; + await api.post('/auth/send-email-otp', {'email': _emailCtrl.text.trim()}); + setState(() { _step2 = true; _loading = false; }); + NotificationsService.showSnackbar( + 'Código enviado a ${_emailCtrl.text.trim()}'); + } catch (e) { + setState(() => _loading = false); + NotificationsService.showSnackbar('Error: ${e.toString()}'); + } + } + + Future _verifyOtp() async { + final code = _otpCtrl.text.trim(); + if (code.length != 6) { + setState(() => _otpError = 'Ingresa el código de 6 dígitos'); + return; + } + setState(() { _loading = true; _otpError = null; }); + try { + await context.read().linkEmailWithOtp( + _emailCtrl.text.trim(), + _passCtrl.text, + code, + ); + if (mounted) NavigationService.replaceTo(Flurorouter.profileRoute); + } catch (e) { + setState(() { _loading = false; _otpError = 'Código incorrecto o expirado'; }); + } + } + @override Widget build(BuildContext context) { - final authProvider = Provider.of(context); + 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 TextEditingController _emailController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - final TextEditingController _confirmPasswordController = - TextEditingController(); - - final RegExp emailRegex = - RegExp(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"); - - RegExp passwordRegex = RegExp(r'^(?=.*?[0-9])'); - - return ChangeNotifierProvider( - create: (_) => EmailFormProvider(), - child: Builder(builder: (context) { - final emailFormProvider = - Provider.of(context, listen: false); - - return ListView( - physics: const ClampingScrollPhysics(), - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: WhiteCard( - child: Column( - children: [ - Container( - margin: const EdgeInsets.only(top: 40), - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 370), - child: Form( - autovalidateMode: - AutovalidateMode.onUserInteraction, - key: emailFormProvider.formKey, - child: Column( - children: [ - TextFormField( - controller: _emailController, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Ingresa un email'; - } - - if (!emailRegex.hasMatch(value)) { - return 'Ingresa un email válido'; - } - return null; - }, - onChanged: (email) => - emailFormProvider.email = email, - decoration: - CustomInputs.loginInputDecoration( - hint: 'Ingresa tu email', - label: 'Email', - icon: Icons.email_outlined, - ), - ), - const SizedBox(height: 20), - TextFormField( - controller: _passwordController, - obscureText: true, - onChanged: (value) => - emailFormProvider.password = value, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Ingresa una contraseña'; - } - - if (value.length < 6) { - return 'La contraseña debe tener al menos 6 caracteres'; - } - - if (!passwordRegex.hasMatch(value)) { - return 'La contraseña debe tener al menos un número'; - } - return null; - }, - decoration: - CustomInputs.loginInputDecoration( - hint: 'Ingresa tu contraseña', - label: 'Contraseña', - icon: Icons.lock_outline, - ), - ), - const SizedBox(height: 20), - TextFormField( - obscureText: true, - controller: _confirmPasswordController, - onChanged: (value) => - emailFormProvider.password = value, - validator: (value) { - if (value != _passwordController.text) { - return 'Las contraseñas no coinciden'; - } - if (value!.isEmpty) { - return 'La contraseña es obligatoria'; - } - return null; - }, - decoration: - CustomInputs.loginInputDecoration( - hint: 'Confirma tu contraseña', - label: 'Confirmar contraseña', - icon: Icons.lock_outline, - ), - ), - const SizedBox(height: 20), - CustomOutlinedButton( - onPressed: () async { - final isValid = - emailFormProvider.validateForm(); - if (isValid) { - await authProvider.addEmailAndPassword( - emailFormProvider.email, - emailFormProvider.password); - } - }, - text: "Guardar", - color: Colors.blue, - ), - const SizedBox(height: 20), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ), + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border), ), - ], - ); - }), + child: _step2 ? _buildStep2(textPrimary, textSec, isDark, border) + : _buildStep1(textPrimary, textSec, isDark, border), + ), + ), + ), + ); + } + + // ── Paso 1: email + contraseña ────────────────────────────────────────────── + Widget _buildStep1(Color textPrimary, Color textSec, bool isDark, Color border) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Header( + icon: Icons.email_outlined, + title: 'Vincular correo', + subtitle: 'Te enviaremos un código de verificación a tu correo.', + ), + const SizedBox(height: 24), + + _Field( + label: 'Correo electrónico', + controller: _emailCtrl, + error: _emailError, + isDark: isDark, + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + onChanged: (_) => setState(() => _emailError = null), + ), + const SizedBox(height: 14), + + _Field( + label: 'Contraseña', + controller: _passCtrl, + error: _passError, + isDark: isDark, + icon: Icons.lock_outline, + obscure: _obscurePass, + onChanged: (_) => setState(() => _passError = null), + suffix: IconButton( + icon: Icon(_obscurePass ? Icons.visibility_outlined : Icons.visibility_off_outlined, + size: 18, color: const Color(0xFF42A4EF)), + onPressed: () => setState(() => _obscurePass = !_obscurePass), + ), + ), + const SizedBox(height: 14), + + _Field( + label: 'Confirmar contraseña', + controller: _confirmCtrl, + error: _confirmError, + isDark: isDark, + icon: Icons.lock_outline, + obscure: _obscureConfirm, + onChanged: (_) => setState(() => _confirmError = null), + suffix: IconButton( + icon: Icon(_obscureConfirm ? Icons.visibility_outlined : Icons.visibility_off_outlined, + size: 18, color: const Color(0xFF42A4EF)), + onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), + ), + ), + const SizedBox(height: 24), + + _SubmitBtn( + label: 'Enviar código al correo', + icon: Icons.send_outlined, + loading: _loading, + onPressed: _sendOtp, + ), + ], + ); + } + + // ── Paso 2: código OTP ────────────────────────────────────────────────────── + Widget _buildStep2(Color textPrimary, Color textSec, bool isDark, Color border) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Header( + icon: Icons.mark_email_read_outlined, + title: 'Revisa tu correo', + subtitle: 'Ingresa el código de 6 dígitos que te enviamos.', + ), + const SizedBox(height: 8), + Text(_emailCtrl.text.trim(), + style: const TextStyle( + color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)), + const SizedBox(height: 24), + + _Field( + label: 'Código de verificación', + controller: _otpCtrl, + error: _otpError, + isDark: isDark, + icon: Icons.pin_outlined, + keyboardType: TextInputType.number, + onChanged: (_) => setState(() => _otpError = null), + ), + const SizedBox(height: 20), + + _SubmitBtn( + label: 'Verificar y vincular', + icon: Icons.verified_outlined, + loading: _loading, + onPressed: _verifyOtp, + ), + const SizedBox(height: 12), + + Center( + child: TextButton.icon( + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Cambiar correo'), + onPressed: () => setState(() { + _step2 = false; + _otpCtrl.clear(); + }), + ), + ), + + Center( + child: TextButton( + onPressed: _loading ? null : _sendOtp, + child: const Text('Reenviar código'), + ), + ), + ], + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +class _Header extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + const _Header({required this.icon, required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final isDark = context.watch().isDark; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFF42A4EF).withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: const Color(0xFF42A4EF), size: 26), + ), + const SizedBox(height: 16), + Text(title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF111827), + )), + const SizedBox(height: 4), + Text(subtitle, + style: TextStyle( + fontSize: 13, + color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF6B7280), + )), + ], + ); + } +} + +class _Field extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? error; + final bool isDark; + final IconData icon; + final bool obscure; + final TextInputType? keyboardType; + final Widget? suffix; + final ValueChanged? onChanged; + + const _Field({ + required this.label, + required this.controller, + required this.error, + required this.isDark, + required this.icon, + this.obscure = false, + this.keyboardType, + this.suffix, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final fill = isDark ? const Color(0xFF0F172A) : const Color(0xFFF9FAFB); + final borderColor = + isDark ? const Color(0xFF334155) : const Color(0xFFD1D5DB); + final hintColor = + isDark ? const Color(0xFF64748B) : const Color(0xFF9CA3AF); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + onChanged: onChanged, + style: TextStyle( + color: isDark ? Colors.white : const Color(0xFF111827), + fontSize: 14), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: hintColor, fontSize: 13), + prefixIcon: Icon(icon, color: const Color(0xFF42A4EF), size: 20), + suffixIcon: suffix, + filled: true, + fillColor: fill, + errorText: null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: error != null ? Colors.redAccent : borderColor)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Color(0xFF42A4EF), width: 2)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 5, left: 4), + child: Text(error!, + style: const TextStyle( + color: Colors.redAccent, fontSize: 11)), + ), + ], + ); + } +} + +class _SubmitBtn extends StatelessWidget { + final String label; + final IconData icon; + final bool loading; + final VoidCallback onPressed; + const _SubmitBtn( + {required this.label, + required this.icon, + required this.loading, + required this.onPressed}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + icon: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Icon(icon, size: 18), + label: Text(label, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 0, + ), + onPressed: loading ? null : onPressed, + ), ); } } diff --git a/lib/ui/views/setup_city_view.dart b/lib/ui/views/setup_city_view.dart new file mode 100644 index 0000000..b541a2c --- /dev/null +++ b/lib/ui/views/setup_city_view.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; + +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/router/router.dart'; +import 'package:prosapp_web_app/services/navigation_service.dart'; +import 'package:provider/provider.dart'; + +class SetupCityView extends StatefulWidget { + const SetupCityView({super.key}); + + @override + State createState() => _SetupCityViewState(); +} + +class _SetupCityViewState extends State { + final _controller = TextEditingController(); + bool _loading = false; + bool _detecting = true; + String? _error; + + @override + void initState() { + super.initState(); + _detectCity(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _detectCity() async { + setState(() => _detecting = 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); + return; + } + + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 8), + ), + ); + + // 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', + ); + final resp = await http.get(url, + headers: {'User-Agent': 'ProsApp/1.0 (prosapp.co)'}); + + if (resp.statusCode == 200) { + final json = jsonDecode(resp.body) as Map; + final address = json['address'] as Map?; + final city = address?['city'] as String? ?? + address?['town'] as String? ?? + address?['municipality'] as String? ?? + address?['county'] as String?; + if (city != null && city.isNotEmpty) { + _controller.text = city; + } + } + } catch (_) { + // GPS no disponible, el usuario ingresa manualmente + } finally { + if (mounted) setState(() => _detecting = false); + } + } + + Future _save() async { + final city = _controller.text.trim(); + if (city.isEmpty) { + setState(() => _error = 'Ingresa tu ciudad'); + return; + } + setState(() { _loading = true; _error = null; }); + try { + await context.read().updateCity(city); + NavigationService.replaceTo(Flurorouter.dashboardRoute); + } catch (_) { + setState(() => _loading = false); + } + } + + void _skip() => NavigationService.replaceTo(Flurorouter.dashboardRoute); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textSec = isDark ? Colors.white54 : Colors.grey[600]; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + 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), + ), + + 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), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF42A4EF))), + const SizedBox(width: 10), + Text('Detectando tu ubicación...', + style: + TextStyle(fontSize: 13, color: textSec)), + ], + ), + ), + + // Campo de ciudad + TextField( + controller: _controller, + textCapitalization: TextCapitalization.words, + onSubmitted: (_) => _save(), + 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)), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location, + color: Color(0xFF42A4EF)), + tooltip: 'Detectar de nuevo', + onPressed: _detectCity, + ), + ), + ), + + const SizedBox(height: 20), + + // Botón guardar + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _loading ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Text('Guardar ciudad', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600)), + ), + ), + + const SizedBox(height: 12), + + // Omitir + TextButton( + onPressed: _skip, + child: Text('Omitir por ahora', + style: TextStyle(color: textSec, fontSize: 13)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/web/index.html b/web/index.html index 7e3dca6..9564528 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + @@ -89,6 +89,13 @@ }); - +