diff --git a/lib/models/setting.dart b/lib/models/setting.dart index 3edfe94..ab6bb64 100644 --- a/lib/models/setting.dart +++ b/lib/models/setting.dart @@ -55,13 +55,13 @@ class Setting { versionIos: _s(doc['version_ios']), versionAndroid: _s(doc['version_android']), horaNotificacion: _s(doc['hora_notificacion']), - tituloSoporte: _s(doc['titulo_soporte'], 'Soporte ProsApp'), - parrafoSoporte: _s(doc['parrafo_soporte'], + tituloSoporte: _s(doc['support_title'] ?? doc['titulo_soporte'], 'Soporte ProsApp'), + parrafoSoporte: _s(doc['support_description'] ?? doc['parrafo_soporte'], 'Estamos aquí para ayudarte. Contáctanos por cualquiera de estos canales.'), - numeroSoporte: _s(doc['numero_soporte'] ?? doc['support_phone']), - emailSoporte: _s(doc['email_soporte'] ?? doc['support_email']), - diasSoporte: _s(doc['dias_soporte'] ?? doc['support_days'], 'Lunes a Viernes'), - horasSoporte: _s(doc['horas_soporte'] ?? doc['support_hours'], '8:00 am - 6:00 pm'), + numeroSoporte: _s(doc['support_phone'] ?? doc['numero_soporte']), + emailSoporte: _s(doc['support_email'] ?? doc['email_soporte']), + diasSoporte: _s(doc['support_days'] ?? doc['dias_soporte'], 'Lunes a Viernes'), + horasSoporte: _s(doc['support_hours'] ?? doc['horas_soporte'], '8:00 am – 6:00 pm'), politicasPrivacidad: _s(doc['politicas_privacidad']), politicasPrivacidadTitle: _s(doc['politicas_privacidad_title']), politicasPrivacidadBody: _s(doc['politicas_privacidad_body']), diff --git a/lib/ui/shared/widgets/navbar_avatar.dart b/lib/ui/shared/widgets/navbar_avatar.dart index 8018ac7..595c55f 100644 --- a/lib/ui/shared/widgets/navbar_avatar.dart +++ b/lib/ui/shared/widgets/navbar_avatar.dart @@ -11,6 +11,10 @@ class NavbarAvatar extends StatelessWidget { Widget build(BuildContext context) { final user = Provider.of(context).user!; final hasPicture = user.picture != null && user.picture!.isNotEmpty; + final encoded = Uri.encodeComponent(user.name.isEmpty ? 'U' : user.name); + final fallbackUrl = + 'https://ui-avatars.com/api/?name=$encoded&background=42A4EF&color=fff&size=80&bold=true&rounded=true'; + final imageUrl = hasPicture ? user.picture! : fallbackUrl; return MouseRegion( cursor: SystemMouseCursors.click, @@ -20,13 +24,12 @@ class NavbarAvatar extends StatelessWidget { width: 36, height: 36, child: ClipOval( - child: hasPicture - ? Image.network( - user.picture!, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _InitialsAvatar(name: user.name), - ) - : _InitialsAvatar(name: user.name), + child: Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Image.network(fallbackUrl, fit: BoxFit.cover), + ), ), ), ), @@ -34,46 +37,3 @@ class NavbarAvatar extends StatelessWidget { } } -class _InitialsAvatar extends StatelessWidget { - final String name; - const _InitialsAvatar({required this.name}); - - String get _initials { - final parts = name.trim().split(RegExp(r'\s+')); - if (parts.isEmpty || parts.first.isEmpty) return '?'; - if (parts.length == 1) return parts.first[0].toUpperCase(); - return (parts.first[0] + parts.last[0]).toUpperCase(); - } - - Color get _bgColor { - final colors = [ - const Color(0xFF6366F1), // indigo - const Color(0xFF8B5CF6), // violet - const Color(0xFFEC4899), // pink - const Color(0xFF14B8A6), // teal - const Color(0xFFF59E0B), // amber - const Color(0xFF10B981), // emerald - const Color(0xFFEF4444), // red - const Color(0xFF3B82F6), // blue - ]; - final index = name.isEmpty ? 0 : name.codeUnitAt(0) % colors.length; - return colors[index]; - } - - @override - Widget build(BuildContext context) { - return Container( - color: _bgColor, - child: Center( - child: Text( - _initials, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } -} diff --git a/lib/ui/views/profile_view.dart b/lib/ui/views/profile_view.dart index 6fcbd91..fa769ce 100644 --- a/lib/ui/views/profile_view.dart +++ b/lib/ui/views/profile_view.dart @@ -253,25 +253,9 @@ class _ProfileViewForm extends StatelessWidget { } } -Widget _buildInitials(String name) { - final parts = name.trim().split(RegExp(r'\s+')); - final initials = parts.length >= 2 - ? '${parts.first[0]}${parts.last[0]}'.toUpperCase() - : (parts.first.isEmpty ? '?' : parts.first[0].toUpperCase()); - final colors = [ - const Color(0xFF6366F1), const Color(0xFF8B5CF6), - const Color(0xFFEC4899), const Color(0xFF14B8A6), - const Color(0xFFF59E0B), const Color(0xFF10B981), - const Color(0xFFEF4444), const Color(0xFF3B82F6), - ]; - final bg = name.isEmpty ? colors[0] : colors[name.codeUnitAt(0) % colors.length]; - return Container( - color: bg, - child: Center( - child: Text(initials, - style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.bold)), - ), - ); +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 { @@ -287,15 +271,9 @@ class _AvatarContainer extends StatelessWidget { final picUrl = profileFormProvider.user!.picture; final hasPicture = picUrl != null && picUrl.isNotEmpty; final userName = profileFormProvider.user!.name; - - Widget avatarImage; - if (hasPicture) { - avatarImage = Image.network(picUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildInitials(userName)); - } else { - avatarImage = _buildInitials(userName); - } - final image = avatarImage; + 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, @@ -378,7 +356,6 @@ class _AvatarContainer extends StatelessWidget { class _EmailLinkCard extends StatefulWidget { const _EmailLinkCard(); - @override State<_EmailLinkCard> createState() => _EmailLinkCardState(); } @@ -389,20 +366,12 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { final _confirmCtrl = TextEditingController(); final _codeCtrl = TextEditingController(); - bool _enabled = false; + bool _expanded = false; bool _codeSent = false; bool _loading = false; bool _obscurePass = true; bool _obscureConfirm = true; - @override - void initState() { - super.initState(); - final user = context.read().user!; - _enabled = user.email != null && user.email!.isNotEmpty; - if (_enabled) _emailCtrl.text = user.email!; - } - @override void dispose() { _emailCtrl.dispose(); @@ -415,27 +384,21 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { Future _sendCode() async { final email = _emailCtrl.text.trim(); final pass = _passCtrl.text; - final confirm = _confirmCtrl.text; - if (!RegExp(r'^[\w.+-]+@[\w-]+\.\w+$').hasMatch(email)) { - _snack('Ingresa un correo válido', error: true); - return; + _snack('Ingresa un correo válido', error: true); return; } if (pass.length < 6) { - _snack('La contraseña debe tener al menos 6 caracteres', error: true); - return; + _snack('La contraseña debe tener al menos 6 caracteres', error: true); return; } - if (pass != confirm) { - _snack('Las contraseñas no coinciden', error: true); - return; + if (pass != _confirmCtrl.text) { + _snack('Las contraseñas no coinciden', error: true); return; } - setState(() => _loading = true); try { await ApiService.instance.post('/auth/send-email-otp', {'email': email}); setState(() => _codeSent = true); _snack('Código enviado a $email'); - } catch (e) { + } catch (_) { _snack('Error al enviar el código', error: true); } finally { setState(() => _loading = false); @@ -444,10 +407,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) { _snack('Ingresa el código de 6 dígitos', error: true); return; } setState(() => _loading = true); try { await ApiService.instance.post('/auth/link-email-otp', { @@ -456,174 +416,177 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { 'code': code, }); await context.read().refreshUser(); - setState(() { _codeSent = false; _codeCtrl.clear(); }); - _snack('Correo vinculado exitosamente'); - } catch (e) { + setState(() { _codeSent = false; _expanded = false; _codeCtrl.clear(); }); + _snack('Correo vinculado correctamente'); + } catch (_) { _snack('Código incorrecto o expirado', error: true); } finally { setState(() => _loading = false); } } - void _snack(String msg, {bool error = false}) { - if (error) { - NotificationsService.showSnackBarError(msg); - } else { - NotificationsService.showSnackbar(msg); - } - } + void _snack(String m, {bool error = false}) => error + ? NotificationsService.showSnackBarError(m) + : NotificationsService.showSnackbar(m); @override Widget build(BuildContext context) { 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(0xFFF5F8FF); + final fillColor = isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFF); final borderColor = isDark ? const Color(0xFF334155) : const Color(0xFFE0E7FF); - InputDecoration _fieldDeco(String label, String hint, IconData icon) => - InputDecoration( + InputDecoration fd(String label, String hint, IconData icon) => InputDecoration( labelText: label, hintText: hint, prefixIcon: Icon(icon, size: 20), 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)), + 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 WhiteCard( - title: 'Acceso con correo', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - hasEmail - ? 'Correo vinculado: ${user.email}' - : 'Vincula un correo para iniciar sesión también con email y contraseña.', - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - ), - Switch( - value: _enabled, - activeColor: const Color(0xFF42A4EF), - onChanged: (val) { - if (hasEmail) return; // ya vinculado, no desactivar desde aquí - setState(() { - _enabled = val; - _codeSent = false; - _codeCtrl.clear(); - }); - }, - ), - ], + 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, + ), ), - - if (_enabled && !hasEmail) ...[ - const SizedBox(height: 16), - - if (!_codeSent) ...[ - TextField( - controller: _emailCtrl, - keyboardType: TextInputType.emailAddress, - decoration: _fieldDeco('Correo electrónico', 'tu@correo.com', Icons.email_outlined), - ), - const SizedBox(height: 12), - TextField( - controller: _passCtrl, - obscureText: _obscurePass, - decoration: _fieldDeco('Contraseña', 'Mínimo 6 caracteres', Icons.lock_outline).copyWith( - suffixIcon: IconButton( - icon: Icon(_obscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 20), - onPressed: () => setState(() => _obscurePass = !_obscurePass), - ), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _confirmCtrl, - obscureText: _obscureConfirm, - decoration: _fieldDeco('Confirmar contraseña', 'Repite la contraseña', Icons.lock_outline).copyWith( - suffixIcon: IconButton( - icon: Icon(_obscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 20), - onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), - ), - ), - ), + 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), const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _loading ? null : _sendCode, - 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: _loading - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Text('Enviar código de verificación', style: TextStyle(fontWeight: FontWeight.w600)), + + if (!_codeSent) ...[ + TextField( + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + decoration: fd('Correo electrónico', 'tu@correo.com', Icons.email_outlined), ), - ), - ] else ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF42A4EF).withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.3)), - ), - child: Row( - children: [ - const Icon(Icons.email_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: 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), ), - ], + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _codeCtrl, - keyboardType: TextInputType.number, - textAlign: TextAlign.center, - maxLength: 6, - style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold, letterSpacing: 10), - decoration: _fieldDeco('', '------', Icons.pin_outlined).copyWith( - counterText: '', - hintStyle: const TextStyle(letterSpacing: 10, color: Colors.grey), + 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), + ), + ), ), - ), - const SizedBox(height: 16), - Row( - children: [ + 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( + '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: 14), + padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - side: const BorderSide(color: Color(0xFF42A4EF)), + side: BorderSide(color: borderColor), ), - child: const Text('← Cambiar datos', style: TextStyle(color: Color(0xFF42A4EF))), + child: const Text('← Volver'), ), ), const SizedBox(width: 10), @@ -631,35 +594,23 @@ class _EmailLinkCardState extends State<_EmailLinkCard> { child: ElevatedButton( onPressed: _loading ? null : _verify, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF42A4EF), + backgroundColor: const Color(0xFF10B981), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), elevation: 0, ), child: _loading - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Verificar', style: TextStyle(fontWeight: FontWeight.w600)), ), ), - ], - ), + ]), + ], + const SizedBox(height: 8), ], ], - - if (hasEmail) ...[ - const SizedBox(height: 12), - Row( - children: [ - const Icon(Icons.verified, color: Color(0xFF10B981), size: 18), - const SizedBox(width: 6), - Text('Correo verificado', style: TextStyle(color: isDark ? Colors.white70 : Colors.black87, fontSize: 13)), - ], - ), - ], - - const SizedBox(height: 4), - ], + ), ), ); }