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

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/app_widgets.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Acerca de la app')),
body: FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snap) {
final version = snap.data?.version ?? '1.0.0';
final build = snap.data?.buildNumber ?? '1';
return ListView(
padding: const EdgeInsets.all(16),
children: [
AppCard(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(
Icons.recycling_rounded,
size: 40,
color: AppTheme.primaryDark,
),
),
const SizedBox(height: 12),
const Text(
'Recolecta',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'Versión $version (build $build)',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
AppSectionTitle(title: 'Acerca de'),
Text(
'Recolecta es una aplicación del Servicio de Limpia de Celaya '
'para informar al ciudadano sobre rutas, horarios y separación '
'correcta de residuos.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
AppSectionTitle(title: 'Créditos'),
Text(
'Desarrollado por el equipo ONLINCESHACK.\n'
'Servicio de Limpia · Celaya, Gto.',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 24),
const Center(
child: Text(
'© 2025 Recolecta',
style: TextStyle(fontSize: 12, color: AppTheme.textHint),
),
),
],
);
},
),
);
}
}

View File

@@ -141,21 +141,33 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: loading
? const SizedBox(
? const FittedBox(
key: ValueKey('loading'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
VideoMascot(size: 34, zoom: 1.5),
SizedBox(width: 12),
Text(
'Saludando a Eco...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
)
: const Text(
'Ingresar',
: const FittedBox(
key: ValueKey('text'),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fit: BoxFit.scaleDown,
child: Text(
'Ingresar',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),

View File

@@ -9,6 +9,7 @@
// 5. FCM badge
// 6. Horario semanal
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -87,11 +88,20 @@ class _EtaResult {
// Provider de ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
Timer? _timer;
@override
Future<_EtaResult> build() => _fetch();
Future<_EtaResult> build() {
// Polling silencioso cada 10 segundos para ver la simulación en vivo
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
ref.onDispose(() => _timer?.cancel());
return _fetch();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
// Eliminamos el estado "loading" explícito para evitar que la UI parpadee
state = await AsyncValue.guard(_fetch);
}

View File

@@ -0,0 +1,39 @@
// lib/features/feedback/feedback_model.dart
// La queja solo registra target_unit_id (número de unidad), NUNCA el chofer.
enum FeedbackType {
noPaso('no_paso', 'No pasó el camión'),
llegoTarde('llego_tarde', 'Llegó tarde'),
comportamiento('comportamiento', 'Comportamiento'),
otro('otro', 'Otro');
final String value;
final String label;
const FeedbackType(this.value, this.label);
}
class FeedbackRequest {
final String addressId;
final FeedbackType type;
final int rating; // 1-5
final String? message;
/// Solo el número de unidad — nunca el ID del chofer.
final String targetUnitId;
const FeedbackRequest({
required this.addressId,
required this.type,
required this.rating,
required this.targetUnitId,
this.message,
});
Map<String, dynamic> toJson() => {
'address_id': addressId,
'type': type.value,
'rating': rating,
'target_unit_id': targetUnitId, // ej. "101"
if (message != null && message!.isNotEmpty) 'message': message,
// ⚠️ NUNCA se manda: driver_id, driver_name, chofer_*
};
}

View File

@@ -0,0 +1,21 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
final helpChatServiceProvider = Provider<HelpChatService>((ref) {
return HelpChatService(ref.read(apiClientProvider));
});
class HelpChatService {
HelpChatService(this._dio);
final Dio _dio;
Future<String> ask(List<Map<String, String>> messages) async {
final res = await _dio.post<Map<String, dynamic>>(
'/chat/help',
data: {'messages': messages},
);
return (res.data?['reply'] as String?) ?? '';
}
}

View File

@@ -0,0 +1,83 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/help_chat_service.dart';
class HelpChatMessage {
final String role; // 'user' | 'assistant'
final String content;
const HelpChatMessage({required this.role, required this.content});
Map<String, String> toJson() => {'role': role, 'content': content};
}
class HelpChatState {
final List<HelpChatMessage> messages;
final bool sending;
final String? error;
const HelpChatState({
this.messages = const [],
this.sending = false,
this.error,
});
HelpChatState copyWith({
List<HelpChatMessage>? messages,
bool? sending,
String? error,
bool clearError = false,
}) => HelpChatState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
error: clearError ? null : (error ?? this.error),
);
}
class HelpChatController extends Notifier<HelpChatState> {
@override
HelpChatState build() => const HelpChatState();
Future<void> send(String text) async {
final trimmed = text.trim();
if (trimmed.isEmpty || state.sending) return;
final newMessages = [
...state.messages,
HelpChatMessage(role: 'user', content: trimmed),
];
state = state.copyWith(
messages: newMessages,
sending: true,
clearError: true,
);
try {
final reply = await ref
.read(helpChatServiceProvider)
.ask(newMessages.map((m) => m.toJson()).toList());
state = state.copyWith(
messages: [
...newMessages,
HelpChatMessage(role: 'assistant', content: reply),
],
sending: false,
);
} catch (e) {
String msg = 'No se pudo enviar el mensaje';
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['detail'] != null)
msg = data['detail'].toString();
}
state = state.copyWith(sending: false, error: msg);
}
}
void reset() {
state = const HelpChatState();
}
}
final helpChatControllerProvider =
NotifierProvider<HelpChatController, HelpChatState>(HelpChatController.new);

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_theme.dart';
import '../providers/help_chat_provider.dart';
const _quickQuestions = [
'¿Cuándo pasa el camión por mi colonia?',
'¿Cómo separo correctamente la basura?',
'¿Qué hago si no pasó el camión?',
'¿Cómo reporto un problema con una unidad?',
];
class HelpFaqScreen extends ConsumerStatefulWidget {
const HelpFaqScreen({super.key});
@override
ConsumerState<HelpFaqScreen> createState() => _HelpFaqScreenState();
}
class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
final _inputCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override
void dispose() {
_inputCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
Future<void> _send(String text) async {
_inputCtrl.clear();
await ref.read(helpChatControllerProvider.notifier).send(text);
await Future.delayed(const Duration(milliseconds: 100));
if (_scrollCtrl.hasClients) {
_scrollCtrl.animateTo(
_scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(helpChatControllerProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Ayuda y preguntas frecuentes'),
actions: [
IconButton(
tooltip: 'Reiniciar conversación',
icon: const Icon(Icons.refresh),
onPressed: state.messages.isEmpty
? null
: () => ref.read(helpChatControllerProvider.notifier).reset(),
),
],
),
body: Column(
children: [
if (state.messages.isEmpty) _QuickQuestions(onSelect: _send),
Expanded(
child: state.messages.isEmpty
? const _EmptyHint()
: ListView.builder(
controller: _scrollCtrl,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
itemCount: state.messages.length,
itemBuilder: (_, i) => _Bubble(message: state.messages[i]),
),
),
if (state.error != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: AppTheme.danger.withOpacity(0.1),
child: Text(
state.error!,
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
),
),
_Composer(
controller: _inputCtrl,
sending: state.sending,
onSend: _send,
),
],
),
);
}
}
class _QuickQuestions extends StatelessWidget {
final ValueChanged<String> onSelect;
const _QuickQuestions({required this.onSelect});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final q in _quickQuestions)
ActionChip(label: Text(q), onPressed: () => onSelect(q)),
],
),
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Hazme una pregunta sobre la recolección de basura,\nseparación de residuos o cómo usar la app.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textSecondary, height: 1.5),
),
),
);
}
}
class _Bubble extends StatelessWidget {
final HelpChatMessage message;
const _Bubble({required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == 'user';
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(
color: isUser ? AppTheme.primary : AppTheme.border,
width: 0.5,
),
),
child: Text(
message.content,
style: TextStyle(
color: isUser ? Colors.white : AppTheme.textPrimary,
fontSize: 14,
height: 1.4,
),
),
),
);
}
}
class _Composer extends StatelessWidget {
final TextEditingController controller;
final bool sending;
final ValueChanged<String> onSend;
const _Composer({
required this.controller,
required this.sending,
required this.onSend,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
decoration: const BoxDecoration(
color: AppTheme.surface,
border: Border(top: BorderSide(color: AppTheme.border, width: 0.5)),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: !sending,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: onSend,
decoration: const InputDecoration(
hintText: 'Escribe tu pregunta…',
border: InputBorder.none,
),
),
),
IconButton(
icon: sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_rounded, color: AppTheme.primary),
onPressed: sending ? null : () => onSend(controller.text),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/widgets/app_widgets.dart';
import '../../shared/widgets/eco_floating_button.dart';
class CitizenShell extends StatelessWidget {
const CitizenShell({super.key, required this.child});
@@ -32,6 +33,7 @@ class CitizenShell extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: child,
floatingActionButton: const EcoFloatingButton(),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex(context),
onTap: (i) => _onTap(context, i),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/widgets/app_widgets.dart';
import '../eta/eta_screen.dart';
import 'citizen_home_screen.dart';
import '../alerts/alerts_screen.dart';
import 'house_screen.dart';
import '../profile/profile_screen.dart';
@@ -16,7 +16,7 @@ class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
static const List<Widget> _screens = [
EtaScreen(),
CitizenHomeScreen(),
AlertsScreen(),
MyHouseScreen(),
ProfileScreen(),

View File

@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http_parser/http_parser.dart';
import '../../../core/network/api_client.dart';
import '../models/incident.dart';
final incidentServiceProvider = Provider<IncidentService>((ref) {
return IncidentService(ref.read(apiClientProvider));
});
class IncidentService {
IncidentService(this._dio);
final Dio _dio;
Future<List<UnitOption>> listUnits() async {
final res = await _dio.get<List<dynamic>>('/incidents/units');
return (res.data ?? [])
.whereType<Map>()
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
Future<IncidentReport> createIncident({
required String category,
required String description,
int? unitId,
File? photo,
}) async {
final form = FormData.fromMap({
'category': category,
'description': description,
if (unitId != null) 'unit_id': unitId,
if (photo != null)
'photo': await MultipartFile.fromFile(
photo.path,
filename: photo.path.split(Platform.pathSeparator).last,
contentType: MediaType('image', _ext(photo.path)),
),
});
final res = await _dio.post<Map<String, dynamic>>(
'/incidents',
data: form,
options: Options(contentType: 'multipart/form-data'),
);
return IncidentReport.fromJson(res.data!);
}
Future<List<IncidentReport>> myIncidents() async {
final res = await _dio.get<List<dynamic>>('/incidents/me');
return (res.data ?? [])
.whereType<Map>()
.map((e) => IncidentReport.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
String _ext(String path) {
final dot = path.lastIndexOf('.');
if (dot == -1) return 'jpeg';
final ext = path.substring(dot + 1).toLowerCase();
if (ext == 'jpg') return 'jpeg';
return ext;
}
}

View File

@@ -0,0 +1,56 @@
class UnitOption {
final int id;
final String? plate;
final String? status;
const UnitOption({required this.id, this.plate, this.status});
factory UnitOption.fromJson(Map<String, dynamic> json) => UnitOption(
id: json['id'] as int,
plate: json['plate'] as String?,
status: json['status'] as String?,
);
String get label =>
plate != null && plate!.isNotEmpty ? 'Unidad $id · $plate' : 'Unidad $id';
}
class IncidentReport {
final int id;
final String userId;
final int? unitId;
final String category;
final String description;
final String? photoUrl;
final String status;
final String? createdAt;
const IncidentReport({
required this.id,
required this.userId,
this.unitId,
required this.category,
required this.description,
this.photoUrl,
required this.status,
this.createdAt,
});
factory IncidentReport.fromJson(Map<String, dynamic> json) => IncidentReport(
id: json['id'] as int,
userId: json['user_id'] as String,
unitId: json['unit_id'] as int?,
category: json['category'] as String,
description: json['description'] as String,
photoUrl: json['photo_url'] as String?,
status: (json['status'] as String?) ?? 'open',
createdAt: json['created_at'] as String?,
);
}
const incidentCategories = <String, String>{
'derrame': 'Derrame de residuos',
'dano_propiedad': 'Daño a propiedad',
'conducta': 'Conducta inadecuada',
'no_recoleccion': 'No pasó el camión',
'otro': 'Otro',
};

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/incident_service.dart';
import '../models/incident.dart';
final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
return ref.read(incidentServiceProvider).listUnits();
});
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
return ref.read(incidentServiceProvider).myIncidents();
});

View File

@@ -0,0 +1,244 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:dio/dio.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/app_widgets.dart';
import '../data/incident_service.dart';
import '../models/incident.dart';
import '../providers/incident_providers.dart';
class ReportIssueScreen extends ConsumerStatefulWidget {
const ReportIssueScreen({super.key});
@override
ConsumerState<ReportIssueScreen> createState() => _ReportIssueScreenState();
}
class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
final _formKey = GlobalKey<FormState>();
final _descCtrl = TextEditingController();
int? _unitId;
String _category = 'no_recoleccion';
File? _photo;
bool _submitting = false;
@override
void dispose() {
_descCtrl.dispose();
super.dispose();
}
Future<void> _pickPhoto() async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1600,
imageQuality: 80,
);
if (picked != null) {
setState(() => _photo = File(picked.path));
}
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _submitting = true);
try {
await ref
.read(incidentServiceProvider)
.createIncident(
category: _category,
description: _descCtrl.text.trim(),
unitId: _unitId,
photo: _photo,
);
ref.invalidate(myIncidentsProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Reporte enviado. ¡Gracias!')),
);
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_friendly(e))));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
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 'No se pudo enviar el reporte';
}
@override
Widget build(BuildContext context) {
final unitsAsync = ref.watch(unitsProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Reportar un problema')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionTitle(title: 'Detalles del reporte'),
// Unidad
Text(
'Unidad relacionada (opcional)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
unitsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text(
'No se pudieron cargar las unidades',
style: const TextStyle(
color: AppTheme.danger,
fontSize: 12,
),
),
data: (units) => DropdownButtonFormField<int?>(
initialValue: _unitId,
isExpanded: true,
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Sin unidad'),
),
for (final u in units)
DropdownMenuItem<int?>(
value: u.id,
child: Text(u.label),
),
],
onChanged: (v) => setState(() => _unitId = v),
),
),
const SizedBox(height: 16),
// Categoría
Text(
'Categoría',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: _category,
isExpanded: true,
items: [
for (final entry in incidentCategories.entries)
DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
],
onChanged: (v) {
if (v != null) setState(() => _category = v);
},
validator: (v) => (v == null || v.isEmpty)
? 'Selecciona una categoría'
: null,
),
const SizedBox(height: 16),
AppFormField(
label: 'Descripción',
controller: _descCtrl,
hint: 'Cuéntanos qué pasó…',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (v) {
final t = (v ?? '').trim();
if (t.length < 3)
return 'Describe el problema (mínimo 3 caracteres)';
return null;
},
),
const SizedBox(height: 16),
// Foto
Row(
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickPhoto,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(
_photo == null ? 'Adjuntar foto' : 'Cambiar foto',
),
),
if (_photo != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Quitar foto',
icon: const Icon(Icons.close, color: AppTheme.danger),
onPressed: _submitting
? null
: () => setState(() => _photo = null),
),
],
],
),
if (_photo != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
child: Image.file(
_photo!,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enviar reporte'),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../models/profile_user.dart';
final profileServiceProvider = Provider<ProfileService>((ref) {
return ProfileService(ref.read(apiClientProvider));
});
class ProfileService {
ProfileService(this._dio);
final Dio _dio;
Future<ProfileUser> getMe() async {
final res = await _dio.get<Map<String, dynamic>>('/users/me');
return ProfileUser.fromJson(res.data!);
}
Future<void> updateMe({String? name, String? email, String? phone}) async {
final payload = <String, dynamic>{};
if (name != null) payload['name'] = name;
if (email != null) payload['email'] = email;
if (phone != null) payload['phone'] = phone;
if (payload.isEmpty) return;
await _dio.patch<void>('/users/me', data: payload);
}
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) async {
await _dio.post<void>(
'/users/me/change-password',
data: {'current_password': currentPassword, 'new_password': newPassword},
);
}
}

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),
);
}
}

