From ad557b8318776e982426653f2efd21e71b163898 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:08:53 -0500 Subject: [PATCH] Improve profile UI/UX and fix nginx JS cache - Profile view: gradient hero banner with overlapping avatar, status badges (phone verified, email, pro state), gender field, city from flat API endpoint, manual validation - nginx: JS files use no-cache/must-revalidate instead of 1y immutable to prevent stale deploys Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/views/profile_view.dart | 855 ++++++++++++++++++++------------- nginx.conf | 23 +- 2 files changed, 543 insertions(+), 335 deletions(-) diff --git a/lib/ui/views/profile_view.dart b/lib/ui/views/profile_view.dart index adafd34..1640429 100644 --- a/lib/ui/views/profile_view.dart +++ b/lib/ui/views/profile_view.dart @@ -2,47 +2,49 @@ 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/pro_state.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'; -import 'package:prosapp_web_app/router/router.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/navigation_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart'; -import 'package:prosapp_web_app/ui/labels/custom_labels.dart'; import 'package:provider/provider.dart'; -// ─── helpers ──────────────────────────────────────────────────────────────── +// ─── 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'; +String _initials(String name) { + final parts = name.trim().split(' ').where((s) => s.isNotEmpty).toList(); + if (parts.isEmpty) return 'U'; + if (parts.length == 1) return parts[0][0].toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); } -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)), - ); +InputDecoration _dec(bool isDark, Color border, String label, IconData icon) { + final fill = isDark ? const Color(0xFF0F172A) : const Color(0xFFF9FAFB); + final hint = isDark ? const Color(0xFF64748B) : const Color(0xFF9CA3AF); + return InputDecoration( + labelText: label, + labelStyle: TextStyle(color: hint, fontSize: 13), + prefixIcon: Icon(icon, size: 18, color: const Color(0xFF42A4EF)), + filled: true, + fillColor: fill, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFF42A4EF), width: 2)), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: border.withOpacity(0.5))), + ); +} -// ─── ProfileView ───────────────────────────────────────────────────────────── +// ─── ProfileView ────────────────────────────────────────────────────────────── class ProfileView extends StatefulWidget { const ProfileView({super.key}); @@ -55,68 +57,43 @@ class _ProfileViewState extends State { @override void initState() { super.initState(); - // Inicializa el form provider con el usuario actual final auth = context.read(); - final pfp = context.read(); - pfp.user = auth.user; + context.read().user = auth.user; } @override Widget build(BuildContext context) { - 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), - ) - ], - ); - }); - } -} - -// ─── _ProfileBody ───────────────────────────────────────────────────────────── - -class _ProfileBody extends StatelessWidget { - const _ProfileBody(); - - @override - Widget build(BuildContext context) { - 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(), - ]), + return LayoutBuilder(builder: (_, c) { + final wide = c.maxWidth >= 700; + return ListView( + padding: EdgeInsets.symmetric( + horizontal: wide ? 24 : 12, vertical: 16), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + children: [ + const _HeroCard(), + const SizedBox(height: 16), + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Expanded(flex: 3, child: _InfoCard()), + SizedBox(width: 14), + Expanded(flex: 2, child: _EmailCard()), + ], + ) + else ...[ + const _InfoCard(), + const SizedBox(height: 14), + const _EmailCard(), + ], + const SizedBox(height: 24), + ], + ), + ), ), ], ); @@ -124,61 +101,193 @@ class _ProfileBody extends StatelessWidget { } } -// ─── _AvatarCard ───────────────────────────────────────────────────────────── +// ─── _HeroCard ──────────────────────────────────────────────────────────────── -class _AvatarCard extends StatelessWidget { - const _AvatarCard(); +class _HeroCard extends StatelessWidget { + const _HeroCard(); @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = context.watch().isDark; final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; + final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB); final user = context.watch().user!; - final pfp = - context.watch().user?.picture; + final pfp = context.watch().user?.picture; final hasPic = pfp != null && pfp.isNotEmpty; - final imgUrl = hasPic ? pfp : _avatarUrl(user.name); + + final proInt = enumToInt(user.proState); + final proLabel = ['Sin solicitud', 'En revisión', 'Profesional', 'No aprobado'][proInt.clamp(0, 3)]; + final proColor = [Colors.grey, const Color(0xFFF59E0B), const Color(0xFF10B981), const Color(0xFFEF4444)][proInt.clamp(0, 3)]; + final proIcon = [Icons.person_outline, Icons.hourglass_empty_rounded, Icons.verified_rounded, Icons.cancel_outlined][proInt.clamp(0, 3)]; 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) - ], + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border), ), - 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)), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + // Banner degradado + Container( + height: 90, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF1565C0), Color(0xFF42A4EF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), ), - Positioned( - bottom: 4, - right: 4, - child: _CameraButton(), + + // Avatar + info + Transform.translate( + offset: const Offset(0, -44), + child: Column( + children: [ + // Avatar con botón cámara + Stack( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cardBg, width: 4), + color: const Color(0xFF42A4EF), + ), + child: ClipOval( + child: hasPic + ? Image.network(pfp, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _AvatarFallback(user.name)) + : _AvatarFallback(user.name), + ), + ), + Positioned( + bottom: 2, + right: 2, + child: _CameraButton(), + ), + ], + ), + + const SizedBox(height: 8), + + // Nombre + Text(user.name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF111827))), + + const SizedBox(height: 4), + + // Teléfono + if (user.phone != null && user.phone!.isNotEmpty) + Text(user.phone!, + style: TextStyle( + fontSize: 13, + color: isDark + ? const Color(0xFF94A3B8) + : const Color(0xFF6B7280))), + + const SizedBox(height: 10), + + // Badges de estado + Wrap( + spacing: 8, + runSpacing: 6, + alignment: WrapAlignment.center, + children: [ + // Teléfono verificado + _Badge( + icon: Icons.phone_android, + label: user.isPhoneVerified ? 'Tel. verificado' : 'Tel. sin verificar', + color: user.isPhoneVerified + ? const Color(0xFF10B981) + : Colors.grey, + ), + // Email + _Badge( + icon: Icons.email_outlined, + label: (user.email != null && user.email!.isNotEmpty) + ? 'Email vinculado' + : 'Sin email', + color: (user.email != null && user.email!.isNotEmpty) + ? const Color(0xFF42A4EF) + : Colors.grey, + ), + // Pro state + _Badge( + icon: proIcon, + label: proLabel, + color: proColor, + ), + ], + ), + + const SizedBox(height: 14), + ], + ), ), - ]), - const SizedBox(height: 12), - if (user.phone != null && user.phone!.isNotEmpty) - Text(user.phone!, + ], + ), + ); + } +} + +class _AvatarFallback extends StatelessWidget { + final String name; + const _AvatarFallback(this.name); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF42A4EF), + child: Center( + child: Text( + _initials(name), + style: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold), + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + const _Badge({required this.icon, required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 5), + Text(label, style: TextStyle( - color: isDark ? Colors.white54 : Colors.black45, - fontSize: 13)), - ]), + fontSize: 11, + color: color, + fontWeight: FontWeight.w600)), + ], + ), ); } } @@ -187,30 +296,29 @@ 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; + final result = await FilePicker.platform + .pickFiles(withData: true, type: FileType.image); + if (result == null || result.files.first.bytes == null) return; + if (!context.mounted) return; NotificationsService.showBusyIndicator(context); - await pfp.uploadPicture(bytes); + await pfp.uploadPicture(result.files.first.bytes!); if (context.mounted) { Navigator.pop(context); context.read().refreshUser(); } }, child: Container( - width: 34, - height: 34, + width: 30, + height: 30, 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), + size: 14, color: Colors.white), ), ); } @@ -218,167 +326,250 @@ class _CameraButton extends StatelessWidget { // ─── _InfoCard ──────────────────────────────────────────────────────────────── -class _InfoCard extends StatelessWidget { +class _InfoCard extends StatefulWidget { const _InfoCard(); + @override + State<_InfoCard> createState() => _InfoCardState(); +} + +class _InfoCardState extends State<_InfoCard> { + List _cities = []; + bool _loadingCities = true; + bool _saving = false; + String? _nameError; + + late TextEditingController _nameCtrl; + + @override + void initState() { + super.initState(); + final pfp = context.read(); + _nameCtrl = TextEditingController(text: pfp.user?.name ?? ''); + _loadCities(); + } + + @override + void dispose() { + _nameCtrl.dispose(); + super.dispose(); + } + + Future _loadCities() async { + try { + final data = await ApiService.instance.get('/locations/cities/all'); + final list = (data as List).map((c) => c['name'] as String).toList(); + if (mounted) setState(() { _cities = list; _loadingCities = false; }); + } catch (_) { + if (mounted) setState(() => _loadingCities = false); + } + } + + Future _save() async { + final name = _nameCtrl.text.trim(); + if (name.length < 3) { + setState(() => _nameError = 'Mínimo 3 caracteres'); + return; + } + setState(() { _nameError = null; _saving = true; }); + try { + final pfp = context.read(); + pfp.copyUserWith(name: name); + await pfp.updateUserInfoNoValid(); + if (mounted) context.read().refreshUser(); + } catch (_) { + if (mounted) NotificationsService.showSnackBarError('Error al guardar'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = context.watch().isDark; 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 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 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 && cities.cities.isNotEmpty) { - final saved = _normalize(user.city!); - currentCity = cities.cities - .map((c) => c.cityName) - .cast() - .firstWhere( - (n) => _normalize(n!) == saved, - orElse: () => null); + // Ciudad seleccionada en la lista + String? selectedCity; + if (user.city != null && user.city!.isNotEmpty && _cities.isNotEmpty) { + final norm = user.city!.toLowerCase().trim(); + selectedCity = _cities.firstWhere( + (c) => c.toLowerCase().trim() == norm, + orElse: () => _cities.firstWhere( + (c) => c.toLowerCase().contains(norm) || norm.contains(c.toLowerCase()), + orElse: () => '', + ), + ); + if (selectedCity!.isEmpty) selectedCity = null; } 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) - ], + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border), ), - child: Form( - key: pfp.formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + const Icon(Icons.person_outline, + color: Color(0xFF42A4EF), size: 18), + const SizedBox(width: 8), Text('Información personal', style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 15, - color: isDark ? Colors.white : Colors.black87)), - const Divider(height: 20), + fontWeight: FontWeight.bold, + color: textPrimary)), + ]), + const SizedBox(height: 16), - // Nombre - TextFormField( - initialValue: user.name, - 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), + // Nombre + TextField( + controller: _nameCtrl, + style: TextStyle(color: textPrimary, fontSize: 14), + onChanged: (_) { + if (_nameError != null) setState(() => _nameError = null); + }, + decoration: _dec(isDark, border, 'Nombre completo', + Icons.badge_outlined), + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 4, left: 4), + child: Text(_nameError!, + style: const TextStyle( + color: Colors.redAccent, fontSize: 12)), ), - const SizedBox(height: 12), - // Teléfono (read-only) - TextFormField( - readOnly: true, - initialValue: user.phone ?? '', - onTap: (user.phone == null || user.phone!.isEmpty) - ? () => NavigationService.navigateTo(Flurorouter.phoneRoute) + const SizedBox(height: 12), + + // Teléfono (solo lectura) + TextField( + readOnly: true, + controller: + TextEditingController(text: user.phone ?? ''), + style: TextStyle(color: textSec, fontSize: 14), + decoration: _dec(isDark, border, 'Teléfono', Icons.phone_outlined) + .copyWith( + suffixIcon: user.isPhoneVerified + ? const Icon(Icons.verified_rounded, + color: Color(0xFF10B981), size: 18) : null, - decoration: _field( - 'Teléfono', 'Número de teléfono', Icons.phone, - fill: fillColor, border: borderColor), ), - const SizedBox(height: 12), + ), - // Ciudad - cities.isLoading - ? const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: CircularProgressIndicator(), + const SizedBox(height: 12), + + // Ciudad + _loadingCities + ? const SizedBox( + height: 48, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)))) + : _cities.isEmpty + ? TextField( + readOnly: true, + controller: TextEditingController( + text: user.city ?? ''), + style: TextStyle(color: textPrimary, fontSize: 14), + decoration: _dec(isDark, border, 'Ciudad', + Icons.location_city_outlined), + ) + : DropdownButtonFormField( + value: selectedCity, + dropdownColor: cardBg, + isExpanded: true, + style: TextStyle(color: textPrimary, fontSize: 14), + hint: Text('Selecciona tu ciudad', + style: + TextStyle(color: textSec, fontSize: 13)), + decoration: _dec(isDark, border, 'Ciudad', + Icons.location_city_outlined), + items: _cities + .map((c) => DropdownMenuItem( + value: c, + child: Text(c, + style: + TextStyle(color: textPrimary)))) + .toList(), + onChanged: (v) { + if (v != null) pfp.copyUserWith(city: v); + }, ), - ) - : 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: 12), + + // Género + DropdownButtonFormField( + value: ['male', 'female', 'other'].contains(user.gender) + ? user.gender + : null, + dropdownColor: cardBg, + style: TextStyle(color: textPrimary, fontSize: 14), + hint: Text('Selecciona género', + style: TextStyle(color: textSec, fontSize: 13)), + decoration: + _dec(isDark, border, 'Género', Icons.wc_outlined), + items: const [ + DropdownMenuItem(value: 'male', child: Text('Masculino')), + DropdownMenuItem( + value: 'female', child: Text('Femenino')), + DropdownMenuItem(value: 'other', child: Text('Otro')), + ], + onChanged: (v) { + if (v != null) pfp.copyUserWith(gender: v); + }, + ), + + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + disabledBackgroundColor: + const Color(0xFF42A4EF).withOpacity(0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 0, ), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Text('Guardar cambios', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 14)), ), - ], - ), + ), + ], ), ); } - - String _normalize(String s) => s - .toLowerCase() - .trim() - .replaceAll('á', 'a') - .replaceAll('é', 'e') - .replaceAll('í', 'i') - .replaceAll('ó', 'o') - .replaceAll('ú', 'u') - .replaceAll('ü', 'u') - .replaceAll('ñ', 'n'); } // ─── _EmailCard ─────────────────────────────────────────────────────────────── class _EmailCard extends StatefulWidget { const _EmailCard(); + @override State<_EmailCard> createState() => _EmailCardState(); } @@ -417,14 +608,13 @@ class _EmailCardState extends State<_EmailCard> { } 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); NotificationsService.showSnackbar('Código enviado a $email'); } catch (_) { _err('No se pudo enviar el código. Verifica el correo.'); } finally { - setState(() => _loading = false); + if (mounted) setState(() => _loading = false); } } @@ -438,8 +628,10 @@ class _EmailCardState extends State<_EmailCard> { 'password': _passCtrl.text, 'code': code, }); - context.read().refreshUser(); - NotificationsService.showSnackbar('Correo vinculado correctamente'); + if (mounted) { + context.read().refreshUser(); + NotificationsService.showSnackbar('Correo vinculado correctamente'); + } } catch (_) { _err('Código incorrecto o expirado'); } finally { @@ -451,34 +643,30 @@ class _EmailCardState extends State<_EmailCard> { @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = context.watch().isDark; 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 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 user = context.watch().user!; final hasEmail = user.email != null && user.email!.isNotEmpty; 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) - ], + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ─ Cabecera ─ + // Header Row(children: [ Container( - width: 38, height: 38, + width: 36, + height: 36, decoration: BoxDecoration( color: hasEmail ? const Color(0xFF10B981).withOpacity(0.12) @@ -489,46 +677,42 @@ class _EmailCardState extends State<_EmailCard> { hasEmail ? Icons.mark_email_read_outlined : Icons.email_outlined, + size: 17, color: hasEmail ? const Color(0xFF10B981) : const Color(0xFF42A4EF), - size: 18, ), ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - hasEmail - ? 'Correo vinculado' - : 'Vincular correo electrónico', + hasEmail ? 'Correo vinculado' : 'Vincular correo', style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 14, - color: isDark ? Colors.white : Colors.black87), + fontWeight: FontWeight.bold, + color: textPrimary), ), Text( hasEmail ? user.email! : 'Inicia sesión también con email y contraseña', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white38 : Colors.black45), + style: TextStyle(fontSize: 12, color: textSec), + overflow: TextOverflow.ellipsis, ), ], ), ), if (hasEmail) - const Icon(Icons.verified, - color: Color(0xFF10B981), size: 22), + const Icon(Icons.verified_rounded, + color: Color(0xFF10B981), size: 20), ]), - // ─ Formulario (solo si no tiene email) ─ if (!hasEmail) ...[ const SizedBox(height: 16), - const Divider(height: 1), + Divider(height: 1, color: border), const SizedBox(height: 16), if (!_codeSent) ...[ @@ -536,25 +720,25 @@ class _EmailCardState extends State<_EmailCard> { TextField( controller: _emailCtrl, keyboardType: TextInputType.emailAddress, - decoration: _field('Correo electrónico', - 'tu@correo.com', Icons.email_outlined, - fill: fillColor, border: borderColor), + style: TextStyle(color: textPrimary, fontSize: 14), + decoration: + _dec(isDark, border, 'Correo electrónico', Icons.email_outlined), ), const SizedBox(height: 10), TextField( controller: _passCtrl, obscureText: _obscurePass, - decoration: _field('Contraseña', - 'Mínimo 6 caracteres', Icons.lock_outline, - fill: fillColor, border: borderColor) + style: TextStyle(color: textPrimary, fontSize: 14), + decoration: _dec(isDark, border, 'Contraseña', + Icons.lock_outline) .copyWith( suffixIcon: IconButton( icon: Icon( _obscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined, - size: 18, - color: Colors.grey), + size: 16, + color: textSec), onPressed: () => setState(() => _obscurePass = !_obscurePass), ), @@ -564,40 +748,40 @@ class _EmailCardState extends State<_EmailCard> { TextField( controller: _confirmCtrl, obscureText: _obscureConfirm, - decoration: _field('Confirmar contraseña', - 'Repite la contraseña', Icons.lock_outline, - fill: fillColor, border: borderColor) + style: TextStyle(color: textPrimary, fontSize: 14), + decoration: _dec(isDark, border, 'Confirmar contraseña', + Icons.lock_outline) .copyWith( suffixIcon: IconButton( icon: Icon( _obscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined, - size: 18, - color: Colors.grey), + size: 16, + color: textSec), onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 14), SizedBox( width: double.infinity, + height: 44, child: ElevatedButton.icon( onPressed: _loading ? null : _sendCode, icon: _loading ? const SizedBox( - width: 16, height: 16, + width: 14, height: 14, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2)) - : const Icon(Icons.send_outlined, size: 18), - label: Text(_loading - ? 'Enviando...' - : 'Enviar código de verificación'), + : const Icon(Icons.send_outlined, size: 16), + label: Text( + _loading ? 'Enviando...' : 'Enviar código al correo', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF42A4EF), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), elevation: 0, @@ -605,9 +789,9 @@ class _EmailCardState extends State<_EmailCard> { ), ), ] else ...[ - // Paso 2 – ingresar código + // Paso 2 Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(11), decoration: BoxDecoration( color: const Color(0xFF42A4EF).withOpacity(0.08), borderRadius: BorderRadius.circular(10), @@ -616,13 +800,13 @@ class _EmailCardState extends State<_EmailCard> { ), child: Row(children: [ const Icon(Icons.mark_email_read_outlined, - color: Color(0xFF42A4EF), size: 18), + color: Color(0xFF42A4EF), size: 16), const SizedBox(width: 8), Expanded( child: Text( 'Código enviado a ${_emailCtrl.text.trim()}', style: const TextStyle( - fontSize: 13, color: Color(0xFF42A4EF)), + fontSize: 12, color: Color(0xFF42A4EF)), ), ), ]), @@ -634,44 +818,52 @@ class _EmailCardState extends State<_EmailCard> { textAlign: TextAlign.center, maxLength: 6, autofocus: true, - style: const TextStyle( - fontSize: 30, + style: TextStyle( + fontSize: 28, fontWeight: FontWeight.bold, - letterSpacing: 14), + letterSpacing: 12, + color: textPrimary), decoration: InputDecoration( counterText: '', hintText: '· · · · · ·', hintStyle: TextStyle( - letterSpacing: 8, color: Colors.grey.shade400), + letterSpacing: 8, + color: textSec, + fontSize: 20), filled: true, - fillColor: fillColor, + fillColor: + isDark ? const Color(0xFF0F172A) : const Color(0xFFF9FAFB), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none), + borderSide: BorderSide(color: border)), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: borderColor)), + borderSide: BorderSide(color: border)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide( color: Color(0xFF42A4EF), width: 2)), ), ), - const SizedBox(height: 14), + const SizedBox(height: 12), Row(children: [ Expanded( child: OutlinedButton( onPressed: _loading ? null - : () => setState( - () { _codeSent = false; _codeCtrl.clear(); }), + : () => setState(() { + _codeSent = false; + _codeCtrl.clear(); + }), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 13), + padding: const EdgeInsets.symmetric(vertical: 11), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), - side: BorderSide(color: borderColor), + side: BorderSide(color: border), + foregroundColor: textPrimary, ), - child: const Text('← Volver'), + child: const Text('← Volver', + style: TextStyle(fontSize: 13)), ), ), const SizedBox(width: 10), @@ -681,7 +873,7 @@ class _EmailCardState extends State<_EmailCard> { style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF10B981), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 13), + padding: const EdgeInsets.symmetric(vertical: 11), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10)), elevation: 0, @@ -692,7 +884,8 @@ class _EmailCardState extends State<_EmailCard> { child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2)) : const Text('Verificar', - style: TextStyle(fontWeight: FontWeight.w600)), + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), ), ), ]), diff --git a/nginx.conf b/nginx.conf index 5cc8c2b..b8cc6a5 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,17 +7,32 @@ server { rewrite ^ /favicon.png permanent; } - # Flutter Web SPA — all routes go to index.html - location / { + # HTML, manifest y service worker: nunca en cache + location ~* \.(html|json)$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; try_files $uri $uri/ /index.html; } - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { + # JS: revalidar siempre (304 si no cambió → rápido, pero nunca sirve stale) + location ~* \.js$ { + expires 0; + add_header Cache-Control "no-cache, must-revalidate"; + try_files $uri =404; + } + + # Imágenes, fuentes e íconos: cache larga (no cambian sin renombrar) + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { expires 1y; add_header Cache-Control "public, immutable"; } + # Flutter Web SPA + location / { + try_files $uri $uri/ /index.html; + } + gzip on; gzip_types text/plain text/css application/json application/javascript text/javascript; }