Actualizacion del programa
This commit is contained in:
1070
celaya_limpia/lib/screens/admin/admin_dashboard_screen.dart
Normal file
1070
celaya_limpia/lib/screens/admin/admin_dashboard_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
262
celaya_limpia/lib/screens/admin/admin_stats_screen.dart
Normal file
262
celaya_limpia/lib/screens/admin/admin_stats_screen.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class AdminStatsScreen extends StatefulWidget {
|
||||
const AdminStatsScreen({super.key});
|
||||
@override State<AdminStatsScreen> createState() => _AdminStatsScreenState();
|
||||
}
|
||||
|
||||
class _AdminStatsScreenState extends State<AdminStatsScreen> {
|
||||
Map<String, dynamic> _stats = {};
|
||||
List<Map<String, dynamic>> _byColonia = [];
|
||||
List<Map<String, dynamic>> _byRoute = [];
|
||||
List<Map<String, dynamic>> _byWeek = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final s = await DbHelper.getAdminStats();
|
||||
final bc = await DbHelper.getReportesByColonia();
|
||||
final br = await DbHelper.getIncidentesByRoute();
|
||||
final bw = await DbHelper.getRatingByWeek();
|
||||
if (mounted) setState(() {
|
||||
_stats = s; _byColonia = bc; _byRoute = br; _byWeek = bw; _loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Dashboard de Estadisticas'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
|
||||
// KPIs
|
||||
Row(children: [
|
||||
_KpiCard('Reportes', '${_stats['total_reportes']}',
|
||||
Icons.report, AppColors.naranjaAlerta),
|
||||
const SizedBox(width: 8),
|
||||
_KpiCard('Calificacion Prom.',
|
||||
(_stats['avg_rating'] as double? ?? 0).toStringAsFixed(1),
|
||||
Icons.star, Colors.amber),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
_KpiCard('Alertas Activas', '${_stats['alertas_activas']}',
|
||||
Icons.warning, AppColors.rojoError),
|
||||
const SizedBox(width: 8),
|
||||
_KpiCard('Conductores', '${_stats['total_conductores']}',
|
||||
Icons.person, AppColors.moradoConductor),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Calificacion por semana (línea)
|
||||
if (_byWeek.isNotEmpty) ...[
|
||||
_SectionTitle('Calificacion promedio semanal'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 180,
|
||||
child: LineChart(LineChartData(
|
||||
minY: 1, maxY: 5,
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, interval: 1,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 10)))),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (v, _) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _byWeek.length) return const SizedBox();
|
||||
return Text('S${_byWeek.length - idx}',
|
||||
style: const TextStyle(fontSize: 9));
|
||||
})),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
gridData: FlGridData(drawHorizontalLine: true, horizontalInterval: 1),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
lineBarsData: [LineChartBarData(
|
||||
spots: _byWeek.reversed.toList().asMap().entries.map((e) =>
|
||||
FlSpot(e.key.toDouble(),
|
||||
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
|
||||
isCurved: true,
|
||||
color: AppColors.verdeAdmin,
|
||||
barWidth: 3,
|
||||
belowBarData: BarAreaData(show: true,
|
||||
color: AppColors.verdeAdmin.withOpacity(0.1)),
|
||||
dotData: const FlDotData(show: true),
|
||||
)],
|
||||
))))),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Reportes por colonia (barras horizontales)
|
||||
if (_byColonia.isNotEmpty) ...[
|
||||
_SectionTitle('Reportes por colonia (Top 10)'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 240,
|
||||
child: BarChart(BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: (_byColonia.map((c) => (c['total'] as int? ?? 0).toDouble())
|
||||
.reduce((a,b)=>a>b?a:b) * 1.2),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 32,
|
||||
getTitlesWidget: (v, _) {
|
||||
final i = v.toInt();
|
||||
if (i < 0 || i >= _byColonia.length) return const SizedBox();
|
||||
final name = (_byColonia[i]['colonia'] as String? ?? '');
|
||||
return Transform.rotate(angle: -0.5,
|
||||
child: Text(name.length > 8 ? '${name.substring(0,8)}.' : name,
|
||||
style: const TextStyle(fontSize: 8)));
|
||||
})),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 9)))),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
barGroups: _byColonia.asMap().entries.map((e) => BarChartGroupData(
|
||||
x: e.key,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
||||
color: AppColors.guindaPrimary,
|
||||
width: 16, borderRadius: BorderRadius.circular(4)),
|
||||
BarChartRodData(
|
||||
toY: (e.value['resueltos'] as int? ?? 0).toDouble(),
|
||||
color: AppColors.verdeExito,
|
||||
width: 16, borderRadius: BorderRadius.circular(4)),
|
||||
],
|
||||
)).toList(),
|
||||
gridData: const FlGridData(drawHorizontalLine: true),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
))))),
|
||||
Padding(padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(children: [
|
||||
_Legend(AppColors.guindaPrimary, 'Total reportes'),
|
||||
const SizedBox(width: 16),
|
||||
_Legend(AppColors.verdeExito, 'Resueltos'),
|
||||
])),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Rutas con más incidentes
|
||||
if (_byRoute.isNotEmpty) ...[
|
||||
_SectionTitle('Rutas con mas incidentes'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Padding(padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(height: 200,
|
||||
child: BarChart(BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: (_byRoute.map((r) => (r['total'] as int? ?? 0).toDouble())
|
||||
.reduce((a,b)=>a>b?a:b) * 1.3),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 28,
|
||||
getTitlesWidget: (v, _) {
|
||||
final i = v.toInt();
|
||||
if (i < 0 || i >= _byRoute.length) return const SizedBox();
|
||||
return Text((_byRoute[i]['route_id'] as String? ?? '').replaceAll('RUTA-','R'),
|
||||
style: const TextStyle(fontSize: 9));
|
||||
})),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
||||
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 9)))),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
barGroups: _byRoute.asMap().entries.map((e) => BarChartGroupData(
|
||||
x: e.key,
|
||||
barRods: [BarChartRodData(
|
||||
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.naranjaAlerta, AppColors.rojoError],
|
||||
begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
||||
width: 20, borderRadius: BorderRadius.circular(4))],
|
||||
)).toList(),
|
||||
gridData: const FlGridData(drawHorizontalLine: true),
|
||||
borderData: FlBorderData(show: true,
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
))))),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Colonias más problemáticas (lista)
|
||||
_SectionTitle('Colonias mas problematicas'),
|
||||
const SizedBox(height: 8),
|
||||
Card(child: Column(children: [
|
||||
..._byColonia.take(5).map((c) {
|
||||
final total = (c['total'] as int? ?? 0);
|
||||
final resueltos = (c['resueltos'] as int? ?? 0);
|
||||
final pct = total > 0 ? resueltos / total : 0.0;
|
||||
return ListTile(dense: true,
|
||||
leading: CircleAvatar(radius: 16,
|
||||
backgroundColor: total > 3 ? AppColors.rojoError.withOpacity(0.15)
|
||||
: AppColors.naranjaAlerta.withOpacity(0.15),
|
||||
child: Text('$total', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
|
||||
color: total > 3 ? AppColors.rojoError : AppColors.naranjaAlerta))),
|
||||
title: Text(c['colonia'] as String? ?? '',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: LinearProgressIndicator(value: pct,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.verdeExito)),
|
||||
trailing: Text('${(pct*100).toInt()}% resuelto',
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
);
|
||||
}),
|
||||
])),
|
||||
const SizedBox(height: 30),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
final String label, value; final IconData icon; final Color color;
|
||||
const _KpiCard(this.label, this.value, this.icon, this.color);
|
||||
@override
|
||||
Widget build(BuildContext context) => Expanded(child: Card(child: Padding(
|
||||
padding: const EdgeInsets.all(14), child: Row(children: [
|
||||
CircleAvatar(radius: 22, backgroundColor: color.withOpacity(0.12),
|
||||
child: Icon(icon, color: color, size: 22)),
|
||||
const SizedBox(width: 10),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]),
|
||||
]))));
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionTitle(this.title);
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
final Color color; final String label;
|
||||
const _Legend(this.color, this.label);
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
||||
]);
|
||||
}
|
||||
272
celaya_limpia/lib/screens/admin/create_route_screen.dart
Normal file
272
celaya_limpia/lib/screens/admin/create_route_screen.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../data/celaya_colonias.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
|
||||
class CreateRouteScreen extends StatefulWidget {
|
||||
final RouteDefinitionModel? editing;
|
||||
const CreateRouteScreen({super.key, this.editing});
|
||||
@override State<CreateRouteScreen> createState() => _CreateRouteScreenState();
|
||||
}
|
||||
|
||||
class _CreateRouteScreenState extends State<CreateRouteScreen> {
|
||||
final _nombreCtrl = TextEditingController();
|
||||
final _routeIdCtrl = TextEditingController();
|
||||
String _turno = 'MATUTINO';
|
||||
String _horaInicio = '06:00';
|
||||
String _horaFin = '08:00';
|
||||
List<String> _diasSeleccionados = [];
|
||||
List<String> _coloniasSeleccionadas = [];
|
||||
String _searchColonia = '';
|
||||
bool _loading = false;
|
||||
|
||||
static const _diasGrupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
|
||||
static const _diasGrupoB = ['MARTES', 'JUEVES', 'SABADO'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.editing != null) {
|
||||
final e = widget.editing!;
|
||||
_nombreCtrl.text = e.nombre;
|
||||
_routeIdCtrl.text = e.routeId;
|
||||
_turno = e.turno;
|
||||
_horaInicio = e.horaInicio;
|
||||
_horaFin = e.horaFin;
|
||||
_diasSeleccionados = List.from(e.dias);
|
||||
_coloniasSeleccionadas = List.from(e.colonias);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> get _filteredColonias => _searchColonia.isEmpty
|
||||
? celayaColonias
|
||||
: celayaColonias.where((c) =>
|
||||
c.toLowerCase().contains(_searchColonia.toLowerCase())).toList();
|
||||
|
||||
Future<void> _guardar() async {
|
||||
if (_nombreCtrl.text.trim().isEmpty) {
|
||||
_snack('Ingresa un nombre para la ruta', isError: true); return; }
|
||||
if (_routeIdCtrl.text.trim().isEmpty) {
|
||||
_snack('Ingresa el ID de la ruta (ej. RUTA-16)', isError: true); return; }
|
||||
if (_diasSeleccionados.isEmpty) {
|
||||
_snack('Selecciona al menos un día', isError: true); return; }
|
||||
if (_coloniasSeleccionadas.isEmpty) {
|
||||
_snack('Selecciona al menos una colonia', isError: true); return; }
|
||||
|
||||
setState(() => _loading = true);
|
||||
final route = RouteDefinitionModel(
|
||||
id: widget.editing?.id,
|
||||
routeId: _routeIdCtrl.text.trim().toUpperCase(),
|
||||
nombre: _nombreCtrl.text.trim(),
|
||||
dias: _diasSeleccionados,
|
||||
horaInicio: _horaInicio,
|
||||
horaFin: _horaFin,
|
||||
turno: _turno,
|
||||
colonias: _coloniasSeleccionadas,
|
||||
);
|
||||
await DbHelper.insertRouteDefinition(route);
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('Ruta guardada correctamente');
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _snack(String msg, {bool isError = false}) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
|
||||
|
||||
Future<TimeOfDay?> _pickTime(String current) async {
|
||||
final parts = current.split(':');
|
||||
return showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: int.parse(parts[0]), minute: int.parse(parts[1])),
|
||||
);
|
||||
}
|
||||
|
||||
String _timeLabel(TimeOfDay t) =>
|
||||
'${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: Text(widget.editing != null ? 'Editar Ruta' : 'Nueva Ruta'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
|
||||
// Info básica
|
||||
_section('Información de la ruta'),
|
||||
_field(_routeIdCtrl, 'ID de Ruta (ej. RUTA-16)', Icons.tag),
|
||||
const SizedBox(height: 12),
|
||||
_field(_nombreCtrl, 'Nombre descriptivo', Icons.route),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Turno
|
||||
_section('Turno de operación'),
|
||||
Row(children: ['MATUTINO','VESPERTINO','NOCTURNO'].map((t) =>
|
||||
Expanded(child: RadioListTile<String>(dense: true, value: t,
|
||||
groupValue: _turno,
|
||||
title: Text(_turnoLabel(t), style: const TextStyle(fontSize: 12)),
|
||||
activeColor: AppColors.verdeAdmin,
|
||||
onChanged: (v) => setState(() => _turno = v!)))
|
||||
).toList()),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Horario
|
||||
_section('Horario de servicio'),
|
||||
Row(children: [
|
||||
Expanded(child: _timeButton('Hora inicio', _horaInicio, () async {
|
||||
final t = await _pickTime(_horaInicio);
|
||||
if (t != null) setState(() => _horaInicio = _timeLabel(t));
|
||||
})),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _timeButton('Hora fin', _horaFin, () async {
|
||||
final t = await _pickTime(_horaFin);
|
||||
if (t != null) setState(() => _horaFin = _timeLabel(t));
|
||||
})),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Días
|
||||
_section('Días de operación'),
|
||||
Container(padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200)),
|
||||
child: const Text(
|
||||
'📅 Selecciona Grupo A (L/M/V) o Grupo B (M/J/S), o días individuales.',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.azulInfo)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
Expanded(child: OutlinedButton(
|
||||
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoA)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.verdeAdmin,
|
||||
side: const BorderSide(color: AppColors.verdeAdmin)),
|
||||
child: const Text('Grupo A\nL/M/V', textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11)))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: OutlinedButton(
|
||||
onPressed: () => setState(() => _diasSeleccionados = List.from(_diasGrupoB)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.moradoConductor,
|
||||
side: const BorderSide(color: AppColors.moradoConductor)),
|
||||
child: const Text('Grupo B\nM/J/S', textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11)))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: AppDias.todos.map((dia) {
|
||||
final sel = _diasSeleccionados.contains(dia);
|
||||
return FilterChip(
|
||||
label: Text(AppDias.label(dia), style: TextStyle(fontSize: 11,
|
||||
color: sel ? Colors.white : AppColors.negroTexto)),
|
||||
selected: sel,
|
||||
selectedColor: AppColors.verdeAdmin,
|
||||
checkmarkColor: Colors.white,
|
||||
onSelected: (v) => setState(() {
|
||||
if (v) _diasSeleccionados.add(dia);
|
||||
else _diasSeleccionados.remove(dia);
|
||||
}),
|
||||
);
|
||||
}).toList()),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Colonias
|
||||
_section('Colonias que cubre (${_coloniasSeleccionadas.length} seleccionadas)'),
|
||||
TextField(
|
||||
onChanged: (v) => setState(() => _searchColonia = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Buscar colonia de Celaya...',
|
||||
prefixIcon: Icon(Icons.search), border: OutlineInputBorder(),
|
||||
filled: true, fillColor: Colors.white, isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(height: 220,
|
||||
decoration: BoxDecoration(color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredColonias.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = _filteredColonias[i];
|
||||
final sel = _coloniasSeleccionadas.contains(c);
|
||||
return CheckboxListTile(dense: true,
|
||||
title: Text(c, style: const TextStyle(fontSize: 12)),
|
||||
value: sel,
|
||||
activeColor: AppColors.verdeAdmin,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
onChanged: (v) => setState(() {
|
||||
if (v == true) _coloniasSeleccionadas.add(c);
|
||||
else _coloniasSeleccionadas.remove(c);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_coloniasSeleccionadas.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 4, runSpacing: 4, children: _coloniasSeleccionadas.map((c) =>
|
||||
Chip(label: Text(c, style: const TextStyle(fontSize: 10)),
|
||||
backgroundColor: AppColors.verdeAdmin.withOpacity(0.1),
|
||||
deleteIconColor: AppColors.verdeAdmin,
|
||||
onDeleted: () => setState(() => _coloniasSeleccionadas.remove(c)))).toList()),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(width: double.infinity, height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _guardar,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading
|
||||
? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.save),
|
||||
label: const Text('GUARDAR RUTA', style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(String title) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.verdeAdmin, fontSize: 15)));
|
||||
|
||||
Widget _field(TextEditingController ctrl, String label, IconData icon) =>
|
||||
TextField(controller: ctrl,
|
||||
decoration: InputDecoration(labelText: label,
|
||||
prefixIcon: Icon(icon, color: AppColors.verdeAdmin),
|
||||
border: const OutlineInputBorder(), filled: true, fillColor: Colors.white));
|
||||
|
||||
Widget _timeButton(String label, String value, VoidCallback onTap) =>
|
||||
InkWell(onTap: onTap,
|
||||
child: Container(padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade400)),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.access_time, color: AppColors.verdeAdmin, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
]),
|
||||
])));
|
||||
|
||||
String _turnoLabel(String t) => t == 'MATUTINO' ? '🌄 Matutino'
|
||||
: t == 'VESPERTINO' ? '🌅 Vespertino' : '🌙 Nocturno';
|
||||
|
||||
@override void dispose() { _nombreCtrl.dispose(); _routeIdCtrl.dispose(); super.dispose(); }
|
||||
}
|
||||
244
celaya_limpia/lib/screens/admin/export_pdf_screen.dart
Normal file
244
celaya_limpia/lib/screens/admin/export_pdf_screen.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class ExportPdfScreen extends StatefulWidget {
|
||||
const ExportPdfScreen({super.key});
|
||||
@override State<ExportPdfScreen> createState() => _ExportPdfScreenState();
|
||||
}
|
||||
|
||||
class _ExportPdfScreenState extends State<ExportPdfScreen> {
|
||||
bool _generating = false;
|
||||
String? _lastPath;
|
||||
|
||||
pw.TableRow _pdfRow(String label, String value, {bool isHeader = false}) =>
|
||||
pw.TableRow(
|
||||
decoration: isHeader ? pw.BoxDecoration(color: PdfColors.grey100) : null,
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(label, style: pw.TextStyle(
|
||||
fontSize: 10, fontWeight: isHeader ? pw.FontWeight.bold : null))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(value, style: pw.TextStyle(
|
||||
fontSize: 10, fontWeight: pw.FontWeight.bold))),
|
||||
]);
|
||||
|
||||
Future<void> _generatePdf() async {
|
||||
setState(() => _generating = true);
|
||||
try {
|
||||
final stats = await DbHelper.getAdminStats();
|
||||
final colonias = await DbHelper.getReportesByColonia();
|
||||
final incidentes = await DbHelper.getIncidentesByRoute();
|
||||
final reviews = await DbHelper.getReviewSummaryByColonia();
|
||||
final now = DateTime.now();
|
||||
const meses = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
|
||||
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
|
||||
final pdf = pw.Document();
|
||||
pdf.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
header: (ctx) => pw.Column(children: [
|
||||
pw.Row(mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [
|
||||
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: [
|
||||
pw.Text('H. AYUNTAMIENTO DE CELAYA', style: pw.TextStyle(
|
||||
fontSize: 14, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.Text('Direccion de Servicios Publicos',
|
||||
style: const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)),
|
||||
pw.Text('Sistema de Recoleccion de Residuos',
|
||||
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600)),
|
||||
]),
|
||||
pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.end, children: [
|
||||
pw.Text('REPORTE MENSUAL', style: pw.TextStyle(
|
||||
fontSize: 12, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.Text('${meses[now.month]} ${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 11)),
|
||||
pw.Text('Generado: ${now.day}/${now.month}/${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600)),
|
||||
]),
|
||||
]),
|
||||
pw.Divider(color: PdfColor.fromHex('C9A84C'), thickness: 2),
|
||||
pw.SizedBox(height: 8),
|
||||
]),
|
||||
build: (ctx) => [
|
||||
pw.Text('RESUMEN EJECUTIVO', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
_pdfRow('Total de reportes ciudadanos', '${stats["total_reportes"]}', isHeader: true),
|
||||
_pdfRow('Total de resenas recibidas', '${stats["total_reviews"]}'),
|
||||
_pdfRow('Calificacion promedio',
|
||||
'${(stats["avg_rating"] as double? ?? 0).toStringAsFixed(2)} / 5.0'),
|
||||
_pdfRow('Alertas activas', '${stats["alertas_activas"]}'),
|
||||
_pdfRow('Conductores', '${stats["total_conductores"]}'),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
if (colonias.isNotEmpty) ...[
|
||||
pw.Text('REPORTES POR COLONIA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {
|
||||
0: const pw.FlexColumnWidth(3), 1: const pw.FlexColumnWidth(1),
|
||||
2: const pw.FlexColumnWidth(1), 3: const pw.FlexColumnWidth(1),
|
||||
},
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Colonia','Total','Resueltos','Pendientes'].map((h) =>
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...colonias.map((c) {
|
||||
final total = c['total'] as int? ?? 0;
|
||||
final res = c['resueltos'] as int? ?? 0;
|
||||
return pw.TableRow(children: [
|
||||
c['colonia'] as String? ?? '', '$total', '$res', '${total - res}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList());
|
||||
}),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
if (incidentes.isNotEmpty) ...[
|
||||
pw.Text('INCIDENTES POR RUTA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(2), 1: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Ruta','Incidentes'].map((h) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...incidentes.map((r) => pw.TableRow(children: [
|
||||
r['route_id'] as String? ?? '', '${r["total"]}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
if (reviews.isNotEmpty) ...[
|
||||
pw.Text('CALIFICACIONES POR COLONIA', style: pw.TextStyle(
|
||||
fontSize: 13, fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('6D1E3A'))),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {0: const pw.FlexColumnWidth(3),
|
||||
1: const pw.FlexColumnWidth(1), 2: const pw.FlexColumnWidth(1)},
|
||||
children: [
|
||||
pw.TableRow(decoration: pw.BoxDecoration(color: PdfColor.fromHex('6D1E3A')),
|
||||
children: ['Colonia','Promedio','Total'].map((h) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(6),
|
||||
child: pw.Text(h, style: pw.TextStyle(color: PdfColors.white,
|
||||
fontWeight: pw.FontWeight.bold, fontSize: 10)))).toList()),
|
||||
...reviews.map((r) => pw.TableRow(children: [
|
||||
r['colonia'] as String? ?? '',
|
||||
'${(r["promedio"] as num? ?? 0).toStringAsFixed(1)}/5',
|
||||
'${r["total"]}',
|
||||
].map((v) => pw.Padding(padding: const pw.EdgeInsets.all(5),
|
||||
child: pw.Text(v, style: const pw.TextStyle(fontSize: 9)))).toList())),
|
||||
]),
|
||||
pw.SizedBox(height: 20),
|
||||
],
|
||||
|
||||
pw.Divider(color: PdfColor.fromHex('C9A84C')),
|
||||
pw.Text('Celaya Limpia - H. Ayuntamiento de Celaya, Gto. - ${now.year}',
|
||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
|
||||
textAlign: pw.TextAlign.center),
|
||||
],
|
||||
));
|
||||
|
||||
// Guardar en directorio temporal y compartir con share_plus
|
||||
final bytes = await pdf.save();
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/reporte_celaya_${now.month}_${now.year}.pdf');
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
setState(() => _lastPath = file.path);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path, mimeType: 'application/pdf')],
|
||||
subject: 'Reporte Mensual Celaya Limpia - ${meses[now.month]} ${now.year}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al generar PDF: $e'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
}
|
||||
if (mounted) setState(() => _generating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Exportar Reporte PDF'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: Center(child: Padding(padding: const EdgeInsets.all(32), child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(width: 100, height: 100,
|
||||
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
|
||||
shape: BoxShape.circle),
|
||||
child: const Icon(Icons.picture_as_pdf, size: 52, color: AppColors.verdeAdmin)),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Reporte Mensual', style: TextStyle(fontSize: 22,
|
||||
fontWeight: FontWeight.bold, color: AppColors.verdeAdmin)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Genera un PDF con el resumen completo:\nreportes, incidentes y calificaciones.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(width: double.infinity, height: 52,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _generating ? null : _generatePdf,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
icon: _generating
|
||||
? const SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.download),
|
||||
label: Text(_generating ? 'Generando...' : 'Generar y Compartir PDF',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)))),
|
||||
if (_lastPath != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: AppColors.verdeExito.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.verdeExito.withOpacity(0.3))),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('PDF generado correctamente',
|
||||
style: TextStyle(color: AppColors.verdeExito, fontWeight: FontWeight.w600,
|
||||
fontSize: 13))),
|
||||
TextButton(onPressed: _generatePdf,
|
||||
child: const Text('Compartir de nuevo',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
|
||||
])),
|
||||
],
|
||||
]))));
|
||||
}
|
||||
179
celaya_limpia/lib/screens/admin/manage_conductors_screen.dart
Normal file
179
celaya_limpia/lib/screens/admin/manage_conductors_screen.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
|
||||
class ManageConductorsScreen extends StatefulWidget {
|
||||
const ManageConductorsScreen({super.key});
|
||||
@override State<ManageConductorsScreen> createState() => _ManageConductorsScreenState();
|
||||
}
|
||||
|
||||
class _ManageConductorsScreenState extends State<ManageConductorsScreen> {
|
||||
List<Map<String, dynamic>> _conductores = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final c = await DbHelper.getConductoresConMeta();
|
||||
if (mounted) setState(() { _conductores = c; _loading = false; });
|
||||
}
|
||||
|
||||
Future<void> _showFormDialog({Map<String, dynamic>? existing}) async {
|
||||
final nombreCtrl = TextEditingController(text: existing?['nombre'] ?? '');
|
||||
final emailCtrl = TextEditingController(text: existing?['email'] ?? '');
|
||||
final passCtrl = TextEditingController();
|
||||
final notasCtrl = TextEditingController(text: existing?['notas'] ?? '');
|
||||
bool activo = (existing?['activo'] as int? ?? 1) == 1;
|
||||
bool obscure = true;
|
||||
|
||||
await showDialog(context: context, builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSt) => AlertDialog(
|
||||
title: Text(existing == null ? 'Nuevo Conductor' : 'Editar Conductor'),
|
||||
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(controller: nombreCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Nombre completo',
|
||||
prefixIcon: Icon(Icons.person_outline), border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: emailCtrl, keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: 'Correo electronico',
|
||||
prefixIcon: Icon(Icons.email_outlined), border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
if (existing == null)
|
||||
TextField(controller: passCtrl, obscureText: obscure,
|
||||
decoration: InputDecoration(labelText: 'Contrasena',
|
||||
prefixIcon: const Icon(Icons.lock_outline), border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setSt(() => obscure = !obscure)))),
|
||||
if (existing == null) const SizedBox(height: 10),
|
||||
TextField(controller: notasCtrl, maxLines: 2,
|
||||
decoration: const InputDecoration(labelText: 'Notas internas (opcional)',
|
||||
border: OutlineInputBorder())),
|
||||
const SizedBox(height: 10),
|
||||
if (existing != null)
|
||||
SwitchListTile(value: activo, dense: true,
|
||||
title: Text(activo ? 'Conductor Activo' : 'Conductor Inactivo',
|
||||
style: TextStyle(color: activo ? AppColors.verdeAdmin : AppColors.rojoError,
|
||||
fontWeight: FontWeight.bold)),
|
||||
activeColor: AppColors.verdeAdmin,
|
||||
onChanged: (v) => setSt(() => activo = v)),
|
||||
])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||
foregroundColor: Colors.white),
|
||||
onPressed: () async {
|
||||
if (nombreCtrl.text.trim().isEmpty || emailCtrl.text.trim().isEmpty) return;
|
||||
if (existing == null) {
|
||||
if (passCtrl.text.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('La contrasena debe tener al menos 6 caracteres'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
await DbHelper.insertConductor(nombreCtrl.text.trim(),
|
||||
emailCtrl.text.trim().toLowerCase(), passCtrl.text);
|
||||
} else {
|
||||
await DbHelper.updateConductor(existing['id'], nombreCtrl.text.trim(),
|
||||
emailCtrl.text.trim().toLowerCase());
|
||||
await DbHelper.updateConductorMeta(existing['id'], activo, notasCtrl.text.trim());
|
||||
}
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
await _load();
|
||||
},
|
||||
child: Text(existing == null ? 'Crear' : 'Guardar')),
|
||||
])));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: Text('Conductores (${_conductores.length})'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||
IconButton(icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Nuevo conductor',
|
||||
onPressed: () => _showFormDialog()),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _conductores.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.person_off, color: AppColors.grisTexto, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Sin conductores registrados',
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.verdeAdmin,
|
||||
foregroundColor: Colors.white),
|
||||
onPressed: () => _showFormDialog(),
|
||||
icon: const Icon(Icons.add), label: const Text('Agregar primer conductor')),
|
||||
]))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _conductores.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = _conductores[i];
|
||||
final activo = (c['activo'] as int? ?? 1) == 1;
|
||||
final incidentes = c['total_incidentes'] as int? ?? 0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: activo
|
||||
? AppColors.verdeAdmin.withOpacity(0.3)
|
||||
: AppColors.rojoError.withOpacity(0.3))),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
CircleAvatar(radius: 22,
|
||||
backgroundColor: activo
|
||||
? AppColors.verdeAdmin.withOpacity(0.15)
|
||||
: Colors.grey.shade200,
|
||||
child: Icon(Icons.person,
|
||||
color: activo ? AppColors.verdeAdmin : AppColors.grisTexto, size: 24)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(c['nombre'] ?? '', style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
Text(c['email'] ?? '', style: const TextStyle(
|
||||
color: AppColors.grisTexto, fontSize: 12)),
|
||||
])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: activo ? AppColors.verdeAdmin.withOpacity(0.1)
|
||||
: AppColors.rojoError.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(activo ? 'Activo' : 'Inactivo',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold,
|
||||
color: activo ? AppColors.verdeAdmin : AppColors.rojoError))),
|
||||
IconButton(icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => _showFormDialog(existing: c)),
|
||||
]),
|
||||
if (incidentes > 0 || (c['notas'] as String?)?.isNotEmpty == true) ...[
|
||||
const Divider(height: 16),
|
||||
if (incidentes > 0)
|
||||
Row(children: [
|
||||
Icon(Icons.warning_amber, size: 14,
|
||||
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta),
|
||||
const SizedBox(width: 4),
|
||||
Text('$incidentes incidente${incidentes != 1 ? 's' : ''} historico${incidentes != 1 ? 's' : ''}',
|
||||
style: TextStyle(fontSize: 12,
|
||||
color: incidentes > 3 ? AppColors.rojoError : AppColors.naranjaAlerta)),
|
||||
]),
|
||||
if ((c['notas'] as String?)?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('Notas: ${c['notas']}',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.grisTexto,
|
||||
fontStyle: FontStyle.italic)),
|
||||
],
|
||||
],
|
||||
])));
|
||||
}),
|
||||
);
|
||||
}
|
||||
212
celaya_limpia/lib/screens/citizen/add_domicilio_screen.dart
Normal file
212
celaya_limpia/lib/screens/citizen/add_domicilio_screen.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../data/celaya_colonias.dart';
|
||||
import '../../data/colonies_data.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../models/route_model.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class AddDomicilioScreen extends StatefulWidget {
|
||||
final DomicilioModel? editing;
|
||||
const AddDomicilioScreen({super.key, this.editing});
|
||||
@override State<AddDomicilioScreen> createState() => _AddDomicilioScreenState();
|
||||
}
|
||||
|
||||
class _AddDomicilioScreenState extends State<AddDomicilioScreen> {
|
||||
final _calleCtrl = TextEditingController();
|
||||
final _aliasCtrl = TextEditingController(text: 'Casa');
|
||||
String? _coloniaSeleccionada;
|
||||
ColonyModel? _coloniaData;
|
||||
bool _loading = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.editing != null) {
|
||||
_calleCtrl.text = widget.editing!.calle;
|
||||
_aliasCtrl.text = widget.editing!.alias;
|
||||
_coloniaSeleccionada = widget.editing!.colonia;
|
||||
_coloniaData = getColonyByName(widget.editing!.colonia);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> get _filteredColonias {
|
||||
if (_searchQuery.isEmpty) return celayaColonias;
|
||||
return celayaColonias
|
||||
.where((c) => c.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _guardar() async {
|
||||
if (_calleCtrl.text.trim().isEmpty || _coloniaSeleccionada == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Por favor completa todos los campos'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
|
||||
final auth = context.read<AuthService>();
|
||||
|
||||
// 1. Buscar primero en colonies_data (rutas predefinidas)
|
||||
final staticData = getColonyByName(_coloniaSeleccionada!);
|
||||
String routeId = staticData?.routeId ?? '';
|
||||
String horario = staticData?.horarioEstimado ?? '';
|
||||
|
||||
// 2. Si no hay match estático, buscar en route_definitions del admin
|
||||
if (routeId.isEmpty) {
|
||||
final routeDefs = await DbHelper.getAllRouteDefinitions();
|
||||
for (final rd in routeDefs) {
|
||||
if (rd.colonias.any((c) =>
|
||||
c.toLowerCase() == _coloniaSeleccionada!.toLowerCase())) {
|
||||
routeId = rd.routeId;
|
||||
horario = '${_turnoLabel(rd.turno)} (${rd.horaInicio}–${rd.horaFin})';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback si no se encontró
|
||||
if (routeId.isEmpty) {
|
||||
routeId = 'RUTA-01';
|
||||
horario = 'Matutino (06:00–08:00)';
|
||||
}
|
||||
|
||||
if (widget.editing != null) {
|
||||
await DbHelper.deleteDomicilio(widget.editing!.id!);
|
||||
}
|
||||
|
||||
final dom = DomicilioModel(
|
||||
userId: auth.currentUser!.id!,
|
||||
alias: _aliasCtrl.text.trim(),
|
||||
calle: _calleCtrl.text.trim(),
|
||||
colonia: _coloniaSeleccionada!,
|
||||
routeId: routeId,
|
||||
horarioEstimado: horario,
|
||||
);
|
||||
await DbHelper.insertDomicilio(dom);
|
||||
await auth.reloadDomicilios();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
String _turnoLabel(String t) =>
|
||||
t == 'MATUTINO' ? 'Matutino' : t == 'VESPERTINO' ? 'Vespertino' : 'Nocturno';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: Text(widget.editing != null ? 'Editar Domicilio' : 'Agregar Domicilio'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Alias
|
||||
TextField(
|
||||
controller: _aliasCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Alias (ej. Casa, Trabajo, Familia)',
|
||||
prefixIcon: Icon(Icons.label_outline, color: AppColors.guindaPrimary),
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Calle
|
||||
TextField(
|
||||
controller: _calleCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Calle y número',
|
||||
prefixIcon: Icon(Icons.signpost_outlined, color: AppColors.guindaPrimary),
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Colonia', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.guindaPrimary, fontSize: 15)),
|
||||
const SizedBox(height: 8),
|
||||
// Buscador de colonias
|
||||
TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Buscar colonia...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Lista de colonias
|
||||
Container(
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredColonias.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = _filteredColonias[i];
|
||||
final isSelected = c == _coloniaSeleccionada;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(c, style: TextStyle(fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? AppColors.guindaPrimary : AppColors.negroTexto)),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check_circle, color: AppColors.guindaPrimary, size: 18)
|
||||
: null,
|
||||
tileColor: isSelected ? AppColors.guindaPrimary.withOpacity(0.08) : null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_coloniaSeleccionada = c;
|
||||
_coloniaData = getColonyByName(c);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_coloniaData != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guindaPrimary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Ruta asignada: ${_coloniaData!.routeId}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.guindaPrimary, fontSize: 13)),
|
||||
Text('Horario: ${_coloniaData!.horarioEstimado}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(width: double.infinity, height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _guardar,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading
|
||||
? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.save),
|
||||
label: Text(widget.editing != null ? 'ACTUALIZAR' : 'GUARDAR DOMICILIO',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override void dispose() { _calleCtrl.dispose(); _aliasCtrl.dispose(); super.dispose(); }
|
||||
}
|
||||
175
celaya_limpia/lib/screens/citizen/ai_camera_screen.dart
Normal file
175
celaya_limpia/lib/screens/citizen/ai_camera_screen.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:tflite_flutter/tflite_flutter.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
List<CameraDescription> _cameras = [];
|
||||
|
||||
class AiCameraScreen extends StatefulWidget {
|
||||
const AiCameraScreen({super.key});
|
||||
@override State<AiCameraScreen> createState() => _AiCameraScreenState();
|
||||
}
|
||||
|
||||
class _AiCameraScreenState extends State<AiCameraScreen> {
|
||||
CameraController? _cam;
|
||||
Interpreter? _interpreter;
|
||||
bool _processing = false;
|
||||
String _result = 'Apunta a un residuo y toca el botón';
|
||||
String _confidence = '';
|
||||
bool _modelLoaded = false;
|
||||
|
||||
// 0=Orgánico, 1=Inorgánico (según waste_classification_model)
|
||||
final _labels = ['Residuo Organico', 'Residuo Inorganico'];
|
||||
final _labelColors = [AppColors.verdeExito, AppColors.naranjaAlerta];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
_cameras = await availableCameras();
|
||||
} catch (_) {}
|
||||
await _initCamera();
|
||||
await _loadModel();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
if (_cameras.isEmpty) return;
|
||||
_cam = CameraController(_cameras[0], ResolutionPreset.medium, enableAudio: false);
|
||||
try {
|
||||
await _cam!.initialize();
|
||||
if (mounted) setState(() {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadModel() async {
|
||||
try {
|
||||
_interpreter = await Interpreter.fromAsset('assets/models/waste_model.tflite');
|
||||
setState(() => _modelLoaded = true);
|
||||
} catch (e) {
|
||||
setState(() => _result = 'Modelo no encontrado.\nAgrega waste_model.tflite a assets/models/');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _classify() async {
|
||||
if (_cam == null || !_cam!.value.isInitialized || _processing || !_modelLoaded) return;
|
||||
setState(() { _processing = true; _result = 'Analizando...'; _confidence = ''; });
|
||||
try {
|
||||
final pic = await _cam!.takePicture();
|
||||
final raw = await File(pic.path).readAsBytes();
|
||||
img.Image? decoded = img.decodeImage(raw);
|
||||
if (decoded == null) throw Exception('No se pudo decodificar');
|
||||
final resized = img.copyResize(decoded, width: 150, height: 150);
|
||||
|
||||
var input = List.generate(1, (_) =>
|
||||
List.generate(150, (_) => List.generate(150, (_) => List.generate(3, (_) => 0.0))));
|
||||
|
||||
for (int y = 0; y < 150; y++) {
|
||||
for (int x = 0; x < 150; x++) {
|
||||
final px = resized.getPixel(x, y);
|
||||
input[0][y][x][0] = px.r / 255.0;
|
||||
input[0][y][x][1] = px.g / 255.0;
|
||||
input[0][y][x][2] = px.b / 255.0;
|
||||
}
|
||||
}
|
||||
var output = List.filled(2, 0.0).reshape([1, 2]);
|
||||
_interpreter!.run(input, output);
|
||||
|
||||
final pred = List<double>.from(output[0]);
|
||||
final maxIdx = pred[0] > pred[1] ? 0 : 1;
|
||||
final conf = pred[maxIdx] * 100;
|
||||
|
||||
await File(pic.path).delete();
|
||||
setState(() {
|
||||
_result = _labels[maxIdx];
|
||||
_confidence = 'Confianza: ${conf.toStringAsFixed(1)}%';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _result = 'Error en análisis');
|
||||
} finally {
|
||||
setState(() => _processing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cam?.dispose();
|
||||
_interpreter?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resultColor = _result.contains('Orgánico') ? AppColors.verdeExito
|
||||
: _result.contains('Inorgánico') ? AppColors.naranjaAlerta
|
||||
: AppColors.guindaPrimary;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Clasificador IA de Residuos'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
),
|
||||
body: Column(children: [
|
||||
// Visor cámara
|
||||
Expanded(flex: 4,
|
||||
child: Container(margin: const EdgeInsets.all(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.guindaPrimary, width: 3)),
|
||||
child: _cam != null && _cam!.value.isInitialized
|
||||
? CameraPreview(_cam!)
|
||||
: const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.camera_alt, color: Colors.white54, size: 48),
|
||||
SizedBox(height: 8),
|
||||
Text('Iniciando cámara...', style: TextStyle(color: Colors.white54)),
|
||||
])),
|
||||
),
|
||||
),
|
||||
// Panel resultado
|
||||
Expanded(flex: 2,
|
||||
child: Container(width: double.infinity,
|
||||
decoration: BoxDecoration(color: AppColors.guindaPrimary.withOpacity(0.06),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28))),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text(_result, textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: resultColor)),
|
||||
if (_confidence.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(_confidence, style: const TextStyle(fontSize: 16, color: Colors.black54, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (!_modelLoaded)
|
||||
Container(padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade300)),
|
||||
child: const Text('ℹ️ Para usar la IA, coloca waste_model.tflite en assets/models/',
|
||||
textAlign: TextAlign.center, style: TextStyle(fontSize: 11))),
|
||||
if (_modelLoaded)
|
||||
SizedBox(width: double.infinity, height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _processing ? null : _classify,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
||||
icon: _processing
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.center_focus_strong),
|
||||
label: Text(_processing ? 'Procesando...' : 'Escanear Residuo',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
270
celaya_limpia/lib/screens/citizen/chatbot_screen.dart
Normal file
270
celaya_limpia/lib/screens/citizen/chatbot_screen.dart
Normal file
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
// ── Árbol de respuestas predefinidas ──────────────────────────────────────
|
||||
class _ChatNode {
|
||||
final String text;
|
||||
final List<_ChatOption> options;
|
||||
final bool isAnswer;
|
||||
const _ChatNode(this.text, this.options, {this.isAnswer = false});
|
||||
}
|
||||
|
||||
class _ChatOption {
|
||||
final String label;
|
||||
final _ChatNode next;
|
||||
const _ChatOption(this.label, this.next);
|
||||
}
|
||||
|
||||
final _chatTree = _ChatNode('Hola, soy el asistente de Celaya Limpia. ¿En que te puedo ayudar?', [
|
||||
_ChatOption('Separacion de residuos', _ChatNode('¿Que quieres saber sobre separacion?', [
|
||||
_ChatOption('Como separo mi basura', _ChatNode(
|
||||
'Separa tus residuos en 3 grupos:\n\n'
|
||||
'ORGANICOS (bolsa verde):\nRestos de comida, cascara de huevo, pasto, hojas.\n\n'
|
||||
'INORGANICOS reciclables (bolsa azul):\nPET, latas, carton limpio, vidrio.\n\n'
|
||||
'NO reciclables (bolsa negra):\nPanales, papel sanitario, colillas, chicles.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que NO debo mezclar', _ChatNode(
|
||||
'NUNCA mezcles:\n\n'
|
||||
'- Pilas o baterias con basura comun\n'
|
||||
'- Aceite de cocina (contamina el agua)\n'
|
||||
'- Medicamentos vencidos\n'
|
||||
'- Jeringas o material medico\n'
|
||||
'- Electronicos con basura doméstica\n\n'
|
||||
'Estos requieren manejo especial.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con el aceite', _ChatNode(
|
||||
'El aceite de cocina usado NO va a la basura ni al drenaje.\n\n'
|
||||
'1. Dejalo enfriar completamente\n'
|
||||
'2. Guardalo en botella de PET cerrada\n'
|
||||
'3. Llevalo a los puntos de acopio del Ayuntamiento de Celaya\n\n'
|
||||
'El aceite reciclado se convierte en biodiesel.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Residuos especiales', _ChatNode('¿Que tipo de residuo especial tienes?', [
|
||||
_ChatOption('Donde dejo electronicos', _ChatNode(
|
||||
'Los aparatos electronicos (celulares, computadoras, focos ahorradores) '
|
||||
'son residuos RAEE.\n\n'
|
||||
'Puntos de acopio en Celaya:\n'
|
||||
'- Tiendas de electronica\n'
|
||||
'- Centros comerciales con contenedores especiales\n'
|
||||
'- Eventos de recoleccion del municipio\n\n'
|
||||
'NUNCA los tires a la basura comun.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con medicamentos', _ChatNode(
|
||||
'Los medicamentos vencidos son residuos peligrosos.\n\n'
|
||||
'- Llevalos a farmacias que tengan programa de devolucion\n'
|
||||
'- Algunos hospitales los reciben\n'
|
||||
'- Nunca los tires al drenaje ni a la basura comun\n\n'
|
||||
'Contaminar el agua con medicamentos afecta a toda la comunidad.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Que hago con pilas y baterias', _ChatNode(
|
||||
'Las pilas y baterias contienen metales pesados toxicos.\n\n'
|
||||
'Depositalas en:\n'
|
||||
'- Supermercados (contenedores naranjas)\n'
|
||||
'- Tiendas de electronica\n'
|
||||
'- Oficinas del Ayuntamiento de Celaya\n\n'
|
||||
'1 pila puede contaminar 600,000 litros de agua.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Sobre el servicio de recoleccion', _ChatNode('¿Que necesitas saber?', [
|
||||
_ChatOption('Cuando debo sacar la basura', _ChatNode(
|
||||
'Celaya Limpia te notificara:\n\n'
|
||||
'1. Cuando el camion salga del relleno sanitario\n'
|
||||
'2. Cuando este a 30 minutos\n'
|
||||
'3. A 15 minutos: este es el momento de sacar tus bolsas\n\n'
|
||||
'NO saques la basura antes del aviso de 15 minutos. '
|
||||
'Atrae fauna nociva y obstruye la acera.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('El camion no paso', _ChatNode(
|
||||
'Si el camion no paso en tu horario habitual:\n\n'
|
||||
'1. Revisa las alertas en la app (puede haber un retraso o incidente)\n'
|
||||
'2. Guarda tu basura bien cerrada\n'
|
||||
'3. Reporta la incidencia desde la seccion "Reportar"\n\n'
|
||||
'El administrador revisara tu reporte y te mantendra informado.',
|
||||
[], isAnswer: true)),
|
||||
_ChatOption('Como califico el servicio', _ChatNode(
|
||||
'Despues de que el camion pase por tu zona, '
|
||||
'la app te mostrara una notificacion para calificar.\n\n'
|
||||
'Puedes dar de 1 a 5 estrellas y dejar un comentario.\n\n'
|
||||
'Tus calificaciones ayudan al Ayuntamiento a identificar '
|
||||
'colonias con problemas y mejorar el servicio.',
|
||||
[], isAnswer: true)),
|
||||
])),
|
||||
_ChatOption('Denuncia o emergencia', _ChatNode(
|
||||
'Para situaciones urgentes:\n\n'
|
||||
'- Reporte de incidencias: usa la seccion "Reportar" en la app\n'
|
||||
'- Emergencias: llama al 911\n'
|
||||
'- Ayuntamiento de Celaya: (461) 614-8000\n'
|
||||
'- SEMARNAT Guanajuato: (477) 717-2600\n\n'
|
||||
'Para basura clandestina o tiraderos ilegales, reportalo al municipio.',
|
||||
[], isAnswer: true)),
|
||||
]);
|
||||
|
||||
// ── Pantalla del chatbot ──────────────────────────────────────────────────
|
||||
class ChatbotScreen extends StatefulWidget {
|
||||
const ChatbotScreen({super.key});
|
||||
@override State<ChatbotScreen> createState() => _ChatbotScreenState();
|
||||
}
|
||||
|
||||
class _ChatbotScreenState extends State<ChatbotScreen> {
|
||||
final List<_Message> _messages = [];
|
||||
_ChatNode _current = _chatTree;
|
||||
final _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Mensaje inicial
|
||||
_messages.add(_Message(text: _chatTree.text, isBot: true));
|
||||
}
|
||||
|
||||
void _handleOption(_ChatOption option) {
|
||||
setState(() {
|
||||
// Mensaje del usuario
|
||||
_messages.add(_Message(text: option.label, isBot: false));
|
||||
// Ir al siguiente nodo
|
||||
_current = option.next;
|
||||
_messages.add(_Message(text: _current.text, isBot: true,
|
||||
isAnswer: _current.isAnswer));
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_scroll.animateTo(_scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
setState(() {
|
||||
_messages.clear();
|
||||
_current = _chatTree;
|
||||
_messages.add(_Message(text: _chatTree.text, isBot: true));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Row(children: [
|
||||
CircleAvatar(radius: 14,
|
||||
backgroundColor: Colors.white24,
|
||||
child: Icon(Icons.smart_toy, color: AppColors.dorado, size: 18)),
|
||||
SizedBox(width: 8),
|
||||
Text('Asistente Celaya Limpia'),
|
||||
]),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Reiniciar',
|
||||
onPressed: _reset),
|
||||
],
|
||||
),
|
||||
body: Column(children: [
|
||||
// Mensajes
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(msg: _messages[i]),
|
||||
),
|
||||
),
|
||||
// Opciones del nodo actual
|
||||
if (_current.options.isNotEmpty)
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Selecciona una opcion:',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.grisTexto,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8,
|
||||
children: _current.options.map((opt) =>
|
||||
ActionChip(
|
||||
label: Text(opt.label, style: const TextStyle(fontSize: 12)),
|
||||
backgroundColor: AppColors.guindaPrimary.withOpacity(0.1),
|
||||
side: const BorderSide(color: AppColors.guindaPrimary),
|
||||
labelStyle: const TextStyle(color: AppColors.guindaPrimary),
|
||||
onPressed: () => _handleOption(opt),
|
||||
)).toList()),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
// Botón de reiniciar al llegar a una respuesta final
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _reset,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.guindaPrimary,
|
||||
side: const BorderSide(color: AppColors.guindaPrimary)),
|
||||
icon: const Icon(Icons.arrow_back, size: 16),
|
||||
label: const Text('Hacer otra pregunta'))),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@override void dispose() { _scroll.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
class _Message {
|
||||
final String text;
|
||||
final bool isBot;
|
||||
final bool isAnswer;
|
||||
const _Message({required this.text, required this.isBot, this.isAnswer = false});
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final _Message msg;
|
||||
const _MessageBubble({super.key, required this.msg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: msg.isBot ? MainAxisAlignment.start : MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (msg.isBot) ...[
|
||||
CircleAvatar(radius: 16,
|
||||
backgroundColor: AppColors.guindaPrimary,
|
||||
child: const Icon(Icons.smart_toy, color: Colors.white, size: 16)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.isBot
|
||||
? (msg.isAnswer ? Colors.green.shade50 : Colors.white)
|
||||
: AppColors.guindaPrimary,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(msg.isBot ? 4 : 16),
|
||||
bottomRight: Radius.circular(msg.isBot ? 16 : 4),
|
||||
),
|
||||
border: msg.isBot ? Border.all(
|
||||
color: msg.isAnswer
|
||||
? Colors.green.shade200
|
||||
: Colors.grey.shade200) : null,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Text(msg.text,
|
||||
style: TextStyle(fontSize: 13, height: 1.5,
|
||||
color: msg.isBot ? AppColors.negroTexto : Colors.white)),
|
||||
)),
|
||||
if (!msg.isBot) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
celaya_limpia/lib/screens/citizen/citizen_guia_screen.dart
Normal file
149
celaya_limpia/lib/screens/citizen/citizen_guia_screen.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import 'ai_camera_screen.dart';
|
||||
|
||||
class CitizenGuiaScreen extends StatelessWidget {
|
||||
const CitizenGuiaScreen({super.key});
|
||||
|
||||
static const _cats = [
|
||||
_Cat(Icons.grass,Color(0xFF2E7D32),'Orgánicos','Restos de comida, jardín','🟢 Bolsa Verde',[
|
||||
'Frutas y verduras','Cáscaras de huevo','Posos de café y té',
|
||||
'Restos de comida preparada','Pasto y hojas','Cáscaras de semillas'],
|
||||
['Aceites en exceso','Carnes en grandes cantidades']),
|
||||
_Cat(Icons.recycling,Color(0xFF1565C0),'Reciclables','Papel, plástico, vidrio, metal','🔵 Bolsa Azul',[
|
||||
'Botellas PET','Latas de aluminio','Cartón y papel limpio',
|
||||
'Vidrio (botellas, frascos)','Periódico y revistas'],
|
||||
['Vidrio roto sin envolver','Papel sucio o mojado','Unicel']),
|
||||
_Cat(Icons.delete,Color(0xFF757575),'No Reciclables','Residuos que no se reusan','⚫ Bolsa Negra',[
|
||||
'Pañales desechables','Toallas sanitarias','Papel higiénico usado',
|
||||
'Colillas de cigarro','Cerámica rota'],['Baterías','Medicamentos','Aceite usado']),
|
||||
_Cat(Icons.warning_amber,Color(0xFFC62828),'Peligrosos','Requieren manejo especial','🔴 Separado',[
|
||||
'Agujas y jeringas','Medicamentos vencidos','Pilas y baterías',
|
||||
'Aceite de cocina usado','Pintura y solventes'],[],isWarn:true),
|
||||
_Cat(Icons.devices_other,Color(0xFFE65100),'Electrónicos (RAEE)','Aparatos electrónicos','🟠 Punto de acopio',[
|
||||
'Celulares viejos','Computadoras','Televisiones',
|
||||
'Focos ahorradores','Cables y cargadores'],[],isSpecial:true),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(automaticallyImplyLeading:false,
|
||||
backgroundColor:AppColors.guindaPrimary, foregroundColor:Colors.white,
|
||||
title:const Text('Guía de Separación'),
|
||||
actions:[IconButton(icon:const Icon(Icons.camera_alt),
|
||||
tooltip:'Clasificar con IA',
|
||||
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen())))],
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado))),
|
||||
body:Column(children:[
|
||||
Container(width:double.infinity,
|
||||
color:AppColors.verdeExito.withOpacity(0.1),
|
||||
padding:const EdgeInsets.symmetric(horizontal:16,vertical:8),
|
||||
child:Row(children:[
|
||||
const Icon(Icons.offline_bolt,color:AppColors.verdeExito,size:16),
|
||||
const SizedBox(width:6),
|
||||
const Text('Disponible sin conexión a internet',
|
||||
style:TextStyle(color:AppColors.verdeExito,fontSize:12,fontWeight:FontWeight.w500)),
|
||||
const Spacer(),
|
||||
TextButton.icon(icon:const Icon(Icons.camera_alt,size:14),
|
||||
label:const Text('Clasificar IA',style:TextStyle(fontSize:12)),
|
||||
style:TextButton.styleFrom(foregroundColor:AppColors.guindaPrimary),
|
||||
onPressed:()=>Navigator.push(context,MaterialPageRoute(builder:(_)=>const AiCameraScreen()))),
|
||||
])),
|
||||
// Importancia de separar
|
||||
Container(margin:const EdgeInsets.fromLTRB(12,8,12,0),
|
||||
padding:const EdgeInsets.all(12),
|
||||
decoration:BoxDecoration(color:Colors.green.shade50,borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:Colors.green.shade200)),
|
||||
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
Text('¿Por qué separar tu basura?',style:TextStyle(fontWeight:FontWeight.bold,color:Color(0xFF2E7D32))),
|
||||
SizedBox(height:6),
|
||||
Text('♻️ El 60% de los residuos en México pueden reciclarse o compostarse, pero solo el 5% lo hace.\n'
|
||||
'🌱 Separar correctamente reduce la contaminación del suelo y agua, genera empleos verdes '
|
||||
'y disminuye los gases de efecto invernadero producidos en rellenos sanitarios.',
|
||||
style:TextStyle(fontSize:12,color:Colors.black87)),
|
||||
])),
|
||||
Expanded(child:ListView.builder(
|
||||
padding:const EdgeInsets.all(12),
|
||||
itemCount:_cats.length,
|
||||
itemBuilder:(ctx,i)=>_CatCard(cat:_cats[i]))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
class _Cat {
|
||||
final IconData icon; final Color color; final String title, subtitle, bolsa;
|
||||
final List<String> items, noItems;
|
||||
final bool isWarn, isSpecial;
|
||||
const _Cat(this.icon,this.color,this.title,this.subtitle,this.bolsa,
|
||||
this.items,this.noItems,{this.isWarn=false,this.isSpecial=false});
|
||||
}
|
||||
|
||||
class _CatCard extends StatefulWidget {
|
||||
final _Cat cat;
|
||||
const _CatCard({super.key, required this.cat});
|
||||
@override State<_CatCard> createState() => _CatCardState();
|
||||
}
|
||||
|
||||
class _CatCardState extends State<_CatCard> {
|
||||
bool _open = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = widget.cat;
|
||||
return Card(margin:const EdgeInsets.only(bottom:10),elevation:2,
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
|
||||
side:BorderSide(color:c.color.withOpacity(0.3))),
|
||||
child:InkWell(borderRadius:BorderRadius.circular(10),
|
||||
onTap:()=>setState(()=>_open=!_open),
|
||||
child:Column(children:[
|
||||
Container(decoration:BoxDecoration(color:c.color.withOpacity(0.1),
|
||||
borderRadius:BorderRadius.vertical(top:const Radius.circular(10),
|
||||
bottom:_open?Radius.zero:const Radius.circular(10))),
|
||||
padding:const EdgeInsets.all(14),
|
||||
child:Row(children:[
|
||||
Container(width:40,height:40,decoration:BoxDecoration(color:c.color,borderRadius:BorderRadius.circular(8)),
|
||||
child:Icon(c.icon,color:Colors.white,size:22)),
|
||||
const SizedBox(width:10),
|
||||
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
Text(c.title,style:TextStyle(fontWeight:FontWeight.bold,fontSize:15,color:c.color)),
|
||||
Text(c.subtitle,style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
|
||||
Text(c.bolsa,style:TextStyle(fontSize:11,fontWeight:FontWeight.w600,color:c.color)),
|
||||
])),
|
||||
Icon(_open?Icons.expand_less:Icons.expand_more,color:c.color),
|
||||
])),
|
||||
if (_open) Padding(padding:const EdgeInsets.fromLTRB(14,0,14,14),
|
||||
child:Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const SizedBox(height:8),
|
||||
Text('✅ Qué va aquí:',style:TextStyle(fontWeight:FontWeight.bold,color:c.color,fontSize:12)),
|
||||
const SizedBox(height:4),
|
||||
...c.items.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
|
||||
child:Row(children:[Icon(Icons.check_circle_outline,size:13,color:c.color),
|
||||
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12))]))),
|
||||
if (c.noItems.isNotEmpty) ...[
|
||||
const SizedBox(height:8),
|
||||
const Text('❌ NO incluir:',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.rojoError,fontSize:12)),
|
||||
...c.noItems.map((e)=>Padding(padding:const EdgeInsets.symmetric(vertical:2),
|
||||
child:Row(children:[const Icon(Icons.cancel_outlined,size:13,color:AppColors.rojoError),
|
||||
const SizedBox(width:6),Text(e,style:const TextStyle(fontSize:12,color:AppColors.rojoError))]))),
|
||||
],
|
||||
if (c.isSpecial) ...[
|
||||
const SizedBox(height:8),
|
||||
Container(padding:const EdgeInsets.all(8),
|
||||
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(6),
|
||||
border:Border.all(color:Colors.orange.shade200)),
|
||||
child:const Text('📍 Lleva a puntos de acopio autorizados por el municipio.',
|
||||
style:TextStyle(fontSize:11))),
|
||||
],
|
||||
if (c.isWarn) ...[
|
||||
const SizedBox(height:8),
|
||||
Container(padding:const EdgeInsets.all(8),
|
||||
decoration:BoxDecoration(color:Colors.red.shade50,borderRadius:BorderRadius.circular(6),
|
||||
border:Border.all(color:Colors.red.shade200)),
|
||||
child:const Text('⚠️ NUNCA mezcles residuos peligrosos con basura común.',
|
||||
style:TextStyle(fontSize:11))),
|
||||
],
|
||||
])),
|
||||
])));
|
||||
}
|
||||
}
|
||||
582
celaya_limpia/lib/screens/citizen/citizen_home_screen.dart
Normal file
582
celaya_limpia/lib/screens/citizen/citizen_home_screen.dart
Normal file
@@ -0,0 +1,582 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/route_simulator_service.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../widgets/route_map_widget.dart';
|
||||
import 'citizen_guia_screen.dart';
|
||||
import 'citizen_reporte_screen.dart';
|
||||
import 'add_domicilio_screen.dart';
|
||||
import 'review_screen.dart';
|
||||
import 'collection_calendar_screen.dart';
|
||||
import 'notification_history_screen.dart';
|
||||
import 'chatbot_screen.dart';
|
||||
import '../settings_screen.dart';
|
||||
|
||||
class CitizenHomeScreen extends StatefulWidget {
|
||||
const CitizenHomeScreen({super.key});
|
||||
@override State<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
|
||||
}
|
||||
|
||||
class _CitizenHomeScreenState extends State<CitizenHomeScreen> {
|
||||
int _tab = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthService>();
|
||||
final sim = context.watch<RouteSimulatorService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
final last = dom != null ? sim.getNotificationForRoute(dom.routeId) : null;
|
||||
|
||||
final tabs = [
|
||||
_HomeTab(auth: auth, sim: sim),
|
||||
const CitizenGuiaScreen(),
|
||||
const CitizenReporteScreen(),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
body: Stack(children: [
|
||||
tabs[_tab],
|
||||
if (last != null)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8, left: 0, right: 0,
|
||||
child: _NotifBanner(notif: last,
|
||||
onDismiss: () => sim.dismissRouteNotification(dom?.routeId ?? '')),
|
||||
),
|
||||
]),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _tab,
|
||||
onDestinationSelected: (i) => setState(() => _tab = i),
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: AppColors.guindaPrimary.withOpacity(0.15),
|
||||
destinations: const [
|
||||
NavigationDestination(icon:Icon(Icons.home_outlined),
|
||||
selectedIcon:Icon(Icons.home,color:AppColors.guindaPrimary),label:'Inicio'),
|
||||
NavigationDestination(icon:Icon(Icons.eco_outlined),
|
||||
selectedIcon:Icon(Icons.eco,color:AppColors.guindaPrimary),label:'Guía'),
|
||||
NavigationDestination(icon:Icon(Icons.report_outlined),
|
||||
selectedIcon:Icon(Icons.report,color:AppColors.guindaPrimary),label:'Reportar'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeTab extends StatefulWidget {
|
||||
final AuthService auth;
|
||||
final RouteSimulatorService sim;
|
||||
const _HomeTab({required this.auth, required this.sim});
|
||||
@override State<_HomeTab> createState() => _HomeTabState();
|
||||
}
|
||||
|
||||
class _HomeTabState extends State<_HomeTab> {
|
||||
RouteStatusModel? _routeStatus;
|
||||
RouteDefinitionModel? _routeDef;
|
||||
|
||||
@override void initState() { super.initState(); _loadStatus(); }
|
||||
|
||||
Future<void> _loadStatus() async {
|
||||
final dom = widget.auth.primaryDomicilio;
|
||||
if (dom == null) return;
|
||||
final s = await DbHelper.getRouteStatus(dom.routeId);
|
||||
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
|
||||
if (mounted) setState(() { _routeStatus = s; _routeDef = rd; });
|
||||
}
|
||||
|
||||
bool get _isRouteProblematic {
|
||||
final s = _routeStatus?.status ?? RouteStatus.enRuta;
|
||||
return s == RouteStatus.cancelada || s == RouteStatus.fallaMecanica || s == RouteStatus.retrasada;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dom = widget.auth.primaryDomicilio;
|
||||
final allDoms = widget.auth.allDomicilios;
|
||||
final routeId = dom?.routeId ?? '';
|
||||
final route = dom != null ? getRouteById(dom.routeId) : null;
|
||||
final isTruckClose = widget.sim.isTruckClose(routeId);
|
||||
final isCompleted = widget.sim.isRouteCompleted(routeId);
|
||||
final needsReview = widget.sim.needsReviewPrompt(routeId);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadStatus,
|
||||
child: CustomScrollView(slivers: [
|
||||
SliverAppBar(expandedHeight: 120, pinned: true,
|
||||
backgroundColor: AppColors.guindaPrimary,
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
flexibleSpace: FlexibleSpaceBar(background: Container(
|
||||
color: AppColors.guindaPrimary,
|
||||
padding: const EdgeInsets.fromLTRB(20, 50, 20, 16),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.delete_sweep_rounded, color: AppColors.dorado, size: 30),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text('Hola, ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const Text('Celaya Limpia', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
|
||||
])),
|
||||
IconButton(icon: const Icon(Icons.logout, color: Colors.white70),
|
||||
onPressed: () async {
|
||||
await widget.auth.logout();
|
||||
if (context.mounted) Navigator.pushReplacementNamed(context, '/login');
|
||||
}),
|
||||
]),
|
||||
)),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(delegate: SliverChildListDelegate([
|
||||
|
||||
// ── Selector de domicilio ────────────────────────────────────
|
||||
if (allDoms.length > 1) _DomicilioSelector(
|
||||
auth: widget.auth, onChanged: _loadStatus),
|
||||
|
||||
// ── Prompt de calificación ───────────────────────────────────
|
||||
if (needsReview && dom != null)
|
||||
_ReviewPromptCard(routeId: routeId, colonia: dom.colonia,
|
||||
sim: widget.sim),
|
||||
|
||||
// ── Estado de ruta (cancelada/falla/retrasada) ───────────────
|
||||
if (_isRouteProblematic)
|
||||
_RouteStatusBanner(status: _routeStatus!)
|
||||
else ...[
|
||||
// ETA Card
|
||||
_EtaCard(sim: widget.sim, routeId: routeId, dom: dom, route: route),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Información detallada de la ruta (días y horario)
|
||||
if (_routeDef != null) _RouteInfoCard(routeDef: _routeDef!),
|
||||
if (_routeDef == null && dom != null) _BasicRouteInfo(dom: dom),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Mapa solo cuando camión está cerca (<15 min)
|
||||
if (isTruckClose && route != null && !isCompleted) ...[
|
||||
_WarningNoPursue(),
|
||||
const SizedBox(height: 8),
|
||||
RouteMapWidget(route: route, simulator: widget.sim, height: 220),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
|
||||
// Aviso privacidad
|
||||
_PrivacyBanner(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Mis domicilios
|
||||
_DomiciliosCard(auth: widget.auth),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Historial notificaciones
|
||||
if (widget.sim.historyForRoute(routeId).isNotEmpty)
|
||||
_HistorialCard(sim: widget.sim, routeId: routeId),
|
||||
|
||||
const SizedBox(height: 80),
|
||||
])),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selector de domicilio activo ──────────────────────────────────────────
|
||||
class _DomicilioSelector extends StatelessWidget {
|
||||
final AuthService auth; final VoidCallback onChanged;
|
||||
const _DomicilioSelector({required this.auth, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.3)),
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)]),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
isExpanded: true,
|
||||
value: auth.primaryDomicilio?.id,
|
||||
icon: const Icon(Icons.swap_horiz, color: AppColors.guindaPrimary),
|
||||
items: auth.allDomicilios.map((d) => DropdownMenuItem(
|
||||
value: d.id,
|
||||
child: Row(children: [
|
||||
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
|
||||
color: AppColors.guindaPrimary, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text('${d.alias} — ${d.colonia}',
|
||||
style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)),
|
||||
]))).toList(),
|
||||
onChanged: (id) async {
|
||||
if (id != null) {
|
||||
await DbHelper.setPrimaryDomicilio(id, auth.currentUser!.id!);
|
||||
await auth.reloadDomicilios();
|
||||
onChanged();
|
||||
}
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt de reseña ──────────────────────────────────────────────────────
|
||||
class _ReviewPromptCard extends StatelessWidget {
|
||||
final String routeId, colonia; final RouteSimulatorService sim;
|
||||
const _ReviewPromptCard({required this.routeId, required this.colonia, required this.sim});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
color: Colors.amber.shade50,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Colors.amber.shade300, width: 1.5)),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(children: [
|
||||
const Row(children: [
|
||||
Text('⭐', style: TextStyle(fontSize: 24)),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: Text('¿Cómo estuvo el servicio de hoy?',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
const Text('El camión pasó por tu colonia. Toma un momento para calificar el servicio.',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Expanded(child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(
|
||||
builder: (_) => ReviewScreen(routeId: routeId, colonia: colonia))),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.amber,
|
||||
foregroundColor: Colors.black87),
|
||||
icon: const Icon(Icons.star, size: 16),
|
||||
label: const Text('Calificar', style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: () => sim.clearReviewPrompt(routeId),
|
||||
child: const Text('Después', style: TextStyle(color: AppColors.grisTexto))),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
|
||||
// ── Info detallada de la ruta ─────────────────────────────────────────────
|
||||
class _RouteInfoCard extends StatelessWidget {
|
||||
final RouteDefinitionModel routeDef;
|
||||
const _RouteInfoCard({required this.routeDef});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('Información de tu ruta', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||
]),
|
||||
const Divider(),
|
||||
Text(routeDef.nombre, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
const Icon(Icons.access_time, size: 13, color: AppColors.grisTexto),
|
||||
const SizedBox(width: 4),
|
||||
Text('${routeDef.horaInicio} — ${routeDef.horaFin} (${_turnoLabel(routeDef.turno)})',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Icon(Icons.calendar_today, size: 13, color: AppColors.grisTexto),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(
|
||||
routeDef.dias.map(AppDias.label).join(', '),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.negroTexto))),
|
||||
]),
|
||||
])));
|
||||
|
||||
String _turnoLabel(String t) => t=='MATUTINO'?'🌄 Matutino':t=='VESPERTINO'?'🌅 Vespertino':'🌙 Nocturno';
|
||||
}
|
||||
|
||||
class _BasicRouteInfo extends StatelessWidget {
|
||||
final DomicilioModel dom;
|
||||
const _BasicRouteInfo({required this.dom});
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.schedule, color: AppColors.guindaPrimary, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('Tu servicio de recolección', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||
]),
|
||||
const Divider(),
|
||||
Text('Ruta: ${dom.routeId}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('Horario: ${dom.horarioEstimado}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
])));
|
||||
}
|
||||
|
||||
// ── Aviso anti-persecución ────────────────────────────────────────────────
|
||||
class _WarningNoPursue extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade300)),
|
||||
child: const Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppColors.rojoError, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: Text(
|
||||
'⚠️ Ya es momento de sacar tu basura.\n'
|
||||
'🚫 NO persigas ni interceptes el camión en movimiento.\n'
|
||||
'✅ Coloca tus bolsas en la acera y espera.',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.rojoError, fontWeight: FontWeight.w500))),
|
||||
]));
|
||||
}
|
||||
|
||||
// ── Mis domicilios ────────────────────────────────────────────────────────
|
||||
class _DomiciliosCard extends StatelessWidget {
|
||||
final AuthService auth;
|
||||
const _DomiciliosCard({required this.auth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final doms = auth.allDomicilios;
|
||||
return Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.home_outlined, color: AppColors.guindaPrimary, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
const Expanded(child: Text('Mis Domicilios',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.guindaPrimary))),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final result = await Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const AddDomicilioScreen()));
|
||||
if (result == true) await auth.reloadDomicilios();
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 14),
|
||||
label: const Text('Agregar', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.guindaPrimary)),
|
||||
]),
|
||||
const Divider(),
|
||||
if (doms.isEmpty)
|
||||
const Text('Sin domicilios registrados',
|
||||
style: TextStyle(color: AppColors.grisTexto, fontSize: 12))
|
||||
else
|
||||
...doms.map((d) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(children: [
|
||||
Icon(d.isPrimary ? Icons.home : Icons.location_on_outlined,
|
||||
color: d.isPrimary ? AppColors.guindaPrimary : AppColors.grisTexto, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${d.alias} — ${d.colonia}',
|
||||
style: TextStyle(fontWeight: d.isPrimary ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 12)),
|
||||
Text(d.calle, style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
|
||||
Text('${d.routeId} • ${d.horarioEstimado}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 10)),
|
||||
])),
|
||||
if (!d.isPrimary)
|
||||
IconButton(icon: const Icon(Icons.star_border, size: 16, color: AppColors.dorado),
|
||||
tooltip: 'Hacer principal',
|
||||
onPressed: () async {
|
||||
await DbHelper.setPrimaryDomicilio(d.id!, auth.currentUser!.id!);
|
||||
await auth.reloadDomicilios();
|
||||
}),
|
||||
IconButton(icon: const Icon(Icons.edit_outlined, size: 14, color: AppColors.grisTexto),
|
||||
onPressed: () async {
|
||||
final result = await Navigator.push(context, MaterialPageRoute(
|
||||
builder: (_) => AddDomicilioScreen(editing: d)));
|
||||
if (result == true) await auth.reloadDomicilios();
|
||||
}),
|
||||
if (!d.isPrimary)
|
||||
IconButton(icon: const Icon(Icons.delete_outline, size: 14, color: AppColors.rojoError),
|
||||
onPressed: () async {
|
||||
await DbHelper.deleteDomicilio(d.id!);
|
||||
await auth.reloadDomicilios();
|
||||
}),
|
||||
]))),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banner de ruta con problema ───────────────────────────────────────────
|
||||
class _RouteStatusBanner extends StatelessWidget {
|
||||
final RouteStatusModel status;
|
||||
const _RouteStatusBanner({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCancelled = status.status == RouteStatus.cancelada;
|
||||
final isFalla = status.status == RouteStatus.fallaMecanica;
|
||||
final isRetrasada = status.status == RouteStatus.retrasada;
|
||||
final color = isCancelled ? AppColors.rojoError : isFalla ? Colors.red.shade800 : AppColors.naranjaAlerta;
|
||||
final icon = isCancelled ? Icons.cancel : isFalla ? Icons.build : Icons.access_time;
|
||||
final titulo = isCancelled ? '❌ Ruta Cancelada Hoy'
|
||||
: isFalla ? '🔧 Falla Mecánica en Servicio' : '⏱️ Servicio con Retraso';
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Container(width: double.infinity, padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(icon, color: Colors.white, size: 26),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(titulo, style: const TextStyle(color: Colors.white,
|
||||
fontSize: 17, fontWeight: FontWeight.bold))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(isCancelled
|
||||
? 'El servicio no se realizará hoy. Guarda tus residuos para mañana.'
|
||||
: isFalla
|
||||
? 'El camión presentó una falla. El Ayuntamiento atiende la situación.'
|
||||
: 'El camión presenta un retraso. El servicio se realizará con demora.',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||||
])),
|
||||
if (status.mensaje != null && status.mensaje!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(width: double.infinity, padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withOpacity(0.4))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.admin_panel_settings, color: color, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text('Mensaje del Ayuntamiento', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: color, fontSize: 13)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(status.mensaje!, style: const TextStyle(fontSize: 13)),
|
||||
])),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Container(padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('💡 Recomendaciones:', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
fontSize: 12, color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 4),
|
||||
Text(isCancelled
|
||||
? '• Guarda tus bolsas en lugar cerrado\n• No dejes residuos en la acera\n• Revisa la app mañana'
|
||||
: isRetrasada
|
||||
? '• Espera el aviso de 15 minutos antes de sacar tu basura\n• El camión llegará eventualmente\n• Recibe la notificación en esta app'
|
||||
: '• Espera confirmación del Ayuntamiento\n• Puede enviarse unidad de reemplazo',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.grisTexto)),
|
||||
])),
|
||||
const SizedBox(height: 12),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ETA Card ──────────────────────────────────────────────────────────────
|
||||
class _EtaCard extends StatelessWidget {
|
||||
final RouteSimulatorService sim; final String routeId; final dom; final route;
|
||||
const _EtaCard({required this.sim, required this.routeId, required this.dom, required this.route});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors:[AppColors.guindaPrimary,AppColors.guindaDark],
|
||||
begin:Alignment.topLeft,end:Alignment.bottomRight),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [BoxShadow(color:AppColors.guindaDark.withOpacity(0.4),blurRadius:8,offset:const Offset(0,4))]),
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children:[
|
||||
const Icon(Icons.local_shipping,color:AppColors.dorado,size:22),
|
||||
const SizedBox(width:8),
|
||||
Expanded(child:Text(route?.name??dom?.routeId??'Tu ruta',
|
||||
style:const TextStyle(color:AppColors.dorado,fontSize:13,fontWeight:FontWeight.w600))),
|
||||
]),
|
||||
const SizedBox(height:8),
|
||||
Text(sim.getEtaText(routeId),
|
||||
style:const TextStyle(color:Colors.white,fontSize:16,fontWeight:FontWeight.bold)),
|
||||
const SizedBox(height:6),
|
||||
if (dom!=null) Text('⏰ ${dom.horarioEstimado}',
|
||||
style:const TextStyle(color:Colors.white60,fontSize:11)),
|
||||
const SizedBox(height:10),
|
||||
LinearProgressIndicator(
|
||||
value:route!=null?(sim.getPositionIndex(routeId)+1)/route.positions.length:0,
|
||||
backgroundColor:Colors.white24,
|
||||
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.dorado)),
|
||||
]));
|
||||
}
|
||||
|
||||
// ── Privacidad ────────────────────────────────────────────────────────────
|
||||
class _PrivacyBanner extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:Colors.amber.shade50,borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:Colors.amber.shade300)),
|
||||
child:const Row(children:[
|
||||
Icon(Icons.shield_outlined,color:Colors.amber,size:18),
|
||||
SizedBox(width:6),
|
||||
Expanded(child:Text('🔒 Solo ves la información de tu ruta asignada.',
|
||||
style:TextStyle(fontSize:11,color:Colors.black87))),
|
||||
]));
|
||||
}
|
||||
|
||||
// ── Historial notificaciones ──────────────────────────────────────────────
|
||||
class _HistorialCard extends StatelessWidget {
|
||||
final RouteSimulatorService sim; final String routeId;
|
||||
const _HistorialCard({required this.sim, required this.routeId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notifs = sim.historyForRoute(routeId).take(5).toList();
|
||||
return Card(child:Padding(padding:const EdgeInsets.all(14),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Text('Alertas recientes',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
|
||||
const Divider(),
|
||||
...notifs.map((n){
|
||||
final color = n.event==NotifEvent.truckProximity||n.event==NotifEvent.truckApproaching15min
|
||||
?AppColors.naranjaAlerta:n.event==NotifEvent.routeCompleted||n.event==NotifEvent.reviewPrompt
|
||||
?AppColors.verdeExito:n.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.azulInfo;
|
||||
return Padding(padding:const EdgeInsets.symmetric(vertical:3),
|
||||
child:Row(children:[
|
||||
Icon(Icons.circle,size:8,color:color),
|
||||
const SizedBox(width:8),
|
||||
Expanded(child:Text(n.title,style:const TextStyle(fontSize:12,fontWeight:FontWeight.w500))),
|
||||
Text('${n.timestamp.hour.toString().padLeft(2,'0')}:${n.timestamp.minute.toString().padLeft(2,'0')}',
|
||||
style:const TextStyle(fontSize:10,color:AppColors.grisTexto)),
|
||||
]));
|
||||
}),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notif Banner ──────────────────────────────────────────────────────────
|
||||
class _NotifBanner extends StatelessWidget {
|
||||
final AppNotification notif; final VoidCallback onDismiss;
|
||||
const _NotifBanner({required this.notif, required this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUrgent = notif.event==NotifEvent.truckProximity||notif.event==NotifEvent.truckApproaching15min;
|
||||
final isReview = notif.event==NotifEvent.reviewPrompt;
|
||||
final color = isUrgent?AppColors.naranjaAlerta
|
||||
:isReview?Colors.amber.shade700
|
||||
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
|
||||
:notif.event==NotifEvent.gpsLost?Colors.red.shade800
|
||||
:AppColors.azulInfo;
|
||||
return Material(color:Colors.transparent,
|
||||
child:Container(margin:const EdgeInsets.all(12),
|
||||
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
|
||||
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:8,offset:Offset(0,4))]),
|
||||
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
|
||||
Icon(isReview?Icons.star:Icons.notifications_active,color:Colors.white,size:24),
|
||||
const SizedBox(width:10),
|
||||
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
|
||||
mainAxisSize:MainAxisSize.min,children:[
|
||||
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
|
||||
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
|
||||
maxLines:2,overflow:TextOverflow.ellipsis),
|
||||
])),
|
||||
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
|
||||
]))));
|
||||
}
|
||||
}
|
||||
221
celaya_limpia/lib/screens/citizen/citizen_reporte_screen.dart
Normal file
221
celaya_limpia/lib/screens/citizen/citizen_reporte_screen.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class CitizenReporteScreen extends StatefulWidget {
|
||||
const CitizenReporteScreen({super.key});
|
||||
@override State<CitizenReporteScreen> createState() => _CitizenReporteScreenState();
|
||||
}
|
||||
|
||||
class _CitizenReporteScreenState extends State<CitizenReporteScreen> {
|
||||
String _tipo = 'CAMION_NO_PASO';
|
||||
final _desc = TextEditingController();
|
||||
int _calif = 5;
|
||||
bool _loading = false, _sent = false;
|
||||
List<ReporteModel> _reportes = [];
|
||||
File? _foto;
|
||||
final _picker = ImagePicker();
|
||||
|
||||
static const _tipos = {
|
||||
'CAMION_NO_PASO': 'El camion no paso',
|
||||
'RETRASO': 'Retraso significativo',
|
||||
'RESIDUOS_NO_RECOGIDOS': 'Residuos no recogidos',
|
||||
'OTRO': 'Otro motivo',
|
||||
};
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null) return;
|
||||
final r = await DbHelper.getReportesByUser(auth.currentUser!.id!);
|
||||
if (mounted) setState(() => _reportes = r);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
final picked = await _picker.pickImage(source: source, imageQuality: 70, maxWidth: 1024);
|
||||
if (picked != null && mounted) setState(() => _foto = File(picked.path));
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('No se pudo acceder a la camara: $e'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
}
|
||||
}
|
||||
|
||||
void _showPhotoOptions() {
|
||||
showModalBottomSheet(context: context, builder: (_) => SafeArea(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ListTile(leading: const Icon(Icons.camera_alt, color: AppColors.guindaPrimary),
|
||||
title: const Text('Tomar foto'),
|
||||
onTap: () { Navigator.pop(context); _pickImage(ImageSource.camera); }),
|
||||
ListTile(leading: const Icon(Icons.photo_library, color: AppColors.guindaPrimary),
|
||||
title: const Text('Elegir de galeria'),
|
||||
onTap: () { Navigator.pop(context); _pickImage(ImageSource.gallery); }),
|
||||
if (_foto != null)
|
||||
ListTile(leading: const Icon(Icons.delete_outline, color: AppColors.rojoError),
|
||||
title: const Text('Quitar foto', style: TextStyle(color: AppColors.rojoError)),
|
||||
onTap: () { Navigator.pop(context); setState(() => _foto = null); }),
|
||||
])));
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null || _desc.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Describe el problema'),
|
||||
backgroundColor: AppColors.rojoError));
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
|
||||
final db = await DbHelper.database;
|
||||
await db.insert('reportes', {
|
||||
'user_id': auth.currentUser!.id,
|
||||
'tipo': _tipo,
|
||||
'descripcion': _desc.text.trim(),
|
||||
'colonia': auth.primaryDomicilio?.colonia ?? '',
|
||||
'route_id': auth.primaryDomicilio?.routeId ?? '',
|
||||
'fecha': DateTime.now().toIso8601String(),
|
||||
'estado': 'PENDIENTE',
|
||||
'calificacion': _calif,
|
||||
'foto_path': _foto?.path,
|
||||
});
|
||||
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = false; _sent = true; _desc.clear(); _foto = null; });
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _sent = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(automaticallyImplyLeading: false,
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Reportar Incidencia'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: _sent
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.verdeExito, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Reporte enviado', style: TextStyle(fontSize: 20,
|
||||
fontWeight: FontWeight.bold, color: AppColors.verdeExito)),
|
||||
const Text('El Ayuntamiento lo revisara pronto.',
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
]))
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
Card(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('Tipo de incidencia', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary, fontSize: 15)),
|
||||
const SizedBox(height: 8),
|
||||
..._tipos.entries.map((e) => RadioListTile<String>(dense: true,
|
||||
value: e.key, groupValue: _tipo,
|
||||
title: Text(e.value, style: const TextStyle(fontSize: 13)),
|
||||
activeColor: AppColors.guindaPrimary,
|
||||
onChanged: (v) => setState(() => _tipo = v!))),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(value: _calif,
|
||||
decoration: const InputDecoration(labelText: 'Calificacion del servicio',
|
||||
border: OutlineInputBorder()),
|
||||
items: [5,4,3,2,1].map((n) => DropdownMenuItem(value: n,
|
||||
child: Text(['Excelente','Bueno','Regular','Malo','Muy malo'][5-n]))).toList(),
|
||||
onChanged: (v) => setState(() => _calif = v!)),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: _desc, maxLines: 3,
|
||||
decoration: const InputDecoration(hintText: 'Describe el problema...',
|
||||
border: OutlineInputBorder(), filled: true, fillColor: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Foto adjunta
|
||||
const Text('Foto del incidente (opcional)',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _showPhotoOptions,
|
||||
child: Container(
|
||||
width: double.infinity, height: _foto != null ? 180 : 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _foto != null
|
||||
? AppColors.guindaPrimary : Colors.grey.shade300,
|
||||
style: BorderStyle.solid)),
|
||||
child: _foto != null
|
||||
? Stack(children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(_foto!, fit: BoxFit.cover,
|
||||
width: double.infinity, height: 180)),
|
||||
Positioned(top: 8, right: 8,
|
||||
child: GestureDetector(onTap: () => setState(() => _foto = null),
|
||||
child: Container(padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.rojoError, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.close, color: Colors.white, size: 16)))),
|
||||
])
|
||||
: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.add_a_photo_outlined,
|
||||
color: AppColors.grisTexto, size: 28),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Agregar foto', style: TextStyle(
|
||||
color: AppColors.grisTexto, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(width: double.infinity, height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading ? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.send),
|
||||
label: const Text('ENVIAR REPORTE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
]))),
|
||||
if (_reportes.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Align(alignment: Alignment.centerLeft,
|
||||
child: Text('Mis Reportes', style: TextStyle(fontWeight: FontWeight.bold,
|
||||
color: AppColors.guindaPrimary, fontSize: 15))),
|
||||
const SizedBox(height: 8),
|
||||
..._reportes.map((r) => Card(margin: const EdgeInsets.only(bottom: 6),
|
||||
child: ListTile(dense: true,
|
||||
leading: CircleAvatar(backgroundColor: AppColors.guindaPrimary, radius: 16,
|
||||
child: const Icon(Icons.report, color: Colors.white, size: 16)),
|
||||
title: Text(_tipos[r.tipo] ?? r.tipo,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(r.descripcion, maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
trailing: Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _estadoColor(r.estado).withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(r.estado, style: TextStyle(fontSize: 9,
|
||||
color: _estadoColor(r.estado), fontWeight: FontWeight.bold)))))),
|
||||
],
|
||||
])),
|
||||
);
|
||||
|
||||
Color _estadoColor(String e) {
|
||||
switch (e) {
|
||||
case 'RESUELTO': return AppColors.verdeExito;
|
||||
case 'EN_REVISION': return AppColors.azulInfo;
|
||||
default: return AppColors.naranjaAlerta;
|
||||
}
|
||||
}
|
||||
|
||||
@override void dispose() { _desc.dispose(); super.dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class CollectionCalendarScreen extends StatefulWidget {
|
||||
const CollectionCalendarScreen({super.key});
|
||||
@override State<CollectionCalendarScreen> createState() => _CollectionCalendarScreenState();
|
||||
}
|
||||
|
||||
class _CollectionCalendarScreenState extends State<CollectionCalendarScreen> {
|
||||
RouteDefinitionModel? _routeDef;
|
||||
List<ReviewModel> _myReviews = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
if (dom != null) {
|
||||
final rd = await DbHelper.getRouteDefinitionById(dom.routeId);
|
||||
final rv = await DbHelper.getAllReviews();
|
||||
final mine = rv.where((r) => r.userId == auth.currentUser?.id).toList();
|
||||
if (mounted) setState(() { _routeDef = rd; _myReviews = mine; _loading = false; });
|
||||
} else {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareSchedule() {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
if (dom == null) return;
|
||||
final rd = _routeDef;
|
||||
final diasStr = rd?.dias.map(_diaLabel).join(', ') ?? 'Lunes, Miércoles y Viernes';
|
||||
final horario = rd != null ? '${rd.horaInicio}–${rd.horaFin}' : dom.horarioEstimado;
|
||||
|
||||
Share.share(
|
||||
'🗑️ Horario de recolección de basura\n'
|
||||
'📍 Colonia: ${dom.colonia}\n'
|
||||
'📅 Días: $diasStr\n'
|
||||
'⏰ Horario: $horario\n'
|
||||
'🚛 Ruta: ${dom.routeId}\n\n'
|
||||
'Descarga Celaya Limpia para recibir avisos en tiempo real.',
|
||||
);
|
||||
}
|
||||
|
||||
String _diaLabel(String d) {
|
||||
const m = {'LUNES':'Lu','MARTES':'Ma','MIERCOLES':'Mi',
|
||||
'JUEVES':'Ju','VIERNES':'Vi','SABADO':'Sa','DOMINGO':'Do'};
|
||||
return m[d] ?? d;
|
||||
}
|
||||
|
||||
// Días del mes actual con marcas de recolección
|
||||
List<Widget> _buildCalendar() {
|
||||
final now = DateTime.now();
|
||||
final first = DateTime(now.year, now.month, 1);
|
||||
final days = DateTime(now.year, now.month + 1, 0).day;
|
||||
final dias = _routeDef?.dias ?? [];
|
||||
|
||||
const weekDays = ['LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
|
||||
final monthName = ['','Enero','Febrero','Marzo','Abril','Mayo','Junio',
|
||||
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'][now.month];
|
||||
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('$monthName ${now.year}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16,
|
||||
color: AppColors.guindaPrimary)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Cabeceras días
|
||||
Row(children: ['Lu','Ma','Mi','Ju','Vi','Sa','Do'].map((d) =>
|
||||
Expanded(child: Center(child: Text(d, style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.grisTexto))))).toList()),
|
||||
const SizedBox(height: 4),
|
||||
// Grilla de días
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7, childAspectRatio: 1),
|
||||
itemCount: (first.weekday - 1) + days,
|
||||
itemBuilder: (_, i) {
|
||||
if (i < first.weekday - 1) return const SizedBox();
|
||||
final day = i - (first.weekday - 1) + 1;
|
||||
final date = DateTime(now.year, now.month, day);
|
||||
final diaSem = weekDays[date.weekday - 1];
|
||||
final isCollection = dias.contains(diaSem);
|
||||
final isToday = day == now.day;
|
||||
final isPast = date.isBefore(DateTime(now.year, now.month, now.day));
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isCollection
|
||||
? (isPast ? AppColors.guindaPrimary.withOpacity(0.4) : AppColors.guindaPrimary)
|
||||
: (isToday ? Colors.grey.shade200 : null),
|
||||
shape: BoxShape.circle,
|
||||
border: isToday ? Border.all(color: AppColors.dorado, width: 2) : null,
|
||||
),
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
Text('$day', style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||||
color: isCollection ? Colors.white : AppColors.negroTexto,
|
||||
)),
|
||||
if (isCollection)
|
||||
Positioned(bottom: 2, child: Container(
|
||||
width: 4, height: 4,
|
||||
decoration: const BoxDecoration(color: AppColors.dorado, shape: BoxShape.circle),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthService>();
|
||||
final dom = auth.primaryDomicilio;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Calendario de Recoleccion'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.share), tooltip: 'Compartir horario',
|
||||
onPressed: _shareSchedule),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Info de la ruta
|
||||
if (dom != null)
|
||||
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 18),
|
||||
SizedBox(width: 6),
|
||||
Text('Tu servicio de recoleccion', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||
]),
|
||||
const Divider(),
|
||||
Text('Colonia: ${dom.colonia}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
Text('Ruta: ${dom.routeId}',
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
if (_routeDef != null) ...[
|
||||
Text('Dias: ${_routeDef!.dias.map(_diaLabel).join(" · ")}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
Text('Horario: ${_routeDef!.horaInicio} - ${_routeDef!.horaFin}',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
] else
|
||||
Text(dom.horarioEstimado,
|
||||
style: const TextStyle(color: AppColors.grisTexto, fontSize: 12)),
|
||||
]))),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Leyenda
|
||||
Row(children: [
|
||||
_Legend(color: AppColors.guindaPrimary, label: 'Dia de recoleccion'),
|
||||
const SizedBox(width: 12),
|
||||
_Legend(color: AppColors.dorado, label: 'Punto en dia activo'),
|
||||
const SizedBox(width: 12),
|
||||
_Legend(color: Colors.grey.shade200, label: 'Hoy'),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Calendario
|
||||
Card(child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildCalendar()))),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Consejos semanales
|
||||
Card(color: Colors.blue.shade50,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: Colors.blue.shade200)),
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Icon(Icons.tips_and_updates, color: AppColors.azulInfo),
|
||||
SizedBox(width: 8),
|
||||
Text('Consejo de la semana', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.azulInfo, fontSize: 14)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(_weeklyTip(), style: const TextStyle(fontSize: 13, color: AppColors.negroTexto)),
|
||||
])),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Mis calificaciones
|
||||
if (_myReviews.isNotEmpty) ...[
|
||||
const Text('Mis calificaciones', style: TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.guindaPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
..._myReviews.take(3).map((r) => Card(margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(dense: true,
|
||||
leading: CircleAvatar(backgroundColor: Colors.amber.shade100,
|
||||
child: Text('${r.estrellas}', style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, color: Colors.amber))),
|
||||
title: Text(r.colonia, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(r.comentario, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
trailing: Text(
|
||||
'${DateTime.tryParse(r.fecha)?.day}/${DateTime.tryParse(r.fecha)?.month}',
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
))),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
String _weeklyTip() {
|
||||
final tips = [
|
||||
'Separa tus residuos en organicos (restos de comida) e inorganicos (plasticos, metales). Facilita el reciclaje y reduce la contaminacion.',
|
||||
'Coloca tus bolsas en la acera SOLO cuando recibas el aviso de 15 minutos. Sacarlas antes atrae fauna nociva.',
|
||||
'El reciclaje de 1 tonelada de papel salva 17 arboles. Dobla tus cajas y periodicos antes de depositarlos.',
|
||||
'Los aceites usados de cocina NO van a la basura. Llevalos a los puntos de acopio del municipio.',
|
||||
'Composta tus restos organicos si tienes jardin. Reduce hasta un 40% tu basura y mejora tu suelo.',
|
||||
'Las pilas y baterias son residuos peligrosos. Depositalas en los contenedores especiales de tiendas.',
|
||||
'Un celular viejo contiene oro, plata y cobre. Llevalo a un punto RAEE para su reciclaje correcto.',
|
||||
];
|
||||
return tips[DateTime.now().weekday % tips.length];
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
final Color color; final String label;
|
||||
const _Legend({required this.color, required this.label});
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
class NotificationHistoryScreen extends StatefulWidget {
|
||||
const NotificationHistoryScreen({super.key});
|
||||
@override State<NotificationHistoryScreen> createState() => _NotificationHistoryScreenState();
|
||||
}
|
||||
|
||||
class _NotificationHistoryScreenState extends State<NotificationHistoryScreen> {
|
||||
List<Map<String, dynamic>> _notifs = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null) return;
|
||||
final n = await DbHelper.getNotifHistory(auth.currentUser!.id!);
|
||||
await DbHelper.markAllNotifsRead(auth.currentUser!.id!);
|
||||
if (mounted) setState(() { _notifs = n; _loading = false; });
|
||||
}
|
||||
|
||||
Color _color(String type) {
|
||||
switch (type) {
|
||||
case 'truckProximity':
|
||||
case 'truckApproaching15min': return AppColors.naranjaAlerta;
|
||||
case 'routeCompleted':
|
||||
case 'reviewPrompt': return AppColors.verdeExito;
|
||||
case 'routeCancelled': return AppColors.rojoError;
|
||||
case 'gpsLost': return Colors.red.shade800;
|
||||
case 'truckStopped': return AppColors.naranjaAlerta;
|
||||
default: return AppColors.azulInfo;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _icon(String type) {
|
||||
switch (type) {
|
||||
case 'truckProximity':
|
||||
case 'truckApproaching15min': return Icons.warning_amber_rounded;
|
||||
case 'routeCompleted': return Icons.check_circle;
|
||||
case 'reviewPrompt': return Icons.star;
|
||||
case 'routeCancelled': return Icons.cancel;
|
||||
case 'gpsLost': return Icons.gps_off;
|
||||
default: return Icons.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
String _timeAgo(String fechaStr) {
|
||||
final f = DateTime.tryParse(fechaStr);
|
||||
if (f == null) return '';
|
||||
final diff = DateTime.now().difference(f);
|
||||
if (diff.inMinutes < 1) return 'Ahora';
|
||||
if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min';
|
||||
if (diff.inHours < 24) return 'Hace ${diff.inHours}h';
|
||||
return '${f.day}/${f.month}/${f.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Historial de Alertas'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await DbHelper.markAllNotifsRead(
|
||||
context.read<AuthService>().currentUser!.id!);
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Marcar leídas', style: TextStyle(color: AppColors.dorado, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _notifs.isEmpty
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.notifications_none, color: Colors.grey.shade400, size: 64),
|
||||
const SizedBox(height: 12),
|
||||
Text('Sin alertas registradas', style: TextStyle(color: Colors.grey.shade500)),
|
||||
]))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _notifs.length,
|
||||
itemBuilder: (_, i) {
|
||||
final n = _notifs[i];
|
||||
final isUnread = (n['leida'] as int?) == 0;
|
||||
final color = _color(n['event_type'] ?? '');
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isUnread ? color.withOpacity(0.05) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isUnread ? color.withOpacity(0.3) : Colors.grey.shade200),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color.withOpacity(0.15),
|
||||
child: Icon(_icon(n['event_type'] ?? ''), color: color, size: 20),
|
||||
),
|
||||
title: Text(n['title'] ?? '', style: TextStyle(
|
||||
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: 13)),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(n['body'] ?? '', style: const TextStyle(fontSize: 11), maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
Text('${n['route_id']} · ${_timeAgo(n['fecha'] ?? '')}',
|
||||
style: TextStyle(fontSize: 10, color: color.withOpacity(0.7))),
|
||||
]),
|
||||
trailing: isUnread
|
||||
? Container(width: 8, height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
183
celaya_limpia/lib/screens/citizen/review_screen.dart
Normal file
183
celaya_limpia/lib/screens/citizen/review_screen.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/route_simulator_service.dart';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
final String routeId;
|
||||
final String colonia;
|
||||
const ReviewScreen({super.key, required this.routeId, required this.colonia});
|
||||
@override State<ReviewScreen> createState() => _ReviewScreenState();
|
||||
}
|
||||
|
||||
class _ReviewScreenState extends State<ReviewScreen> {
|
||||
int _estrellas = 5;
|
||||
final _comentCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _sent = false;
|
||||
|
||||
static const _labels = ['', 'Muy malo', 'Malo', 'Regular', 'Bueno', 'Excelente'];
|
||||
static const _colors = [
|
||||
Colors.transparent, AppColors.rojoError, AppColors.naranjaAlerta,
|
||||
Colors.amber, AppColors.verdeExito, AppColors.verdeExito,
|
||||
];
|
||||
|
||||
Future<void> _enviar() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null) return;
|
||||
|
||||
// Verificar si ya calificó hoy
|
||||
final yaCalificado = await DbHelper.hasReviewedRoute(
|
||||
auth.currentUser!.id!, widget.routeId);
|
||||
if (yaCalificado && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Ya calificaste este servicio hoy'),
|
||||
backgroundColor: AppColors.azulInfo));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
await DbHelper.insertReview(ReviewModel(
|
||||
userId: auth.currentUser!.id!,
|
||||
colonia: widget.colonia,
|
||||
routeId: widget.routeId,
|
||||
estrellas: _estrellas,
|
||||
comentario: _comentCtrl.text.trim().isEmpty
|
||||
? 'Sin comentario' : _comentCtrl.text.trim(),
|
||||
fecha: DateTime.now().toIso8601String(),
|
||||
nombreUsuario: auth.currentUser!.nombre,
|
||||
));
|
||||
|
||||
context.read<RouteSimulatorService>().clearReviewPrompt(widget.routeId);
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = false; _sent = true; });
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
title: const Text('Calificar el Servicio'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
),
|
||||
body: _sent
|
||||
? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Text('⭐', style: TextStyle(fontSize: 64)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('¡Gracias por tu calificación!',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
|
||||
color: AppColors.guindaPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Tu opinión ayuda a mejorar el servicio\nde recolección en Celaya.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.grisTexto)),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Volver al inicio')),
|
||||
]))
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(children: [
|
||||
// Header
|
||||
Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guindaPrimary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.guindaPrimary.withOpacity(0.2))),
|
||||
child: Column(children: [
|
||||
const Icon(Icons.local_shipping, color: AppColors.guindaPrimary, size: 36),
|
||||
const SizedBox(height: 8),
|
||||
Text(widget.routeId, style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, color: AppColors.guindaPrimary)),
|
||||
Text(widget.colonia, style: const TextStyle(
|
||||
color: AppColors.grisTexto, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Estrellas
|
||||
const Text('¿Cómo calificarías el servicio de hoy?',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 16),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: List.generate(5, (i) {
|
||||
final star = i + 1;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _estrellas = star),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
_estrellas >= star ? Icons.star : Icons.star_border,
|
||||
color: _estrellas >= star ? Colors.amber : Colors.grey,
|
||||
size: 44,
|
||||
),
|
||||
),
|
||||
);
|
||||
})),
|
||||
const SizedBox(height: 8),
|
||||
Text(_labels[_estrellas],
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
|
||||
color: _colors[_estrellas])),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Comentario
|
||||
const Align(alignment: Alignment.centerLeft,
|
||||
child: Text('Comentario (opcional)',
|
||||
style: TextStyle(fontWeight: FontWeight.w600))),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _comentCtrl,
|
||||
maxLines: 4,
|
||||
maxLength: 200,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cuéntanos cómo estuvo el servicio...',
|
||||
border: OutlineInputBorder(),
|
||||
filled: true, fillColor: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Aviso
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200)),
|
||||
child: const Row(children: [
|
||||
Icon(Icons.info_outline, color: AppColors.azulInfo, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Expanded(child: Text(
|
||||
'Tu calificación es anónima para otros ciudadanos, '
|
||||
'pero el Ayuntamiento la usará para mejorar el servicio.',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.azulInfo))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(width: double.infinity, height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _loading ? null : _enviar,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
icon: _loading
|
||||
? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.star),
|
||||
label: const Text('ENVIAR CALIFICACIÓN',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override void dispose() { _comentCtrl.dispose(); super.dispose(); }
|
||||
}
|
||||
456
celaya_limpia/lib/screens/driver/driver_home_screen.dart
Normal file
456
celaya_limpia/lib/screens/driver/driver_home_screen.dart
Normal file
@@ -0,0 +1,456 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/route_simulator_service.dart';
|
||||
import '../../database/db_helper.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../widgets/route_map_widget.dart';
|
||||
import '../settings_screen.dart';
|
||||
|
||||
class DriverHomeScreen extends StatefulWidget {
|
||||
const DriverHomeScreen({super.key});
|
||||
@override State<DriverHomeScreen> createState() => _DriverHomeScreenState();
|
||||
}
|
||||
|
||||
class _DriverHomeScreenState extends State<DriverHomeScreen> {
|
||||
int _tab = 0;
|
||||
List<AssignmentModel> _assignments = [];
|
||||
String? _todayRouteId;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final auth = context.read<AuthService>();
|
||||
if (auth.currentUser == null) return;
|
||||
final list = await DbHelper.getAsignacionesByConductor(auth.currentUser!.id!);
|
||||
final today = _todayDia();
|
||||
setState(() {
|
||||
_assignments = list;
|
||||
final match = list.where((a) => a.diaSemana == today);
|
||||
_todayRouteId = match.isNotEmpty ? match.first.routeId : null;
|
||||
});
|
||||
if (_todayRouteId != null) {
|
||||
context.read<RouteSimulatorService>().startRoute(_todayRouteId!);
|
||||
}
|
||||
}
|
||||
|
||||
String _todayDia() {
|
||||
const d = ['','LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'];
|
||||
return d[DateTime.now().weekday];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthService>();
|
||||
final sim = context.watch<RouteSimulatorService>();
|
||||
final route = _todayRouteId != null ? getRouteById(_todayRouteId!) : null;
|
||||
|
||||
// Solo notificaciones de la ruta actual del conductor
|
||||
final lastNotif = _todayRouteId != null
|
||||
? sim.getNotificationForRoute(_todayRouteId!) : null;
|
||||
|
||||
final tabs = [
|
||||
_DriverMainTab(auth:auth, sim:sim, route:route,
|
||||
assignments:_assignments, todayRouteId:_todayRouteId, onRefresh:_load),
|
||||
if (route != null) _DriverMapTab(route:route, sim:sim)
|
||||
else const Center(child:Text('Sin ruta hoy')),
|
||||
_DriverReportesTab(conductorId:auth.currentUser?.id, todayRouteId:_todayRouteId),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(children:[
|
||||
tabs[_tab],
|
||||
if (lastNotif != null)
|
||||
Positioned(top:MediaQuery.of(context).padding.top+8, left:0, right:0,
|
||||
child:_NotifBanner(notif:lastNotif,
|
||||
onDismiss:()=>sim.dismissRouteNotification(_todayRouteId??''))),
|
||||
]),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _tab,
|
||||
onDestinationSelected: (i) => setState(()=>_tab=i),
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: AppColors.moradoConductor.withOpacity(0.15),
|
||||
destinations: const [
|
||||
NavigationDestination(icon:Icon(Icons.dashboard_outlined),
|
||||
selectedIcon:Icon(Icons.dashboard,color:AppColors.moradoConductor),label:'Mi Ruta'),
|
||||
NavigationDestination(icon:Icon(Icons.map_outlined),
|
||||
selectedIcon:Icon(Icons.map,color:AppColors.moradoConductor),label:'Mapa'),
|
||||
NavigationDestination(icon:Icon(Icons.report_problem_outlined),
|
||||
selectedIcon:Icon(Icons.report_problem,color:AppColors.moradoConductor),label:'Incidente'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab principal ─────────────────────────────────────────────────────────
|
||||
class _DriverMainTab extends StatefulWidget {
|
||||
final AuthService auth; final RouteSimulatorService sim;
|
||||
final route; final assignments; final todayRouteId; final VoidCallback onRefresh;
|
||||
const _DriverMainTab({required this.auth, required this.sim, required this.route,
|
||||
required this.assignments, required this.todayRouteId, required this.onRefresh});
|
||||
@override State<_DriverMainTab> createState() => _DriverMainTabState();
|
||||
}
|
||||
|
||||
class _DriverMainTabState extends State<_DriverMainTab> {
|
||||
List<ReporteModel> _ciudadanoReportes = [];
|
||||
|
||||
@override void initState() { super.initState(); _loadReportes(); }
|
||||
|
||||
Future<void> _loadReportes() async {
|
||||
if (widget.todayRouteId == null) return;
|
||||
final all = await DbHelper.getAllReportes();
|
||||
final filtered = all.where((r) => r.routeId == widget.todayRouteId).toList();
|
||||
if (mounted) setState(() => _ciudadanoReportes = filtered.take(5).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final posIdx = widget.todayRouteId != null
|
||||
? widget.sim.getPositionIndex(widget.todayRouteId!) : 0;
|
||||
final gpsOk = widget.todayRouteId != null
|
||||
? widget.sim.isGpsActive(widget.todayRouteId!) : true;
|
||||
|
||||
return CustomScrollView(slivers:[
|
||||
SliverAppBar(pinned:true, backgroundColor:AppColors.moradoConductor, foregroundColor:Colors.white,
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado)),
|
||||
title:Text('Conductor: ${widget.auth.currentUser?.nombre.split(' ').first ?? ''}',
|
||||
style:const TextStyle(fontSize:16,fontWeight:FontWeight.bold)),
|
||||
actions:[IconButton(icon:const Icon(Icons.logout),
|
||||
onPressed:()async{ await widget.auth.logout();
|
||||
if(context.mounted) Navigator.pushReplacementNamed(context,'/login');})]),
|
||||
|
||||
SliverPadding(padding:const EdgeInsets.all(14),sliver:SliverList(delegate:SliverChildListDelegate([
|
||||
// Ruta de hoy
|
||||
Card(color:AppColors.moradoConductor.withOpacity(0.08),
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12),
|
||||
side:BorderSide(color:AppColors.moradoConductor.withOpacity(0.3))),
|
||||
child:Padding(padding:const EdgeInsets.all(14),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
Row(children:[
|
||||
const Icon(Icons.today,color:AppColors.moradoConductor),
|
||||
const SizedBox(width:8),
|
||||
Text('Hoy — ${_todayLabel()}',
|
||||
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
|
||||
]),
|
||||
const Divider(),
|
||||
if (widget.route != null)...[
|
||||
Text(widget.route.name,style:const TextStyle(fontWeight:FontWeight.bold,fontSize:14)),
|
||||
Text('Camión ${widget.route.truckId} • Turno: ${widget.route.turno}',
|
||||
style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
|
||||
const SizedBox(height:8),
|
||||
Row(children:[
|
||||
Icon(gpsOk?Icons.gps_fixed:Icons.gps_off,
|
||||
color:gpsOk?AppColors.verdeExito:AppColors.rojoError,size:16),
|
||||
const SizedBox(width:4),
|
||||
Text(gpsOk?'GPS Activo':'⚠️ GPS Desactivado',
|
||||
style:TextStyle(color:gpsOk?AppColors.verdeExito:AppColors.rojoError,
|
||||
fontWeight:FontWeight.bold,fontSize:12)),
|
||||
const Spacer(),
|
||||
Text('Posición ${posIdx+1}/8',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
|
||||
]),
|
||||
const SizedBox(height:8),
|
||||
LinearProgressIndicator(value:(posIdx+1)/8,
|
||||
backgroundColor:Colors.grey.shade300,
|
||||
valueColor:const AlwaysStoppedAnimation<Color>(AppColors.moradoConductor)),
|
||||
const SizedBox(height:6),
|
||||
Text(widget.sim.getEtaText(widget.todayRouteId??''),
|
||||
style:const TextStyle(fontSize:13,fontWeight:FontWeight.w500)),
|
||||
] else
|
||||
const Text('⚠️ Sin ruta asignada hoy.',style:TextStyle(color:AppColors.rojoError)),
|
||||
]))),
|
||||
const SizedBox(height:10),
|
||||
|
||||
// Instrucciones
|
||||
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Text('📋 Instrucciones de Ruta',
|
||||
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
|
||||
const Divider(),
|
||||
const Text('• Sigue la ruta asignada sin desviaciones\n'
|
||||
'• Mantén el GPS activo en todo momento\n'
|
||||
'• Reporta incidentes desde "Incidente"\n'
|
||||
'• Si hay problema, el admin decidirá si se cancela o retrasa',
|
||||
style:TextStyle(fontSize:12,color:AppColors.grisTexto)),
|
||||
]))),
|
||||
const SizedBox(height:10),
|
||||
|
||||
// Reportes ciudadanos de SU ruta
|
||||
if (_ciudadanoReportes.isNotEmpty) ...[
|
||||
Card(color:Colors.orange.shade50,
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(10),
|
||||
side:BorderSide(color:Colors.orange.shade200)),
|
||||
child:Padding(padding:const EdgeInsets.all(12),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Row(children:[
|
||||
Icon(Icons.people,color:AppColors.naranjaAlerta,size:16),
|
||||
SizedBox(width:6),
|
||||
Text('Reportes de tu ruta hoy',
|
||||
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.naranjaAlerta,fontSize:13)),
|
||||
]),
|
||||
const Divider(),
|
||||
..._ciudadanoReportes.map((r)=>Padding(
|
||||
padding:const EdgeInsets.symmetric(vertical:3),
|
||||
child:Row(children:[
|
||||
const Icon(Icons.person_outline,size:12,color:AppColors.grisTexto),
|
||||
const SizedBox(width:4),
|
||||
Expanded(child:Text(r.descripcion,style:const TextStyle(fontSize:11),
|
||||
maxLines:1,overflow:TextOverflow.ellipsis)),
|
||||
]))),
|
||||
]))),
|
||||
const SizedBox(height:10),
|
||||
],
|
||||
|
||||
// Horario LMV / MJS
|
||||
Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Text('Mi Horario',
|
||||
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor)),
|
||||
const Divider(),
|
||||
if (widget.assignments.isEmpty)
|
||||
const Text('Sin asignaciones. Contacta al administrador.',
|
||||
style:TextStyle(color:AppColors.grisTexto,fontSize:12))
|
||||
else ...[
|
||||
_scheduleGroup(widget.assignments,'LUNES','MIERCOLES','VIERNES',
|
||||
'Lunes, Miércoles y Viernes'),
|
||||
const SizedBox(height:8),
|
||||
_scheduleGroup(widget.assignments,'MARTES','JUEVES','SABADO',
|
||||
'Martes, Jueves y Sábado'),
|
||||
],
|
||||
]))),
|
||||
const SizedBox(height:80),
|
||||
]))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _scheduleGroup(List<AssignmentModel> all, String d1, String d2, String d3, String label) {
|
||||
AssignmentModel? found;
|
||||
for (final dia in [d1,d2,d3]) {
|
||||
try { found = all.firstWhere((a)=>a.diaSemana==dia); break; } catch(_){}
|
||||
}
|
||||
return Container(padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.06),
|
||||
borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.2))),
|
||||
child:Row(children:[
|
||||
const Icon(Icons.calendar_today,size:14,color:AppColors.moradoConductor),
|
||||
const SizedBox(width:6),
|
||||
Expanded(child:Text(label,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:12))),
|
||||
if (found!=null)
|
||||
Container(padding:const EdgeInsets.symmetric(horizontal:8,vertical:3),
|
||||
decoration:BoxDecoration(color:AppColors.moradoConductor,borderRadius:BorderRadius.circular(8)),
|
||||
child:Text('${found.routeId} • ${found.turno}',
|
||||
style:const TextStyle(fontSize:11,color:Colors.white,fontWeight:FontWeight.bold)))
|
||||
else
|
||||
const Text('Sin asignar',style:TextStyle(fontSize:11,color:AppColors.grisTexto)),
|
||||
]));
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
const d=['','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo'];
|
||||
return d[DateTime.now().weekday];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab mapa ──────────────────────────────────────────────────────────────
|
||||
class _DriverMapTab extends StatelessWidget {
|
||||
final route; final sim;
|
||||
const _DriverMapTab({required this.route, required this.sim});
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar:AppBar(automaticallyImplyLeading:false,
|
||||
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
|
||||
title:Text(route.name,style:const TextStyle(fontSize:13)),
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado))),
|
||||
body:DriverRouteMap(route:route,simulator:sim));
|
||||
}
|
||||
|
||||
// ── Tab reporte incidente — usa routeId actual ────────────────────────────
|
||||
class _DriverReportesTab extends StatefulWidget {
|
||||
final int? conductorId;
|
||||
final String? todayRouteId; // Ruta actual del conductor
|
||||
const _DriverReportesTab({required this.conductorId, required this.todayRouteId});
|
||||
@override State<_DriverReportesTab> createState() => _DriverReportesTabState();
|
||||
}
|
||||
|
||||
class _DriverReportesTabState extends State<_DriverReportesTab> {
|
||||
String _tipo = 'INCIDENTE_LLANTA';
|
||||
final _desc = TextEditingController();
|
||||
bool _loading = false, _sent = false;
|
||||
List<AlertaModel> _misIncidentes = [];
|
||||
|
||||
static const _tipos = {
|
||||
'INCIDENTE_LLANTA': '🔧 Llanta ponchada',
|
||||
'INCIDENTE_MECANICA': '🔥 Falla mecánica',
|
||||
'INCIDENTE_ACCIDENTE': '🚑 Accidente',
|
||||
'INCIDENTE_CAMINO': '🚧 Camino bloqueado',
|
||||
'INCIDENTE_COMBUSTIBLE':'⛽ Sin combustible',
|
||||
'INCIDENTE_OTRO': '📝 Otro',
|
||||
};
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
|
||||
Future<void> _load() async {
|
||||
final all = await DbHelper.getAlertas();
|
||||
// Solo incidentes de la ruta actual del conductor
|
||||
final mine = all.where((a) =>
|
||||
a.tipo.startsWith('INCIDENTE_') &&
|
||||
a.routeId == (widget.todayRouteId ?? '')).toList();
|
||||
if (mounted) setState(() => _misIncidentes = mine);
|
||||
}
|
||||
|
||||
Future<void> _enviar() async {
|
||||
if (widget.todayRouteId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content:Text('No tienes ruta asignada hoy'),
|
||||
backgroundColor:AppColors.rojoError)); return;
|
||||
}
|
||||
if (_desc.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content:Text('Describe el incidente'),backgroundColor:AppColors.rojoError)); return;
|
||||
}
|
||||
setState(()=>_loading=true);
|
||||
// Guardar el incidente asociado a la RUTA ACTUAL
|
||||
await DbHelper.insertAlerta(AlertaModel(
|
||||
tipo: _tipo,
|
||||
routeId: widget.todayRouteId!, // ← ID de la ruta actual, no del conductor
|
||||
mensaje: '${_tipos[_tipo]}: ${_desc.text.trim()}',
|
||||
fecha: DateTime.now().toIso8601String(),
|
||||
));
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
setState(() { _loading=false; _sent=true; });
|
||||
_desc.clear();
|
||||
await Future.delayed(const Duration(seconds:2));
|
||||
if (mounted) setState(()=>_sent=false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor:AppColors.grisFondo,
|
||||
appBar:AppBar(automaticallyImplyLeading:false,
|
||||
backgroundColor:AppColors.moradoConductor,foregroundColor:Colors.white,
|
||||
title:const Text('Reportar Incidente'),
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado))),
|
||||
body: _sent
|
||||
? const Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
|
||||
Icon(Icons.check_circle,color:AppColors.verdeExito,size:64),
|
||||
SizedBox(height:12),
|
||||
Text('¡Incidente reportado!',style:TextStyle(fontSize:18,fontWeight:FontWeight.bold,color:AppColors.verdeExito)),
|
||||
Text('El administrador será notificado.',style:TextStyle(color:AppColors.grisTexto)),
|
||||
]))
|
||||
: SingleChildScrollView(padding:const EdgeInsets.all(16),child:Column(children:[
|
||||
// Info ruta actual
|
||||
if (widget.todayRouteId != null)
|
||||
Container(margin:const EdgeInsets.only(bottom:12),
|
||||
padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.08),
|
||||
borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:AppColors.moradoConductor.withOpacity(0.3))),
|
||||
child:Row(children:[
|
||||
const Icon(Icons.route,color:AppColors.moradoConductor,size:16),
|
||||
const SizedBox(width:6),
|
||||
Text('Incidente en: ${widget.todayRouteId}',
|
||||
style:const TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:13)),
|
||||
]))
|
||||
else
|
||||
Container(margin:const EdgeInsets.only(bottom:12),
|
||||
padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:Colors.orange.shade50,borderRadius:BorderRadius.circular(8)),
|
||||
child:const Text('⚠️ No tienes ruta asignada hoy',
|
||||
style:TextStyle(color:AppColors.naranjaAlerta,fontWeight:FontWeight.bold))),
|
||||
|
||||
Card(shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
|
||||
child:Padding(padding:const EdgeInsets.all(16),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Text('Tipo de incidente',
|
||||
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:15)),
|
||||
const SizedBox(height:8),
|
||||
..._tipos.entries.map((e)=>RadioListTile<String>(dense:true,
|
||||
value:e.key,groupValue:_tipo,
|
||||
title:Text(e.value,style:const TextStyle(fontSize:13)),
|
||||
activeColor:AppColors.moradoConductor,
|
||||
onChanged:(v)=>setState(()=>_tipo=v!))),
|
||||
const SizedBox(height:10),
|
||||
const Text('Descripción',style:TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
|
||||
const SizedBox(height:6),
|
||||
TextField(controller:_desc,maxLines:3,
|
||||
decoration:const InputDecoration(hintText:'Describe qué pasó...',
|
||||
border:OutlineInputBorder(),filled:true,fillColor:Colors.white)),
|
||||
const SizedBox(height:12),
|
||||
Container(padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:Colors.orange.shade50,
|
||||
borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:Colors.orange.shade200)),
|
||||
child:const Text(
|
||||
'⚠️ El administrador verá este incidente en tu ruta actual '
|
||||
'y decidirá si continúa, se retrasa o se cancela.',
|
||||
style:TextStyle(fontSize:11,color:Colors.black87))),
|
||||
const SizedBox(height:14),
|
||||
SizedBox(width:double.infinity,height:48,
|
||||
child:ElevatedButton.icon(
|
||||
onPressed:(_loading||widget.todayRouteId==null)?null:_enviar,
|
||||
style:ElevatedButton.styleFrom(backgroundColor:AppColors.moradoConductor,
|
||||
foregroundColor:Colors.white,
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
|
||||
icon:_loading?const SizedBox(width:18,height:18,
|
||||
child:CircularProgressIndicator(color:Colors.white,strokeWidth:2))
|
||||
:const Icon(Icons.send),
|
||||
label:const Text('ENVIAR INCIDENTE',style:TextStyle(fontWeight:FontWeight.bold)))),
|
||||
]))),
|
||||
|
||||
if (_misIncidentes.isNotEmpty)...[
|
||||
const SizedBox(height:16),
|
||||
const Align(alignment:Alignment.centerLeft,
|
||||
child:Text('Mis incidentes de hoy',
|
||||
style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.moradoConductor,fontSize:14))),
|
||||
const SizedBox(height:8),
|
||||
..._misIncidentes.take(5).map((a)=>Card(margin:const EdgeInsets.only(bottom:6),
|
||||
child:ListTile(dense:true,
|
||||
leading:CircleAvatar(backgroundColor:AppColors.moradoConductor,radius:16,
|
||||
child:const Icon(Icons.warning,color:Colors.white,size:14)),
|
||||
title:Text(_tipos[a.tipo]??a.tipo,
|
||||
style:const TextStyle(fontSize:12,fontWeight:FontWeight.w600)),
|
||||
subtitle:Text(a.mensaje,maxLines:1,overflow:TextOverflow.ellipsis,
|
||||
style:const TextStyle(fontSize:11)),
|
||||
trailing:Icon(a.resuelta?Icons.check_circle:Icons.pending,
|
||||
color:a.resuelta?AppColors.verdeExito:AppColors.naranjaAlerta,size:18)))),
|
||||
],
|
||||
])),
|
||||
);
|
||||
|
||||
@override void dispose(){ _desc.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
// ── Notif banner conductor ────────────────────────────────────────────────
|
||||
class _NotifBanner extends StatelessWidget {
|
||||
final AppNotification notif; final VoidCallback onDismiss;
|
||||
const _NotifBanner({required this.notif, required this.onDismiss});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = notif.event==NotifEvent.gpsLost?Colors.red.shade800
|
||||
:notif.event==NotifEvent.truckStopped?AppColors.naranjaAlerta
|
||||
:notif.event==NotifEvent.routeCancelled?AppColors.rojoError
|
||||
:AppColors.moradoConductor;
|
||||
return Material(color:Colors.transparent,
|
||||
child:Container(margin:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:color,borderRadius:BorderRadius.circular(12),
|
||||
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
|
||||
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
|
||||
const Icon(Icons.notification_important,color:Colors.white,size:22),
|
||||
const SizedBox(width:8),
|
||||
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,
|
||||
mainAxisSize:MainAxisSize.min,children:[
|
||||
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
|
||||
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
|
||||
maxLines:2,overflow:TextOverflow.ellipsis),
|
||||
])),
|
||||
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
|
||||
]))));
|
||||
}
|
||||
}
|
||||
106
celaya_limpia/lib/screens/login_screen.dart
Normal file
106
celaya_limpia/lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../services/auth_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@override State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passCtrl = TextEditingController();
|
||||
bool _loading = false, _obscure = true;
|
||||
|
||||
Future<void> _login() async {
|
||||
if (_emailCtrl.text.isEmpty || _passCtrl.text.isEmpty) {
|
||||
_snack('Llena todos los campos', isError: true); return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
final err = await context.read<AuthService>().login(_emailCtrl.text, _passCtrl.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
if (err != null) { _snack(err, isError: true); return; }
|
||||
final rol = context.read<AuthService>().rol;
|
||||
switch (rol) {
|
||||
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
|
||||
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
|
||||
default: Navigator.pushReplacementNamed(context, '/home'); break;
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg, {bool isError = false}) => ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content:Text(msg),
|
||||
backgroundColor: isError ? AppColors.rojoError : AppColors.verdeExito));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
body: SingleChildScrollView(child: Column(children: [
|
||||
Container(width:double.infinity, color:AppColors.guindaPrimary,
|
||||
padding:const EdgeInsets.only(top:60,bottom:28),
|
||||
child:Column(children:[
|
||||
Container(width:84,height:84,
|
||||
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
|
||||
border:Border.all(color:AppColors.dorado,width:2.5)),
|
||||
child:const Icon(Icons.delete_sweep_rounded,size:44,color:AppColors.dorado)),
|
||||
const SizedBox(height:14),
|
||||
const Text('H. AYUNTAMIENTO DE CELAYA',
|
||||
style:TextStyle(color:Colors.white,fontSize:13,fontWeight:FontWeight.bold,letterSpacing:1.2)),
|
||||
const SizedBox(height:4),
|
||||
const Text('Sistema de Recolección de Residuos',
|
||||
style:TextStyle(color:AppColors.dorado,fontSize:13)),
|
||||
])),
|
||||
Container(height:4,color:AppColors.dorado),
|
||||
Padding(padding:const EdgeInsets.all(24), child:Card(elevation:4,
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),
|
||||
child:Padding(padding:const EdgeInsets.all(24), child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
const Text('Iniciar Sesión',style:TextStyle(fontSize:20,
|
||||
fontWeight:FontWeight.bold,color:AppColors.guindaPrimary)),
|
||||
const SizedBox(height:16),
|
||||
// Accesos rápidos demo
|
||||
Container(padding:const EdgeInsets.all(10),
|
||||
decoration:BoxDecoration(color:Colors.blue.shade50,borderRadius:BorderRadius.circular(8)),
|
||||
child:const Column(crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
Text('Demo rápido:',style:TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.azulInfo)),
|
||||
Text('Admin: admin@celaya.gob.mx / admin123',style:TextStyle(fontSize:11)),
|
||||
Text('Conductor: conductor@celaya.gob.mx / conductor123',style:TextStyle(fontSize:11)),
|
||||
])),
|
||||
const SizedBox(height:16),
|
||||
TextField(controller:_emailCtrl,keyboardType:TextInputType.emailAddress,
|
||||
decoration:const InputDecoration(labelText:'Correo electrónico',
|
||||
prefixIcon:Icon(Icons.email_outlined,color:AppColors.guindaPrimary),
|
||||
border:OutlineInputBorder())),
|
||||
const SizedBox(height:12),
|
||||
TextField(controller:_passCtrl,obscureText:_obscure,
|
||||
decoration:InputDecoration(labelText:'Contraseña',
|
||||
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
|
||||
border:const OutlineInputBorder(),
|
||||
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
|
||||
onPressed:()=>setState(()=>_obscure=!_obscure)))),
|
||||
const SizedBox(height:20),
|
||||
SizedBox(width:double.infinity,height:50,
|
||||
child:ElevatedButton(onPressed:_loading?null:_login,
|
||||
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
|
||||
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
|
||||
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
|
||||
:const Text('ENTRAR',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
|
||||
const SizedBox(height:12),
|
||||
const Divider(),
|
||||
const SizedBox(height:12),
|
||||
SizedBox(width:double.infinity,height:50,
|
||||
child:OutlinedButton(onPressed:()=>Navigator.pushNamed(context,'/register'),
|
||||
style:OutlinedButton.styleFrom(foregroundColor:AppColors.guindaPrimary,
|
||||
side:const BorderSide(color:AppColors.guindaPrimary),
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
|
||||
child:const Text('CREAR CUENTA CIUDADANO',style:TextStyle(fontWeight:FontWeight.bold)))),
|
||||
])))),
|
||||
const Padding(padding:EdgeInsets.only(bottom:20),
|
||||
child:Text('Gobierno Municipal de Celaya • Guanajuato',
|
||||
style:TextStyle(color:AppColors.grisTexto,fontSize:11))),
|
||||
])),
|
||||
);
|
||||
@override void dispose() { _emailCtrl.dispose(); _passCtrl.dispose(); super.dispose(); }
|
||||
}
|
||||
121
celaya_limpia/lib/screens/onboarding/onboarding_screen.dart
Normal file
121
celaya_limpia/lib/screens/onboarding/onboarding_screen.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
@override State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final _ctrl = PageController();
|
||||
int _page = 0;
|
||||
|
||||
static const _pages = [
|
||||
_OnboardPage(icon:Icons.delete_sweep_rounded, color:AppColors.guindaPrimary,
|
||||
title:'Bienvenido a Celaya Limpia',
|
||||
subtitle:'El sistema de recoleccion inteligente del H. Ayuntamiento de Celaya.',
|
||||
desc:'Recibe alertas en tiempo real, conoce tu horario y ayuda a mantener tu ciudad limpia.'),
|
||||
_OnboardPage(icon:Icons.notifications_active, color:AppColors.azulInfo,
|
||||
title:'Alertas inteligentes',
|
||||
subtitle:'Te avisamos exactamente cuando debes sacar tu basura.',
|
||||
desc:'Recibiras 3 alertas:\n\n30 min antes: el camion esta en camino.\n15 min antes: saca tus bolsas a la acera.\nAl pasar: confirma que fue recogida.\n\nNunca mas pierdas al camion recolector.'),
|
||||
_OnboardPage(icon:Icons.recycling, color:AppColors.verdeExito,
|
||||
title:'Guia de separacion',
|
||||
subtitle:'Aprende a separar correctamente tus residuos.',
|
||||
desc:'Bolsa verde: organicos (comida, jardin)\nBolsa azul: reciclables (PET, latas)\nBolsa negra: no reciclables\n\nUsa la camara IA para identificar si un residuo es organico o inorganico al instante.'),
|
||||
_OnboardPage(icon:Icons.star, color:Colors.amber,
|
||||
title:'Tu opinion importa',
|
||||
subtitle:'Califica el servicio y ayuda a mejorarlo.',
|
||||
desc:'Despues de cada recoleccion podras:\n\nCalificar de 1 a 5 estrellas\nDejar comentarios al Ayuntamiento\nReportar incidencias con foto\n\nTus reportes son atendidos por el equipo municipal.'),
|
||||
];
|
||||
|
||||
Future<void> _finish() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool('onboarding_done', true);
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(children: [
|
||||
PageView.builder(controller:_ctrl, onPageChanged:(p)=>setState(()=>_page=p),
|
||||
itemCount:_pages.length,
|
||||
itemBuilder:(_,i)=>_PageContent(page:_pages[i])),
|
||||
Positioned(bottom:120, left:0, right:0,
|
||||
child:Row(mainAxisAlignment:MainAxisAlignment.center,
|
||||
children:List.generate(_pages.length,(i)=>AnimatedContainer(
|
||||
duration:const Duration(milliseconds:250),
|
||||
margin:const EdgeInsets.symmetric(horizontal:4),
|
||||
width:_page==i?24:8, height:8,
|
||||
decoration:BoxDecoration(
|
||||
color:_page==i?_pages[i].color:Colors.grey.shade300,
|
||||
borderRadius:BorderRadius.circular(4)))))),
|
||||
Positioned(bottom:40, left:24, right:24,
|
||||
child:Row(children:[
|
||||
if (_page>0)
|
||||
TextButton(onPressed:()=>_ctrl.previousPage(
|
||||
duration:const Duration(milliseconds:300),curve:Curves.easeOut),
|
||||
child:const Text('Atras',style:TextStyle(color:AppColors.grisTexto)))
|
||||
else
|
||||
TextButton(onPressed:_finish,
|
||||
child:const Text('Omitir',style:TextStyle(color:AppColors.grisTexto))),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed:_page<_pages.length-1
|
||||
?()=>_ctrl.nextPage(duration:const Duration(milliseconds:300),curve:Curves.easeOut)
|
||||
:_finish,
|
||||
style:ElevatedButton.styleFrom(
|
||||
backgroundColor:_pages[_page].color, foregroundColor:Colors.white,
|
||||
padding:const EdgeInsets.symmetric(horizontal:28,vertical:12),
|
||||
shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(25))),
|
||||
child:Text(_page<_pages.length-1?'Siguiente':'Comenzar',
|
||||
style:const TextStyle(fontWeight:FontWeight.bold))),
|
||||
])),
|
||||
]));
|
||||
}
|
||||
@override void dispose(){ _ctrl.dispose(); super.dispose(); }
|
||||
}
|
||||
|
||||
class _OnboardPage {
|
||||
final IconData icon; final Color color;
|
||||
final String title, subtitle, desc;
|
||||
const _OnboardPage({required this.icon,required this.color,
|
||||
required this.title,required this.subtitle,required this.desc});
|
||||
}
|
||||
|
||||
class _PageContent extends StatelessWidget {
|
||||
final _OnboardPage page;
|
||||
const _PageContent({super.key, required this.page});
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration:BoxDecoration(gradient:LinearGradient(
|
||||
begin:Alignment.topCenter, end:Alignment.bottomCenter,
|
||||
colors:[page.color, page.color.withOpacity(0.85), Colors.white],
|
||||
stops:const[0,0.4,0.7])),
|
||||
child:SafeArea(child:Column(children:[
|
||||
const SizedBox(height:48),
|
||||
Container(width:120,height:120,
|
||||
decoration:BoxDecoration(color:Colors.white.withOpacity(0.2),shape:BoxShape.circle,
|
||||
border:Border.all(color:Colors.white.withOpacity(0.5),width:2)),
|
||||
child:Icon(page.icon,size:60,color:Colors.white)),
|
||||
const SizedBox(height:28),
|
||||
Padding(padding:const EdgeInsets.symmetric(horizontal:32),child:Column(children:[
|
||||
Text(page.title,textAlign:TextAlign.center,
|
||||
style:const TextStyle(fontSize:24,fontWeight:FontWeight.bold,color:Colors.white)),
|
||||
const SizedBox(height:10),
|
||||
Text(page.subtitle,textAlign:TextAlign.center,
|
||||
style:TextStyle(fontSize:14,color:Colors.white.withOpacity(0.9))),
|
||||
])),
|
||||
const SizedBox(height:32),
|
||||
Expanded(child:Container(margin:const EdgeInsets.symmetric(horizontal:20),
|
||||
padding:const EdgeInsets.all(22),
|
||||
decoration:BoxDecoration(color:Colors.white,borderRadius:BorderRadius.circular(20),
|
||||
boxShadow:[BoxShadow(color:page.color.withOpacity(0.2),blurRadius:20,offset:const Offset(0,8))]),
|
||||
child:Text(page.desc,textAlign:TextAlign.left,
|
||||
style:const TextStyle(fontSize:13,height:1.7,color:AppColors.negroTexto)))),
|
||||
const SizedBox(height:110),
|
||||
])));
|
||||
}
|
||||
105
celaya_limpia/lib/screens/register_screen.dart
Normal file
105
celaya_limpia/lib/screens/register_screen.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../data/colonies_data.dart';
|
||||
import '../data/celaya_colonias.dart';
|
||||
import '../models/route_model.dart';
|
||||
import '../services/auth_service.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
@override State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _nombre = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _calle = TextEditingController();
|
||||
ColonyModel? _colony;
|
||||
bool _loading = false, _obscure = true;
|
||||
|
||||
Future<void> _register() async {
|
||||
if ([_nombre,_email,_pass,_calle].any((c)=>c.text.trim().isEmpty) || _colony==null) {
|
||||
_snack('Completa todos los campos', isError:true); return; }
|
||||
if (_pass.text.length < 6) { _snack('Contraseña mínimo 6 caracteres', isError:true); return; }
|
||||
setState(()=>_loading=true);
|
||||
final err = await context.read<AuthService>().register(
|
||||
nombre:_nombre.text, email:_email.text, password:_pass.text,
|
||||
calle:_calle.text, colonia:_colony!.colonia,
|
||||
routeId:_colony!.routeId, horarioEstimado:_colony!.horarioEstimado);
|
||||
if (!mounted) return;
|
||||
setState(()=>_loading=false);
|
||||
if (err!=null) { _snack(err,isError:true); return; }
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
}
|
||||
|
||||
void _snack(String msg,{bool isError=false}) => ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content:Text(msg),
|
||||
backgroundColor:isError?AppColors.rojoError:AppColors.verdeExito));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.grisFondo,
|
||||
appBar: AppBar(backgroundColor:AppColors.guindaPrimary,foregroundColor:Colors.white,
|
||||
title:const Text('Registro Ciudadano'),
|
||||
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
|
||||
child:Container(height:4,color:AppColors.dorado))),
|
||||
body: SingleChildScrollView(padding:const EdgeInsets.all(20), child:Column(children:[
|
||||
_field(_nombre,'Nombre completo',Icons.badge_outlined),
|
||||
const SizedBox(height:12),
|
||||
_field(_email,'Correo electrónico',Icons.email_outlined,type:TextInputType.emailAddress),
|
||||
const SizedBox(height:12),
|
||||
TextField(controller:_pass,obscureText:_obscure,
|
||||
decoration:InputDecoration(labelText:'Contraseña (mín. 6)',
|
||||
prefixIcon:const Icon(Icons.lock_outline,color:AppColors.guindaPrimary),
|
||||
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white,
|
||||
suffixIcon:IconButton(icon:Icon(_obscure?Icons.visibility_off:Icons.visibility),
|
||||
onPressed:()=>setState(()=>_obscure=!_obscure)))),
|
||||
const SizedBox(height:20),
|
||||
const Align(alignment:Alignment.centerLeft,
|
||||
child:Text('Domicilio',style:TextStyle(fontWeight:FontWeight.bold,color:AppColors.guindaPrimary,fontSize:16))),
|
||||
const SizedBox(height:10),
|
||||
_field(_calle,'Calle y número',Icons.signpost_outlined),
|
||||
const SizedBox(height:12),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration:const InputDecoration(labelText:'Colonia',
|
||||
prefixIcon:Icon(Icons.location_city,color:AppColors.guindaPrimary),
|
||||
border:OutlineInputBorder(),filled:true,fillColor:Colors.white),
|
||||
hint:const Text('Selecciona tu colonia'),
|
||||
value:_colony?.colonia, isExpanded:true,
|
||||
items:celayaColonias.map((n)=>DropdownMenuItem(value:n,child:Text(n,style:const TextStyle(fontSize:13)))).toList(),
|
||||
onChanged:(v){ if(v!=null) setState((){
|
||||
_colony = getColonyByName(v) ?? ColonyModel(
|
||||
colonia:v, routeId:'RUTA-01', horarioEstimado:'Matutino (06:00-08:00)');
|
||||
}); }),
|
||||
if (_colony!=null) ...[
|
||||
const SizedBox(height:10),
|
||||
Container(padding:const EdgeInsets.all(12),
|
||||
decoration:BoxDecoration(color:AppColors.guindaPrimary.withOpacity(0.08),
|
||||
borderRadius:BorderRadius.circular(8),
|
||||
border:Border.all(color:AppColors.guindaPrimary.withOpacity(0.3))),
|
||||
child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
|
||||
Text('Ruta: ${_colony!.routeId}',style:const TextStyle(color:AppColors.guindaPrimary,fontWeight:FontWeight.bold)),
|
||||
Text('Horario: ${_colony!.horarioEstimado}',style:const TextStyle(color:AppColors.grisTexto,fontSize:12)),
|
||||
]))],
|
||||
const SizedBox(height:24),
|
||||
SizedBox(width:double.infinity,height:50,
|
||||
child:ElevatedButton(onPressed:_loading?null:_register,
|
||||
style:ElevatedButton.styleFrom(backgroundColor:AppColors.guindaPrimary,
|
||||
foregroundColor:Colors.white,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(8))),
|
||||
child:_loading?const CircularProgressIndicator(color:Colors.white,strokeWidth:2)
|
||||
:const Text('REGISTRARME',style:TextStyle(fontWeight:FontWeight.bold,letterSpacing:1)))),
|
||||
const SizedBox(height:20),
|
||||
])),
|
||||
);
|
||||
|
||||
Widget _field(TextEditingController c, String label, IconData icon,
|
||||
{TextInputType type=TextInputType.text}) =>
|
||||
TextField(controller:c,keyboardType:type,
|
||||
decoration:InputDecoration(labelText:label,
|
||||
prefixIcon:Icon(icon,color:AppColors.guindaPrimary),
|
||||
border:const OutlineInputBorder(),filled:true,fillColor:Colors.white));
|
||||
|
||||
@override void dispose(){ _nombre.dispose();_email.dispose();_pass.dispose();_calle.dispose();super.dispose(); }
|
||||
}
|
||||
53
celaya_limpia/lib/screens/settings_screen.dart
Normal file
53
celaya_limpia/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../services/theme_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<ThemeService>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Configuracion'),
|
||||
backgroundColor: AppColors.guindaPrimary, foregroundColor: Colors.white,
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado))),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
Card(child: Column(children: [
|
||||
const Padding(padding: EdgeInsets.all(14),
|
||||
child: Row(children: [
|
||||
Icon(Icons.palette_outlined, color: AppColors.guindaPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('Apariencia', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
])),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
value: theme.isDark,
|
||||
onChanged: (_) => theme.toggle(),
|
||||
secondary: Icon(theme.isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
color: theme.isDark ? Colors.amber : AppColors.guindaPrimary),
|
||||
title: Text(theme.isDark ? 'Modo oscuro activo' : 'Modo claro activo'),
|
||||
subtitle: const Text('Util para rutas nocturnas'),
|
||||
activeColor: AppColors.guindaPrimary,
|
||||
),
|
||||
])),
|
||||
const SizedBox(height: 12),
|
||||
Card(child: Column(children: [
|
||||
const Padding(padding: EdgeInsets.all(14),
|
||||
child: Row(children: [
|
||||
Icon(Icons.info_outline, color: AppColors.guindaPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('Acerca de', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
])),
|
||||
const Divider(height: 1),
|
||||
const ListTile(leading: Icon(Icons.location_city), title: Text('H. Ayuntamiento de Celaya'),
|
||||
subtitle: Text('Guanajuato, Mexico')),
|
||||
const ListTile(leading: Icon(Icons.code), title: Text('Version 2.0.0'),
|
||||
subtitle: Text('Sistema Integral de Recoleccion de Residuos')),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
celaya_limpia/lib/screens/splash_screen.dart
Normal file
53
celaya_limpia/lib/screens/splash_screen.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/route_simulator_service.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@override State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
@override
|
||||
void initState() { super.initState(); _go(); }
|
||||
|
||||
Future<void> _go() async {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
final auth = context.read<AuthService>();
|
||||
context.read<RouteSimulatorService>().startAllRoutes();
|
||||
if (auth.isLoggedIn) {
|
||||
_navigate(auth.rol);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
}
|
||||
|
||||
void _navigate(String rol) {
|
||||
switch (rol) {
|
||||
case 'ADMINISTRADOR': Navigator.pushReplacementNamed(context, '/admin'); break;
|
||||
case 'CONDUCTOR': Navigator.pushReplacementNamed(context, '/driver'); break;
|
||||
default: Navigator.pushReplacementNamed(context, '/home'); break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: AppColors.guindaPrimary,
|
||||
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(width:100,height:100,
|
||||
decoration:BoxDecoration(color:Colors.white12,shape:BoxShape.circle,
|
||||
border:Border.all(color:AppColors.dorado,width:3)),
|
||||
child:const Icon(Icons.delete_sweep_rounded,size:52,color:AppColors.dorado)),
|
||||
const SizedBox(height:20),
|
||||
const Text('CELAYA LIMPIA',style:TextStyle(color:Colors.white,fontSize:26,
|
||||
fontWeight:FontWeight.bold,letterSpacing:2)),
|
||||
const SizedBox(height:4),
|
||||
const Text('H. Ayuntamiento de Celaya',style:TextStyle(color:Colors.white60,fontSize:13)),
|
||||
const SizedBox(height:40),
|
||||
const CircularProgressIndicator(valueColor:AlwaysStoppedAnimation<Color>(AppColors.dorado)),
|
||||
])),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user