Actualizacion del programa

This commit is contained in:
2026-05-23 01:40:39 -06:00
parent 458af32fcf
commit c6a1a67469
132 changed files with 11009 additions and 168 deletions

90
celaya_limpia/README.md Normal file
View File

@@ -0,0 +1,90 @@
# 🗑️ Celaya Limpia — Sistema Integral de Recolección de Residuos
## H. Ayuntamiento de Celaya, Guanajuato
---
## 👥 Roles del Sistema
### 🏠 CIUDADANO
- ETA del camión recolector en tiempo real
- **Mapa visible SOLO cuando el camión está a <15 min**
- Guía de separación de residuos (sin internet)
- Clasificador de residuos con IA (cámara)
- Reporte de incidencias
### 🚛 CONDUCTOR
- Vista de su ruta asignada por día
- Mapa de su ruta específica
- Alertas de GPS desactivado
- Horario semanal asignado por el admin
### ⚙️ ADMINISTRADOR
- Mapa de TODAS las rutas simultáneamente
- Control de estado de rutas (cancelar, marcar falla, retraso)
- Gestión de reportes ciudadanos
- Asignación de rutas a conductores por día/turno
- Panel de alertas (GPS perdido, camión detenido +30min)
---
## 🔑 Cuentas Demo
| Rol | Email | Contraseña |
|-----|-------|-----------|
| Administrador | admin@celaya.gob.mx | admin123 |
| Conductor | conductor@celaya.gob.mx | conductor123 |
| Ciudadano | Crear desde la app | - |
---
## 🚀 Cómo ejecutar
```bash
flutter pub get
flutter run
```
---
## 🤖 IA de Clasificación de Residuos
1. Convierte tu modelo: `waste_clasification.h5` `waste_model.tflite`
2. Coloca el archivo en: `assets/models/waste_model.tflite`
3. El modelo clasifica: **Orgánico (0) / Inorgánico (1)**
Script de conversión:
```python
import tensorflow as tf
model = tf.keras.models.load_model('waste_clasification.h5')
converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()
with open('assets/models/waste_model.tflite', 'wb') as f:
f.write(tflite_model)
```
---
## 🗺️ Mapas
Usa **OpenStreetMap** (gratuito, sin API Key)
---
## 🔔 Notificaciones Simuladas
| Evento | Cuándo |
|--------|--------|
| 🚛 Ruta Iniciada | posición 12 |
| Camión Cercano | posición 4 (~15 min) |
| 🏁 Servicio Finalizado | posición 8 |
| 📡 GPS Perdido | Admin lo activa manualmente |
| Camión Detenido | Sin movimiento 30+ min |
---
## 📋 Rutas disponibles
- 15 rutas con GPS real de Celaya
- Turnos: Matutino, Vespertino, Nocturno
- 40+ colonias mapeadas

View File

@@ -0,0 +1,35 @@
# Configuración de Google Maps
## 1. Obtener tu API Key (GRATIS para desarrollo)
1. Ve a https://console.cloud.google.com
2. Crea un proyecto nuevo o selecciona uno existente
3. Activa las siguientes APIs:
- **Maps SDK for Android**
- **Maps SDK for iOS** (si lo necesitas)
4. Ve a "Credenciales" → "Crear credencial" → "Clave de API"
5. Copia tu clave
## 2. Agregar la clave al proyecto
Abre este archivo:
```
android/app/src/main/AndroidManifest.xml
```
Busca esta línea y reemplaza `TU_API_KEY_DE_GOOGLE_MAPS_AQUI` con tu clave real:
```xml
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="TU_API_KEY_DE_GOOGLE_MAPS_AQUI"/>
```
## 3. Restricciones recomendadas (seguridad)
En Google Cloud Console, restringe tu API key a:
- **Aplicaciones Android** → tu package name: `com.example.celaya_limpia`
## Notas
- El mapa funcionará sin restricciones durante desarrollo
- La capa de tráfico en tiempo real requiere que actives "Maps SDK for Android"
- El plan gratuito incluye $200 USD mensuales (suficiente para esta app)

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,40 @@
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.celaya_limpia"
compileSdk = flutter.compileSdkVersion
ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.example.celaya_limpia"
minSdk = 21
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,43 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="Celaya Limpia"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Google Maps API Key - Reemplaza con tu clave real -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="TU_API_KEY_DE_GOOGLE_MAPS_AQUI"/>
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,19 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
}
}

View File

