vistas de ciudadano, escalar animaciones de mascota, implementacion de chatbot para concientizacion, modificacion de datos de ciudadano, modificacion de vista principal

This commit is contained in:
shinra32
2026-05-23 05:03:05 -06:00
parent 89dcc6250b
commit ca076607c7
39 changed files with 2909 additions and 560 deletions

View File

@@ -1,125 +1,409 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recolecta_app/core/theme/app_theme.dart';
import 'package:recolecta_app/core/widgets/app_widgets.dart';
import 'package:recolecta_app/core/services/auth_controller.dart';
import 'package:recolecta_app/core/api/api_service.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import 'data/profile_service.dart';
import 'providers/profile_providers.dart';
class EditProfileScreen extends ConsumerStatefulWidget {
const EditProfileScreen({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_EditProfileScreenState();
ConsumerState<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
// TODO: Si deseas pre-llenar los datos, aquí puedes llamar a tu API
// (ej. GET /users/me) usando ref.read(apiServiceProvider)
}
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _currentPasswordCtrl = TextEditingController();
final _newPasswordCtrl = TextEditingController();
final _confirmPasswordCtrl = TextEditingController();
bool _saving = false;
bool _prefilled = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_nameCtrl.dispose();
_emailCtrl.dispose();
_phoneCtrl.dispose();
_currentPasswordCtrl.dispose();
_newPasswordCtrl.dispose();
_confirmPasswordCtrl.dispose();
super.dispose();
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) {
return;
void _prefill(Map<String, String?> data) {
if (_prefilled) return;
_nameCtrl.text = data['name'] ?? '';
_emailCtrl.text = data['email'] ?? '';
_phoneCtrl.text = _formatPhoneInitial(data['phone']);
_prefilled = true;
}
// Normaliza un teléfono almacenado (con o sin lada/guiones) al formato 000-000-0000
String _formatPhoneInitial(String? raw) {
if (raw == null || raw.isEmpty) return '';
final digits = raw.replaceAll(RegExp(r'\D'), '');
final last10 = digits.length > 10
? digits.substring(digits.length - 10)
: digits;
if (last10.length <= 3) return last10;
if (last10.length <= 6) {
return '${last10.substring(0, 3)}-${last10.substring(3)}';
}
setState(() {
_isLoading = true;
});
return '${last10.substring(0, 3)}-${last10.substring(3, 6)}-${last10.substring(6)}';
}
String _friendly(Object e) {
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['detail'] != null) {
return data['detail'].toString();
}
return e.message ?? 'Error de red';
}
return e.toString();
}
bool get _wantsPasswordChange =>
_currentPasswordCtrl.text.isNotEmpty ||
_newPasswordCtrl.text.isNotEmpty ||
_confirmPasswordCtrl.text.isNotEmpty;
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
try {
final apiService = ref.read(apiServiceProvider);
await apiService.updateUser({
'name': _nameController.text,
'email': _emailController.text,
});
await ref
.read(profileServiceProvider)
.updateMe(
name: _nameCtrl.text.trim(),
email: _emailCtrl.text.trim().isEmpty
? null
: _emailCtrl.text.trim(),
phone: _phoneCtrl.text.trim().isEmpty
? null
: _phoneCtrl.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Perfil actualizado con éxito')),
);
Navigator.of(context).pop();
if (_wantsPasswordChange) {
await ref
.read(profileServiceProvider)
.changePassword(
currentPassword: _currentPasswordCtrl.text,
newPassword: _newPasswordCtrl.text,
);
_currentPasswordCtrl.clear();
_newPasswordCtrl.clear();
_confirmPasswordCtrl.clear();
}
ref.invalidate(currentUserProvider);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
_wantsPasswordChange
? 'Perfil y contraseña actualizados'
: 'Perfil actualizado',
),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al actualizar el perfil: $e')),
);
}
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Error: ${_friendly(e)}')));
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final userAsync = ref.watch(currentUserProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Editar Perfil'),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.only(right: 16.0),
child: CircularProgressIndicator(),
)
else
TextButton(onPressed: _saveProfile, child: const Text('Guardar')),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nombre',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Por favor ingresa tu nombre';
}
return null;
},
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Editar perfil')),
body: userAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No se pudo cargar el perfil:\n${_friendly(e)}',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.danger),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Correo Electrónico',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Por favor ingresa un correo válido';
}
return null;
},
),
],
),
),
data: (user) {
_prefill({
'name': user.name,
'email': user.email,
'phone': user.phone,
});
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const AppSectionTitle(title: 'Datos personales'),
AppCard(
child: Column(
children: [
AppFormField(
label: 'Nombre',
controller: _nameCtrl,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Ingresa tu nombre'
: null,
),
const SizedBox(height: 12),
AppFormField(
label: 'Correo electrónico',
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
if (!v.contains('@')) return 'Correo inválido';
return null;
},
),
const SizedBox(height: 12),
_PhoneField(controller: _phoneCtrl),
],
),
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Cambiar contraseña'),
AppCard(
child: Column(
children: [
AppFormField(
label: 'Contraseña actual',
controller: _currentPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Nueva contraseña',
controller: _newPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.length < 6) {
return 'Mínimo 6 caracteres';
}
return null;
},
),
const SizedBox(height: 12),
AppFormField(
label: 'Confirmar nueva contraseña',
controller: _confirmPasswordCtrl,
obscureText: true,
validator: (v) {
if (!_wantsPasswordChange) return null;
if (v == null || v.isEmpty) {
return 'Confirma la contraseña';
}
if (v != _newPasswordCtrl.text) return 'No coincide';
return null;
},
),
const SizedBox(height: 8),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Déjalo en blanco si no deseas cambiarla.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Guardar cambios'),
),
),
const SizedBox(height: 24),
],
),
);
},
),
);
}
}
// ── Campo de teléfono con lada +52 y formato 000-000-0000 ─────────────────────
class _PhoneField extends StatelessWidget {
final TextEditingController controller;
const _PhoneField({required this.controller});
static const _ladas = [(flag: '🇲🇽', code: '+52', name: 'México')];
@override
Widget build(BuildContext context) {
final lada = _ladas.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Teléfono',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
border: Border.all(color: AppTheme.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(lada.flag, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 6),
Text(
lada.code,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_PhoneInputFormatter(),
],
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
decoration: InputDecoration(
hintText: '000-000-0000',
hintStyle: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
filled: true,
fillColor: AppTheme.background,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 15,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(
color: AppTheme.primary,
width: 1.5,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(color: AppTheme.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
borderSide: const BorderSide(
color: AppTheme.danger,
width: 1.5,
),
),
),
validator: (v) {
if (v == null || v.isEmpty) return null; // opcional
final digits = v.replaceAll('-', '');
if (digits.length != 10) {
return 'Ingresa exactamente 10 dígitos';
}
return null;
},
),
),
],
),
],
);
}
}
class _PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final String formatted;
if (digits.length <= 3) {
formatted = digits;
} else if (digits.length <= 6) {
formatted = '${digits.substring(0, 3)}-${digits.substring(3)}';
} else {
formatted =
'${digits.substring(0, 3)}-${digits.substring(3, 6)}-${digits.substring(6)}';
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}