- 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>
402 lines
13 KiB
Dart
402 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:prosapp_web_app/providers/auth_provider.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 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 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);
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|