@@ -0,0 +1,5 @@
package com.example.ejemplo
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,35 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
// Forzar Java 17 en todos los subproyectos (plugins)
gradle.projectsEvaluated {
subprojects {
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = "17"
targetCompatibility = "17"
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

160
celaya_limpia/android/gradlew vendored Normal file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
celaya_limpia/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,2 @@
sdk.dir=C:\\Users\\benga\\AppData\\Local\\Android\\sdk
flutter.sdk=C:\\flutter

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

View File

@@ -0,0 +1 @@
placeholder - add waste_model.tflite here

View File

@@ -0,0 +1,2 @@
Organico
Inorganico

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,15 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=C:\flutter
FLUTTER_APPLICATION_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo
FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo\ios\Flutter\ephemeral\Packages\.packages\FlutterFramework
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib\main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,25 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
//
// Generated file. Do not edit.
//
import PackageDescription
let package = Package(
name: "FlutterGeneratedPluginSwiftPackage",
platforms: [
.iOS("13.0")
],
products: [
.library(name: "FlutterGeneratedPluginSwiftPackage", type: .static, targets: ["FlutterGeneratedPluginSwiftPackage"])
],
dependencies: [
],
targets: [
.target(
name: "FlutterGeneratedPluginSwiftPackage"
)
]
)

View File

@@ -0,0 +1,32 @@
#
# Generated file, do not edit.
#
import lldb
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
base = frame.register["x0"].GetValueAsAddress()
page_len = frame.register["x1"].GetValueAsUnsigned()
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
# first page to see if handled it correctly. This makes diagnosing
# misconfiguration (e.g. missing breakpoint) easier.
data = bytearray(page_len)
data[0:8] = b'IHELPED!'
error = lldb.SBError()
frame.GetThread().GetProcess().WriteMemory(base, data, error)
if not error.Success():
print(f'Failed to write into {base}[+{page_len}]', error)
return
def __lldb_init_module(debugger: lldb.SBDebugger, _):
target = debugger.GetDummyTarget()
# Caveat: must use BreakpointCreateByRegEx here and not
# BreakpointCreateByName. For some reasons callback function does not
# get carried over from dummy target for the later.
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
bp.SetAutoContinue(True)
print("-- LLDB integration loaded --")

View File

@@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py

View File

@@ -0,0 +1,12 @@
FLUTTER_ROOT=C:\flutter
FLUTTER_APPLICATION_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo
FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo\ios\Flutter\ephemeral\Packages\.packages\FlutterFramework
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib\main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1,14 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=C:\flutter"
export "FLUTTER_APPLICATION_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo"
export "FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=C:\Users\benga\OneDrive\Documentos\app\ejemplo\ios\Flutter\ephemeral\Packages\.packages\FlutterFramework"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

View File

@@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.ejemplo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View File

@@ -0,0 +1,14 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
}
@end

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Ejemplo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ejemplo</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
class AppColors {
static const Color guindaPrimary = Color(0xFF6D1E3A);
static const Color guindaDark = Color(0xFF4A1228);
static const Color guindaLight = Color(0xFF9B3D5C);
static const Color dorado = Color(0xFFC9A84C);
static const Color blanco = Color(0xFFFFFFFF);
static const Color grisFondo = Color(0xFFF5F5F5);
static const Color grisTexto = Color(0xFF757575);
static const Color negroTexto = Color(0xFF212121);
static const Color verdeExito = Color(0xFF2E7D32);
static const Color rojoError = Color(0xFFC62828);
static const Color naranjaAlerta = Color(0xFFE65100);
static const Color azulInfo = Color(0xFF1565C0);
static const Color moradoConductor= Color(0xFF4A148C);
static const Color verdeAdmin = Color(0xFF1B5E20);
}
class AppRoles {
static const String ciudadano = 'CIUDADANO';
static const String conductor = 'CONDUCTOR';
static const String administrador = 'ADMINISTRADOR';
}
class AppTurnos {
static const String matutino = 'MATUTINO';
static const String vespertino= 'VESPERTINO';
static const String nocturno = 'NOCTURNO';
}
class AppDias {
static const List<String> todos = [
'LUNES','MARTES','MIERCOLES','JUEVES','VIERNES','SABADO','DOMINGO'
];
static String label(String dia) {
const m = {
'LUNES':'Lunes','MARTES':'Martes','MIERCOLES':'Miércoles',
'JUEVES':'Jueves','VIERNES':'Viernes','SABADO':'Sábado','DOMINGO':'Domingo',
};
return m[dia] ?? dia;
}
}
class RouteStatus {
static const String enRuta = 'EN_RUTA';
static const String cancelada = 'CANCELADA';
static const String retrasada = 'RETRASADA';
static const String fallaMecanica= 'FALLA_MECANICA';
static const String finalizada = 'FINALIZADA';
static Color color(String status) {
switch (status) {
case enRuta: return AppColors.verdeExito;
case cancelada: return AppColors.rojoError;
case retrasada: return AppColors.naranjaAlerta;
case fallaMecanica: return Colors.red.shade900;
case finalizada: return AppColors.grisTexto;
default: return AppColors.grisTexto;
}
}
static String label(String status) {
switch (status) {
case enRuta: return '🚛 En Ruta';
case cancelada: return '❌ Cancelada';
case retrasada: return '⏱️ Retrasada';
case fallaMecanica: return '🔧 Falla Mecánica';
case finalizada: return '✅ Finalizada';
default: return status;
}
}
}

View File

@@ -0,0 +1,243 @@
// 240 colonias oficiales de Celaya, Guanajuato
const List<String> celayaColonias = [
'10 de Abril',
'10 de mayo',
'15 de Mayo',
'3 Guerras',
'Alameda',
'Álamos',
'Álamos Oriente',
'Alfredo Vázquez Bonfil',
'Américas del Bajío',
'Arboledas de Camargo',
'Arboledas del Campestre',
'Arcada Alameda',
'Baalam Residencial',
'Benito Juárez',
'Bosques de la Alameda',
'Bosques del Sol',
'Brisas del Carmen',
'Bugambilias',
'Calesa',
'Camargo',
'Campestre Celaya',
'Canal de Camargo',
'Canal de Labradores',
'Capitales de Europa',
'Celaya Centro',
'Ciudadela',
'Ciudad Industrial',
'Claustros de Arboledas',
'Conjunto Habitacional Girasoles',
'Cuauhtémoc',
'Del Bosque',
'Del Parque',
'Del Valle',
'Don Gu',
'Dos Plazas',
'Ejidal',
'El Atrio',
'El Campanario',
'El Campanario Residencial',
'El Cantar',
'El Dorado',
'El Haba',
'El Junco Residencial',
'El Olivar',
'El Panamericano',
'El Paraíso de los Ángeles',
'El Vergel',
'Emeteria Valencia',
'Emiliano Zapata',
'Emiliano Zapata Sur',
'Enrique Colunga',
'Esmeralda',
'Exelaris',
'Felipe Ángeles',
'Floresta del Sur',
'FOVISSSTE',
'Galaxias del Parque',
'Geo Villas Los Sauces',
'Gobernadores',
'Granada',
'Gran Clase',
'Guadalupe',
'Guanajuato',
'Hacienda del Bosque',
'Hacienda del Sol',
'Hidalgo',
'Imperial',
'Independencia',
'Industriales',
'Jacarandas',
'Jardines de Celaya 1a Secc',
'Jardines de Celaya 2a Secc',
'Jardines de Celaya 3a Secc',
'Jardines del Centro',
'Jardines del Sur',
'José Suárez Irigoyen',
'Juan Pablo II',
'Karina',
'La Campiña',
'La Capilla',
'La Cruz',
'La Escondida',
'La Favorita',
'La Fundación',
'La Herradura',
'La Joya',
'La Misión',
'La Purísima',
'Las Alamedas',
'Las Américas',
'Las Arboledas',
'Las Arenas',
'Las Aves',
'Las Brisas',
'Las Carretas',
'Las Casas',
'Las Delicias',
'Las Flores',
'Las Fuentes',
'Las Insurgentes',
'La Soledad',
'Latinoamericana',
'La Trinidad',
'Lázaro Cárdenas',
'Lindavista',
'López Portillo',
'Los Ángeles',
'Los Frailes',
'Los Impresionistas',
'Los Lagos',
'Los Laureles',
'Los Naranjos',
'Los Olivos Residencial',
'Los Pinos',
'Los Pirules',
'Los Pirules Don Gu',
'Los Portones',
'Los Santos',
'Los Sauces',
'Los Tules',
'Los Veintes',
'Magno Residencial',
'Mediterráneo',
'México',
'Miguel Alemán',
'Misión de La Esperanza',
'Misión Santa Fe',
'Moctezuma',
'Monte Blanco',
'Nat Tha Hi',
'Nueva Santa María',
'Nueva Terraza',
'Nuevo Celaya',
'Nuevo Tecnológico',
'Obrero Mundial',
'Oro',
'Palas Atenea',
'Palma Real',
'Parque Central',
'Parque Verde',
'Pedregal del Junco',
'Porta Maggiore',
'Portones de la Hacienda',
'Praderas del Bosque',
'Praderas de Santa Julia',
'Praderas de Santa Lucía',
'Prados el Naranjal',
'Privada Ciruelo',
'Privada del Pedregal',
'Privada del Real',
'Privada el Sauz',
'Progreso Solidaridad',
'Providencia',
'Puerta Grande',
'Puertas del Sol',
'Puertas de Santa María',
'Puesta del Sol',
'Punta Norte',
'Quinta Santa María',
'Raquet Club Cross',
'Real de Celaya',
'Real de San Antonio',
'Recursos Hidráulicos',
'Reforma',
'Reforma',
'Residencial Las Margaritas',
'Residencial Las Praderas',
'Residencial Paraíso',
'Residencial San Pablo',
'Residencial Santiago',
'Residencial Tecnológico',
'Residencial Xochipilli',
'Resurrección',
'Revolución',
'Rinconada del Bosque',
'Rinconada Laureles',
'Rinconada Los Álamos',
'Rinconada San Jorge',
'Rincón de Cantarranas',
'Riveras del Campestre',
'Rosalinda',
'San Andrés',
'San Antonio',
'San Antonio',
'San Francisco',
'San Gabriel',
'San José de Torres',
'San Juan',
'San Juan de Dios',
'San Juanico',
'San Juanico 1a Secc',
'San Juanico 2a Secc',
'San Martín de Camargo',
'San Miguel',
'San Rafael',
'San Román',
'Santa Anita',
'Santa Bárbara',
'Santa Cecilia',
'Santa Fe de los Naranjos',
'Santa Isabel',
'Santa María',
'Santa María',
'Santa Rita',
'Santa Teresa',
'Santiaguito',
'Suiza',
'Tahi',
'Tierras Negras',
'Tierra y Libertad',
'Tres Lunas',
'Valle de La Primavera',
'Valle de los Naranjos III Sección',
'Valle de los Naranjos II Sección',
'Valle del Real',
'Valle Hermoso',
'Valle Naranjos',
'Ventanales de Santa María',
'Villa Arbolada',
'Villa de Celaya',
'Villa de los Álamos',
'Villa de los Reyes',
'Villa Jardín',
'Villas de Benavente',
'Villas de Benavente II',
'Villas de La Esperanza',
'Villas de La Hacienda',
'Villas del Bajío',
'Villas del Palmar',
'Villas del Paraíso',
'Villas del Rocío',
'Villas del Romeral',
'Villas del Tenis',
'Villas Reales',
'Villas Vicenza',
'Viñas de La Herradura',
'Virgen del Refugio',
'Zempoala',
'Zona de Oro',
'Zona de Oro del Bajío',
];

View File

@@ -0,0 +1,51 @@
import '../models/route_model.dart';
final List<ColonyModel> coloniesData = [
ColonyModel(colonia:'Zona Centro',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:15)'),
ColonyModel(colonia:'Las Arboledas',routeId:'RUTA-01',horarioEstimado:'Matutino (07:00-07:30)'),
ColonyModel(colonia:'Centro Histórico',routeId:'RUTA-01',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Barrio de Santiago',routeId:'RUTA-01',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Col. Obrera',routeId:'RUTA-01',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Av. Tecnológico',routeId:'RUTA-02',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Col. Magisterial',routeId:'RUTA-02',horarioEstimado:'Matutino (06:40-07:15)'),
ColonyModel(colonia:'Fracc. Las Américas',routeId:'RUTA-02',horarioEstimado:'Matutino (06:55-07:30)'),
ColonyModel(colonia:'Col. Constitución',routeId:'RUTA-02',horarioEstimado:'Matutino (06:30-07:05)'),
ColonyModel(colonia:'San Juanico',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:45-15:15)'),
ColonyModel(colonia:'Col. Los Álamos',routeId:'RUTA-03',horarioEstimado:'Vespertino (14:30-15:00)'),
ColonyModel(colonia:'Fracc. El Dorado',routeId:'RUTA-03',horarioEstimado:'Vespertino (15:00-15:30)'),
ColonyModel(colonia:'Los Olivos',routeId:'RUTA-04',horarioEstimado:'Matutino (07:00-07:40)'),
ColonyModel(colonia:'Col. Revolución',routeId:'RUTA-04',horarioEstimado:'Matutino (06:35-07:10)'),
ColonyModel(colonia:'Col. Ladrillera',routeId:'RUTA-04',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Rancho Seco',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:00-15:35)'),
ColonyModel(colonia:'Col. El Potrero',routeId:'RUTA-05',horarioEstimado:'Vespertino (14:45-15:20)'),
ColonyModel(colonia:'Col. Los Sauces',routeId:'RUTA-05',horarioEstimado:'Vespertino (15:15-15:50)'),
ColonyModel(colonia:'Rumbos de Roque',routeId:'RUTA-06',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Col. Vista Hermosa',routeId:'RUTA-06',horarioEstimado:'Matutino (06:45-07:20)'),
ColonyModel(colonia:'Ciudad Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:30-07:10)'),
ColonyModel(colonia:'Parque Industrial',routeId:'RUTA-07',horarioEstimado:'Matutino (06:50-07:25)'),
ColonyModel(colonia:'Universidad Latina',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:30-23:00)'),
ColonyModel(colonia:'Col. Del Moral',routeId:'RUTA-08',horarioEstimado:'Nocturno (22:00-22:30)'),
ColonyModel(colonia:'Hospital General',routeId:'RUTA-09',horarioEstimado:'Matutino (06:20-07:00)'),
ColonyModel(colonia:'Col. Peñuelas',routeId:'RUTA-09',horarioEstimado:'Matutino (06:50-07:20)'),
ColonyModel(colonia:'UG Sur',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:30-22:00)'),
ColonyModel(colonia:'Eje Juan Pablo II',routeId:'RUTA-10',horarioEstimado:'Nocturno (21:00-21:30)'),
ColonyModel(colonia:'Torres Landa',routeId:'RUTA-11',horarioEstimado:'Matutino (06:45-07:15)'),
ColonyModel(colonia:'Zona de Oro',routeId:'RUTA-11',horarioEstimado:'Matutino (06:30-07:00)'),
ColonyModel(colonia:'Las Insurgentes',routeId:'RUTA-12',horarioEstimado:'Matutino (06:35-07:10)'),
ColonyModel(colonia:'Col. Independencia',routeId:'RUTA-12',horarioEstimado:'Matutino (06:50-07:20)'),
ColonyModel(colonia:'Trojes',routeId:'RUTA-13',horarioEstimado:'Matutino (06:40-07:10)'),
ColonyModel(colonia:'Irrigación',routeId:'RUTA-13',horarioEstimado:'Matutino (06:55-07:25)'),
ColonyModel(colonia:'Col. Benito Juárez',routeId:'RUTA-13',horarioEstimado:'Matutino (06:30-07:00)'),
ColonyModel(colonia:'La Toscana',routeId:'RUTA-14',horarioEstimado:'Vespertino (15:00-15:35)'),
ColonyModel(colonia:'Fracc. La Laborcita',routeId:'RUTA-14',horarioEstimado:'Vespertino (14:45-15:20)'),
ColonyModel(colonia:'San José de Celaya',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:45-23:20)'),
ColonyModel(colonia:'Col. Camino Real',routeId:'RUTA-15',horarioEstimado:'Nocturno (22:30-23:00)'),
ColonyModel(colonia:'Col. Jardín',routeId:'RUTA-15',horarioEstimado:'Nocturno (23:00-23:30)'),
];
ColonyModel? getColonyByName(String name) {
try { return coloniesData.firstWhere((c) => c.colonia.toLowerCase() == name.toLowerCase()); }
catch (_) { return null; }
}
List<String> get colonyNames => coloniesData.map((c) => c.colonia).toList()..sort();

View File

@@ -0,0 +1,159 @@
import '../models/route_model.dart';
final List<RouteModel> routesData = [
RouteModel(routeId:'RUTA-01',name:'Zona Centro - Las Arboledas',truckId:101,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'),
RoutePosition(positionId:2,lat:20.5185,lng:-100.8450,speed:45,timestamp:'06:12'),
RoutePosition(positionId:3,lat:20.5215,lng:-100.8142,speed:22,timestamp:'06:25'),
RoutePosition(positionId:4,lat:20.5212,lng:-100.8175,speed:15,timestamp:'06:38'),
RoutePosition(positionId:5,lat:20.5210,lng:-100.8210,speed:0,timestamp:'06:50'),
RoutePosition(positionId:6,lat:20.5235,lng:-100.8212,speed:18,timestamp:'07:05'),
RoutePosition(positionId:7,lat:20.5260,lng:-100.8215,speed:20,timestamp:'07:18'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'07:40'),
]),
RouteModel(routeId:'RUTA-02',name:'Sector Norte - Av. Tecnológico',truckId:102,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:05'),
RoutePosition(positionId:2,lat:20.5280,lng:-100.8135,speed:38,timestamp:'06:18'),
RoutePosition(positionId:3,lat:20.5410,lng:-100.8130,speed:25,timestamp:'06:30'),
RoutePosition(positionId:4,lat:20.5445,lng:-100.8132,speed:12,timestamp:'06:45'),
RoutePosition(positionId:5,lat:20.5480,lng:-100.8135,speed:0,timestamp:'06:58'),
RoutePosition(positionId:6,lat:20.5515,lng:-100.8138,speed:15,timestamp:'07:10'),
RoutePosition(positionId:7,lat:20.5540,lng:-100.8110,speed:22,timestamp:'07:25'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:50'),
]),
RouteModel(routeId:'RUTA-03',name:'Sector Poniente - San Juanico',truckId:103,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:00'),
RoutePosition(positionId:2,lat:20.5250,lng:-100.8510,speed:42,timestamp:'14:15'),
RoutePosition(positionId:3,lat:20.5290,lng:-100.8320,speed:20,timestamp:'14:30'),
RoutePosition(positionId:4,lat:20.5315,lng:-100.8355,speed:15,timestamp:'14:45'),
RoutePosition(positionId:5,lat:20.5340,lng:-100.8390,speed:0,timestamp:'15:00'),
RoutePosition(positionId:6,lat:20.5362,lng:-100.8425,speed:10,timestamp:'15:15'),
RoutePosition(positionId:7,lat:20.5330,lng:-100.8430,speed:18,timestamp:'15:28'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:35,timestamp:'15:45'),
]),
RouteModel(routeId:'RUTA-04',name:'Oriente - Los Olivos',truckId:104,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:15'),
RoutePosition(positionId:2,lat:20.5260,lng:-100.8010,speed:45,timestamp:'06:30'),
RoutePosition(positionId:3,lat:20.5295,lng:-100.7890,speed:24,timestamp:'06:45'),
RoutePosition(positionId:4,lat:20.5320,lng:-100.7850,speed:12,timestamp:'06:58'),
RoutePosition(positionId:5,lat:20.5350,lng:-100.7790,speed:0,timestamp:'07:12'),
RoutePosition(positionId:6,lat:20.5310,lng:-100.7760,speed:15,timestamp:'07:25'),
RoutePosition(positionId:7,lat:20.5270,lng:-100.7820,speed:26,timestamp:'07:38'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:48,timestamp:'07:58'),
]),
RouteModel(routeId:'RUTA-05',name:'Sector Sur - Rancho Seco',truckId:105,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:20'),
RoutePosition(positionId:2,lat:20.5050,lng:-100.8620,speed:35,timestamp:'14:32'),
RoutePosition(positionId:3,lat:20.5020,lng:-100.8350,speed:22,timestamp:'14:45'),
RoutePosition(positionId:4,lat:20.4995,lng:-100.8210,speed:14,timestamp:'14:58'),
RoutePosition(positionId:5,lat:20.4970,lng:-100.8150,speed:0,timestamp:'15:10'),
RoutePosition(positionId:6,lat:20.5010,lng:-100.8120,speed:16,timestamp:'15:22'),
RoutePosition(positionId:7,lat:20.5060,lng:-100.8160,speed:25,timestamp:'15:35'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'15:55'),
]),
RouteModel(routeId:'RUTA-06',name:'Norte Extremo - Rumbos de Roque',truckId:106,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:00'),
RoutePosition(positionId:2,lat:20.5380,lng:-100.8380,speed:40,timestamp:'06:15'),
RoutePosition(positionId:3,lat:20.5610,lng:-100.8370,speed:30,timestamp:'06:30'),
RoutePosition(positionId:4,lat:20.5750,lng:-100.8360,speed:15,timestamp:'06:45'),
RoutePosition(positionId:5,lat:20.5820,lng:-100.8350,speed:0,timestamp:'07:00'),
RoutePosition(positionId:6,lat:20.5780,lng:-100.8310,speed:20,timestamp:'07:15'),
RoutePosition(positionId:7,lat:20.5650,lng:-100.8320,speed:28,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'07:55'),
]),
RouteModel(routeId:'RUTA-07',name:'Nororiente - Ciudad Industrial',truckId:107,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:10'),
RoutePosition(positionId:2,lat:20.5350,lng:-100.8050,speed:44,timestamp:'06:24'),
RoutePosition(positionId:3,lat:20.5450,lng:-100.7950,speed:25,timestamp:'06:38'),
RoutePosition(positionId:4,lat:20.5480,lng:-100.7850,speed:18,timestamp:'06:52'),
RoutePosition(positionId:5,lat:20.5510,lng:-100.7750,speed:0,timestamp:'07:05'),
RoutePosition(positionId:6,lat:20.5460,lng:-100.7720,speed:12,timestamp:'07:18'),
RoutePosition(positionId:7,lat:20.5390,lng:-100.7820,speed:30,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:52'),
]),
RouteModel(routeId:'RUTA-08',name:'Suroriente - Universidad Latina',truckId:108,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:00'),
RoutePosition(positionId:2,lat:20.5180,lng:-100.8310,speed:38,timestamp:'22:15'),
RoutePosition(positionId:3,lat:20.5245,lng:-100.7980,speed:30,timestamp:'22:30'),
RoutePosition(positionId:4,lat:20.5210,lng:-100.7995,speed:14,timestamp:'22:45'),
RoutePosition(positionId:5,lat:20.5175,lng:-100.8010,speed:0,timestamp:'23:00'),
RoutePosition(positionId:6,lat:20.5140,lng:-100.8030,speed:18,timestamp:'23:15'),
RoutePosition(positionId:7,lat:20.5110,lng:-100.8055,speed:22,timestamp:'23:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:40,timestamp:'23:50'),
]),
RouteModel(routeId:'RUTA-09',name:'Poniente - Hospital General',truckId:109,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:02'),
RoutePosition(positionId:2,lat:20.5210,lng:-100.8650,speed:45,timestamp:'06:12'),
RoutePosition(positionId:3,lat:20.5260,lng:-100.8520,speed:26,timestamp:'06:24'),
RoutePosition(positionId:4,lat:20.5275,lng:-100.8490,speed:12,timestamp:'06:36'),
RoutePosition(positionId:5,lat:20.5285,lng:-100.8460,speed:0,timestamp:'06:48'),
RoutePosition(positionId:6,lat:20.5250,lng:-100.8470,speed:15,timestamp:'07:00'),
RoutePosition(positionId:7,lat:20.5220,lng:-100.8550,speed:32,timestamp:'07:12'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:30'),
]),
RouteModel(routeId:'RUTA-10',name:'Eje Juan Pablo II - UG Sur',truckId:110,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'21:00'),
RoutePosition(positionId:2,lat:20.5015,lng:-100.8520,speed:40,timestamp:'21:15'),
RoutePosition(positionId:3,lat:20.4990,lng:-100.8390,speed:28,timestamp:'21:30'),
RoutePosition(positionId:4,lat:20.4950,lng:-100.8320,speed:18,timestamp:'21:45'),
RoutePosition(positionId:5,lat:20.4920,lng:-100.8280,speed:0,timestamp:'22:00'),
RoutePosition(positionId:6,lat:20.4945,lng:-100.8240,speed:14,timestamp:'22:15'),
RoutePosition(positionId:7,lat:20.4980,lng:-100.8300,speed:30,timestamp:'22:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:38,timestamp:'22:50'),
]),
RouteModel(routeId:'RUTA-11',name:'Zona de Oro - Torres Landa',truckId:111,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:04'),
RoutePosition(positionId:2,lat:20.5240,lng:-100.8350,speed:36,timestamp:'06:16'),
RoutePosition(positionId:3,lat:20.5280,lng:-100.8250,speed:22,timestamp:'06:29'),
RoutePosition(positionId:4,lat:20.5295,lng:-100.8210,speed:10,timestamp:'06:42'),
RoutePosition(positionId:5,lat:20.5310,lng:-100.8170,speed:0,timestamp:'06:55'),
RoutePosition(positionId:6,lat:20.5290,lng:-100.8140,speed:16,timestamp:'07:08'),
RoutePosition(positionId:7,lat:20.5260,lng:-100.8220,speed:28,timestamp:'07:21'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:42,timestamp:'07:42'),
]),
RouteModel(routeId:'RUTA-12',name:'Nororiente - Las Insurgentes',truckId:112,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:08'),
RoutePosition(positionId:2,lat:20.5280,lng:-100.8080,speed:40,timestamp:'06:22'),
RoutePosition(positionId:3,lat:20.5320,lng:-100.7980,speed:24,timestamp:'06:35'),
RoutePosition(positionId:4,lat:20.5340,lng:-100.7940,speed:15,timestamp:'06:48'),
RoutePosition(positionId:5,lat:20.5360,lng:-100.7900,speed:0,timestamp:'07:00'),
RoutePosition(positionId:6,lat:20.5310,lng:-100.7920,speed:12,timestamp:'07:12'),
RoutePosition(positionId:7,lat:20.5270,lng:-100.8020,speed:26,timestamp:'07:25'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:44,timestamp:'07:48'),
]),
RouteModel(routeId:'RUTA-13',name:'Sector Norte - Trojes e Irrigación',truckId:113,status:'EN_RUTA',turno:'MATUTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'06:12'),
RoutePosition(positionId:2,lat:20.5360,lng:-100.8190,speed:35,timestamp:'06:26'),
RoutePosition(positionId:3,lat:20.5420,lng:-100.8080,speed:28,timestamp:'06:40'),
RoutePosition(positionId:4,lat:20.5440,lng:-100.8040,speed:14,timestamp:'06:54'),
RoutePosition(positionId:5,lat:20.5460,lng:-100.8000,speed:0,timestamp:'07:06'),
RoutePosition(positionId:6,lat:20.5410,lng:-100.8020,speed:18,timestamp:'07:18'),
RoutePosition(positionId:7,lat:20.5370,lng:-100.8120,speed:25,timestamp:'07:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:39,timestamp:'07:54'),
]),
RouteModel(routeId:'RUTA-14',name:'Sur Poniente - La Toscana',truckId:114,status:'EN_RUTA',turno:'VESPERTINO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'14:16'),
RoutePosition(positionId:2,lat:20.5150,lng:-100.8580,speed:42,timestamp:'14:28'),
RoutePosition(positionId:3,lat:20.5140,lng:-100.8390,speed:26,timestamp:'14:41'),
RoutePosition(positionId:4,lat:20.5125,lng:-100.8310,speed:16,timestamp:'14:54'),
RoutePosition(positionId:5,lat:20.5110,lng:-100.8250,speed:0,timestamp:'15:06'),
RoutePosition(positionId:6,lat:20.5135,lng:-100.8280,speed:12,timestamp:'15:18'),
RoutePosition(positionId:7,lat:20.5160,lng:-100.8420,speed:32,timestamp:'15:30'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:45,timestamp:'15:51'),
]),
RouteModel(routeId:'RUTA-15',name:'Norponiente - San José de Celaya',truckId:115,status:'EN_RUTA',turno:'NOCTURNO',positions:[
RoutePosition(positionId:1,lat:20.5111,lng:-100.9037,speed:0,timestamp:'22:30'),
RoutePosition(positionId:2,lat:20.5320,lng:-100.8590,speed:38,timestamp:'22:45'),
RoutePosition(positionId:3,lat:20.5390,lng:-100.8480,speed:24,timestamp:'23:00'),
RoutePosition(positionId:4,lat:20.5420,lng:-100.8440,speed:15,timestamp:'23:15'),
RoutePosition(positionId:5,lat:20.5450,lng:-100.8410,speed:0,timestamp:'23:30'),
RoutePosition(positionId:6,lat:20.5410,lng:-100.8430,speed:14,timestamp:'23:45'),
RoutePosition(positionId:7,lat:20.5360,lng:-100.8520,speed:28,timestamp:'00:00'),
RoutePosition(positionId:8,lat:20.5111,lng:-100.9037,speed:41,timestamp:'00:20'),
]),
];
RouteModel? getRouteById(String id) {
try { return routesData.firstWhere((r) => r.routeId == id); }
catch (_) { return null; }
}

