simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas
This commit is contained in:
@@ -2,7 +2,6 @@ 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});
|
||||
@@ -11,93 +10,52 @@ class AboutScreen extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _buildPageHeader(context, version, build),
|
||||
),
|
||||
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,
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 32),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
_SectionLabel('Descripción'),
|
||||
const SizedBox(height: 10),
|
||||
_InfoCard(
|
||||
icon: Icons.info_outline_rounded,
|
||||
content: 'RecolectApp es una aplicación del Servicio de Limpia de Celaya '
|
||||
'para informar al ciudadano sobre rutas, horarios y separación '
|
||||
'correcta de residuos.',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_SectionLabel('Créditos'),
|
||||
const SizedBox(height: 10),
|
||||
_InfoCard(
|
||||
icon: Icons.people_outline_rounded,
|
||||
content: 'Desarrollado por el equipo ONLINCESHACK.\nServicio de Limpia · Celaya, Gto.',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_SectionLabel('Tecnología'),
|
||||
const SizedBox(height: 10),
|
||||
_TechRow(icon: Icons.phone_android_rounded, label: 'Flutter · Dart'),
|
||||
const SizedBox(height: 8),
|
||||
_TechRow(icon: Icons.cloud_outlined, label: 'FastAPI · Supabase'),
|
||||
const SizedBox(height: 8),
|
||||
_TechRow(icon: Icons.notifications_outlined, label: 'Firebase Cloud Messaging'),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: Text(
|
||||
'© 2025 RecolectApp · Todos los derechos reservados',
|
||||
style: const TextStyle(fontSize: 11, color: AppTheme.textHint),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -106,4 +64,208 @@ class AboutScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context, String version, String build) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
stops: [0.0, 0.6, 1.0],
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.recycling_rounded,
|
||||
size: 42,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'RecolectApp',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Versión $version (build $build)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Text(
|
||||
'Servicio de Limpia · Celaya, Gto.',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String content;
|
||||
const _InfoCard({required this.icon, required this.content});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9.5),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 3, color: AppTheme.primary),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: AppTheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TechRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const _TechRow({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppTheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,202 +213,279 @@ class _AddAddressPageState extends ConsumerState<AddAddressPage> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: AppTheme.textPrimary),
|
||||
title: const Text(
|
||||
'Agregar dirección',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppFormCard(
|
||||
icon: Icons.home_outlined,
|
||||
title: 'Dirección de tu casa',
|
||||
child: Column(
|
||||
children: [
|
||||
AppFormField(
|
||||
label: 'Etiqueta',
|
||||
hint: 'Ej. Mi Casa, Trabajo',
|
||||
controller: _labelCtrl,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Código Postal',
|
||||
hint: 'Ej. 38000',
|
||||
controller: _cpCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) => _validarCP(v, coloniasList),
|
||||
),
|
||||
if (_selectedColonia != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
AppFormCard(
|
||||
icon: Icons.home_outlined,
|
||||
title: 'Dirección de tu casa',
|
||||
child: Column(
|
||||
children: [
|
||||
AppFormField(
|
||||
label: 'Etiqueta',
|
||||
hint: 'Ej. Mi Casa, Trabajo',
|
||||
controller: _labelCtrl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Código Postal',
|
||||
hint: 'Ej. 38000',
|
||||
controller: _cpCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) => _validarCP(v, coloniasList),
|
||||
),
|
||||
if (_selectedColonia != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight.withValues(alpha: 0.5),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.radiusSm),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: AppTheme.primary,
|
||||
size: 18,
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: AppTheme.primary,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Colonia: ${_selectedColonia!.nombre}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Colonia: ${_selectedColonia!.nombre}',
|
||||
if (_selectedColonia!.horarioEstimado !=
|
||||
null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
fontSize: 13,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_selectedColonia!.horarioEstimado != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Horario ${_selectedColonia!.turno?.toLowerCase() ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_selectedColonia!.horarioEstimado!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Calle y número',
|
||||
hint: 'Av. Insurgentes 245',
|
||||
controller: _calleCtrl,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Toca el mapa para ubicar tu casa exacta:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: mapCenter,
|
||||
initialZoom: 15.0,
|
||||
cameraConstraint: bounds != null
|
||||
? CameraConstraint.containCenter(bounds: bounds)
|
||||
: const CameraConstraint.unconstrained(),
|
||||
onTap: (_, latlng) => _fetchStreetName(latlng),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.onlineshack.recolecta',
|
||||
),
|
||||
if (_selectedLocation != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: _selectedLocation!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: AppTheme.danger,
|
||||
size: 40,
|
||||
Text(
|
||||
_selectedColonia!.horarioEstimado!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 24),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Ingresa un código postal con servicio\npara asignar tu colonia.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _guardar,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
key: ValueKey('loading'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const FittedBox(
|
||||
key: ValueKey('text'),
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Guardar dirección'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppFormField(
|
||||
label: 'Calle y número',
|
||||
hint: 'Av. Insurgentes 245',
|
||||
controller: _calleCtrl,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Toca el mapa para ubicar tu casa exacta:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: mapCenter,
|
||||
initialZoom: 15.0,
|
||||
cameraConstraint: bounds != null
|
||||
? CameraConstraint.containCenter(
|
||||
bounds: bounds)
|
||||
: const CameraConstraint.unconstrained(),
|
||||
onTap: (_, latlng) => _fetchStreetName(latlng),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName:
|
||||
'com.onlineshack.recolecta',
|
||||
),
|
||||
if (_selectedLocation != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: _selectedLocation!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: AppTheme.danger,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 24),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Ingresa un código postal con servicio\npara asignar tu colonia.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildSaveButton(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Agregar dirección',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Registra tu domicilio de recolección',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_home_outlined,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _guardar,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
key: ValueKey('loading'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Row(
|
||||
key: ValueKey('text'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Guardar dirección'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ class AddressMapCard extends StatelessWidget {
|
||||
// Si existen coordenadas exactas las usa, de lo contrario cae al centro de la colonia
|
||||
final center = (lat != null && lng != null)
|
||||
? LatLng(lat!, lng!)
|
||||
: kColoniaCenter(colonia);
|
||||
: kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_widgets.dart';
|
||||
import 'data/admin_service.dart';
|
||||
import 'models/admin_driver.dart';
|
||||
import 'models/admin_incident.dart';
|
||||
import 'models/admin_route.dart';
|
||||
import 'models/admin_unit.dart';
|
||||
import 'models/admin_user.dart';
|
||||
@@ -67,6 +68,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSimulationTick() async {
|
||||
try {
|
||||
final events = await _service.simulationTick();
|
||||
if (events.isEmpty) {
|
||||
_snack('Tick enviado · sin eventos nuevos en esta posición');
|
||||
} else {
|
||||
final tipos = events
|
||||
.map((e) => e['event']?.toString() ?? '?')
|
||||
.toSet()
|
||||
.join(', ');
|
||||
_snack('Tick enviado · ${events.length} push FCM ($tipos)');
|
||||
}
|
||||
} catch (e) {
|
||||
_snack('No se pudo avanzar la simulación: $e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -74,6 +92,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
appBar: AppBar(
|
||||
title: const Text('Panel de administración'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Avanzar simulación (envía push FCM)',
|
||||
icon: const Icon(Icons.play_circle_fill_rounded),
|
||||
onPressed: _handleSimulationTick,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refrescar',
|
||||
icon: const Icon(Icons.refresh),
|
||||
@@ -101,7 +124,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
tabs: const [
|
||||
Tab(text: 'Usuarios'),
|
||||
Tab(text: 'Rutas'),
|
||||
Tab(text: 'Camiones'),
|
||||
Tab(text: 'Unidades'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -118,7 +141,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
? 'Nuevo usuario'
|
||||
: _activeTab == 1
|
||||
? 'Nueva ruta'
|
||||
: 'Nuevo camión',
|
||||
: 'Nueva unidad',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -372,7 +395,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
DropdownButtonFormField<int?>(
|
||||
initialValue: truckId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Camión asignado',
|
||||
labelText: 'Unidad asignada',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppTheme.radiusMd,
|
||||
@@ -445,7 +468,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formulario camión (unit) ────────────────────────────────────────────────
|
||||
// ── Formulario unidad ───────────────────────────────────────────────────────
|
||||
Future<void> _showUnitForm({AdminUnitModel? unit}) async {
|
||||
final isEdit = unit != null;
|
||||
final idCtrl = TextEditingController(text: unit?.id.toString() ?? '');
|
||||
@@ -463,7 +486,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
),
|
||||
title: Text(isEdit ? 'Editar camión' : 'Nuevo camión'),
|
||||
title: Text(isEdit ? 'Editar unidad' : 'Nueva unidad'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: SingleChildScrollView(
|
||||
@@ -561,7 +584,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
|
||||
if (saved == true) {
|
||||
ref.invalidate(adminUnitsProvider);
|
||||
ref.invalidate(adminRoutesProvider);
|
||||
_snack(isEdit ? 'Camión actualizado' : 'Camión creado');
|
||||
_snack(isEdit ? 'Unidad actualizada' : 'Unidad creada');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +797,7 @@ class _RoutesTab extends ConsumerWidget {
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
'Camión: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
|
||||
'Unidad: ${unit?.displayPlate ?? (r.truckId == null ? 'Sin asignar' : '#${r.truckId}')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
@@ -845,6 +868,7 @@ class _RoutesTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab Unidades ──────────────────────────────────────────────────────────────
|
||||
class _TrucksTab extends ConsumerWidget {
|
||||
const _TrucksTab();
|
||||
|
||||
@@ -866,7 +890,7 @@ class _TrucksTab extends ConsumerWidget {
|
||||
),
|
||||
data: (units) {
|
||||
if (units.isEmpty) {
|
||||
return const _EmptyView('No hay camiones registrados.');
|
||||
return const _EmptyView('No hay unidades registradas.');
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
@@ -892,6 +916,7 @@ class _TrucksTab extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Encabezado: placa + badge estado ─────────────────
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -907,6 +932,7 @@ class _TrucksTab extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// ── Detalles ──────────────────────────────────────────
|
||||
Text(
|
||||
'ID: #${t.id}',
|
||||
style: const TextStyle(
|
||||
@@ -926,9 +952,21 @@ class _TrucksTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── Botones de acción ─────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Ver incidencias
|
||||
TextButton.icon(
|
||||
onPressed: () => _showIncidentsSheet(context, ref, t),
|
||||
icon: const Icon(Icons.warning_amber_rounded, size: 18),
|
||||
label: const Text('Incidencias'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Editar
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
final state = context
|
||||
@@ -938,11 +976,12 @@ class _TrucksTab extends ConsumerWidget {
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Editar'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
// Eliminar
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmAndDelete(
|
||||
context,
|
||||
tipo: 'camión',
|
||||
tipo: 'unidad',
|
||||
onConfirm: () async {
|
||||
await ref
|
||||
.read(adminServiceProvider)
|
||||
@@ -968,6 +1007,23 @@ class _TrucksTab extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Abre el bottom sheet de incidencias ───────────────────────────────────
|
||||
void _showIncidentsSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AdminUnitModel unit,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppTheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _IncidentsSheet(unit: unit),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _unitStatusBadge(String status) {
|
||||
switch (status) {
|
||||
case 'inactive':
|
||||
@@ -980,6 +1036,374 @@ class _TrucksTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom sheet de incidencias ───────────────────────────────────────────────
|
||||
class _IncidentsSheet extends ConsumerStatefulWidget {
|
||||
const _IncidentsSheet({required this.unit});
|
||||
final AdminUnitModel unit;
|
||||
|
||||
@override
|
||||
ConsumerState<_IncidentsSheet> createState() => _IncidentsSheetState();
|
||||
}
|
||||
|
||||
class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final async = ref.watch(adminIncidentsByUnitProvider(widget.unit.id));
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.92,
|
||||
builder: (_, controller) => Column(
|
||||
children: [
|
||||
// ── Handle ─────────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── Header ─────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Incidencias — ${widget.unit.displayPlate}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
tooltip: 'Nueva incidencia',
|
||||
onPressed: () => _showCreateIncidentDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// ── Lista ───────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppTheme.danger,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
e.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(
|
||||
adminIncidentsByUnitProvider(widget.unit.id),
|
||||
),
|
||||
child: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (incidents) {
|
||||
if (incidents.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Sin incidencias registradas.',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
itemCount: incidents.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, i) => _IncidentCard(incident: incidents[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateIncidentDialog(BuildContext context) async {
|
||||
String type = 'otro';
|
||||
final desc = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setStateDialog) => AlertDialog(
|
||||
backgroundColor: AppTheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
),
|
||||
title: const Text('Nueva incidencia'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: type,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Categoría',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'derrame',
|
||||
child: Text('💧 Derrame'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'dano_propiedad',
|
||||
child: Text('💥 Daño a propiedad'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'conducta',
|
||||
child: Text('😠 Conducta'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'no_recoleccion',
|
||||
child: Text('🗑 No recolección'),
|
||||
),
|
||||
DropdownMenuItem(value: 'otro', child: Text('📋 Otro')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) setStateDialog(() => type = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: desc,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Descripción',
|
||||
helperText: 'Mínimo 3 caracteres',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
final t = (v ?? '').trim();
|
||||
if (t.length < 3) return 'Describe brevemente lo ocurrido';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||
try {
|
||||
await ref
|
||||
.read(adminServiceProvider)
|
||||
.createIncident(
|
||||
unitId: widget.unit.id,
|
||||
type: type,
|
||||
description: desc.text.trim(),
|
||||
);
|
||||
if (ctx.mounted) Navigator.pop(ctx, true);
|
||||
} catch (e) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: AppTheme.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true) {
|
||||
ref.invalidate(adminIncidentsByUnitProvider(widget.unit.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tarjeta individual de incidencia ──────────────────────────────────────────
|
||||
class _IncidentCard extends StatelessWidget {
|
||||
const _IncidentCard({required this.incident});
|
||||
final AdminIncidentModel incident;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_typeIcon(incident.type),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Tipo + fecha ──────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
_typeBadge(incident.type),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDate(incident.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Conductor ─────────────────────────────────────────
|
||||
if (incident.driverName != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.person_outline,
|
||||
size: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
incident.driverName!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// ── Ruta ─────────────────────────────────────────────
|
||||
if (incident.routeId != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Ruta: ${incident.routeId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Descripción ───────────────────────────────────────
|
||||
if (incident.description != null &&
|
||||
incident.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
incident.description!,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeIcon(String type) {
|
||||
IconData icon;
|
||||
Color color;
|
||||
switch (type) {
|
||||
case 'derrame':
|
||||
icon = Icons.water_drop_outlined;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case 'dano_propiedad':
|
||||
icon = Icons.report_gmailerrorred_outlined;
|
||||
color = AppTheme.danger;
|
||||
break;
|
||||
case 'conducta':
|
||||
icon = Icons.sentiment_very_dissatisfied_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case 'no_recoleccion':
|
||||
icon = Icons.delete_forever_outlined;
|
||||
color = Colors.deepOrange;
|
||||
break;
|
||||
default:
|
||||
icon = Icons.info_outline;
|
||||
color = AppTheme.textSecondary;
|
||||
}
|
||||
return Icon(icon, color: color, size: 22);
|
||||
}
|
||||
|
||||
Widget _typeBadge(String type) {
|
||||
switch (type) {
|
||||
case 'derrame':
|
||||
return AppStatusBadge.amber('Derrame');
|
||||
case 'dano_propiedad':
|
||||
return AppStatusBadge.danger('Daño');
|
||||
case 'conducta':
|
||||
return AppStatusBadge.amber('Conducta');
|
||||
case 'no_recoleccion':
|
||||
return AppStatusBadge.danger('No recolección');
|
||||
default:
|
||||
return AppStatusBadge.gray('Otro');
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
final d = dt.toLocal();
|
||||
final day = d.day.toString().padLeft(2, '0');
|
||||
final month = d.month.toString().padLeft(2, '0');
|
||||
final hour = d.hour.toString().padLeft(2, '0');
|
||||
final minute = d.minute.toString().padLeft(2, '0');
|
||||
return '$day/$month/${d.year} $hour:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared widgets ────────────────────────────────────────────────────────────
|
||||
class _EmptyView extends StatelessWidget {
|
||||
const _EmptyView(this.message);
|
||||
@@ -1079,4 +1503,3 @@ void _confirmAndDelete(
|
||||
}
|
||||
|
||||
// EOF
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../models/admin_driver.dart';
|
||||
import '../models/admin_incident.dart';
|
||||
import '../models/admin_route.dart';
|
||||
import '../models/admin_unit.dart';
|
||||
import '../models/admin_user.dart';
|
||||
@@ -188,4 +189,42 @@ class AdminService {
|
||||
Future<void> deleteDriver(String id) async {
|
||||
await _dio.delete<void>('/admin/drivers/$id');
|
||||
}
|
||||
|
||||
// ── Incidents ────────────────────────────────────────────────────────────────
|
||||
Future<List<AdminIncidentModel>> listIncidentsByUnit(int unitId) async {
|
||||
final res = await _dio.get<List<dynamic>>('/admin/units/$unitId/incidents');
|
||||
return (res.data ?? [])
|
||||
.whereType<Map>()
|
||||
.map((e) => AdminIncidentModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<AdminIncidentModel> createIncident({
|
||||
required int unitId,
|
||||
required String type,
|
||||
String? description,
|
||||
}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/admin/units/$unitId/incidents',
|
||||
data: {
|
||||
'unit_id': unitId,
|
||||
'type': type,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
},
|
||||
);
|
||||
return AdminIncidentModel.fromJson(res.data!);
|
||||
}
|
||||
|
||||
// ── Simulación ──────────────────────────────────────────────────────────────
|
||||
/// Avanza una vez la simulación (`positionId += 1` en todas las rutas) y
|
||||
/// dispara los push FCM correspondientes. Devuelve los eventos disparados.
|
||||
Future<List<Map<String, dynamic>>> simulationTick() async {
|
||||
final res = await _dio.post<Map<String, dynamic>>('/simulation/tick');
|
||||
final events = (res.data?['events'] as List?) ?? const [];
|
||||
return events
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal file
67
recolecta_app/lib/features/admin/models/admin_incident.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
// lib/features/admin/models/admin_incident.dart
|
||||
|
||||
class AdminIncidentModel {
|
||||
final String id;
|
||||
final int unitId;
|
||||
final String? routeId;
|
||||
// Mapea a `incidents.category` en la base de datos.
|
||||
final String type;
|
||||
final String? description;
|
||||
final String? driverName;
|
||||
final String status; // open | in_review | resolved
|
||||
final String? photoUrl;
|
||||
final DateTime createdAt;
|
||||
|
||||
const AdminIncidentModel({
|
||||
required this.id,
|
||||
required this.unitId,
|
||||
this.routeId,
|
||||
required this.type,
|
||||
this.description,
|
||||
this.driverName,
|
||||
this.status = 'open',
|
||||
this.photoUrl,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory AdminIncidentModel.fromJson(Map<String, dynamic> json) =>
|
||||
AdminIncidentModel(
|
||||
// El id en DB es BIGSERIAL; el backend lo serializa como string,
|
||||
// pero por defensa aceptamos number también.
|
||||
id: json['id'].toString(),
|
||||
unitId: (json['unit_id'] as num).toInt(),
|
||||
routeId: json['route_id'] as String?,
|
||||
type: (json['type'] as String?) ?? 'otro',
|
||||
description: json['description'] as String?,
|
||||
driverName: json['driver_name'] as String?,
|
||||
status: (json['status'] as String?) ?? 'open',
|
||||
photoUrl: json['photo_url'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'derrame':
|
||||
return 'Derrame';
|
||||
case 'dano_propiedad':
|
||||
return 'Daño a propiedad';
|
||||
case 'conducta':
|
||||
return 'Conducta';
|
||||
case 'no_recoleccion':
|
||||
return 'No recolección';
|
||||
default:
|
||||
return 'Otro';
|
||||
}
|
||||
}
|
||||
|
||||
String get statusLabel {
|
||||
switch (status) {
|
||||
case 'in_review':
|
||||
return 'En revisión';
|
||||
case 'resolved':
|
||||
return 'Resuelta';
|
||||
default:
|
||||
return 'Abierta';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/admin_service.dart';
|
||||
import '../models/admin_driver.dart';
|
||||
import '../models/admin_incident.dart';
|
||||
import '../models/admin_route.dart';
|
||||
import '../models/admin_unit.dart';
|
||||
import '../models/admin_user.dart';
|
||||
@@ -21,3 +22,8 @@ final adminUnitsProvider = FutureProvider<List<AdminUnitModel>>((ref) {
|
||||
final adminDriversProvider = FutureProvider<List<AdminDriverModel>>((ref) {
|
||||
return ref.read(adminServiceProvider).listDrivers();
|
||||
});
|
||||
|
||||
final adminIncidentsByUnitProvider =
|
||||
FutureProvider.family<List<AdminIncidentModel>, int>((ref, unitId) {
|
||||
return ref.read(adminServiceProvider).listIncidentsByUnit(unitId);
|
||||
});
|
||||
|
||||
38
recolecta_app/lib/features/alerts/alerts_provider.dart
Normal file
38
recolecta_app/lib/features/alerts/alerts_provider.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AppAlert {
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime timestamp;
|
||||
|
||||
AppAlert({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class AlertsNotifier extends Notifier<List<AppAlert>> {
|
||||
@override
|
||||
List<AppAlert> build() => [];
|
||||
|
||||
void addAlert(String title, String body) {
|
||||
state = [
|
||||
AppAlert(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
title: title,
|
||||
body: body,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
...state,
|
||||
];
|
||||
}
|
||||
|
||||
void clearAll() => state = [];
|
||||
}
|
||||
|
||||
final alertsProvider = NotifierProvider<AlertsNotifier, List<AppAlert>>(
|
||||
AlertsNotifier.new,
|
||||
);
|
||||
@@ -1,218 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/models/ui_models.dart';
|
||||
import '../../core/widgets/app_widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AlertsScreen extends StatefulWidget {
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import 'alerts_provider.dart';
|
||||
|
||||
class AlertsScreen extends ConsumerWidget {
|
||||
const AlertsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AlertsScreen> createState() => _AlertsScreenState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final alerts = ref.watch(alertsProvider);
|
||||
|
||||
class _AlertsScreenState extends State<AlertsScreen> {
|
||||
final UIAlertaModel _alertaActiva = UIAlertaModel(
|
||||
id: 'alerta-001',
|
||||
tipo: TipoAlerta.cercana,
|
||||
distanciaMetros: 180,
|
||||
fecha: DateTime.now(),
|
||||
direccionCasa: 'Av. Insurgentes 245',
|
||||
leida: false,
|
||||
);
|
||||
|
||||
final List<UIAlertaModel> _historial = [
|
||||
UIAlertaModel(
|
||||
id: 'h-001',
|
||||
tipo: TipoAlerta.cercana,
|
||||
distanciaMetros: 200,
|
||||
fecha: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
direccionCasa: 'Av. Insurgentes 245',
|
||||
leida: true,
|
||||
),
|
||||
UIAlertaModel(
|
||||
id: 'h-002',
|
||||
tipo: TipoAlerta.cercana,
|
||||
distanciaMetros: 200,
|
||||
fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)),
|
||||
direccionCasa: 'Av. Insurgentes 245',
|
||||
leida: true,
|
||||
),
|
||||
UIAlertaModel(
|
||||
id: 'h-003',
|
||||
tipo: TipoAlerta.cercana,
|
||||
distanciaMetros: 200,
|
||||
fecha: DateTime.now().subtract(
|
||||
const Duration(days: 4, hours: 1, minutes: 30)),
|
||||
direccionCasa: 'Av. Insurgentes 245',
|
||||
leida: true,
|
||||
),
|
||||
UIAlertaModel(
|
||||
id: 'h-004',
|
||||
tipo: TipoAlerta.cercana,
|
||||
distanciaMetros: 200,
|
||||
fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)),
|
||||
direccionCasa: 'Av. Insurgentes 245',
|
||||
leida: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Alertas'),
|
||||
title: const Text('Mis Alertas'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Limpiar',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13)),
|
||||
),
|
||||
if (alerts.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
tooltip: 'Limpiar historial',
|
||||
onPressed: () => ref.read(alertsProvider.notifier).clearAll(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: AppTheme.primary,
|
||||
onRefresh: () async =>
|
||||
Future.delayed(const Duration(milliseconds: 800)),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_AlertaActivaCard(alerta: _alertaActiva),
|
||||
const SizedBox(height: 20),
|
||||
if (_historial.isEmpty)
|
||||
const _EmptyState()
|
||||
else ...[
|
||||
const AppSectionTitle(title: 'Historial de alertas'),
|
||||
..._historial.map((a) => _AlertaHistorialItem(alerta: a)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
body: alerts.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: alerts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return _AlertCard(alert: alerts[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alerta activa ─────────────────────────────────────────────────────────────
|
||||
class _AlertaActivaCard extends StatelessWidget {
|
||||
final UIAlertaModel alerta;
|
||||
const _AlertaActivaCard({required this.alerta});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.notifications_active,
|
||||
color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('¡El camión está cerca!',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark)),
|
||||
Text(alerta.fechaFormateada,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text('Ahora',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white)),
|
||||
),
|
||||
],
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 64,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('El camión se encuentra a',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.primaryDark)),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
alerta.distanciaTexto,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primary,
|
||||
height: 1.1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text('de tu casa en ${alerta.direccionCasa}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.primaryDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Llegada estimada:',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppTheme.primaryDark)),
|
||||
const SizedBox(width: 6),
|
||||
Text(alerta.tiempoEstimadoTexto,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progreso,
|
||||
backgroundColor:
|
||||
AppTheme.primaryMid.withValues(alpha: 0.4),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
minHeight: 7,
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No tienes notificaciones nuevas',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Row(
|
||||
children: [
|
||||
Text('Lejos',
|
||||
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
|
||||
Spacer(),
|
||||
Text('Tu casa',
|
||||
style: TextStyle(fontSize: 10, color: AppTheme.primary)),
|
||||
],
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Aquí aparecerán los avisos del camión.',
|
||||
style: TextStyle(fontSize: 14, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -220,108 +67,77 @@ class _AlertaActivaCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ítem de historial ─────────────────────────────────────────────────────────
|
||||
class _AlertaHistorialItem extends StatelessWidget {
|
||||
final UIAlertaModel alerta;
|
||||
const _AlertaHistorialItem({required this.alerta});
|
||||
class _AlertCard extends StatelessWidget {
|
||||
final AppAlert alert;
|
||||
|
||||
const _AlertCard({required this.alert});
|
||||
|
||||
String _timeAgo(DateTime date) {
|
||||
final diff = DateTime.now().difference(date);
|
||||
if (diff.inMinutes < 1) return 'Justo ahora';
|
||||
if (diff.inHours < 1) return 'Hace ${diff.inMinutes} min';
|
||||
if (diff.inDays < 1) return 'Hace ${diff.inHours} h';
|
||||
return 'Hace ${diff.inDays} d';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.background,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications_active_outlined,
|
||||
color: AppTheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
child: const Icon(Icons.notifications_outlined,
|
||||
color: AppTheme.textSecondary, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Camión a ${alerta.distanciaTexto}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(alerta.fechaFormateada,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
Text(
|
||||
alert.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
alert.body,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_timeAgo(alert.timestamp),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_EtiquetaDia(texto: alerta.etiquetaFecha),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EtiquetaDia extends StatelessWidget {
|
||||
final String texto;
|
||||
const _EtiquetaDia({required this.texto});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final esHoy = texto == 'Hoy';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: esHoy ? AppTheme.primaryLight : AppTheme.background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
texto,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sin alertas ───────────────────────────────────────────────────────────────
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 60),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.notifications_outlined,
|
||||
color: AppTheme.primary, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text('Sin alertas por ahora',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary)),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Te notificaremos cuando el camión\nesté cerca de tu casa.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,6 +145,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
key: ValueKey('loading'),
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
VideoMascot(size: 34, zoom: 1.5),
|
||||
@@ -227,7 +228,7 @@ class _GreenHeader extends StatelessWidget {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
stops: [0.0, 0.6, 1.0],
|
||||
colors: [Color(0xFF0A4A38), Color(0xFF0F6E56), Color(0xFF1D9E75)],
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,9 @@ class VideoMascot extends StatelessWidget {
|
||||
'assets/animations/blink_saludo.gif',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Plan B: si el archivo no existe o hay error, mostramos la huellita
|
||||
// Plan B: si el archivo no existe o hay error, mostramos el bote
|
||||
return const Center(
|
||||
child: Icon(Icons.pets, color: Colors.white, size: 48),
|
||||
child: Icon(Icons.delete_outline, color: Colors.white, size: 48),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -123,6 +123,12 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
||||
if (items.isEmpty) return const _EtaResult.noAddress();
|
||||
|
||||
final addressId = items.first['id'] as String;
|
||||
final routeId = items.first['route_id'] as String?;
|
||||
// Publica el routeId activo para que `_FcmStatusBadge` deje de mostrar
|
||||
// "suscribiendo..." y refleje el topic real al que está suscrito el cliente.
|
||||
Future.microtask(() {
|
||||
ref.read(activeRouteIdProvider.notifier).set(routeId);
|
||||
});
|
||||
final etaResp = await dio.get<dynamic>(
|
||||
'/eta',
|
||||
queryParameters: {'address_id': addressId},
|
||||
@@ -149,6 +155,10 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
|
||||
class ActiveRouteIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void set(String? value) {
|
||||
if (state != value) state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||||
@@ -312,7 +322,11 @@ class _EcoChatBanner extends StatelessWidget {
|
||||
color: Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.pets, color: Colors.white, size: 28),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
@@ -408,14 +422,14 @@ class _EtaHeroCard extends StatelessWidget {
|
||||
Color _bgColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
if (result.isCompleted) return cs.surfaceContainerHighest;
|
||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||||
return const Color(0xFFE1F5EE); // teal-50
|
||||
if (result.isNearby) return const Color(0xFFF5EDD8); // beige dorado claro
|
||||
return const Color(0xFFE8D5DB); // rosa claro institucional
|
||||
}
|
||||
|
||||
Color _accentColor(BuildContext context) {
|
||||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
|
||||
return const Color(0xFF1D9E75); // teal-400
|
||||
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
||||
return const Color(0xFF9B1B4A); // vino principal
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -693,7 +707,7 @@ class _FcmStatusBadge extends ConsumerWidget {
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1D9E75),
|
||||
color: Color(0xFF1E7A46),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// lib/features/feedback/feedback_provider.dart
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/api_client.dart';
|
||||
import 'feedback_model.dart';
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Service
|
||||
// ──────────────────────────────────────────
|
||||
class FeedbackService {
|
||||
final Dio _dio;
|
||||
FeedbackService(this._dio);
|
||||
|
||||
Future<void> submit(FeedbackRequest req) async {
|
||||
await _dio.post<void>('/feedback', data: req.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
final feedbackServiceProvider = Provider<FeedbackService>(
|
||||
(ref) => FeedbackService(ref.read(apiClientProvider)),
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Estado del formulario
|
||||
// ──────────────────────────────────────────
|
||||
enum FeedbackFormStatus { idle, loading, success, error }
|
||||
|
||||
class FeedbackFormState {
|
||||
final FeedbackType selectedType;
|
||||
final int rating;
|
||||
final String message;
|
||||
final FeedbackFormStatus status;
|
||||
final String? errorMessage;
|
||||
|
||||
const FeedbackFormState({
|
||||
this.selectedType = FeedbackType.noPaso,
|
||||
this.rating = 3,
|
||||
this.message = '',
|
||||
this.status = FeedbackFormStatus.idle,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
FeedbackFormState copyWith({
|
||||
FeedbackType? selectedType,
|
||||
int? rating,
|
||||
String? message,
|
||||
FeedbackFormStatus? status,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return FeedbackFormState(
|
||||
selectedType: selectedType ?? this.selectedType,
|
||||
rating: rating ?? this.rating,
|
||||
message: message ?? this.message,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Notifier
|
||||
// ──────────────────────────────────────────
|
||||
class FeedbackNotifier extends Notifier<FeedbackFormState> {
|
||||
@override
|
||||
FeedbackFormState build() => const FeedbackFormState();
|
||||
|
||||
void setType(FeedbackType type) => state = state.copyWith(selectedType: type);
|
||||
|
||||
void setRating(int r) => state = state.copyWith(rating: r);
|
||||
|
||||
void setMessage(String m) => state = state.copyWith(message: m);
|
||||
|
||||
void reset() => state = const FeedbackFormState();
|
||||
|
||||
Future<void> submit({
|
||||
required String addressId,
|
||||
required String unitId,
|
||||
}) async {
|
||||
state = state.copyWith(status: FeedbackFormStatus.loading);
|
||||
try {
|
||||
final req = FeedbackRequest(
|
||||
addressId: addressId,
|
||||
type: state.selectedType,
|
||||
rating: state.rating,
|
||||
message: state.message,
|
||||
targetUnitId: unitId,
|
||||
);
|
||||
await ref.read(feedbackServiceProvider).submit(req);
|
||||
state = state.copyWith(status: FeedbackFormStatus.success);
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final detail = (data is Map && data['detail'] != null)
|
||||
? data['detail'].toString()
|
||||
: null;
|
||||
state = state.copyWith(
|
||||
status: FeedbackFormStatus.error,
|
||||
errorMessage: detail ?? e.message ?? 'Error al enviar',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: FeedbackFormStatus.error,
|
||||
errorMessage: 'Error al enviar: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final feedbackProvider = NotifierProvider<FeedbackNotifier, FeedbackFormState>(
|
||||
FeedbackNotifier.new,
|
||||
);
|
||||
|
||||
@@ -1,17 +1,564 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// lib/features/feedback/feedback_screen.dart
|
||||
// Buzón de retroalimentación. Expone "Unidad 101", nunca el nombre del chofer.
|
||||
|
||||
class FeedbackScreen extends StatelessWidget {
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../eta/eta_provider.dart'; // activeAddressIdProvider
|
||||
import '../incidents/providers/incident_providers.dart'; // assignedUnitProvider
|
||||
import 'feedback_model.dart';
|
||||
import 'feedback_provider.dart';
|
||||
|
||||
class FeedbackScreen extends ConsumerStatefulWidget {
|
||||
const FeedbackScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FeedbackScreen> createState() => _FeedbackScreenState();
|
||||
}
|
||||
|
||||
class _FeedbackScreenState extends ConsumerState<FeedbackScreen> {
|
||||
final _messageController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formState = ref.watch(feedbackProvider);
|
||||
|
||||
if (formState.status == FeedbackFormStatus.success) {
|
||||
return _SuccessView(
|
||||
onReset: () {
|
||||
ref.read(feedbackProvider.notifier).reset();
|
||||
_messageController.clear();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Retroalimentación')),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'TODO: Feedback Screen - Formulario de queja hacia la unidad',
|
||||
backgroundColor: AppTheme.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Tipo de reporte
|
||||
_SectionLabel('Tipo de reporte'),
|
||||
const SizedBox(height: 10),
|
||||
_TypeChips(
|
||||
selected: formState.selectedType,
|
||||
onSelect: (t) =>
|
||||
ref.read(feedbackProvider.notifier).setType(t),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Rating
|
||||
_SectionLabel('Calificación del servicio'),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: _StarRating(
|
||||
rating: formState.rating,
|
||||
onRate: (r) =>
|
||||
ref.read(feedbackProvider.notifier).setRating(r),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Unidad (sin exponer chofer)
|
||||
_SectionLabel('Unidad involucrada'),
|
||||
const SizedBox(height: 10),
|
||||
const _UnitBadge(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Mensaje libre
|
||||
_SectionLabel('Descripción (opcional)'),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
maxLines: 4,
|
||||
maxLength: 300,
|
||||
onChanged: (v) =>
|
||||
ref.read(feedbackProvider.notifier).setMessage(v),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cuéntanos qué pasó...',
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Error
|
||||
if (formState.status == FeedbackFormStatus.error)
|
||||
_ErrorBanner(
|
||||
message: formState.errorMessage ?? 'Error',
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildSubmitButton(context, formState, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Buzón de retroalimentación',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Tu opinión mejora el servicio',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.rate_review_outlined,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton(
|
||||
BuildContext context,
|
||||
FeedbackFormState formState,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: formState.status == FeedbackFormStatus.loading
|
||||
? null
|
||||
: () {
|
||||
final addressId = ref.read(activeAddressIdProvider);
|
||||
final unit = ref.read(assignedUnitProvider).value;
|
||||
if (addressId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Selecciona una dirección activa antes de enviar.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (unit == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Tu zona aún no tiene una unidad asignada.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(feedbackProvider.notifier).submit(
|
||||
addressId: addressId,
|
||||
unitId: unit.id.toString(),
|
||||
);
|
||||
},
|
||||
child: formState.status == FeedbackFormStatus.loading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Enviar reporte'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chips de tipo ─────────────────────────────────────────────────────────────
|
||||
class _TypeChips extends StatelessWidget {
|
||||
final FeedbackType selected;
|
||||
final ValueChanged<FeedbackType> onSelect;
|
||||
const _TypeChips({required this.selected, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: FeedbackType.values.map((t) {
|
||||
final isSelected = t == selected;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(t.label),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onSelect(t),
|
||||
selectedColor: AppTheme.primaryLight,
|
||||
backgroundColor: AppTheme.surface,
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppTheme.primary : AppTheme.border,
|
||||
width: isSelected ? 1.5 : 0.5,
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppTheme.primaryDark : AppTheme.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stars ─────────────────────────────────────────────────────────────────────
|
||||
class _StarRating extends StatelessWidget {
|
||||
final int rating;
|
||||
final ValueChanged<int> onRate;
|
||||
const _StarRating({required this.rating, required this.onRate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (i) {
|
||||
final filled = i < rating;
|
||||
return GestureDetector(
|
||||
onTap: () => onRate(i + 1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
filled ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||
size: 42,
|
||||
color: filled ? const Color(0xFFEF9F27) : AppTheme.border,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badge de unidad (sin exponer chofer) ──────────────────────────────────────
|
||||
class _UnitBadge extends ConsumerWidget {
|
||||
const _UnitBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unitAsync = ref.watch(assignedUnitProvider);
|
||||
final unitLabel = unitAsync.when(
|
||||
loading: () => 'Detectando unidad…',
|
||||
error: (_, _) => 'Unidad no disponible',
|
||||
data: (u) => u == null ? 'Sin unidad asignada' : 'Unidad ${u.id}',
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_shipping_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
unitLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Solo se registra el número de unidad. El operador no es identificado.',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error banner ──────────────────────────────────────────────────────────────
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.dangerLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Success view ──────────────────────────────────────────────────────────────
|
||||
class _SuccessView extends StatelessWidget {
|
||||
final VoidCallback onReset;
|
||||
const _SuccessView({required this.onReset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Text(
|
||||
'Buzón de retroalimentación',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
size: 40,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Reporte enviado',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Gracias. Tu retroalimentación ayuda a mejorar el servicio. El reporte fue registrado de forma anónima.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: onReset,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primary,
|
||||
side: const BorderSide(color: AppTheme.primary),
|
||||
),
|
||||
child: const Text('Enviar otro reporte'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textSecondary,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,20 +48,14 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
||||
|
||||
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
|
||||
body: Column(
|
||||
children: [
|
||||
_GradientHeader(
|
||||
hasMessages: state.messages.isNotEmpty,
|
||||
onReset: state.messages.isEmpty
|
||||
? null
|
||||
: () => ref.read(helpChatControllerProvider.notifier).reset(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (state.messages.isEmpty) _QuickQuestions(onSelect: _send),
|
||||
Expanded(
|
||||
child: state.messages.isEmpty
|
||||
@@ -80,7 +74,7 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: AppTheme.danger.withOpacity(0.1),
|
||||
color: AppTheme.danger.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
|
||||
@@ -97,20 +91,120 @@ class _HelpFaqScreenState extends ConsumerState<HelpFaqScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientHeader extends StatelessWidget {
|
||||
final bool hasMessages;
|
||||
final VoidCallback? onReset;
|
||||
const _GradientHeader({required this.hasMessages, this.onReset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
stops: [0.0, 0.6, 1.0],
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const Spacer(),
|
||||
if (hasMessages)
|
||||
IconButton(
|
||||
tooltip: 'Reiniciar conversación',
|
||||
icon: const Icon(Icons.refresh_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
onPressed: onReset,
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.support_agent_rounded,
|
||||
color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ayuda y soporte',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Pregunta lo que necesites',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final q in _quickQuestions)
|
||||
ActionChip(label: Text(q), onPressed: () => onSelect(q)),
|
||||
ActionChip(
|
||||
label: Text(q, style: const TextStyle(fontSize: 12)),
|
||||
onPressed: () => onSelect(q),
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
side: const BorderSide(color: AppTheme.primary, width: 0.5),
|
||||
labelStyle: const TextStyle(color: AppTheme.primaryDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../core/network/api_client.dart';
|
||||
import '../notifications/notification_service.dart';
|
||||
import '../../shared/widgets/prevention_banner.dart';
|
||||
import '../../shared/widgets/progress_steps.dart';
|
||||
import '../separation_guide/ai_pet_chat_screen.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modelo de resultado ETA
|
||||
@@ -75,11 +77,19 @@ class _EtaResult {
|
||||
// Provider de ETA
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
Future<_EtaResult> build() => _fetch();
|
||||
Future<_EtaResult> build() {
|
||||
// Consulta silenciosa cada 10 segundos para ver el avance en tiempo real
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(_fetch);
|
||||
}
|
||||
|
||||
@@ -127,6 +137,20 @@ final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
|
||||
class ActiveRouteIdNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void set(String? value) {
|
||||
debugPrint('📡 [FCM] Evaluando suscripción a la ruta: $value');
|
||||
if (state != value) {
|
||||
final oldRoute = state;
|
||||
state = value;
|
||||
if (oldRoute != null) NotificationService.unsubscribeFromRoute(oldRoute);
|
||||
if (value != null) {
|
||||
NotificationService.subscribeToRoute(
|
||||
value,
|
||||
).catchError((e) => debugPrint('❌ Error FCM: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||||
@@ -149,7 +173,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
|
||||
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)F
|
||||
NotificationService.onFcmMessage.addListener(_onPush);
|
||||
}
|
||||
|
||||
@@ -242,11 +266,15 @@ class _EtaContent extends StatelessWidget {
|
||||
const PreventionBanner(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 5. Badge de suscripción FCM ─────────────────────────────────
|
||||
// ── 5. Banner del Chat IA (Eco) ─────────────────────────────────
|
||||
const _EcoChatBanner(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── 6. Badge de suscripción FCM ─────────────────────────────────
|
||||
const _FcmStatusBadge(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── 6. Horario semanal ──────────────────────────────────────────
|
||||
// ── 7. Horario semanal ──────────────────────────────────────────
|
||||
AppSectionTitle(title: 'Horario del camión'),
|
||||
_HorarioCard(),
|
||||
const SizedBox(height: 24),
|
||||
@@ -256,6 +284,71 @@ class _EtaContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Banner de Eco (Chat IA)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
class _EcoChatBanner extends StatelessWidget {
|
||||
const _EcoChatBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryDark,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'¿Dudas sobre reciclaje?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Pregúntale a Eco, tu asistente inteligente',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mapa de ubicación del domicilio (no interactivo)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -322,13 +415,13 @@ class _EtaHeroCard extends StatelessWidget {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
if (result.isCompleted) return cs.surfaceContainerHighest;
|
||||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||||
return const Color(0xFFE1F5EE); // teal-50
|
||||
return const Color(0xFFE8D5DB); // rosa claro institucional
|
||||
}
|
||||
|
||||
Color _accentColor(BuildContext context) {
|
||||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||||
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
|
||||
return const Color(0xFF1D9E75); // teal-400
|
||||
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
|
||||
return const Color(0xFF9B1B4A); // vino principal
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -606,7 +699,7 @@ class _FcmStatusBadge extends ConsumerWidget {
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1D9E75),
|
||||
color: Color(0xFF1E7A46),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,13 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_widgets.dart';
|
||||
import '../../shared/widgets/eco_floating_button.dart';
|
||||
import '../notifications/notification_service.dart';
|
||||
import '../alerts/alerts_provider.dart';
|
||||
|
||||
class CitizenShell extends StatelessWidget {
|
||||
class CitizenShell extends ConsumerStatefulWidget {
|
||||
const CitizenShell({super.key, required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<CitizenShell> createState() => _CitizenShellState();
|
||||
}
|
||||
|
||||
class _CitizenShellState extends ConsumerState<CitizenShell> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NotificationService.onFcmMessage.addListener(_onGlobalPush);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onGlobalPush() {
|
||||
final msg = NotificationService.onFcmMessage.lastMessage;
|
||||
if (msg?.notification != null) {
|
||||
final title = msg!.notification!.title ?? 'Nueva Alerta';
|
||||
final body = msg.notification!.body ?? '';
|
||||
|
||||
// Guardamos la alerta en el historial
|
||||
ref.read(alertsProvider.notifier).addAlert(title, body);
|
||||
|
||||
// Mostramos un globo de notificación amigable dentro de la app
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.primaryDark,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.notifications_active,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'VER',
|
||||
textColor: AppTheme.primaryLight,
|
||||
onPressed: () => context.go('/alerts'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _currentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
if (location.startsWith('/alerts')) return 1;
|
||||
@@ -32,7 +114,7 @@ class CitizenShell extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
body: widget.child,
|
||||
floatingActionButton: const EcoFloatingButton(),
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
currentIndex: _currentIndex(context),
|
||||
|
||||
@@ -75,95 +75,170 @@ class _MyHouseScreenState extends State<MyHouseScreen> {
|
||||
if (_casa == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(title: const Text('Mi casa')),
|
||||
body: const Center(child: Text('No tienes un domicilio registrado.')),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildPageHeader(context, showEdit: false),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No tienes un domicilio registrado.',
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildAddressButton(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Mi casa'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _mostrarEditarDireccion(context),
|
||||
tooltip: 'Editar dirección',
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildPageHeader(context, showEdit: true)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const AppSectionTitle(title: 'Domicilio registrado'),
|
||||
_CasaCard(casa: _casa!),
|
||||
const SizedBox(height: 20),
|
||||
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
||||
_MapaColoniaRestringido(
|
||||
colonia: _casa!.colonia,
|
||||
lat: _casa!.lat,
|
||||
lng: _casa!.lng,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const AppSectionTitle(title: 'Radio de alerta'),
|
||||
_RadioAlertaCard(
|
||||
radioActual: _casa!.radioAlertaMetros,
|
||||
onChanged: (v) => setState(
|
||||
() => _casa = _casa!.copyWith(radioAlertaMetros: v),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const AppSectionTitle(title: 'Notificaciones'),
|
||||
_NotificacionesCard(
|
||||
casa: _casa!,
|
||||
onAlertaCercanaChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
|
||||
onAlertaMediaChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
|
||||
onRecordatorioChanged: (v) => setState(
|
||||
() => _casa = _casa!.copyWith(recordatorioDiario: v),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const AppSectionTitle(title: 'Horario del camión'),
|
||||
_HorarioCard(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildAddressButton(context)),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context, {required bool showEdit}) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_CasaCard(casa: _casa!),
|
||||
const SizedBox(height: 16),
|
||||
const AppSectionTitle(title: 'Mapa del Sector (Restringido)'),
|
||||
_MapaColoniaRestringido(
|
||||
colonia: _casa!.colonia,
|
||||
lat: _casa!.lat,
|
||||
lng: _casa!.lng,
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.home_outlined,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const AppSectionTitle(title: 'Radio de alerta'),
|
||||
_RadioAlertaCard(
|
||||
radioActual: _casa!.radioAlertaMetros,
|
||||
onChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(radioAlertaMetros: v)),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mi Casa',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Domicilio registrado',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const AppSectionTitle(title: 'Notificaciones'),
|
||||
_NotificacionesCard(
|
||||
casa: _casa!,
|
||||
onAlertaCercanaChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(alertaCercana: v)),
|
||||
onAlertaMediaChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(alertaMedia: v)),
|
||||
onRecordatorioChanged: (v) =>
|
||||
setState(() => _casa = _casa!.copyWith(recordatorioDiario: v)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const AppSectionTitle(title: 'Horario del camión'),
|
||||
_HorarioCard(),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (showEdit)
|
||||
GestureDetector(
|
||||
onTap: () => _mostrarEditarDireccion(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.edit_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddressButton(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 96),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final added = await context.push<bool>('/add-address');
|
||||
if (added == true && mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
_cargarDomicilio();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_home_outlined,
|
||||
color: AppTheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Agregar otra dirección',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add_home_outlined, size: 20),
|
||||
label: const Text('Agregar otra dirección'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -191,64 +266,82 @@ class _CasaCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.primaryMid, width: 0.8),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg - 0.5),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.home_outlined,
|
||||
color: AppTheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(width: 3, color: AppTheme.primary),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
casa.alias,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.home_outlined,
|
||||
color: AppTheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
casa.alias,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AppStatusBadge.green(
|
||||
casa.activa ? 'Activa' : 'Inactiva',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AppStatusBadge.green(casa.activa ? 'Activa' : 'Inactiva'),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
const Divider(color: AppTheme.borderLight),
|
||||
const SizedBox(height: 10),
|
||||
_DetailRow(
|
||||
icon: Icons.location_on_outlined,
|
||||
text: casa.direccionCompleta,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DetailRow(
|
||||
icon: Icons.radar_outlined,
|
||||
text:
|
||||
'Alerta a ${casa.radioAlertaMetros} m de distancia',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Divider(color: AppTheme.borderLight),
|
||||
const SizedBox(height: 10),
|
||||
_DetailRow(
|
||||
icon: Icons.location_on_outlined,
|
||||
text: casa.direccionCompleta,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DetailRow(
|
||||
icon: Icons.radar_outlined,
|
||||
text: 'Alerta a ${casa.radioAlertaMetros} m de distancia',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -263,14 +356,16 @@ class _MapaColoniaRestringido extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final center = kColoniaCenter(colonia);
|
||||
final center =
|
||||
kColoniasCoordinates[colonia] ?? const LatLng(20.5222, -100.8123);
|
||||
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.border, width: 1),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: FlutterMap(
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_widgets.dart';
|
||||
import '../notifications/notification_service.dart';
|
||||
import '../alerts/alerts_provider.dart';
|
||||
import 'citizen_home_screen.dart';
|
||||
import '../alerts/alerts_screen.dart';
|
||||
import 'house_screen.dart';
|
||||
import '../profile/profile_screen.dart';
|
||||
|
||||
class MainShell extends StatefulWidget {
|
||||
class MainShell extends ConsumerStatefulWidget {
|
||||
const MainShell({super.key});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
ConsumerState<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
static const List<Widget> _screens = [
|
||||
@@ -22,6 +27,77 @@ class _MainShellState extends State<MainShell> {
|
||||
ProfileScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
NotificationService.onFcmMessage.addListener(_onGlobalPush);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NotificationService.onFcmMessage.removeListener(_onGlobalPush);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onGlobalPush() {
|
||||
final msg = NotificationService.onFcmMessage.lastMessage;
|
||||
if (msg?.notification != null) {
|
||||
final title = msg!.notification!.title ?? 'Nueva Alerta';
|
||||
final body = msg.notification!.body ?? '';
|
||||
|
||||
ref.read(alertsProvider.notifier).addAlert(title, body);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: AppTheme.primaryDark,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.notifications_active,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'VER',
|
||||
textColor: AppTheme.primaryLight,
|
||||
onPressed: () => setState(() => _currentIndex = 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -16,13 +16,38 @@ class IncidentService {
|
||||
final Dio _dio;
|
||||
|
||||
Future<List<UnitOption>> listUnits() async {
|
||||
final res = await _dio.get<List<dynamic>>('/incidents/units');
|
||||
final res = await _dio.get<List<dynamic>>(
|
||||
'/incidents/units',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 6),
|
||||
sendTimeout: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
return (res.data ?? [])
|
||||
.whereType<Map>()
|
||||
.map((e) => UnitOption.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Devuelve la unidad asignada al domicilio (vía su ruta).
|
||||
/// `null` si el backend responde 404 (sin ruta o sin unidad).
|
||||
Future<UnitOption?> getAddressUnit(String addressId) async {
|
||||
try {
|
||||
final res = await _dio.get<Map<String, dynamic>>(
|
||||
'/addresses/$addressId/unit',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 6),
|
||||
sendTimeout: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
if (res.data == null) return null;
|
||||
return UnitOption.fromJson(res.data!);
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<IncidentReport> createIncident({
|
||||
required String category,
|
||||
required String description,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../eta/eta_provider.dart';
|
||||
import '../data/incident_service.dart';
|
||||
import '../models/incident.dart';
|
||||
|
||||
@@ -10,3 +11,13 @@ final unitsProvider = FutureProvider<List<UnitOption>>((ref) async {
|
||||
final myIncidentsProvider = FutureProvider<List<IncidentReport>>((ref) async {
|
||||
return ref.read(incidentServiceProvider).myIncidents();
|
||||
});
|
||||
|
||||
/// Unidad asignada al domicilio activo del ciudadano.
|
||||
/// Se deriva en backend a partir de `addresses.route_id → routes.truck_id`.
|
||||
/// Devuelve `null` si el ciudadano aún no tiene una dirección activa
|
||||
/// o si su ruta no tiene unidad asignada.
|
||||
final assignedUnitProvider = FutureProvider<UnitOption?>((ref) async {
|
||||
final addressId = ref.watch(activeAddressIdProvider);
|
||||
if (addressId == null) return null;
|
||||
return ref.read(incidentServiceProvider).getAddressUnit(addressId);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descCtrl = TextEditingController();
|
||||
|
||||
int? _unitId;
|
||||
String _category = 'no_recoleccion';
|
||||
File? _photo;
|
||||
bool _submitting = false;
|
||||
@@ -49,12 +48,13 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final assignedUnit = ref.read(assignedUnitProvider).value;
|
||||
await ref
|
||||
.read(incidentServiceProvider)
|
||||
.createIncident(
|
||||
category: _category,
|
||||
description: _descCtrl.text.trim(),
|
||||
unitId: _unitId,
|
||||
unitId: assignedUnit?.id,
|
||||
photo: _photo,
|
||||
);
|
||||
ref.invalidate(myIncidentsProvider);
|
||||
@@ -76,169 +76,357 @@ class _ReportIssueScreenState extends ConsumerState<ReportIssueScreen> {
|
||||
String _friendly(Object e) {
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['detail'] != null)
|
||||
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);
|
||||
final assignedUnitAsync = ref.watch(assignedUnitProvider);
|
||||
|
||||
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(
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const AppSectionTitle(title: 'Detalles del reporte'),
|
||||
AppFormCard(
|
||||
icon: Icons.local_shipping_outlined,
|
||||
title: 'Unidad asignada a tu zona',
|
||||
child: _AssignedUnitBadge(
|
||||
assignedUnitAsync: assignedUnitAsync,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppFormCard(
|
||||
icon: Icons.category_outlined,
|
||||
title: 'Categoría del problema',
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _category,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
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),
|
||||
AppFormCard(
|
||||
icon: Icons.description_outlined,
|
||||
title: 'Descripción',
|
||||
child: AppFormField(
|
||||
label: 'Cuéntanos qué pasó',
|
||||
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),
|
||||
AppFormCard(
|
||||
icon: Icons.photo_camera_outlined,
|
||||
title: 'Evidencia fotográfica',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickPhoto,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primary,
|
||||
side: const BorderSide(
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
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.radiusMd),
|
||||
child: Image.file(
|
||||
_photo!,
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _buildSubmitButton(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
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,
|
||||
'Reportar un problema',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
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
|
||||
SizedBox(height: 2),
|
||||
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'),
|
||||
),
|
||||
'Ayúdanos a mejorar el servicio',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bug_report_outlined,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Enviar reporte'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssignedUnitBadge extends StatelessWidget {
|
||||
const _AssignedUnitBadge({required this.assignedUnitAsync});
|
||||
|
||||
final AsyncValue<UnitOption?> assignedUnitAsync;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return assignedUnitAsync.when(
|
||||
loading: () => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.background,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.border),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Detectando unidad asignada…',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (_, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.dangerLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: AppTheme.danger),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No se pudo obtener la unidad asignada. El reporte se enviará sin unidad.',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (unit) {
|
||||
if (unit == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.amberLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppTheme.amber.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: AppTheme.amber),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tu zona aún no tiene una unidad asignada.',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.amber),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
border: Border.all(color: AppTheme.primaryMid),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_shipping_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
unit.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
//
|
||||
// Regla de privacidad: los payloads de push NUNCA contienen lat/lng.
|
||||
// El backend solo manda title/body desde notificaciones.json.
|
||||
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/router/app_router.dart';
|
||||
|
||||
// Canal Android de alta prioridad para alertas de proximidad
|
||||
const _kChannelId = 'recolecta_alerts';
|
||||
const _kChannelName = 'Alertas de recolección';
|
||||
const _kChannelDesc = 'Notificaciones de llegada del camión recolector';
|
||||
|
||||
|
||||
/// Notifier simple: la EtaScreen lo escucha para refrescar sin polling.
|
||||
class _FcmMessageNotifier extends ChangeNotifier {
|
||||
RemoteMessage? lastMessage;
|
||||
@@ -21,26 +25,26 @@ class _FcmMessageNotifier extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handler de background/terminated (top-level, fuera de clase)
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
// Solo loguear; la EtaScreen se refrescará cuando la app vuelva a foreground.
|
||||
debugPrint('[FCM background] ${message.notification?.title}');
|
||||
}
|
||||
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
|
||||
|
||||
static final _messaging = FirebaseMessaging.instance;
|
||||
static final _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
static final onFcmMessage = _FcmMessageNotifier();
|
||||
|
||||
|
||||
/// Inicializar una sola vez en main.dart
|
||||
static Future<void> initialize() async {
|
||||
// Registrar handler de background
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
|
||||
// Solicitar permisos (iOS + Android 13+)
|
||||
final settings = await _messaging.requestPermission(
|
||||
alert: true,
|
||||
@@ -48,7 +52,7 @@ class NotificationService {
|
||||
sound: true,
|
||||
);
|
||||
debugPrint('[FCM] Permission: ${settings.authorizationStatus}');
|
||||
|
||||
|
||||
// Canal Android
|
||||
const androidChannel = AndroidNotificationChannel(
|
||||
_kChannelId,
|
||||
@@ -58,34 +62,61 @@ class NotificationService {
|
||||
);
|
||||
await _localNotifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
|
||||
|
||||
// Inicializar flutter_local_notifications
|
||||
const initSettings = InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(),
|
||||
);
|
||||
await _localNotifications.initialize(initSettings);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
// Tap del banner mostrado en foreground por flutter_local_notifications
|
||||
_openNotificationsScreen();
|
||||
},
|
||||
);
|
||||
|
||||
// Foreground: mostrar notificación local + notificar EtaScreen
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
_showLocalNotification(message);
|
||||
onFcmMessage.notify(message);
|
||||
});
|
||||
|
||||
|
||||
// Tap en notificación cuando la app estaba en background
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||
onFcmMessage.notify(message);
|
||||
_openNotificationsScreen();
|
||||
});
|
||||
|
||||
|
||||
// Verificar si la app abrió desde una notificación (terminated)
|
||||
final initial = await _messaging.getInitialMessage();
|
||||
if (initial != null) {
|
||||
onFcmMessage.notify(initial);
|
||||
// Esperar a que el router termine de montar el árbol antes de navegar.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openNotificationsScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Navega a `/notifications` usando el Navigator raíz. Tolerante a que
|
||||
/// la app aún no esté montada (no hace nada en ese caso).
|
||||
static void _openNotificationsScreen() {
|
||||
final ctx = rootNavigatorKey.currentContext;
|
||||
if (ctx == null) {
|
||||
debugPrint('[FCM] tap recibido pero el navigator aún no está listo');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
GoRouter.of(ctx).push('/notifications');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] no se pudo navegar a /notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Suscribir al topic de la ruta del ciudadano.
|
||||
/// Llamar justo después de que verified = true en el domicilio.
|
||||
static Future<void> subscribeToRoute(String routeId) async {
|
||||
@@ -93,18 +124,18 @@ class NotificationService {
|
||||
await _messaging.subscribeToTopic(topic);
|
||||
debugPrint('[FCM] Suscrito a $topic');
|
||||
}
|
||||
|
||||
|
||||
/// Desuscribir (al cambiar de domicilio / colonia)
|
||||
static Future<void> unsubscribeFromRoute(String routeId) async {
|
||||
final topic = 'topic_$routeId';
|
||||
await _messaging.unsubscribeFromTopic(topic);
|
||||
debugPrint('[FCM] Desuscrito de $topic');
|
||||
}
|
||||
|
||||
|
||||
static Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||
final notification = message.notification;
|
||||
if (notification == null) return;
|
||||
|
||||
|
||||
// El payload del backend es solo title+body; NUNCA contiene coordenadas.
|
||||
await _localNotifications.show(
|
||||
notification.hashCode,
|
||||
@@ -127,4 +158,4 @@ class NotificationService {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,14 @@ class NotificationsNotifier extends Notifier<List<NotificationItem>> {
|
||||
);
|
||||
state = [item, ...state];
|
||||
}
|
||||
|
||||
|
||||
void addSimulationEvent(String title, String body, FcmEventType type) {
|
||||
state = [
|
||||
NotificationItem(title: title, body: body, type: type, receivedAt: DateTime.now()),
|
||||
...state,
|
||||
];
|
||||
}
|
||||
|
||||
void clearAll() => state = [];
|
||||
}
|
||||
|
||||
@@ -148,7 +155,7 @@ class _FcmTopicBadge extends StatelessWidget {
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1D9E75),
|
||||
color: Color(0xFF1E7A46),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
@@ -188,7 +195,7 @@ class _PrivacyNote extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAEEDA), // amber-50
|
||||
color: const Color(0xFFF5EDD8), // beige dorado claro
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFFAC775)),
|
||||
),
|
||||
@@ -196,12 +203,12 @@ class _PrivacyNote extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 18, color: Color(0xFFBA7517)),
|
||||
size: 18, color: Color(0xFFC8A36A)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Los mensajes no revelan la ubicación del camión. Solo se muestra el tiempo estimado de llegada.',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF633806)),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF7A5410)),
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
@@ -254,13 +261,13 @@ class _NotificationCard extends StatelessWidget {
|
||||
Color _accentColor() {
|
||||
switch (item.type) {
|
||||
case FcmEventType.routeStart:
|
||||
return const Color(0xFF1D9E75);
|
||||
return const Color(0xFF9B1B4A);
|
||||
case FcmEventType.truckProximity:
|
||||
return const Color(0xFFBA7517);
|
||||
return const Color(0xFFC8A36A);
|
||||
case FcmEventType.routeCompleted:
|
||||
return Colors.grey;
|
||||
return const Color(0xFF1E7A46);
|
||||
case FcmEventType.reassignment:
|
||||
return const Color(0xFF378ADD);
|
||||
return const Color(0xFF004A7C);
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
@@ -279,62 +286,75 @@ class _NotificationCard extends StatelessWidget {
|
||||
final accent = _accentColor();
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border(
|
||||
left: BorderSide(color: accent, width: 3),
|
||||
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||
right: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||
bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 0.5),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(_icon, size: 16, color: accent),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9.5),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 3, color: accent),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(_icon, size: 16, color: accent),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.body,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_relativeTime(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.body,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_relativeTime(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
||||
_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'), '');
|
||||
@@ -132,7 +131,6 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(title: const Text('Editar perfil')),
|
||||
body: userAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
@@ -154,114 +152,225 @@ class _EditProfileScreenState extends ConsumerState<EditProfileScreen> {
|
||||
|
||||
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,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildPageHeader(context)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const AppSectionTitle(title: 'Datos personales'),
|
||||
AppFormCard(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Información personal',
|
||||
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'),
|
||||
AppFormCard(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'Seguridad',
|
||||
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: 32),
|
||||
]),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: _buildSaveButton(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
MediaQuery.of(context).padding.top + 12,
|
||||
20,
|
||||
24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Editar perfil',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Actualiza tu información personal',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _saving ? null : _save,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: _saving
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.save_outlined, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Guardar cambios'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -369,7 +478,7 @@ class _PhoneField extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return null; // opcional
|
||||
if (v == null || v.isEmpty) return null;
|
||||
final digits = v.replaceAll('-', '');
|
||||
if (digits.length != 10) {
|
||||
return 'Ingresa exactamente 10 dígitos';
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 '../separation_guide/ai_pet_chat_screen.dart';
|
||||
import 'models/profile_user.dart';
|
||||
import 'providers/profile_providers.dart';
|
||||
|
||||
@@ -20,91 +19,95 @@ class ProfileScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
appBar: AppBar(title: const Text('Mi perfil')),
|
||||
body: RefreshIndicator(
|
||||
color: AppTheme.primary,
|
||||
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,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _ProfileHeroHeader(
|
||||
user: userAsync.asData?.value,
|
||||
fallbackRole: authRole,
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const AppSectionTitle(title: '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: 'Herramientas'),
|
||||
AppMenuTile(
|
||||
icon: Icons.feedback_outlined,
|
||||
title: 'Buzón de retroalimentación',
|
||||
subtitle: 'Califica el servicio de recolección',
|
||||
onTap: () => context.push('/feedback'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const AppSectionTitle(title: 'Soporte'),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -157,11 +160,11 @@ class ProfileScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encabezado ────────────────────────────────────────────────────────────────
|
||||
class _ProfileHeader extends StatelessWidget {
|
||||
// ── Hero header con gradiente ─────────────────────────────────────────────────
|
||||
class _ProfileHeroHeader extends StatelessWidget {
|
||||
final ProfileUser? user;
|
||||
final String fallbackRole;
|
||||
const _ProfileHeader({required this.user, required this.fallbackRole});
|
||||
const _ProfileHeroHeader({required this.user, required this.fallbackRole});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -171,66 +174,89 @@ class _ProfileHeader extends StatelessWidget {
|
||||
final initials = user?.initials ?? 'U';
|
||||
final displayName = user?.displayName ?? 'Usuario';
|
||||
final email = user?.email ?? '…';
|
||||
final roleLabel = isAdmin
|
||||
? 'Administrador'
|
||||
: isDriver
|
||||
? 'Chofer'
|
||||
: 'Ciudadano';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
boxShadow: AppTheme.softShadow,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
MediaQuery.of(context).padding.top + 20,
|
||||
24,
|
||||
32,
|
||||
),
|
||||
child: Row(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF9B1B4A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(28),
|
||||
bottomRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar con iniciales
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppTheme.primaryMid, width: 1.5),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
email,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
AppStatusBadge.green(
|
||||
isAdmin
|
||||
? 'Administrador'
|
||||
: isDriver
|
||||
? 'Chofer'
|
||||
: 'Ciudadano',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
email,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
roleLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -238,4 +264,3 @@ class _ProfileHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../auth/widgets/video_mascot.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@@ -38,9 +40,10 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
duration: const Duration(seconds: 6),
|
||||
)..repeat();
|
||||
|
||||
_logoScale = Tween<double>(begin: 0.2, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _logoCtrl, curve: Curves.elasticOut),
|
||||
);
|
||||
_logoScale = Tween<double>(
|
||||
begin: 0.5,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _logoCtrl, curve: Curves.easeOutBack));
|
||||
_logoOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _logoCtrl,
|
||||
@@ -95,11 +98,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
colors: [
|
||||
Color(0xFF0A4A38),
|
||||
Color(0xFF0F6E56),
|
||||
Color(0xFF1D9E75),
|
||||
],
|
||||
colors: [Color(0xFF4A0E26), Color(0xFF6D1234), Color(0xFF9B1B4A)],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
@@ -114,89 +113,83 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
|
||||
// Logo central
|
||||
ScaleTransition(
|
||||
scale: _logoScale,
|
||||
child: FadeTransition(
|
||||
opacity: _logoOpacity,
|
||||
child: Container(
|
||||
width: 118,
|
||||
height: 118,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.35),
|
||||
width: 2,
|
||||
// Logo central
|
||||
ScaleTransition(
|
||||
scale: _logoScale,
|
||||
child: FadeTransition(
|
||||
opacity: _logoOpacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.recycling_rounded,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
child: const VideoMascot(size: 130, zoom: 6.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 36),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// Nombre de la app
|
||||
SlideTransition(
|
||||
position: _textSlide,
|
||||
child: FadeTransition(
|
||||
opacity: _textOpacity,
|
||||
child: const Text(
|
||||
'RecolectApp',
|
||||
// Nombre de la app
|
||||
SlideTransition(
|
||||
position: _textSlide,
|
||||
child: FadeTransition(
|
||||
opacity: _textOpacity,
|
||||
child: const Text(
|
||||
'RecolectApp',
|
||||
style: TextStyle(
|
||||
fontSize: 38,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -1.0,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Subtítulo
|
||||
FadeTransition(
|
||||
opacity: _subtitleOpacity,
|
||||
child: Text(
|
||||
'Sistema de Recolección Inteligente',
|
||||
style: TextStyle(
|
||||
fontSize: 38,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -1.0,
|
||||
height: 1.1,
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
const Spacer(flex: 3),
|
||||
|
||||
// Subtítulo
|
||||
FadeTransition(
|
||||
opacity: _subtitleOpacity,
|
||||
child: Text(
|
||||
'Sistema de Recolección Inteligente',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: FontWeight.w400,
|
||||
// Indicador de carga
|
||||
FadeTransition(
|
||||
opacity: _subtitleOpacity,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(bottom: 52),
|
||||
child: _DotsLoader(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
|
||||
// Indicador de carga
|
||||
FadeTransition(
|
||||
opacity: _subtitleOpacity,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(bottom: 52),
|
||||
child: _DotsLoader(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user