View File

@@ -0,0 +1,43 @@
class ProfileUser {
final String id;
final String? email;
final String? phone;
final String? name;
final String role;
final String? createdAt;
const ProfileUser({
required this.id,
this.email,
this.phone,
this.name,
required this.role,
this.createdAt,
});
factory ProfileUser.fromJson(Map<String, dynamic> json) => ProfileUser(
id: json['id'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
name: json['name'] as String?,
role: (json['role'] as String?) ?? 'citizen',
createdAt: json['created_at'] as String?,
);
bool get isAdmin => role == 'admin';
bool get isDriver => role == 'driver';
String get displayName {
if (name != null && name!.trim().isNotEmpty) return name!.trim();
if (email != null && email!.isNotEmpty) return email!;
return 'Usuario';
}
String get initials {
final source = (name != null && name!.trim().isNotEmpty)
? name!.trim()
: (email ?? '');
if (source.isEmpty) return 'U';
return source[0].toUpperCase();
}
}

View File

@@ -5,144 +5,112 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/services/auth_controller.dart';
import '../../core/storage/secure_storage.dart';
import '../../core/constants/auth_constants.dart';
import '../separation_guide/ai_pet_chat_screen.dart';
import 'models/profile_user.dart';
import 'providers/profile_providers.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authControllerProvider).asData?.value;
final storage = ref.read(secureStorageProvider);
final authRole =
ref.watch(authControllerProvider).asData?.value.userRole ?? 'citizen';
final userAsync = ref.watch(currentUserProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(title: const Text('Mi perfil')),
body: FutureBuilder<_ProfileData>(
future: _loadProfile(storage),
builder: (context, snapshot) {
final profile =
snapshot.data ??
_ProfileData(
email: authState?.token != null ? '' : '',
role: authState?.userRole ?? 'citizen',
);
return ListView(
padding: const EdgeInsets.all(16),
children: [
_ProfileHeader(profile: profile),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mi cuenta'),
AppMenuTile(
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: profile.email,
onTap: () => context.go('/edit-profile'),
),
AppMenuTile(
icon: Icons.lock_outline,
title: 'Cambiar contraseña',
onTap: () {},
),
AppMenuTile(
icon: Icons.email_outlined,
title: 'Correo',
subtitle: profile.email,
onTap: () {},
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Configuración'),
AppMenuTile(
icon: Icons.calendar_month_outlined,
title: 'Horario del camión',
subtitle: 'Mi ruta asignada',
onTap: () {},
),
AppMenuTile(
icon: Icons.notifications_outlined,
title: 'Notificaciones',
subtitle: 'Gestiona tus alertas',
onTap: () {},
),
if (profile.role == 'admin')
AppMenuTile(
icon: Icons.admin_panel_settings_outlined,
title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Soporte'),
AppMenuTile(
icon: Icons.pets,
title: 'Hablar con Eco (Asistente IA)',
subtitle: 'Guía de separación de residuos',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
),
AppMenuTile(
icon: Icons.help_outline,
title: 'Ayuda y preguntas frecuentes',
onTap: () {},
),
AppMenuTile(
icon: Icons.bug_report_outlined,
title: 'Reportar un problema',
onTap: () {},
),
AppMenuTile(
icon: Icons.info_outline,
title: 'Acerca de la app',
subtitle: 'Versión 1.0.0',
onTap: () {},
),
const SizedBox(height: 16),
AppMenuTile(
icon: Icons.logout_rounded,
title: 'Cerrar sesión',
iconColor: AppTheme.danger,
titleColor: AppTheme.danger,
trailing: const SizedBox.shrink(),
onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: AppTheme.textHint,
height: 1.6,
),
),
),
const SizedBox(height: 24),
],
);
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(currentUserProvider);
await ref.read(currentUserProvider.future);
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_ProfileHeader(
user: userAsync.asData?.value,
fallbackRole: authRole,
),
const SizedBox(height: 20),
const AppSectionTitle(title: 'Mi cuenta'),
AppMenuTile(
icon: Icons.person_outline,
title: 'Editar perfil',
subtitle: 'Nombre, correo, teléfono y contraseña',
onTap: () => context.push('/edit-profile'),
),
if ((userAsync.asData?.value.isAdmin ?? false) ||
authRole == 'admin')
AppMenuTile(
icon: Icons.admin_panel_settings_outlined,
title: 'Panel de administración',
subtitle: 'Gestiona usuarios, rutas y camiones',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 16),
const AppSectionTitle(title: 'Soporte'),
AppMenuTile(
icon: Icons.pets,
title: 'Hablar con Eco (Asistente IA)',
subtitle: 'Guía de separación de residuos',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
),
AppMenuTile(
icon: Icons.help_outline,
title: 'Ayuda y preguntas frecuentes',
subtitle: 'Chatea con nuestro asistente',
onTap: () => context.push('/help'),
),
AppMenuTile(
icon: Icons.bug_report_outlined,
title: 'Reportar un problema',
subtitle: 'Reporta una unidad o incidente',
onTap: () => context.push('/report-issue'),
),
AppMenuTile(
icon: Icons.info_outline,
title: 'Acerca de la app',
onTap: () => context.push('/about'),
),
const SizedBox(height: 16),
AppMenuTile(
icon: Icons.logout_rounded,
title: 'Cerrar sesión',
iconColor: AppTheme.danger,
titleColor: AppTheme.danger,
trailing: const SizedBox.shrink(),
onTap: () => _confirmarCerrarSesion(context, ref),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Recolecta v1.0.0\nServicio de Limpia · Celaya, Gto.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: AppTheme.textHint,
height: 1.6,
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Future<_ProfileData> _loadProfile(dynamic storage) async {
final role =
await storage.read(key: authUserRoleStorageKey) as String? ?? 'citizen';
return _ProfileData(role: role);
}
void _confirmarCerrarSesion(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
@@ -189,26 +157,21 @@ class ProfileScreen extends ConsumerWidget {
}
}
// ── Datos de perfil ───────────────────────────────────────────────────────────
class _ProfileData {
final String email;
final String role;
const _ProfileData({this.email = '', this.role = 'citizen'});
String get iniciales => email.isNotEmpty ? email[0].toUpperCase() : 'U';
String get displayName => email;
bool get isAdmin => role == 'admin';
}
// ── Encabezado ────────────────────────────────────────────────────────────────
class _ProfileHeader extends StatelessWidget {
final _ProfileData profile;
const _ProfileHeader({required this.profile});
final ProfileUser? user;
final String fallbackRole;
const _ProfileHeader({required this.user, required this.fallbackRole});
@override
Widget build(BuildContext context) {
final role = user?.role ?? fallbackRole;
final isAdmin = role == 'admin';
final isDriver = role == 'driver';
final initials = user?.initials ?? 'U';
final displayName = user?.displayName ?? 'Usuario';
final email = user?.email ?? '';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -229,7 +192,7 @@ class _ProfileHeader extends StatelessWidget {
),
child: Center(
child: Text(
profile.iniciales,
initials,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
@@ -244,7 +207,7 @@ class _ProfileHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.displayName,
displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
@@ -253,7 +216,7 @@ class _ProfileHeader extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
profile.email,
email,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
@@ -261,7 +224,11 @@ class _ProfileHeader extends StatelessWidget {
),
const SizedBox(height: 6),
AppStatusBadge.green(
profile.isAdmin ? 'Administrador' : 'Ciudadano',
isAdmin
? 'Administrador'
: isDriver
? 'Chofer'
: 'Ciudadano',
),
],
),
@@ -271,3 +238,4 @@ class _ProfileHeader extends StatelessWidget {
);
}
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/profile_service.dart';
import '../models/profile_user.dart';
final currentUserProvider = FutureProvider<ProfileUser>((ref) async {
return ref.read(profileServiceProvider).getMe();
});