View File

@@ -0,0 +1,388 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/models.dart';
class DbHelper {
static Database? _db;
static Future<Database> get database async {
_db ??= await _initDb();
return _db!;
}
static Future<Database> _initDb() async {
final path = join(await getDatabasesPath(), 'celaya_v3.db');
return openDatabase(path, version: 1, onCreate: _onCreate);
}
static Future<void> _onCreate(Database db, int v) async {
await db.execute('''CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, rol TEXT NOT NULL)''');
await db.execute('''CREATE TABLE domicilios(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, alias TEXT DEFAULT 'Casa',
calle TEXT NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, horario_estimado TEXT NOT NULL,
is_primary INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE route_definitions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT UNIQUE NOT NULL, nombre TEXT NOT NULL,
dias TEXT NOT NULL, hora_inicio TEXT NOT NULL,
hora_fin TEXT NOT NULL, turno TEXT NOT NULL,
colonias TEXT NOT NULL, activa INTEGER DEFAULT 1)''');
await db.execute('''CREATE TABLE route_status(
route_id TEXT PRIMARY KEY, status TEXT NOT NULL,
mensaje TEXT, updated_at TEXT)''');
await db.execute('''CREATE TABLE asignaciones(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conductor_id INTEGER NOT NULL, route_id TEXT NOT NULL,
dia_semana TEXT NOT NULL, turno TEXT NOT NULL)''');
await db.execute('''CREATE TABLE alertas(
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT NOT NULL, route_id TEXT NOT NULL,
mensaje TEXT NOT NULL, fecha TEXT NOT NULL,
resuelta INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE reportes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, tipo TEXT NOT NULL,
descripcion TEXT NOT NULL, colonia TEXT NOT NULL,
route_id TEXT, fecha TEXT NOT NULL,
estado TEXT DEFAULT 'PENDIENTE', calificacion INTEGER DEFAULT 5,
foto_path TEXT)''');
await db.execute('''CREATE TABLE reviews(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, colonia TEXT NOT NULL,
route_id TEXT NOT NULL, estrellas INTEGER NOT NULL,
comentario TEXT NOT NULL, fecha TEXT NOT NULL,
nombre_usuario TEXT DEFAULT 'Ciudadano')''');
await db.execute('''CREATE TABLE notification_history(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, route_id TEXT NOT NULL,
event_type TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL, fecha TEXT NOT NULL,
leida INTEGER DEFAULT 0)''');
await db.execute('''CREATE TABLE user_meta(
user_id INTEGER PRIMARY KEY, activo INTEGER DEFAULT 1,
notas TEXT)''');
await db.insert('users', {'nombre':'Administrador','email':'admin@celaya.gob.mx',
'password':'admin123','rol':'ADMINISTRADOR'});
final conductorId = await db.insert('users', {'nombre':'Juan Conductor',
'email':'conductor@celaya.gob.mx','password':'conductor123','rol':'CONDUCTOR'});
await db.insert('user_meta', {'user_id': conductorId, 'activo': 1});
}
// ── USERS ────────────────────────────────────────────────────────────────
static Future<int> insertUser(UserModel u) async =>
(await database).insert('users', u.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
static Future<UserModel?> getUserByEmail(String email) async {
final res = await (await database).query('users', where:'email=?', whereArgs:[email]);
return res.isEmpty ? null : UserModel.fromMap(res.first);
}
static Future<UserModel?> getUserById(int id) async {
final res = await (await database).query('users', where:'id=?', whereArgs:[id]);
return res.isEmpty ? null : UserModel.fromMap(res.first);
}
static Future<List<UserModel>> getUsersByRol(String rol) async {
final res = await (await database).query('users', where:'rol=?', whereArgs:[rol]);
return res.map((m) => UserModel.fromMap(m)).toList();
}
// ── DOMICILIOS ───────────────────────────────────────────────────────────
static Future<int> insertDomicilio(DomicilioModel d) async {
final db = await database;
final existing = await db.query('domicilios', where:'user_id=?', whereArgs:[d.userId]);
final isPrimary = existing.isEmpty ? 1 : (d.isPrimary ? 1 : 0);
return db.insert('domicilios', {...d.toMap(), 'is_primary': isPrimary});
}
static Future<List<DomicilioModel>> getDomiciliosByUser(int userId) async {
final res = await (await database).query('domicilios',
where:'user_id=?', whereArgs:[userId], orderBy:'is_primary DESC, id ASC');
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
static Future<DomicilioModel?> getPrimaryDomicilio(int userId) async {
final db = await database;
var res = await db.query('domicilios',
where:'user_id=? AND is_primary=1', whereArgs:[userId]);
if (res.isEmpty) {
res = await db.query('domicilios', where:'user_id=?', whereArgs:[userId], limit:1);
}
return res.isEmpty ? null : DomicilioModel.fromMap(res.first);
}
static Future<void> setPrimaryDomicilio(int domId, int userId) async {
final db = await database;
await db.update('domicilios', {'is_primary':0}, where:'user_id=?', whereArgs:[userId]);
await db.update('domicilios', {'is_primary':1}, where:'id=?', whereArgs:[domId]);
}
static Future<void> deleteDomicilio(int id) async =>
(await database).delete('domicilios', where:'id=?', whereArgs:[id]);
static Future<List<DomicilioModel>> getDomiciliosByRoute(String routeId) async {
final res = await (await database).query('domicilios',
where:'route_id=?', whereArgs:[routeId]);
return res.map((m) => DomicilioModel.fromMap(m)).toList();
}
// ── ROUTE DEFINITIONS ────────────────────────────────────────────────────
static Future<int> insertRouteDefinition(RouteDefinitionModel r) async =>
(await database).insert('route_definitions', r.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
static Future<List<RouteDefinitionModel>> getAllRouteDefinitions() async {
final res = await (await database).query('route_definitions', orderBy:'route_id ASC');
return res.map((m) => RouteDefinitionModel.fromMap(m)).toList();
}
static Future<RouteDefinitionModel?> getRouteDefinitionById(String routeId) async {
final res = await (await database).query('route_definitions',
where:'route_id=?', whereArgs:[routeId]);
return res.isEmpty ? null : RouteDefinitionModel.fromMap(res.first);
}
static Future<void> updateRouteDefinition(RouteDefinitionModel r) async =>
(await database).update('route_definitions', r.toMap(),
where:'route_id=?', whereArgs:[r.routeId]);
// ── ROUTE STATUS ─────────────────────────────────────────────────────────
static Future<void> upsertRouteStatus(RouteStatusModel s) async =>
(await database).insert('route_status', s.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
static Future<RouteStatusModel?> getRouteStatus(String routeId) async {
final res = await (await database).query('route_status',
where:'route_id=?', whereArgs:[routeId]);
return res.isEmpty ? null : RouteStatusModel.fromMap(res.first);
}
static Future<List<RouteStatusModel>> getAllRouteStatuses() async {
final res = await (await database).query('route_status');
return res.map((m) => RouteStatusModel.fromMap(m)).toList();
}
// ── ASIGNACIONES ─────────────────────────────────────────────────────────
static Future<void> upsertAsignacion(AssignmentModel a) async {
final db = await database;
final ex = await db.query('asignaciones',
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
if (ex.isEmpty) {
await db.insert('asignaciones', a.toMap());
} else {
await db.update('asignaciones', {'route_id':a.routeId,'turno':a.turno},
where:'conductor_id=? AND dia_semana=?', whereArgs:[a.conductorId, a.diaSemana]);
}
}
static Future<List<AssignmentModel>> getAsignacionesByConductor(int id) async {
final res = await (await database).query('asignaciones',
where:'conductor_id=?', whereArgs:[id]);
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
static Future<List<AssignmentModel>> getAllAsignaciones() async {
final res = await (await database).query('asignaciones');
return res.map((m) => AssignmentModel.fromMap(m)).toList();
}
// ── ALERTAS ──────────────────────────────────────────────────────────────
static Future<int> insertAlerta(AlertaModel a) async =>
(await database).insert('alertas', a.toMap());
static Future<List<AlertaModel>> getAlertas({bool soloNoResueltas=false}) async {
final db = await database;
final res = soloNoResueltas
? await db.query('alertas', where:'resuelta=0', orderBy:'fecha DESC')
: await db.query('alertas', orderBy:'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
static Future<List<AlertaModel>> getIncidentesConductor() async {
final res = await (await database).query('alertas',
where:"tipo LIKE 'INCIDENTE_%'", orderBy:'fecha DESC');
return res.map((m) => AlertaModel.fromMap(m)).toList();
}
static Future<void> resolverAlerta(int id) async =>
(await database).update('alertas', {'resuelta':1}, where:'id=?', whereArgs:[id]);
// ── REPORTES ─────────────────────────────────────────────────────────────
static Future<int> insertReporte(ReporteModel r) async =>
(await database).insert('reportes', r.toMap());
static Future<List<ReporteModel>> getAllReportes() async {
final res = await (await database).query('reportes', orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<List<ReporteModel>> getReportesByUser(int userId) async {
final res = await (await database).query('reportes',
where:'user_id=?', whereArgs:[userId], orderBy:'fecha DESC');
return res.map((m) => ReporteModel.fromMap(m)).toList();
}
static Future<List<Map<String, dynamic>>> getReportesConUsuario() async {
final db = await database;
return db.rawQuery('''
SELECT r.*, u.nombre as user_nombre, u.email as user_email
FROM reportes r LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.fecha DESC''');
}
static Future<void> updateReporteEstado(int id, String estado) async =>
(await database).update('reportes', {'estado':estado}, where:'id=?', whereArgs:[id]);
// ── REVIEWS ──────────────────────────────────────────────────────────────
static Future<int> insertReview(ReviewModel r) async =>
(await database).insert('reviews', r.toMap());
static Future<List<ReviewModel>> getAllReviews() async {
final res = await (await database).query('reviews', orderBy:'fecha DESC');
return res.map((m) => ReviewModel.fromMap(m)).toList();
}
static Future<bool> hasReviewedRoute(int userId, String routeId) async {
final today = DateTime.now().toIso8601String().substring(0, 10);
final res = await (await database).query('reviews',
where:"user_id=? AND route_id=? AND fecha LIKE '$today%'",
whereArgs:[userId, routeId]);
return res.isNotEmpty;
}
static Future<List<Map<String, dynamic>>> getReviewSummaryByColonia() async {
final db = await database;
return db.rawQuery('''
SELECT colonia, route_id,
AVG(estrellas) as promedio,
COUNT(*) as total,
MIN(estrellas) as min_est,
MAX(estrellas) as max_est
FROM reviews GROUP BY colonia ORDER BY promedio ASC''');
}
// ── NOTIFICATION HISTORY ─────────────────────────────────────────────────
static Future<int> insertNotifHistory({
int? userId, required String routeId, required String eventType,
required String title, required String body,
}) async => (await database).insert('notification_history', {
'user_id': userId, 'route_id': routeId, 'event_type': eventType,
'title': title, 'body': body,
'fecha': DateTime.now().toIso8601String(), 'leida': 0,
});
static Future<List<Map<String, dynamic>>> getNotifHistory(int userId) async =>
(await database).query('notification_history',
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId],
orderBy: 'fecha DESC', limit: 50);
static Future<int> countUnreadNotifs(int userId) async {
final res = await (await database).rawQuery(
'SELECT COUNT(*) as c FROM notification_history WHERE (user_id IS NULL OR user_id=?) AND leida=0',
[userId]);
return (res.first['c'] as int? ?? 0);
}
static Future<void> markAllNotifsRead(int userId) async =>
(await database).update('notification_history', {'leida': 1},
where: 'user_id IS NULL OR user_id = ?', whereArgs: [userId]);
// ── CONDUCTORES CON METADATA ─────────────────────────────────────────────
static Future<List<Map<String, dynamic>>> getConductoresConMeta() async {
final db = await database;
return db.rawQuery('''
SELECT u.*, COALESCE(m.activo, 1) as activo, m.notas,
(SELECT COUNT(*) FROM alertas a
WHERE a.tipo LIKE 'INCIDENTE_%'
AND a.route_id IN (
SELECT route_id FROM asignaciones WHERE conductor_id = u.id
)) as total_incidentes
FROM users u
LEFT JOIN user_meta m ON m.user_id = u.id
WHERE u.rol = 'CONDUCTOR'
ORDER BY u.nombre ASC''');
}
static Future<void> updateConductorMeta(int userId, bool activo, String notas) async {
final db = await database;
final ex = await db.query('user_meta', where:'user_id=?', whereArgs:[userId]);
if (ex.isEmpty) {
await db.insert('user_meta', {'user_id':userId,'activo':activo?1:0,'notas':notas});
} else {
await db.update('user_meta', {'activo':activo?1:0,'notas':notas},
where:'user_id=?', whereArgs:[userId]);
}
}
static Future<int> insertConductor(String nombre, String email, String password) async {
final db = await database;
final uid = await db.insert('users',
{'nombre':nombre,'email':email,'password':password,'rol':'CONDUCTOR'},
conflictAlgorithm: ConflictAlgorithm.abort);
await db.insert('user_meta', {'user_id':uid,'activo':1});
return uid;
}
static Future<void> updateConductor(int id, String nombre, String email) async =>
(await database).update('users', {'nombre':nombre,'email':email},
where:'id=?', whereArgs:[id]);
// ── ESTADÍSTICAS ─────────────────────────────────────────────────────────
static Future<Map<String, dynamic>> getAdminStats() async {
final db = await database;
final totalReportes = (await db.rawQuery('SELECT COUNT(*) as c FROM reportes')).first['c'];
final totalReviews = (await db.rawQuery('SELECT COUNT(*) as c FROM reviews')).first['c'];
final avgRating = (await db.rawQuery('SELECT AVG(estrellas) as a FROM reviews')).first['a'];
final totalAlertas = (await db.rawQuery('SELECT COUNT(*) as c FROM alertas WHERE resuelta=0')).first['c'];
final totalConductores = (await db.rawQuery(
"SELECT COUNT(*) as c FROM users WHERE rol='CONDUCTOR'")).first['c'];
return {
'total_reportes': totalReportes ?? 0,
'total_reviews': totalReviews ?? 0,
'avg_rating': (avgRating as num?)?.toDouble() ?? 0.0,
'alertas_activas': totalAlertas ?? 0,
'total_conductores': totalConductores ?? 0,
};
}
static Future<List<Map<String, dynamic>>> getReportesByColonia() async {
final db = await database;
return db.rawQuery('''
SELECT colonia, COUNT(*) as total,
SUM(CASE WHEN estado='RESUELTO' THEN 1 ELSE 0 END) as resueltos
FROM reportes GROUP BY colonia ORDER BY total DESC LIMIT 10''');
}
static Future<List<Map<String, dynamic>>> getIncidentesByRoute() async {
final db = await database;
return db.rawQuery('''
SELECT route_id, COUNT(*) as total
FROM alertas WHERE tipo LIKE 'INCIDENTE_%'
GROUP BY route_id ORDER BY total DESC LIMIT 10''');
}
static Future<List<Map<String, dynamic>>> getRatingByWeek() async {
final db = await database;
return db.rawQuery('''
SELECT strftime('%W', fecha) as semana,
AVG(estrellas) as promedio,
COUNT(*) as total
FROM reviews
GROUP BY semana ORDER BY semana DESC LIMIT 8''');
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/app_colors.dart';
import 'services/auth_service.dart';
import 'services/route_simulator_service.dart';
import 'services/theme_service.dart';
import 'screens/splash_screen.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/citizen/citizen_home_screen.dart';
import 'screens/driver/driver_home_screen.dart';
import 'screens/admin/admin_dashboard_screen.dart';
import 'screens/onboarding/onboarding_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final onboardingDone = prefs.getBool('onboarding_done') ?? false;
runApp(CelayaLimpiaApp(onboardingDone: onboardingDone));
}
class CelayaLimpiaApp extends StatelessWidget {
final bool onboardingDone;
const CelayaLimpiaApp({super.key, required this.onboardingDone});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => RouteSimulatorService()),
ChangeNotifierProvider(create: (_) => ThemeService()),
],
child: Consumer<ThemeService>(
builder: (_, themeService, __) => MaterialApp(
title: 'Celaya Limpia',
debugShowCheckedModeBanner: false,
themeMode: themeService.themeMode,
theme: _lightTheme(),
darkTheme: _darkTheme(),
initialRoute: onboardingDone ? '/splash' : '/onboarding',
routes: {
'/onboarding': (_) => const OnboardingScreen(),
'/splash': (_) => const SplashScreen(),
'/login': (_) => const LoginScreen(),
'/register': (_) => const RegisterScreen(),
'/home': (_) => const CitizenHomeScreen(),
'/driver': (_) => const DriverHomeScreen(),
'/admin': (_) => const AdminDashboardScreen(),
},
),
),
);
}
ThemeData _lightTheme() => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaPrimary,
primary: AppColors.guindaPrimary, secondary: AppColors.dorado),
scaffoldBackgroundColor: AppColors.grisFondo,
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.guindaPrimary, width: 2)),
labelStyle: TextStyle(color: AppColors.guindaPrimary)),
);
ThemeData _darkTheme() => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(seedColor: AppColors.guindaLight,
brightness: Brightness.dark, primary: AppColors.guindaLight,
secondary: AppColors.dorado),
scaffoldBackgroundColor: const Color(0xFF121212),
cardColor: const Color(0xFF1E1E1E),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.guindaLight, width: 2)),
labelStyle: TextStyle(color: AppColors.guindaLight)),
);
}

