feat: 6 mejoras simultáneas en prosappweb
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f433bb2f6e
commit
0711d30f07
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> 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<void> verifyPhoneNumberForLink(String phoneNumber) async {
|
||||
try {
|
||||
await _api.post('/auth/send-otp', {'phone': phoneNumber});
|
||||
@@ -141,6 +154,17 @@ class AuthProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<bool> isAuthenticated() async {
|
||||
final token = await _api.getToken();
|
||||
if (token == null) {
|
||||
|
||||
@@ -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<AuthProvider>(context!);
|
||||
if (authProvider.authStatus == AuthStatus.authenticated) {
|
||||
return const SetupCityView();
|
||||
} else {
|
||||
return const LoginView();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
static Handler support = Handler(
|
||||
handlerFunc: (context, params) {
|
||||
final authProvider = Provider.of<AuthProvider>(context!);
|
||||
|
||||
@@ -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(
|
||||
|
||||
+388
-142
@@ -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<EmailView> createState() => _EmailViewState();
|
||||
}
|
||||
|
||||
class _EmailViewState extends State<EmailView> {
|
||||
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<void> _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<void> _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<AuthProvider>().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<AuthProvider>(context);
|
||||
final isDark = context.watch<ThemeProvider>().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<EmailFormProvider>(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<ThemeProvider>().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<String>? 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SetupCityView> createState() => _SetupCityViewState();
|
||||
}
|
||||
|
||||
class _SetupCityViewState extends State<SetupCityView> {
|
||||
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<void> _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<String, dynamic>;
|
||||
final address = json['address'] as Map<String, dynamic>?;
|
||||
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<void> _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<AuthProvider>().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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
<meta charset="UTF-8">
|
||||
@@ -89,6 +89,13 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
<script>
|
||||
// Force cache busting: append build timestamp so stale JS/CSS is never reused
|
||||
var _v = new Date().getTime();
|
||||
var _s = document.createElement('script');
|
||||
_s.src = 'flutter_bootstrap.js?v=' + _v;
|
||||
_s.async = true;
|
||||
document.body.appendChild(_s);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user