From efbdaf2dbede61a3dced0720cf755cfbc67ed988 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:10:38 -0500 Subject: [PATCH] fix: perfil - inicializacion correcta, ciudad con key, correo siempre visible - ProfileView vuelve a StatefulWidget para inicializar ProfileFormProvider - Quitar import custom_inputs.dart sin usar - DropdownButtonFormField con ValueKey para forzar rebuild cuando cargan ciudades - Normalizacion de acentos en ciudad (cucuta == cucuta con/sin tilde) - _EmailCard siempre muestra el formulario (no requiere expandir) - Eliminado campo email read-only del formulario principal Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/views/profile_view.dart | 1024 +++++++++++++++++--------------- 1 file changed, 556 insertions(+), 468 deletions(-) diff --git a/lib/ui/views/profile_view.dart b/lib/ui/views/profile_view.dart index fa769ce..a10999d 100644 --- a/lib/ui/views/profile_view.dart +++ b/lib/ui/views/profile_view.dart @@ -1,8 +1,8 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; import 'package:prosapp_web_app/models/city.dart'; -import 'package:prosapp_web_app/models/usuario.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/cities_provider.dart'; import 'package:prosapp_web_app/providers/profile_form_provider.dart'; @@ -10,12 +10,40 @@ 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:prosapp_web_app/services/notifications_service.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/ui/labels/custom_labels.dart'; -import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +// ─── helpers ──────────────────────────────────────────────────────────────── + +String _avatarUrl(String name) { + final encoded = Uri.encodeComponent(name.isEmpty ? 'U' : name); + return 'https://ui-avatars.com/api/?name=$encoded&background=42A4EF&color=fff&size=200&bold=true&rounded=true'; +} + +InputDecoration _field(String label, String hint, IconData icon, + {Color? fill, Color? border}) => + InputDecoration( + labelText: label, + hintText: hint, + prefixIcon: Icon(icon, size: 20), + filled: true, + fillColor: fill, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border ?? Colors.grey.shade300)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + const BorderSide(color: Color(0xFF42A4EF), width: 2)), + ); + +// ─── ProfileView ───────────────────────────────────────────────────────────── + class ProfileView extends StatefulWidget { const ProfileView({super.key}); @@ -24,349 +52,343 @@ class ProfileView extends StatefulWidget { } class _ProfileViewState extends State { - Usuario? user; - List cities = []; - @override void initState() { super.initState(); - - final authProvider = Provider.of(context, listen: false); - final profileFormProvider = - Provider.of(context, listen: false); - final citiesProvider = Provider.of(context, listen: false); - - profileFormProvider.user = authProvider.user; - setState(() { - cities = citiesProvider.cities; - user = authProvider.user; - }); + // Inicializa el form provider con el usuario actual + final auth = context.read(); + final pfp = context.read(); + pfp.user = auth.user; } @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraints) { - if (constraints.maxWidth < 700) { - return ListView( - physics: const ClampingScrollPhysics(), - children: const [SizedBox(height: 10), _ProfileViewBody()], - ); - } else { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: ListView( - physics: const ClampingScrollPhysics(), - children: const [SizedBox(height: 10), _ProfileViewBody()], - ), - ); - } + return LayoutBuilder(builder: (context, c) { + final narrow = c.maxWidth < 700; + const body = _ProfileBody(); + return narrow + ? ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 12), + children: [body], + ) + : ListView( + physics: const ClampingScrollPhysics(), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: body), + ) + ], + ); }); } } -class _ProfileViewBody extends StatelessWidget { - const _ProfileViewBody(); +// ─── _ProfileBody ───────────────────────────────────────────────────────────── + +class _ProfileBody extends StatelessWidget { + const _ProfileBody(); @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraints) { - if (constraints.maxWidth < 700) { - return const Column( - children: [ - _AvatarContainer(containerFull: true), - _ProfileViewForm(), - _EmailLinkCard(), - ], - ); - } else { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: const Column( - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: 250, child: _AvatarContainer(containerFull: true)), - Expanded(child: _ProfileViewForm()), - ], - ), - ), - _EmailLinkCard(), - ], + return LayoutBuilder(builder: (context, c) { + if (c.maxWidth < 700) { + return const Column(children: [ + _AvatarCard(), + SizedBox(height: 12), + _InfoCard(), + SizedBox(height: 12), + _EmailCard(), + SizedBox(height: 24), + ]); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SizedBox(width: 260, child: _AvatarCard()), + SizedBox(width: 16), + Expanded( + child: Column(children: [ + _InfoCard(), + SizedBox(height: 12), + _EmailCard(), + ]), + ), + ], + ); + }); + } +} + +// ─── _AvatarCard ───────────────────────────────────────────────────────────── + +class _AvatarCard extends StatelessWidget { + const _AvatarCard(); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; + final user = context.watch().user!; + final pfp = + context.watch().user?.picture; + final hasPic = pfp != null && pfp.isNotEmpty; + final imgUrl = hasPic ? pfp : _avatarUrl(user.name); + + return Container( + width: double.infinity, + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), blurRadius: 6) + ], + ), + child: Column(children: [ + Text(user.name, + style: CustomLabels.h2, textAlign: TextAlign.center), + const SizedBox(height: 16), + Stack(children: [ + ClipOval( + child: SizedBox( + width: 130, + height: 130, + child: Image.network(imgUrl, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Image.network( + _avatarUrl(user.name), + fit: BoxFit.cover)), ), ), - ); - } - }); + Positioned( + bottom: 4, + right: 4, + child: _CameraButton(), + ), + ]), + const SizedBox(height: 12), + if (user.phone != null && user.phone!.isNotEmpty) + Text(user.phone!, + style: TextStyle( + color: isDark ? Colors.white54 : Colors.black45, + fontSize: 13)), + ]), + ); } } -class _ProfileViewForm extends StatelessWidget { - const _ProfileViewForm(); +class _CameraButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final pfp = context.watch(); + + return GestureDetector( + onTap: () async { + final result = await FilePicker.platform.pickFiles(withData: true); + if (result == null) return; + final bytes = result.files.first.bytes; + if (bytes == null) return; + NotificationsService.showBusyIndicator(context); + await pfp.uploadPicture(bytes); + if (context.mounted) { + Navigator.pop(context); + context.read().refreshUser(); + } + }, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: const Color(0xFF42A4EF), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon(Icons.camera_alt_outlined, + size: 16, color: Colors.white), + ), + ); + } +} + +// ─── _InfoCard ──────────────────────────────────────────────────────────────── + +class _InfoCard extends StatelessWidget { + const _InfoCard(); @override Widget build(BuildContext context) { - final profileFormProvider = Provider.of(context); - final citiesProvider = Provider.of(context); - final cities = citiesProvider.cities; - final user = profileFormProvider.user!; final isDark = Theme.of(context).brightness == Brightness.dark; - final textColor = isDark ? Colors.white : Colors.black87; + final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; + final fillColor = + isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFF); + final borderColor = + isDark ? const Color(0xFF334155) : const Color(0xFFE0E7FF); - // Normaliza el valor actual para que coincida exactamente con la lista - final cityNames = cities.map((c) => c.cityName).toList(); + final pfp = context.watch(); + final cities = context.watch(); + final user = pfp.user!; + + // Ciudad que coincide con la guardada String? currentCity; - if (user.city != null && user.city!.isNotEmpty) { - final saved = user.city!.toLowerCase().trim(); - currentCity = cityNames.firstWhere( - (n) => n.toLowerCase().trim() == saved, - orElse: () => '', - ); - if (currentCity!.isEmpty) currentCity = null; + if (user.city != null && user.city!.isNotEmpty && cities.cities.isNotEmpty) { + final saved = _normalize(user.city!); + currentCity = cities.cities + .map((c) => c.cityName) + .cast() + .firstWhere( + (n) => _normalize(n!) == saved, + orElse: () => null); } - return WhiteCard( - title: 'Información general', + return Container( + width: double.infinity, + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), blurRadius: 6) + ], + ), child: Form( - key: profileFormProvider.formKey, - autovalidateMode: AutovalidateMode.always, + key: pfp.formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 10), + Text('Información personal', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: isDark ? Colors.white : Colors.black87)), + const Divider(height: 20), + + // Nombre TextFormField( initialValue: user.name, - validator: (value) { - if (value == null || value.isEmpty) { - return 'El nombre es obligatorio'; - } - if (value.trim().length < 4) { - return 'El nombre debe tener al menos 4 caracteres'; - } - return null; - }, - onChanged: (value) { - profileFormProvider.copyUserWith(name: value); - }, - decoration: CustomInputs.formInputDecoration( - hint: 'Nombre de usuario', - label: 'Nombre', - icon: Icons.person_outline, - ), + validator: (v) => (v == null || v.trim().length < 3) + ? 'Mínimo 3 caracteres' + : null, + onChanged: (v) => pfp.copyUserWith(name: v), + decoration: _field('Nombre', 'Tu nombre completo', + Icons.person_outline, + fill: fillColor, border: borderColor), ), - const SizedBox(height: 20), + const SizedBox(height: 12), + + // Teléfono (read-only) TextFormField( readOnly: true, - onTap: user.phone == null || user.phone!.isEmpty - ? () { - NavigationService.navigateTo(Flurorouter.phoneRoute); - } - : null, initialValue: user.phone ?? '', - decoration: CustomInputs.formInputDecoration( - hint: 'Número de teléfono', - label: 'Teléfono', - icon: Icons.phone, - ), - ), - const SizedBox(height: 20), - TextFormField( - readOnly: true, - onTap: user.email == null || user.email!.isEmpty - ? () { - NavigationService.navigateTo(Flurorouter.emailRoute); - } + onTap: (user.phone == null || user.phone!.isEmpty) + ? () => NavigationService.navigateTo(Flurorouter.phoneRoute) : null, - initialValue: user.email ?? '', - decoration: CustomInputs.formInputDecoration( - hint: 'Correo del usuario', - label: 'Correo', - icon: Icons.email_outlined, - ), + decoration: _field( + 'Teléfono', 'Número de teléfono', Icons.phone, + fill: fillColor, border: borderColor), ), - const SizedBox(height: 20), - if (citiesProvider.isLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: CircularProgressIndicator()), - ) - else - DropdownButtonFormField( - validator: (value) { - if (value == null) return 'La ciudad es obligatoria'; - return null; - }, - value: currentCity, - isExpanded: true, - decoration: CustomInputs.formInputDecoration( - hint: 'Selecciona tu ciudad', - label: 'Ciudad', - icon: Icons.location_city_outlined, - ), - items: cities.map((City ciudad) { - return DropdownMenuItem( - value: ciudad.cityName, - child: Text( - '${ciudad.cityName} - ${ciudad.stateOfCity}', - style: TextStyle( - color: textColor, - fontWeight: FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), - onChanged: (value) { - if (value != null) profileFormProvider.copyUserWith(city: value); - }, - ), - const SizedBox(height: 20), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 130), - child: ElevatedButton( - onPressed: () async { - await profileFormProvider.updateUserInfo(); + const SizedBox(height: 12), - Provider.of(context, listen: false) - .refreshUser(); - }, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - Colors.blue.shade400, - - ), - shape: WidgetStateProperty.all(const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5)), - )), - shadowColor: WidgetStateProperty.all(Colors.transparent), - ), - child: const Text('Guardar', - style: TextStyle(color: Colors.white)), - ), - ), - const SizedBox(height: 10), - ], - ), - ), - ); - } -} - -String _uiAvatarUrl(String name) { - final encoded = Uri.encodeComponent(name.isEmpty ? 'Usuario' : name); - return 'https://ui-avatars.com/api/?name=$encoded&background=42A4EF&color=fff&size=200&bold=true&rounded=true'; -} - -class _AvatarContainer extends StatelessWidget { - final bool containerFull; - - const _AvatarContainer({required this.containerFull}); - - @override - Widget build(BuildContext context) { - final user = Provider.of(context).user!; - final profileFormProvider = Provider.of(context); - - final picUrl = profileFormProvider.user!.picture; - final hasPicture = picUrl != null && picUrl.isNotEmpty; - final userName = profileFormProvider.user!.name; - final imageUrl = hasPicture ? picUrl! : _uiAvatarUrl(userName); - final image = Image.network(imageUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Image.network(_uiAvatarUrl(userName), fit: BoxFit.cover)); - - return WhiteCard( - width: containerFull ? null : 250, - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - user.name, - style: CustomLabels.h2, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - SizedBox( - width: 160, - height: 160, - child: Stack( - children: [ - SizedBox( - width: 200, - height: 200, - child: ClipOval(child: image), - ), - Positioned( - bottom: 5, - right: 5, - child: Container( - width: 45, - height: 45, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(100), - border: Border.all(color: Colors.white, width: 5), - ), - child: FloatingActionButton( - onPressed: () async { - FilePickerResult? result = - await FilePicker.platform.pickFiles( - withData: true, - ); - - if (result != null) { - PlatformFile file = result.files.first; - Uint8List? fileBytes = file.bytes; - - if (fileBytes != null) { - NotificationsService.showBusyIndicator(context); - await profileFormProvider - .uploadPicture(fileBytes); - Provider.of(context, listen: false) - .refreshUser(); - - Navigator.pop(context); - } - } else {} - }, - backgroundColor: Colors.indigo, - elevation: 0, - child: const Icon( - Icons.camera_alt_outlined, - size: 20, - color: Colors.white, - ), - ), + // Ciudad + cities.isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: CircularProgressIndicator(), ), ) - ], + : DropdownButtonFormField( + key: ValueKey(cities.cities.length), + value: currentCity, + isExpanded: true, + validator: (v) => + v == null ? 'Selecciona tu ciudad' : null, + decoration: _field( + 'Ciudad', 'Selecciona tu ciudad', + Icons.location_city_outlined, + fill: fillColor, border: borderColor), + items: cities.cities.map((City c) { + return DropdownMenuItem( + value: c.cityName, + child: Text( + '${c.cityName} – ${c.stateOfCity}', + style: TextStyle( + fontSize: 13, + color: isDark + ? Colors.white + : Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (v) { + if (v != null) pfp.copyUserWith(city: v); + }, + ), + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + await pfp.updateUserInfo(); + if (context.mounted) { + context.read().refreshUser(); + NotificationsService.showSnackbar('Perfil actualizado'); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 0, + ), + child: const Text('Guardar cambios', + style: TextStyle(fontWeight: FontWeight.w600)), ), ), - const SizedBox(height: 20), ], ), ), ); } + + String _normalize(String s) => s + .toLowerCase() + .trim() + .replaceAll('á', 'a') + .replaceAll('é', 'e') + .replaceAll('í', 'i') + .replaceAll('ó', 'o') + .replaceAll('ú', 'u') + .replaceAll('ü', 'u') + .replaceAll('ñ', 'n'); } -// ── Sección vinculación correo ─────────────────────────────────────────────── +// ─── _EmailCard ─────────────────────────────────────────────────────────────── -class _EmailLinkCard extends StatefulWidget { - const _EmailLinkCard(); +class _EmailCard extends StatefulWidget { + const _EmailCard(); @override - State<_EmailLinkCard> createState() => _EmailLinkCardState(); + State<_EmailCard> createState() => _EmailCardState(); } -class _EmailLinkCardState extends State<_EmailLinkCard> { +class _EmailCardState extends State<_EmailCard> { final _emailCtrl = TextEditingController(); final _passCtrl = TextEditingController(); final _confirmCtrl = TextEditingController(); final _codeCtrl = TextEditingController(); - bool _expanded = false; bool _codeSent = false; bool _loading = false; bool _obscurePass = true; @@ -385,21 +407,22 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { final email = _emailCtrl.text.trim(); final pass = _passCtrl.text; if (!RegExp(r'^[\w.+-]+@[\w-]+\.\w+$').hasMatch(email)) { - _snack('Ingresa un correo válido', error: true); return; + _err('Ingresa un correo válido'); return; } if (pass.length < 6) { - _snack('La contraseña debe tener al menos 6 caracteres', error: true); return; + _err('La contraseña debe tener mínimo 6 caracteres'); return; } if (pass != _confirmCtrl.text) { - _snack('Las contraseñas no coinciden', error: true); return; + _err('Las contraseñas no coinciden'); return; } setState(() => _loading = true); try { - await ApiService.instance.post('/auth/send-email-otp', {'email': email}); + await ApiService.instance + .post('/auth/send-email-otp', {'email': email}); setState(() => _codeSent = true); - _snack('Código enviado a $email'); + NotificationsService.showSnackbar('Código enviado a $email'); } catch (_) { - _snack('Error al enviar el código', error: true); + _err('No se pudo enviar el código. Verifica el correo.'); } finally { setState(() => _loading = false); } @@ -407,7 +430,7 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { Future _verify() async { final code = _codeCtrl.text.trim(); - if (code.length != 6) { _snack('Ingresa el código de 6 dígitos', error: true); return; } + if (code.length != 6) { _err('Ingresa los 6 dígitos'); return; } setState(() => _loading = true); try { await ApiService.instance.post('/auth/link-email-otp', { @@ -416,201 +439,266 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { 'code': code, }); await context.read().refreshUser(); - setState(() { _codeSent = false; _expanded = false; _codeCtrl.clear(); }); - _snack('Correo vinculado correctamente'); + NotificationsService.showSnackbar('Correo vinculado correctamente'); } catch (_) { - _snack('Código incorrecto o expirado', error: true); + _err('Código incorrecto o expirado'); } finally { - setState(() => _loading = false); + if (mounted) setState(() => _loading = false); } } - void _snack(String m, {bool error = false}) => error - ? NotificationsService.showSnackBarError(m) - : NotificationsService.showSnackbar(m); + void _err(String m) => NotificationsService.showSnackBarError(m); @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; + final fillColor = + isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFF); + final borderColor = + isDark ? const Color(0xFF334155) : const Color(0xFFE0E7FF); final user = context.watch().user!; final hasEmail = user.email != null && user.email!.isNotEmpty; - final isDark = Theme.of(context).brightness == Brightness.dark; - final fillColor = isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFF); - final borderColor = isDark ? const Color(0xFF334155) : const Color(0xFFE0E7FF); - InputDecoration fd(String label, String hint, IconData icon) => InputDecoration( - labelText: label, - hintText: hint, - prefixIcon: Icon(icon, size: 20), - filled: true, - fillColor: fillColor, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: borderColor)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2)), - ); + return Container( + width: double.infinity, + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), blurRadius: 6) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ─ Cabecera ─ + Row(children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration( + color: hasEmail + ? const Color(0xFF10B981).withOpacity(0.12) + : const Color(0xFF42A4EF).withOpacity(0.12), + shape: BoxShape.circle, + ), + child: Icon( + hasEmail + ? Icons.mark_email_read_outlined + : Icons.email_outlined, + color: hasEmail + ? const Color(0xFF10B981) + : const Color(0xFF42A4EF), + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hasEmail + ? 'Correo vinculado' + : 'Vincular correo electrónico', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: isDark ? Colors.white : Colors.black87), + ), + Text( + hasEmail + ? user.email! + : 'Inicia sesión también con email y contraseña', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white38 : Colors.black45), + ), + ], + ), + ), + if (hasEmail) + const Icon(Icons.verified, + color: Color(0xFF10B981), size: 22), + ]), - return WhiteCard( - child: Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - initiallyExpanded: hasEmail, - onExpansionChanged: (v) { if (!hasEmail) setState(() => _expanded = v); }, - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - leading: Container( - width: 36, height: 36, - decoration: BoxDecoration( - color: hasEmail - ? const Color(0xFF10B981).withOpacity(0.12) - : const Color(0xFF42A4EF).withOpacity(0.12), - shape: BoxShape.circle, - ), - child: Icon( - hasEmail ? Icons.mark_email_read_outlined : Icons.email_outlined, - color: hasEmail ? const Color(0xFF10B981) : const Color(0xFF42A4EF), - size: 18, - ), - ), - title: Text( - hasEmail ? 'Correo vinculado' : 'Vincular correo electrónico', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - ), - subtitle: Text( - hasEmail - ? user.email! - : 'Agrega un correo y contraseña para iniciar sesión sin SMS', - style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.black45), - ), - trailing: hasEmail - ? const Icon(Icons.verified, color: Color(0xFF10B981), size: 20) - : null, - children: [ - if (!hasEmail) ...[ - const Divider(height: 1), + // ─ Formulario (solo si no tiene email) ─ + if (!hasEmail) ...[ + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + + if (!_codeSent) ...[ + // Paso 1 + TextField( + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + decoration: _field('Correo electrónico', + 'tu@correo.com', Icons.email_outlined, + fill: fillColor, border: borderColor), + ), + const SizedBox(height: 10), + TextField( + controller: _passCtrl, + obscureText: _obscurePass, + decoration: _field('Contraseña', + 'Mínimo 6 caracteres', Icons.lock_outline, + fill: fillColor, border: borderColor) + .copyWith( + suffixIcon: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 18, + color: Colors.grey), + onPressed: () => + setState(() => _obscurePass = !_obscurePass), + ), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _confirmCtrl, + obscureText: _obscureConfirm, + decoration: _field('Confirmar contraseña', + 'Repite la contraseña', Icons.lock_outline, + fill: fillColor, border: borderColor) + .copyWith( + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 18, + color: Colors.grey), + onPressed: () => + setState(() => _obscureConfirm = !_obscureConfirm), + ), + ), + ), const SizedBox(height: 16), - - if (!_codeSent) ...[ - TextField( - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - decoration: fd('Correo electrónico', 'tu@correo.com', Icons.email_outlined), - ), - const SizedBox(height: 10), - TextField( - controller: _passCtrl, - obscureText: _obscurePass, - decoration: fd('Contraseña', 'Mínimo 6 caracteres', Icons.lock_outline).copyWith( - suffixIcon: IconButton( - icon: Icon(_obscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), - onPressed: () => setState(() => _obscurePass = !_obscurePass), - ), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _loading ? null : _sendCode, + icon: _loading + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.send_outlined, size: 18), + label: Text(_loading + ? 'Enviando...' + : 'Enviar código de verificación'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 0, ), ), - const SizedBox(height: 10), - TextField( - controller: _confirmCtrl, - obscureText: _obscureConfirm, - decoration: fd('Confirmar contraseña', 'Repite la contraseña', Icons.lock_outline).copyWith( - suffixIcon: IconButton( - icon: Icon(_obscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), - onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), - ), - ), + ), + ] else ...[ + // Paso 2 – ingresar código + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF42A4EF).withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFF42A4EF).withOpacity(0.3)), ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _loading ? null : _sendCode, - icon: _loading - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Icon(Icons.send_outlined, size: 18), - label: Text(_loading ? 'Enviando...' : 'Enviar código de verificación'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF42A4EF), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - elevation: 0, - ), - ), - ), - ] else ...[ - // Paso 2: ingresar código - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: const Color(0xFF42A4EF).withOpacity(0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.25)), - ), - child: Row(children: [ - const Icon(Icons.mark_email_read_outlined, color: Color(0xFF42A4EF), size: 18), - const SizedBox(width: 8), - Expanded(child: Text( + child: Row(children: [ + const Icon(Icons.mark_email_read_outlined, + color: Color(0xFF42A4EF), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( 'Código enviado a ${_emailCtrl.text.trim()}', - style: const TextStyle(fontSize: 13, color: Color(0xFF42A4EF)), - )), - ]), - ), - const SizedBox(height: 14), - TextField( - controller: _codeCtrl, - keyboardType: TextInputType.number, - textAlign: TextAlign.center, - maxLength: 6, - autofocus: true, - style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, letterSpacing: 12), - decoration: InputDecoration( - counterText: '', - hintText: '······', - hintStyle: const TextStyle(letterSpacing: 12, color: Colors.grey), - filled: true, - fillColor: fillColor, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: borderColor)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2)), - ), - ), - const SizedBox(height: 14), - Row(children: [ - Expanded( - child: OutlinedButton( - onPressed: _loading ? null : () => setState(() { _codeSent = false; _codeCtrl.clear(); }), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 13), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - side: BorderSide(color: borderColor), - ), - child: const Text('← Volver'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton( - onPressed: _loading ? null : _verify, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF10B981), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 13), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - elevation: 0, - ), - child: _loading - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Text('Verificar', style: TextStyle(fontWeight: FontWeight.w600)), + style: const TextStyle( + fontSize: 13, color: Color(0xFF42A4EF)), ), ), ]), - ], - const SizedBox(height: 8), + ), + const SizedBox(height: 14), + TextField( + controller: _codeCtrl, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 6, + autofocus: true, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + letterSpacing: 14), + decoration: InputDecoration( + counterText: '', + hintText: '· · · · · ·', + hintStyle: TextStyle( + letterSpacing: 8, color: Colors.grey.shade400), + filled: true, + fillColor: fillColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: borderColor)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide( + color: Color(0xFF42A4EF), width: 2)), + ), + ), + const SizedBox(height: 14), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: _loading + ? null + : () => setState( + () { _codeSent = false; _codeCtrl.clear(); }), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + side: BorderSide(color: borderColor), + ), + child: const Text('← Volver'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: _loading ? null : _verify, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 0, + ), + child: _loading + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Text('Verificar', + style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + ]), ], ], - ), + ], ), ); }