View File

@@ -0,0 +1,186 @@
// ── USER ──────────────────────────────────────────────────────────────────
class UserModel {
final int? id;
final String nombre;
final String email;
final String password;
final String rol;
UserModel({this.id, required this.nombre, required this.email,
required this.password, required this.rol});
Map<String, dynamic> toMap() =>
{'id':id,'nombre':nombre,'email':email,'password':password,'rol':rol};
factory UserModel.fromMap(Map<String, dynamic> m) => UserModel(
id:m['id'], nombre:m['nombre'], email:m['email'],
password:m['password'], rol:m['rol']);
}
// ── DOMICILIO (User → Domicilio → Zona → Ruta) ────────────────────────────
class DomicilioModel {
final int? id;
final int userId;
final String alias; // "Casa", "Trabajo", etc.
final String calle;
final String colonia; // Zona de cobertura
final String routeId; // Ruta asignada
final String horarioEstimado;
final bool isPrimary;
DomicilioModel({this.id, required this.userId, this.alias = 'Casa',
required this.calle, required this.colonia, required this.routeId,
required this.horarioEstimado, this.isPrimary = true});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'alias':alias,
'calle':calle,'colonia':colonia,'route_id':routeId,
'horario_estimado':horarioEstimado,'is_primary':isPrimary?1:0};
factory DomicilioModel.fromMap(Map<String, dynamic> m) => DomicilioModel(
id:m['id'], userId:m['user_id'], alias:m['alias']??'Casa',
calle:m['calle'], colonia:m['colonia'], routeId:m['route_id'],
horarioEstimado:m['horario_estimado'], isPrimary:m['is_primary']==1);
}
// ── RUTA DINÁMICA (creada por admin) ──────────────────────────────────────
class RouteDefinitionModel {
final int? id;
final String routeId;
final String nombre;
final List<String> dias; // ['LUNES','MIERCOLES','VIERNES']
final String horaInicio; // '06:00'
final String horaFin; // '08:00'
final String turno; // MATUTINO|VESPERTINO|NOCTURNO
final List<String> colonias; // colonias que cubre
final bool activa;
RouteDefinitionModel({this.id, required this.routeId, required this.nombre,
required this.dias, required this.horaInicio, required this.horaFin,
required this.turno, required this.colonias, this.activa = true});
Map<String, dynamic> toMap() => {
'id':id,'route_id':routeId,'nombre':nombre,
'dias':dias.join(','),'hora_inicio':horaInicio,'hora_fin':horaFin,
'turno':turno,'colonias':colonias.join('|'),'activa':activa?1:0,
};
factory RouteDefinitionModel.fromMap(Map<String, dynamic> m) =>
RouteDefinitionModel(
id:m['id'], routeId:m['route_id'], nombre:m['nombre'],
dias:(m['dias']??'').toString().split(',').where((s)=>s.isNotEmpty).toList(),
horaInicio:m['hora_inicio']??'06:00', horaFin:m['hora_fin']??'08:00',
turno:m['turno']??'MATUTINO',
colonias:(m['colonias']??'').toString().split('|').where((s)=>s.isNotEmpty).toList(),
activa:m['activa']==1,
);
}
// ── RESEÑA DEL SERVICIO ───────────────────────────────────────────────────
class ReviewModel {
final int? id;
final int userId;
final String colonia;
final String routeId;
final int estrellas; // 1-5
final String comentario;
final String fecha;
final String nombreUsuario;
ReviewModel({this.id, required this.userId, required this.colonia,
required this.routeId, required this.estrellas, required this.comentario,
required this.fecha, this.nombreUsuario = ''});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'colonia':colonia,
'route_id':routeId,'estrellas':estrellas,'comentario':comentario,
'fecha':fecha,'nombre_usuario':nombreUsuario};
factory ReviewModel.fromMap(Map<String, dynamic> m) => ReviewModel(
id:m['id'], userId:m['user_id'], colonia:m['colonia'],
routeId:m['route_id'], estrellas:m['estrellas'],
comentario:m['comentario']??'', fecha:m['fecha'],
nombreUsuario:m['nombre_usuario']??'Ciudadano');
}
// ── ASSIGNMENT ────────────────────────────────────────────────────────────
class AssignmentModel {
final int? id;
final int conductorId;
final String routeId;
final String diaSemana;
final String turno;
AssignmentModel({this.id, required this.conductorId, required this.routeId,
required this.diaSemana, required this.turno});
Map<String, dynamic> toMap() => {'id':id,'conductor_id':conductorId,
'route_id':routeId,'dia_semana':diaSemana,'turno':turno};
factory AssignmentModel.fromMap(Map<String, dynamic> m) => AssignmentModel(
id:m['id'], conductorId:m['conductor_id'], routeId:m['route_id'],
diaSemana:m['dia_semana'], turno:m['turno']);
}
// ── ROUTE STATUS ──────────────────────────────────────────────────────────
class RouteStatusModel {
final String routeId;
final String status;
final String? mensaje;
final String updatedAt;
RouteStatusModel({required this.routeId, required this.status,
this.mensaje, required this.updatedAt});
Map<String, dynamic> toMap() => {'route_id':routeId,'status':status,
'mensaje':mensaje,'updated_at':updatedAt};
factory RouteStatusModel.fromMap(Map<String, dynamic> m) => RouteStatusModel(
routeId:m['route_id'], status:m['status'],
mensaje:m['mensaje'], updatedAt:m['updated_at']);
}
// ── ALERTA ────────────────────────────────────────────────────────────────
class AlertaModel {
final int? id;
final String tipo;
final String routeId;
final String mensaje;
final String fecha;
final bool resuelta;
AlertaModel({this.id, required this.tipo, required this.routeId,
required this.mensaje, required this.fecha, this.resuelta=false});
Map<String, dynamic> toMap() => {'id':id,'tipo':tipo,'route_id':routeId,
'mensaje':mensaje,'fecha':fecha,'resuelta':resuelta?1:0};
factory AlertaModel.fromMap(Map<String, dynamic> m) => AlertaModel(
id:m['id'], tipo:m['tipo'], routeId:m['route_id'],
mensaje:m['mensaje'], fecha:m['fecha'], resuelta:m['resuelta']==1);
}
// ── REPORTE ───────────────────────────────────────────────────────────────
class ReporteModel {
final int? id;
final int userId;
final String tipo;
final String descripcion;
final String colonia;
final String routeId;
final String fecha;
final String estado;
final int calificacion;
ReporteModel({this.id, required this.userId, required this.tipo,
required this.descripcion, required this.colonia, required this.routeId,
required this.fecha, this.estado='PENDIENTE', this.calificacion=5});
Map<String, dynamic> toMap() => {'id':id,'user_id':userId,'tipo':tipo,
'descripcion':descripcion,'colonia':colonia,'route_id':routeId,
'fecha':fecha,'estado':estado,'calificacion':calificacion};
factory ReporteModel.fromMap(Map<String, dynamic> m) => ReporteModel(
id:m['id'], userId:m['user_id'], tipo:m['tipo'],
descripcion:m['descripcion'], colonia:m['colonia'],
routeId:m['route_id']??'', fecha:m['fecha'],
estado:m['estado'], calificacion:m['calificacion']??5);
}

View File

@@ -0,0 +1,39 @@
import 'package:latlong2/latlong.dart';
class RoutePosition {
final int positionId;
final double lat;
final double lng;
final int speed;
final String timestamp;
RoutePosition({required this.positionId, required this.lat,
required this.lng, required this.speed, required this.timestamp});
LatLng get latLng => LatLng(lat, lng);
}
class RouteModel {
final String routeId;
final String name;
final int truckId;
String status;
final List<RoutePosition> positions;
final String turno;
RouteModel({required this.routeId, required this.name,
required this.truckId, required this.status,
required this.positions, this.turno = 'MATUTINO'});
List<LatLng> get polylinePoints =>
positions.map((p) => LatLng(p.lat, p.lng)).toList();
}
class ColonyModel {
final String colonia;
final String routeId;
final String horarioEstimado;
ColonyModel({required this.colonia, required this.routeId,
required this.horarioEstimado});
}

File diff suppressed because it is too large Load Diff

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

View 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(); }
}

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

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

View 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:0008: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(); }
}

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

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

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

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

View 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(); }
}

View File

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

View File

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

View 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(); }
}

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

View 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(); }
}

Some files were not shown because too many files have changed in this diff Show More