Files
prosappweb/lib/ui/views/profile_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 efbdaf2dbe 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 <noreply@anthropic.com>
2026-06-27 10:10:38 -05:00

706 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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/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 ────────────────────────────────────────────────────────────────
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});
@override
State<ProfileView> createState() => _ProfileViewState();
}
class _ProfileViewState extends State<ProfileView> {
@override
void initState() {
super.initState();
// Inicializa el form provider con el usuario actual
final auth = context.read<AuthProvider>();
final pfp = context.read<ProfileFormProvider>();
pfp.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(),
]),
),
],
);
});
}
}
// ─── _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<AuthProvider>().user!;
final pfp =
context.watch<ProfileFormProvider>().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 _CameraButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final pfp = context.watch<ProfileFormProvider>();
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<AuthProvider>().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 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 pfp = context.watch<ProfileFormProvider>();
final cities = context.watch<CitiesProvider>();
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<String?>()
.firstWhere(
(n) => _normalize(n!) == saved,
orElse: () => 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)
],
),
child: Form(
key: pfp.formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: (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: 12),
// Teléfono (read-only)
TextFormField(
readOnly: true,
initialValue: user.phone ?? '',
onTap: (user.phone == null || user.phone!.isEmpty)
? () => NavigationService.navigateTo(Flurorouter.phoneRoute)
: 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(),
),
)
: DropdownButtonFormField<String>(
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<String>(
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<AuthProvider>().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)),
),
),
],
),
),
);
}
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();
}
class _EmailCardState extends State<_EmailCard> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _confirmCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
bool _codeSent = false;
bool _loading = false;
bool _obscurePass = true;
bool _obscureConfirm = true;
@override
void dispose() {
_emailCtrl.dispose();
_passCtrl.dispose();
_confirmCtrl.dispose();
_codeCtrl.dispose();
super.dispose();
}
Future<void> _sendCode() async {
final email = _emailCtrl.text.trim();
final pass = _passCtrl.text;
if (!RegExp(r'^[\w.+-]+@[\w-]+\.\w+$').hasMatch(email)) {
_err('Ingresa un correo válido'); return;
}
if (pass.length < 6) {
_err('La contraseña debe tener mínimo 6 caracteres'); return;
}
if (pass != _confirmCtrl.text) {
_err('Las contraseñas no coinciden'); return;
}
setState(() => _loading = true);
try {
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);
}
}
Future<void> _verify() async {
final code = _codeCtrl.text.trim();
if (code.length != 6) { _err('Ingresa los 6 dígitos'); return; }
setState(() => _loading = true);
try {
await ApiService.instance.post('/auth/link-email-otp', {
'email': _emailCtrl.text.trim(),
'password': _passCtrl.text,
'code': code,
});
await context.read<AuthProvider>().refreshUser();
NotificationsService.showSnackbar('Correo vinculado correctamente');
} catch (_) {
_err('Código incorrecto o expirado');
} finally {
if (mounted) setState(() => _loading = false);
}
}
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<AuthProvider>().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)
],
),
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),
]),
// ─ 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),
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.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)),
),
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: 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)),
),
),
]),
],
],
],
),
);
}
}