Actualizacion del programa
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
@@ -8,6 +9,10 @@ import '../../models/models.dart';
|
||||
import '../../data/routes_data.dart';
|
||||
import '../../models/route_model.dart' show ColonyModel;
|
||||
import 'create_route_screen.dart';
|
||||
import 'admin_stats_screen.dart';
|
||||
import 'manage_conductors_screen.dart';
|
||||
import 'export_pdf_screen.dart';
|
||||
import '../../screens/settings_screen.dart';
|
||||
import '../../widgets/route_map_widget.dart';
|
||||
|
||||
class AdminDashboardScreen extends StatefulWidget {
|
||||
@@ -27,6 +32,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
_AdminHomeTab(sim:sim, auth:auth),
|
||||
_AdminMapTab(sim:sim),
|
||||
_AdminReportesTab(),
|
||||
_AdminConductoresTab(),
|
||||
_AdminAssignmentsTab(),
|
||||
_AdminAlertasTab(sim:sim),
|
||||
_AdminRoutesTab(),
|
||||
@@ -136,6 +142,15 @@ class _AdminHomeTabState extends State<_AdminHomeTab> {
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
title: const Text('Panel Administrador', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.picture_as_pdf), tooltip: 'Exportar PDF',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const ExportPdfScreen()))),
|
||||
IconButton(icon: const Icon(Icons.bar_chart), tooltip: 'Estadisticas',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const AdminStatsScreen()))),
|
||||
IconButton(icon: const Icon(Icons.settings_outlined), tooltip: 'Configuracion',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()))),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||
IconButton(icon: const Icon(Icons.logout),
|
||||
onPressed: () async { await widget.auth.logout();
|
||||
@@ -431,6 +446,7 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
|
||||
final routeId = r['route_id']??'';
|
||||
final estado = r['estado']??'PENDIENTE';
|
||||
final id = r['id'] as int?;
|
||||
final fotoPath = r['foto_path'] as String?;
|
||||
return Card(margin:const EdgeInsets.only(bottom:8),
|
||||
child:Padding(padding:const EdgeInsets.all(12),child:Column(
|
||||
crossAxisAlignment:CrossAxisAlignment.start, children:[
|
||||
@@ -455,6 +471,12 @@ class _AdminReportesTabState extends State<_AdminReportesTab> {
|
||||
const SizedBox(height:6),
|
||||
Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
|
||||
Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)),
|
||||
if (fotoPath != null && fotoPath.isNotEmpty) ...[
|
||||
const SizedBox(height:6),
|
||||
ClipRRect(borderRadius:BorderRadius.circular(6),
|
||||
child:Image.file(File(fotoPath), height:100, width:double.infinity,
|
||||
fit:BoxFit.cover)),
|
||||
],
|
||||
const SizedBox(height:6),
|
||||
Row(children:[
|
||||
Text('⭐'*calif,style:const TextStyle(fontSize:11)),
|
||||
@@ -793,6 +815,25 @@ class _AdminBanner extends StatelessWidget {
|
||||
]))));
|
||||
}
|
||||
|
||||
|
||||
// ── TAB Conductores (delega a ManageConductorsScreen) ────────────────────
|
||||
class _AdminConductoresTab extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(automaticallyImplyLeading: false,
|
||||
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
||||
title: const Text('Gestión de Conductores'),
|
||||
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
|
||||
child: Container(height: 4, color: AppColors.dorado)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.open_in_full),
|
||||
tooltip: 'Ver en pantalla completa',
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const ManageConductorsScreen()))),
|
||||
]),
|
||||
body: const ManageConductorsScreen());
|
||||
}
|
||||
|
||||
// ── TAB 6: Gestión de Rutas ───────────────────────────────────────────────
|
||||
class _AdminRoutesTab extends StatefulWidget {
|
||||
@override State<_AdminRoutesTab> createState() => _AdminRoutesTabState();
|
||||
|
||||
262
lib/screens/admin/admin_stats_screen.dart
Normal file
262
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)),
|
||||
]);
|
||||
}
|
||||
244
lib/screens/admin/export_pdf_screen.dart
Normal file
244
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
lib/screens/admin/manage_conductors_screen.dart
Normal file
179
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)),
|
||||
],
|
||||
],
|
||||
])));
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user