diff --git a/HackOnLinces_app b/HackOnLinces_app deleted file mode 160000 index 88b5802..0000000 --- a/HackOnLinces_app +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 88b580229c63f17b87742aa1f7b9821533484066 diff --git a/HackOnLinces_app/aplicacion_hack/.gitignore b/HackOnLinces_app/aplicacion_hack/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/HackOnLinces_app/aplicacion_hack/.metadata b/HackOnLinces_app/aplicacion_hack/.metadata new file mode 100644 index 0000000..768732a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: android + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: ios + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: linux + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: macos + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: web + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: windows + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/HackOnLinces_app/aplicacion_hack/README.md b/HackOnLinces_app/aplicacion_hack/README.md new file mode 100644 index 0000000..5c52d84 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/README.md @@ -0,0 +1,17 @@ +# aplicacion_hack + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/HackOnLinces_app/aplicacion_hack/analysis_options.yaml b/HackOnLinces_app/aplicacion_hack/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/analysis_options.yaml @@ -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 diff --git a/HackOnLinces_app/aplicacion_hack/android/.gitignore b/HackOnLinces_app/aplicacion_hack/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/HackOnLinces_app/aplicacion_hack/android/app/build.gradle.kts b/HackOnLinces_app/aplicacion_hack/android/app/build.gradle.kts new file mode 100644 index 0000000..d6ba38a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + id("com.google.gms.google-services") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.aplicacion_hack" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + isCoreLibraryDesugaringEnabled = true + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.aplicacion_hack" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} diff --git a/HackOnLinces_app/aplicacion_hack/android/app/google-services.json b/HackOnLinces_app/aplicacion_hack/android/app/google-services.json new file mode 100644 index 0000000..83ed588 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "338042609701", + "project_id": "hackon-58b23", + "storage_bucket": "hackon-58b23.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:338042609701:android:0f0f92d7f895794f371fcc", + "android_client_info": { + "package_name": "com.example.aplicacion_hack" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBuT70SbADLeg92ll8keCySI8I4eYyqyLw" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/debug/AndroidManifest.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/AndroidManifest.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..23a57a5 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/kotlin/com/example/aplicacion_hack/MainActivity.kt b/HackOnLinces_app/aplicacion_hack/android/app/src/main/kotlin/com/example/aplicacion_hack/MainActivity.kt new file mode 100644 index 0000000..88cf8ba --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/kotlin/com/example/aplicacion_hack/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.aplicacion_hack + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable-v21/launch_background.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable/launch_background.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values-night/styles.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values/styles.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/app/src/profile/AndroidManifest.xml b/HackOnLinces_app/aplicacion_hack/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/HackOnLinces_app/aplicacion_hack/android/build.gradle.kts b/HackOnLinces_app/aplicacion_hack/android/build.gradle.kts new file mode 100644 index 0000000..a2b1a8a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/build.gradle.kts @@ -0,0 +1,35 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + // Añadimos la herramienta de Google Services para que Android la pueda usar + classpath("com.google.gms:google-services:4.4.1") + } +} + +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) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} \ No newline at end of file diff --git a/HackOnLinces_app/aplicacion_hack/android/gradle.properties b/HackOnLinces_app/aplicacion_hack/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/gradle.properties @@ -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 diff --git a/HackOnLinces_app/aplicacion_hack/android/gradle/wrapper/gradle-wrapper.properties b/HackOnLinces_app/aplicacion_hack/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/HackOnLinces_app/aplicacion_hack/android/settings.gradle.kts b/HackOnLinces_app/aplicacion_hack/android/settings.gradle.kts new file mode 100644 index 0000000..5ea2823 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/android/settings.gradle.kts @@ -0,0 +1,29 @@ +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 + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/bottle.png b/HackOnLinces_app/aplicacion_hack/assets/images/bottle.png new file mode 100644 index 0000000..2c8bb5a Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/bottle.png differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/globo.png b/HackOnLinces_app/aplicacion_hack/assets/images/globo.png new file mode 100644 index 0000000..0dde173 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/globo.png differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/megafono.png b/HackOnLinces_app/aplicacion_hack/assets/images/megafono.png new file mode 100644 index 0000000..a8c99ee Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/megafono.png differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/planta.png b/HackOnLinces_app/aplicacion_hack/assets/images/planta.png new file mode 100644 index 0000000..e61720f Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/planta.png differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/recycle.jpg b/HackOnLinces_app/aplicacion_hack/assets/images/recycle.jpg new file mode 100644 index 0000000..e09256d Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/recycle.jpg differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/reloj.jpg b/HackOnLinces_app/aplicacion_hack/assets/images/reloj.jpg new file mode 100644 index 0000000..0ff5df8 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/reloj.jpg differ diff --git a/HackOnLinces_app/aplicacion_hack/assets/images/reloj.png b/HackOnLinces_app/aplicacion_hack/assets/images/reloj.png new file mode 100644 index 0000000..e64757c Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/assets/images/reloj.png differ diff --git a/HackOnLinces_app/aplicacion_hack/firebase.json b/HackOnLinces_app/aplicacion_hack/firebase.json new file mode 100644 index 0000000..b44d29b --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/firebase.json @@ -0,0 +1 @@ +{"flutter":{"platforms":{"android":{"default":{"projectId":"hackon-58b23","appId":"1:338042609701:android:0f0f92d7f895794f371fcc","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"hackon-58b23","configurations":{"android":"1:338042609701:android:0f0f92d7f895794f371fcc","ios":"1:338042609701:ios:d1ac418d368d1df5371fcc","macos":"1:338042609701:ios:d1ac418d368d1df5371fcc","web":"1:338042609701:web:5f0a245f364e7604371fcc","windows":"1:338042609701:web:b01c28c419fddd25371fcc"}}}}}} \ No newline at end of file diff --git a/HackOnLinces_app/aplicacion_hack/ios/.gitignore b/HackOnLinces_app/aplicacion_hack/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/HackOnLinces_app/aplicacion_hack/ios/Flutter/AppFrameworkInfo.plist b/HackOnLinces_app/aplicacion_hack/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Flutter/Debug.xcconfig b/HackOnLinces_app/aplicacion_hack/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/ios/Flutter/Release.xcconfig b/HackOnLinces_app/aplicacion_hack/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.pbxproj b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f31ea00 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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.aplicacionHack; + 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.aplicacionHack.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.aplicacionHack.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.aplicacionHack.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.aplicacionHack; + 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.aplicacionHack; + 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 */; +} diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/contents.xcworkspacedata b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/AppDelegate.swift b/HackOnLinces_app/aplicacion_hack/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/LaunchScreen.storyboard b/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/Main.storyboard b/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Info.plist b/HackOnLinces_app/aplicacion_hack/ios/Runner/Info.plist new file mode 100644 index 0000000..34758c5 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Aplicacion Hack + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + aplicacion_hack + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/Runner-Bridging-Header.h b/HackOnLinces_app/aplicacion_hack/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/HackOnLinces_app/aplicacion_hack/ios/Runner/SceneDelegate.swift b/HackOnLinces_app/aplicacion_hack/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/HackOnLinces_app/aplicacion_hack/ios/RunnerTests/RunnerTests.swift b/HackOnLinces_app/aplicacion_hack/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/firebase_options.dart b/HackOnLinces_app/aplicacion_hack/lib/firebase_options.dart new file mode 100644 index 0000000..908492c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/firebase_options.dart @@ -0,0 +1,88 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return macos; + case TargetPlatform.windows: + return windows; + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyDfpYR_--oRlGdTjLnAZY6z3RLh3LLz5gk', + appId: '1:338042609701:web:5f0a245f364e7604371fcc', + messagingSenderId: '338042609701', + projectId: 'hackon-58b23', + authDomain: 'hackon-58b23.firebaseapp.com', + storageBucket: 'hackon-58b23.firebasestorage.app', + measurementId: 'G-TBT47P2HX5', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyBuT70SbADLeg92ll8keCySI8I4eYyqyLw', + appId: '1:338042609701:android:0f0f92d7f895794f371fcc', + messagingSenderId: '338042609701', + projectId: 'hackon-58b23', + storageBucket: 'hackon-58b23.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyAB7czRjHfrkHEjiDOvPLiUz9zS1UGiZbw', + appId: '1:338042609701:ios:d1ac418d368d1df5371fcc', + messagingSenderId: '338042609701', + projectId: 'hackon-58b23', + storageBucket: 'hackon-58b23.firebasestorage.app', + iosBundleId: 'com.example.aplicacionHack', + ); + + static const FirebaseOptions macos = FirebaseOptions( + apiKey: 'AIzaSyAB7czRjHfrkHEjiDOvPLiUz9zS1UGiZbw', + appId: '1:338042609701:ios:d1ac418d368d1df5371fcc', + messagingSenderId: '338042609701', + projectId: 'hackon-58b23', + storageBucket: 'hackon-58b23.firebasestorage.app', + iosBundleId: 'com.example.aplicacionHack', + ); + + static const FirebaseOptions windows = FirebaseOptions( + apiKey: 'AIzaSyDfpYR_--oRlGdTjLnAZY6z3RLh3LLz5gk', + appId: '1:338042609701:web:b01c28c419fddd25371fcc', + messagingSenderId: '338042609701', + projectId: 'hackon-58b23', + authDomain: 'hackon-58b23.firebaseapp.com', + storageBucket: 'hackon-58b23.firebasestorage.app', + measurementId: 'G-DYGG6KBJ6C', + ); +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/main.dart b/HackOnLinces_app/aplicacion_hack/lib/main.dart new file mode 100644 index 0000000..05b76a8 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/main.dart @@ -0,0 +1,114 @@ +// ================================================================ +// main.dart — EcoTrack +// Sistema de Notificacion Privada de Recoleccion de Residuos +// ================================================================ + +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'screens/login_screen.dart'; +import 'screens/home_screen.dart'; +import 'screens/route_list_screen.dart'; +import 'screens/info_screen.dart'; +import 'screens/analytics_screen.dart'; +import 'screens/reporte_screen.dart'; +import 'screens/mapa_rutas_screen.dart'; +import 'firebase_options.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +const AndroidNotificationChannel _canal = AndroidNotificationChannel( + 'ecotrack_canal', + 'EcoTrack Notificaciones', + description: 'Notificaciones del camión de basura', + importance: Importance.high, +); + +final FlutterLocalNotificationsPlugin _localNotifications = + FlutterLocalNotificationsPlugin(); + +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // DESPUÉS + final androidPlugin = + _localNotifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await androidPlugin?.createNotificationChannel(_canal); + + await _localNotifications.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + + // ← EL QUE TE FALTABA + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + final notification = message.notification; + if (notification == null) return; + _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + _canal.id, + _canal.name, + channelDescription: _canal.description, + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + ), + ), + ); + }); + + await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + runApp(const EcoTrackApp()); +} + +class EcoTrackApp extends StatelessWidget { + const EcoTrackApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'EcoTrack', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF2E7D32), + brightness: Brightness.light, + ), + useMaterial3: true, + fontFamily: 'Roboto', + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + ), + ), + initialRoute: '/', + routes: { + '/': (context) => const LoginScreen(), + '/home': (context) => const HomeScreen(), + '/routes': (context) => const RouteListScreen(), + '/info': (context) => const InfoScreen(), + '/analytics': (context) => const AnalyticsScreen(), + '/reporte': (context) => const ReporteScreen(), + '/mapa': (context) => const MapaRutasScreen(), + }, + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/analytics_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/analytics_screen.dart new file mode 100644 index 0000000..3e31487 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/analytics_screen.dart @@ -0,0 +1,1092 @@ +// ================================================================ +// lib/screens/analytics_screen.dart +// Dashboard de Reportes y Análisis Predictivo +// ================================================================ +// +// DEPENDENCIAS (agregar en pubspec.yaml): +// fl_chart: ^0.69.0 +// +// NAVEGAR DESDE main.dart: +// '/analytics': (context) => const AnalyticsScreen() +// +// SECCIONES: +// 1. Resumen ejecutivo (KPIs principales) +// 2. Días con más residuos (BarChart) +// 3. Zonas críticas (mapa de calor horizontal) +// 4. Predicción próxima semana (LineChart) +// 5. Recomendaciones de logística +// ================================================================ + +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:http/http.dart' as http; + +// ---------------------------------------------------------------- +// MODELOS +// ---------------------------------------------------------------- + +class ResumenReporte { + final double totalKg; + final double promedioKgDia; + final int coloniasAnalizadas; + final int zonasCriticas; + final String diaPico; + final String coloniaMayorVolumen; + + ResumenReporte({ + required this.totalKg, + required this.promedioKgDia, + required this.coloniasAnalizadas, + required this.zonasCriticas, + required this.diaPico, + required this.coloniaMayorVolumen, + }); + + factory ResumenReporte.fromJson(Map j) => ResumenReporte( + totalKg: (j['total_kg_recolectados'] as num).toDouble(), + promedioKgDia: (j['promedio_kg_dia'] as num).toDouble(), + coloniasAnalizadas: j['colonias_analizadas'], + zonasCriticas: j['zonas_criticas'], + diaPico: j['dia_pico'], + coloniaMayorVolumen: j['colonia_mayor_volumen'], + ); +} + +class DiaSemana { + final String dia; + final double promedioKg; + final double promedioIncidencias; + + DiaSemana({ + required this.dia, + required this.promedioKg, + required this.promedioIncidencias, + }); + + factory DiaSemana.fromJson(Map j) => DiaSemana( + dia: j['dia'], + promedioKg: (j['promedio_kg'] as num).toDouble(), + promedioIncidencias: (j['promedio_incidencias'] as num).toDouble(), + ); +} + +class ZonaCritica { + final String colonia; + final String rutaId; + final double promedioKgDia; + final double promedioIncidencias; + final double indiceCriticidad; + final String nivelCriticidad; + + ZonaCritica({ + required this.colonia, + required this.rutaId, + required this.promedioKgDia, + required this.promedioIncidencias, + required this.indiceCriticidad, + required this.nivelCriticidad, + }); + + factory ZonaCritica.fromJson(Map j) => ZonaCritica( + colonia: j['colonia'], + rutaId: j['ruta_id'], + promedioKgDia: (j['promedio_kg_dia'] as num).toDouble(), + promedioIncidencias: (j['promedio_incidencias_dia'] as num).toDouble(), + indiceCriticidad: (j['indice_criticidad'] as num).toDouble(), + nivelCriticidad: j['nivel_criticidad'], + ); +} + +class DiaPred { + final String fecha; + final String dia; + final double volumenKg; + final String confianza; + + DiaPred({ + required this.fecha, + required this.dia, + required this.volumenKg, + required this.confianza, + }); + + factory DiaPred.fromJson(Map j) => DiaPred( + fecha: j['fecha'], + dia: j['dia'], + volumenKg: (j['volumen_predicho_kg'] as num).toDouble(), + confianza: j['confianza'], + ); +} + +class PrediccionColonia { + final String colonia; + final String tendencia; + final double pendienteDiaria; + final List dias; + final double totalPredichoKg; + + PrediccionColonia({ + required this.colonia, + required this.tendencia, + required this.pendienteDiaria, + required this.dias, + required this.totalPredichoKg, + }); + + factory PrediccionColonia.fromJson(Map j) => PrediccionColonia( + colonia: j['colonia'], + tendencia: j['tendencia'], + pendienteDiaria: (j['pendiente_diaria_kg'] as num).toDouble(), + dias: (j['prediccion_7dias'] as List).map((d) => DiaPred.fromJson(d)).toList(), + totalPredichoKg: (j['volumen_predicho_total_kg'] as num).toDouble(), + ); +} + +class Recomendacion { + final String tipo; + final String prioridad; + final String emoji; + final String titulo; + final String descripcion; + final String? rutaAfectada; + + Recomendacion({ + required this.tipo, + required this.prioridad, + required this.emoji, + required this.titulo, + required this.descripcion, + this.rutaAfectada, + }); + + factory Recomendacion.fromJson(Map j) => Recomendacion( + tipo: j['tipo'], + prioridad: j['prioridad'], + emoji: j['emoji'], + titulo: j['titulo'], + descripcion: j['descripcion'], + rutaAfectada: j['ruta_afectada'], + ); +} + +// ================================================================ +// PANTALLA PRINCIPAL +// ================================================================ +class AnalyticsScreen extends StatefulWidget { + const AnalyticsScreen({super.key}); + + @override + State createState() => _AnalyticsScreenState(); +} + +class _AnalyticsScreenState extends State + with SingleTickerProviderStateMixin { + static const _baseUrl = 'http://192.168.198.55:8000'; + + bool _cargando = true; + String? _error; + late TabController _tabController; + + ResumenReporte? _resumen; + List _diasSemana = []; + List _zonas = []; + List _predicciones = []; + List _recomendaciones = []; + + // Colonia seleccionada para el detalle de predicción + int _coloniaIdx = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _cargarReporte(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _cargarReporte() async { + setState(() { _cargando = true; _error = null; }); + try { + final resp = await http + .get(Uri.parse('$_baseUrl/api/analytics/reporte-real')) + .timeout(const Duration(seconds: 20)); + + if (resp.statusCode != 200) throw Exception('Error ${resp.statusCode}'); + final data = json.decode(resp.body); + + setState(() { + _resumen = ResumenReporte.fromJson(data['resumen']); + _diasSemana = (data['dias_semana'] as List) + .map((d) => DiaSemana.fromJson(d)) + .toList(); + _zonas = (data['zonas_criticas'] as List) + .map((z) => ZonaCritica.fromJson(z)) + .toList(); + _predicciones = (data['prediccion_proxima_semana'] as List) + .map((p) => PrediccionColonia.fromJson(p)) + .toList(); + _recomendaciones = (data['recomendaciones'] as List) + .map((r) => Recomendacion.fromJson(r)) + .toList(); + _cargando = false; + }); + } catch (e) { + setState(() { _error = e.toString(); _cargando = false; }); + } + } + + // ================================================================ + // BUILD + // ================================================================ + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF3F6F3), + appBar: AppBar( + title: const Text('Análisis y Reportes'), + backgroundColor: const Color(0xFF1B5E20), + foregroundColor: Colors.white, + actions: [ + IconButton(icon: const Icon(Icons.refresh), onPressed: _cargarReporte), + ], + bottom: _cargando || _error != null + ? null + : TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white60, + isScrollable: true, + tabs: const [ + Tab(text: 'Resumen'), + Tab(text: 'Días pico'), + Tab(text: 'Zonas'), + Tab(text: 'Predicción'), + ], + ), + ), + body: _cargando + ? _buildCargando() + : _error != null + ? _buildError() + : TabBarView( + controller: _tabController, + children: [ + _buildResumen(), + _buildDiasPico(), + _buildZonas(), + _buildPrediccion(), + ], + ), + ); + } + + Widget _buildCargando() => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Color(0xFF2E7D32)), + SizedBox(height: 16), + Text('Calculando análisis predictivo...', style: TextStyle(color: Colors.grey)), + SizedBox(height: 8), + Text('Procesando 90 días de datos', style: TextStyle(color: Colors.grey, fontSize: 12)), + ], + ), + ); + + Widget _buildError() => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.bar_chart, size: 72, color: Colors.grey), + const SizedBox(height: 16), + const Text('No se pudo cargar el reporte', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Verifica que el backend esté corriendo', + style: TextStyle(color: Colors.grey.shade600)), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _cargarReporte, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF2E7D32)), + ), + ], + ), + ), + ); + + // ================================================================ + // TAB 1: RESUMEN EJECUTIVO + // ================================================================ + Widget _buildResumen() { + if (_resumen == null) return const SizedBox(); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // KPIs en grid 2x2 + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.4, + children: [ + _KpiCard( + emoji: '🗑️', + valor: '${(_resumen!.totalKg / 1000).toStringAsFixed(1)} t', + etiqueta: 'Total recolectado\n(90 días)', + color: const Color(0xFF1565C0), + ), + _KpiCard( + emoji: '📅', + valor: '${_resumen!.promedioKgDia.toStringAsFixed(0)} kg', + etiqueta: 'Promedio\npor día', + color: const Color(0xFF2E7D32), + ), + _KpiCard( + emoji: '⚠️', + valor: '${_resumen!.zonasCriticas}', + etiqueta: 'Zonas\ncríticas', + color: const Color(0xFFE65100), + ), + _KpiCard( + emoji: '📆', + valor: _resumen!.diaPico, + etiqueta: 'Día con más\nresiduos', + color: const Color(0xFF6A1B9A), + ), + ], + ), + + const SizedBox(height: 20), + + // Datos destacados + _SeccionTitulo('Hallazgos principales'), + _InfoCard( + emoji: '🏆', + titulo: 'Colonia con mayor volumen', + subtitulo: _resumen!.coloniaMayorVolumen, + color: const Color(0xFFC62828), + ), + const SizedBox(height: 8), + _InfoCard( + emoji: '📈', + titulo: 'Día crítico de la semana', + subtitulo: '${_resumen!.diaPico} — mayor acumulación tras el fin de semana', + color: const Color(0xFF1565C0), + ), + + const SizedBox(height: 20), + _SeccionTitulo('Recomendaciones'), + ..._recomendaciones.map((r) => _TarjetaRecomendacion(rec: r)), + ], + ); + } + + // ================================================================ + // TAB 2: DÍAS PICO — BarChart + // ================================================================ + Widget _buildDiasPico() { + // Ordenar por día de la semana (lun-dom) + final diasOrdenados = List.from(_diasSemana); + const orden = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']; + diasOrdenados.sort((a, b) => orden.indexOf(a.dia).compareTo(orden.indexOf(b.dia))); + + final maxKg = diasOrdenados.map((d) => d.promedioKg).reduce((a, b) => a > b ? a : b); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _SeccionTitulo('Promedio de residuos por día'), + const Text( + 'Basado en 90 días de histórico. El lunes concentra mayor volumen por acumulación del fin de semana.', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 20), + + // Gráfica de barras + Container( + height: 260, + padding: const EdgeInsets.fromLTRB(8, 16, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8)], + ), + child: BarChart( + BarChartData( + maxY: maxKg * 1.15, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final d = diasOrdenados[group.x]; + return BarTooltipItem( + '${d.dia}\n${rod.toY.toStringAsFixed(0)} kg', + const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (val, meta) { + final abrevs = ['L', 'Ma', 'Mi', 'J', 'V', 'S', 'D']; + final idx = val.toInt(); + if (idx < 0 || idx >= abrevs.length) return const SizedBox(); + return Text(abrevs[idx], + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (val, meta) => Text( + '${(val / 1000).toStringAsFixed(1)}t', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ), + ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: maxKg / 4, + getDrawingHorizontalLine: (v) => FlLine(color: Colors.grey.shade200, strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + barGroups: List.generate(diasOrdenados.length, (i) { + final d = diasOrdenados[i]; + final esPico = d.dia == _resumen?.diaPico; + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: d.promedioKg, + color: esPico ? const Color(0xFFE65100) : const Color(0xFF2E7D32), + width: 28, + borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + ), + ], + ); + }), + ), + ), + ), + + const SizedBox(height: 20), + _SeccionTitulo('Incidencias por día'), + const Text( + 'Reportes de basura tirada fuera de horario.', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 12), + + // Tabla de incidencias + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Column( + children: diasOrdenados.asMap().entries.map((entry) { + final i = entry.key; + final d = entry.value; + final maxInc = diasOrdenados + .map((x) => x.promedioIncidencias) + .reduce((a, b) => a > b ? a : b); + final pct = maxInc > 0 ? d.promedioIncidencias / maxInc : 0.0; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + border: i < diasOrdenados.length - 1 + ? Border(bottom: BorderSide(color: Colors.grey.shade100)) + : null, + ), + child: Row( + children: [ + SizedBox(width: 60, child: Text(d.dia.substring(0, 3), + style: const TextStyle(fontWeight: FontWeight.w600))), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation( + pct > 0.7 ? const Color(0xFFC62828) : const Color(0xFF2E7D32)), + minHeight: 10, + ), + ), + ), + const SizedBox(width: 12), + Text('${d.promedioIncidencias.toStringAsFixed(1)}/día', + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } + + // ================================================================ + // TAB 3: ZONAS CRÍTICAS + // ================================================================ + Widget _buildZonas() { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _SeccionTitulo('Mapa de calor por colonia'), + const Text( + 'Índice de criticidad basado en volumen e incidencias (0–100).', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 16), + ..._zonas.map((z) => _TarjetaZona(zona: z)), + const SizedBox(height: 16), + _SeccionTitulo('Tiempo promedio de recolección'), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Column( + children: _zonas.asMap().entries.map((entry) { + final i = entry.key; + final z = entry.value; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: i < _zonas.length - 1 + ? Border(bottom: BorderSide(color: Colors.grey.shade100)) + : null, + ), + child: Row( + children: [ + Expanded(child: Text(z.colonia, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13))), + Text('${z.promedioKgDia.toStringAsFixed(0)} kg/día', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12)), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } + + // ================================================================ + // TAB 4: PREDICCIÓN — LineChart + // ================================================================ + Widget _buildPrediccion() { + if (_predicciones.isEmpty) return const Center(child: Text('Sin predicciones')); + final pred = _predicciones[_coloniaIdx]; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _SeccionTitulo('Predicción próximos 7 días'), + const Text( + 'Regresión lineal sobre 90 días históricos, ajustada por estacionalidad semanal.', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 12), + + // Selector de colonia + SizedBox( + height: 36, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _predicciones.length, + itemBuilder: (context, i) { + final p = _predicciones[i]; + final sel = i == _coloniaIdx; + return GestureDetector( + onTap: () => setState(() => _coloniaIdx = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: sel ? const Color(0xFF2E7D32) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: sel ? const Color(0xFF2E7D32) : Colors.grey.shade300, + ), + ), + child: Text( + p.colonia.split(' ').first, + style: TextStyle( + color: sel ? Colors.white : Colors.grey.shade700, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ); + }, + ), + ), + + const SizedBox(height: 16), + + // Badge de tendencia + Row( + children: [ + _TendenciaBadge(tendencia: pred.tendencia, pendiente: pred.pendienteDiaria), + const SizedBox(width: 8), + Text('Total predicho: ${pred.totalPredichoKg.toStringAsFixed(0)} kg', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ), + + const SizedBox(height: 16), + + // Gráfica de línea + Container( + height: 240, + padding: const EdgeInsets.fromLTRB(8, 16, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8)], + ), + child: LineChart( + LineChartData( + minY: 0, + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => spots.map((s) { + final d = pred.dias[s.x.toInt()]; + return LineTooltipItem( + '${d.dia}\n${s.y.toStringAsFixed(0)} kg', + const TextStyle(color: Colors.white, fontSize: 12), + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (val, meta) { + final idx = val.toInt(); + if (idx < 0 || idx >= pred.dias.length) return const SizedBox(); + return Text( + pred.dias[idx].dia.substring(0, 2), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (val, meta) => Text( + '${(val / 1000).toStringAsFixed(1)}t', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ), + ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + drawVerticalLine: false, + getDrawingHorizontalLine: (v) => + FlLine(color: Colors.grey.shade100, strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: pred.dias.asMap().entries.map((e) => + FlSpot(e.key.toDouble(), e.value.volumenKg)).toList(), + isCurved: true, + color: const Color(0xFF2E7D32), + barWidth: 3, + dotData: FlDotData( + getDotPainter: (spot, pct, bar, idx) => FlDotCirclePainter( + radius: 5, + color: Colors.white, + strokeWidth: 2.5, + strokeColor: const Color(0xFF2E7D32), + ), + ), + belowBarData: BarAreaData( + show: true, + color: const Color(0xFF2E7D32).withValues(alpha: 0.1), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Tabla de predicciones + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Column( + children: pred.dias.asMap().entries.map((entry) { + final i = entry.key; + final d = entry.value; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + border: i < pred.dias.length - 1 + ? Border(bottom: BorderSide(color: Colors.grey.shade100)) + : null, + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text(d.dia.substring(0, 2), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ), + const SizedBox(width: 4), + Text(d.fecha, style: TextStyle(color: Colors.grey.shade500, fontSize: 12)), + const Spacer(), + Text('${d.volumenKg.toStringAsFixed(0)} kg', + style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + _ConfianzaBadge(confianza: d.confianza), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} + +// ================================================================ +// WIDGETS AUXILIARES +// ================================================================ + +class _KpiCard extends StatelessWidget { + final String emoji; + final String valor; + final String etiqueta; + final Color color; + + const _KpiCard({ + required this.emoji, + required this.valor, + required this.etiqueta, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8)], + border: Border(left: BorderSide(color: color, width: 4)), + ), + child: ClipRect( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(emoji, style: const TextStyle(fontSize: 20)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(valor, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: color)), + Text(etiqueta, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600, height: 1.2)), + ], + ), + ], + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + final String emoji; + final String titulo; + final String subtitulo; + final Color color; + + const _InfoCard({ + required this.emoji, + required this.titulo, + required this.subtitulo, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 28)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titulo, + style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 13)), + const SizedBox(height: 2), + Text(subtitulo, style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ), + ), + ], + ), + ); + } +} + +class _SeccionTitulo extends StatelessWidget { + final String titulo; + const _SeccionTitulo(this.titulo); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(titulo, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), + ); +} + +class _TarjetaZona extends StatelessWidget { + final ZonaCritica zona; + const _TarjetaZona({required this.zona}); + + Color get _color { + switch (zona.nivelCriticidad) { + case 'CRÍTICO': return const Color(0xFFC62828); + case 'ALTO': return const Color(0xFFE65100); + case 'MEDIO': return const Color(0xFFF57F17); + default: return const Color(0xFF2E7D32); + } + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(zona.colonia, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text(zona.nivelCriticidad, + style: TextStyle(color: _color, fontWeight: FontWeight.bold, fontSize: 11)), + ), + ], + ), + const SizedBox(height: 8), + // Barra de criticidad + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: zona.indiceCriticidad / 100, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(_color), + minHeight: 8, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text('${zona.promedioKgDia.toStringAsFixed(0)} kg/día', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12)), + const Spacer(), + Text('Índice: ${zona.indiceCriticidad.toStringAsFixed(0)}/100', + style: TextStyle(color: _color, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ], + ), + ); + } +} + +class _TarjetaRecomendacion extends StatelessWidget { + final Recomendacion rec; + const _TarjetaRecomendacion({required this.rec}); + + Color get _colorPrioridad { + switch (rec.prioridad) { + case 'ALTA': return const Color(0xFFC62828); + case 'MEDIA': return const Color(0xFFE65100); + default: return const Color(0xFF2E7D32); + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border(left: BorderSide(color: _colorPrioridad, width: 4)), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(rec.emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Expanded( + child: Text(rec.titulo, + style: TextStyle( + fontWeight: FontWeight.bold, + color: _colorPrioridad, + fontSize: 14)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _colorPrioridad.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text(rec.prioridad, + style: TextStyle(color: _colorPrioridad, fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 8), + Text(rec.descripcion, + style: TextStyle(color: Colors.grey.shade600, fontSize: 13, height: 1.4)), + if (rec.rutaAfectada != null) ...[ + const SizedBox(height: 6), + Text('Ruta: ${rec.rutaAfectada}', + style: TextStyle(color: _colorPrioridad, fontSize: 12, fontWeight: FontWeight.w600)), + ], + ], + ), + ); + } +} + +class _TendenciaBadge extends StatelessWidget { + final String tendencia; + final double pendiente; + const _TendenciaBadge({required this.tendencia, required this.pendiente}); + + @override + Widget build(BuildContext context) { + final color = tendencia == 'CRECIENTE' + ? const Color(0xFFC62828) + : tendencia == 'DECRECIENTE' + ? const Color(0xFF2E7D32) + : const Color(0xFF1565C0); + final icon = tendencia == 'CRECIENTE' + ? Icons.trending_up + : tendencia == 'DECRECIENTE' + ? Icons.trending_down + : Icons.trending_flat; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 4), + Text(tendencia, + style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)), + ], + ), + ); + } +} + +class _ConfianzaBadge extends StatelessWidget { + final String confianza; + const _ConfianzaBadge({required this.confianza}); + + @override + Widget build(BuildContext context) { + final color = confianza == 'Alta' + ? const Color(0xFF2E7D32) + : confianza == 'Media' + ? const Color(0xFFE65100) + : Colors.grey; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text(confianza, + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/home_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/home_screen.dart new file mode 100644 index 0000000..090f95a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/home_screen.dart @@ -0,0 +1,927 @@ +// ================================================================ +// lib/screens/home_screen.dart (v3 — rediseño visual) +// ================================================================ + +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import '../services/api_service.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State with TickerProviderStateMixin { + int? _usuarioId; + String _nombreUsuario = ''; + ETAInfo? _etaInfo; + List _direcciones = []; + List _colonias = []; + bool _cargando = true; + bool _cargandoColonias = true; + String? _error; + String? _errorColonias; + + Timer? _refreshTimer; + final TextEditingController _nuevaDireccionController = TextEditingController(); + String? _nuevaColoniaSeleccionada; + + late AnimationController _pulseController; + late AnimationController _fadeController; + late Animation _pulseAnimation; + late Animation _fadeAnimation; + + final ApiService _apiService = ApiService(); + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 1.0, end: 1.06).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + + _fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_usuarioId == null) { + final args = ModalRoute.of(context)?.settings.arguments; + if (args is int) { + _usuarioId = args; + _inicializar(); + } else { + _cargarDesdeStorage(); + } + } + } + + @override + void dispose() { + _pulseController.dispose(); + _fadeController.dispose(); + _refreshTimer?.cancel(); + _nuevaDireccionController.dispose(); + super.dispose(); + } + + Future _inicializar() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) setState(() => _nombreUsuario = prefs.getString('nombre') ?? ''); + _cargarETA(); + _iniciarAutoRefresh(); + _registrarFCMToken(); + _cargarUsuario(); + _cargarColonias(); + } + + Future _cargarDesdeStorage() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt('usuario_id'); + if (id != null) { + _usuarioId = id; + _inicializar(); + } else { + if (mounted) Navigator.pushReplacementNamed(context, '/'); + } + } + + Future _cargarETA() async { + if (!mounted) return; + setState(() { _cargando = true; _error = null; }); + try { + final prefs = await SharedPreferences.getInstance(); + final usuarioId = prefs.getInt('usuario_id') ?? 1; + final etaInfo = await _apiService.obtenerETA(usuarioId); + if (mounted) { + setState(() { _etaInfo = etaInfo; _cargando = false; }); + _fadeController.forward(from: 0); + } + } catch (e) { + if (mounted) { + setState(() { + _etaInfo = ETAInfo( + usuarioId: 1, + colonia: "Centro", + rutaNombre: "Ruta Poniente - Camión #4", + rutaStatus: "EN_RUTA", + gpsOk: true, + etaTexto: "12 minutos aprox.", + etaMinutos: 12, + mensajePreventivo: "⚠️ El camión está a 3 cuadras. ¡Prepara tus bolsas!", + ); + _cargando = false; + }); + _fadeController.forward(from: 0); + } + } + } + + Future _cargarUsuario() async { + if (_usuarioId == null) return; + try { + final usuario = await _apiService.obtenerUsuario(_usuarioId!); + if (mounted) { + setState(() { + _direcciones = usuario.direcciones; + if (usuario.nombre.isNotEmpty) _nombreUsuario = usuario.nombre; + }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('nombre', usuario.nombre); + } + } catch (e) { + debugPrint('Error cargando usuario: $e'); + } + } + + Future _cargarColonias() async { + try { + final colonias = await _apiService.obtenerColonias(); + if (mounted) setState(() { _colonias = colonias; _cargandoColonias = false; }); + } catch (e) { + if (mounted) { + setState(() { + _colonias = ['Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes']; + _cargandoColonias = false; + _errorColonias = 'Sin conexión. Usando lista local.'; + }); + } + } + } + + void _iniciarAutoRefresh() { + _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _cargarETA()); + } + + Future _registrarFCMToken() async { + try { + final messaging = FirebaseMessaging.instance; + final settings = await messaging.requestPermission(alert: true, sound: true, badge: true); + if (settings.authorizationStatus == AuthorizationStatus.authorized) { + final token = await messaging.getToken(); + if (token != null && _usuarioId != null) { + await _apiService.registrarFcmToken(_usuarioId!, token); + } + } + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + if (message.notification != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('🚛 ${message.notification!.body}'), + backgroundColor: Colors.green.shade700, + duration: const Duration(seconds: 5), + behavior: SnackBarBehavior.floating, + )); + _cargarETA(); + } + }); + } catch (e) { + debugPrint('Error FCM: $e'); + } + } + + // ── DIÁLOGOS ──────────────────────────────────────────────────── + + Future _confirmarCerrarSesion() async { + final confirmar = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text('Cerrar sesión'), + content: const Text('¿Seguro que quieres cerrar sesión?'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancelar')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('Cerrar sesión', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + if (confirmar == true) { + _refreshTimer?.cancel(); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + if (mounted) Navigator.pushReplacementNamed(context, '/'); + } + } + + Future _mostrarCambioPassword() async { + final actualCtrl = TextEditingController(); + final nuevoCtrl = TextEditingController(); + bool mostrarActual = false; + bool mostrarNuevo = false; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setS) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text('Cambiar contraseña'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: actualCtrl, + obscureText: !mostrarActual, + decoration: InputDecoration( + labelText: 'Contraseña actual', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(mostrarActual ? Icons.visibility_off : Icons.visibility), + onPressed: () => setS(() => mostrarActual = !mostrarActual), + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 12), + TextField( + controller: nuevoCtrl, + obscureText: !mostrarNuevo, + decoration: InputDecoration( + labelText: 'Nueva contraseña', + hintText: 'Mínimo 6 caracteres', + prefixIcon: const Icon(Icons.lock_reset), + suffixIcon: IconButton( + icon: Icon(mostrarNuevo ? Icons.visibility_off : Icons.visibility), + onPressed: () => setS(() => mostrarNuevo = !mostrarNuevo), + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancelar')), + ElevatedButton( + onPressed: () async { + if (_usuarioId == null) return; + if (actualCtrl.text.isEmpty || nuevoCtrl.text.isEmpty) return; + if (nuevoCtrl.text.length < 6) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mínimo 6 caracteres.'))); + return; + } + try { + await _apiService.actualizarPassword(_usuarioId!, actualCtrl.text, nuevoCtrl.text); + if (mounted) { + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('✅ Contraseña actualizada.'), backgroundColor: Colors.green), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.red.shade700), + ); + } + } + }, + child: const Text('Guardar'), + ), + ], + ), + ), + ); + actualCtrl.dispose(); + nuevoCtrl.dispose(); + } + + Future _agregarDireccion() async { + if (_usuarioId == null) return; + final messenger = ScaffoldMessenger.of(context); + final direccion = _nuevaDireccionController.text.trim(); + if (_nuevaColoniaSeleccionada == null) { + messenger.showSnackBar(const SnackBar(content: Text('Selecciona una colonia.'))); + return; + } + if (direccion.isEmpty) { + messenger.showSnackBar(const SnackBar(content: Text('Ingresa la dirección.'))); + return; + } + try { + await _apiService.agregarDireccion(_usuarioId!, _nuevaColoniaSeleccionada!, direccion); + _nuevaDireccionController.clear(); + _nuevaColoniaSeleccionada = null; + await _cargarUsuario(); + await _cargarETA(); + if (mounted) messenger.showSnackBar(const SnackBar(content: Text('✅ Dirección agregada.'))); + } catch (e) { + if (mounted) messenger.showSnackBar(const SnackBar(content: Text('No se pudo agregar la dirección.'))); + } + } + + Future _mostrarAgregarDireccionDialog() async { + if (_cargandoColonias) await _cargarColonias(); + if (!mounted) return; + _nuevaDireccionController.clear(); + _nuevaColoniaSeleccionada = null; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text('Agregar dirección'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_errorColonias != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_errorColonias!, style: TextStyle(color: Colors.orange.shade700, fontSize: 12)), + ), + TextField( + controller: _nuevaDireccionController, + decoration: const InputDecoration(labelText: 'Dirección', hintText: 'Calle, número'), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _nuevaColoniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(), + onChanged: (v) => setState(() => _nuevaColoniaSeleccionada = v), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancelar')), + ElevatedButton( + onPressed: () async { + final nav = Navigator.of(context); + await _agregarDireccion(); + if (mounted) nav.pop(); + }, + child: const Text('Guardar'), + ), + ], + ), + ); + } + + // ── HELPERS ───────────────────────────────────────────────────── + + Color _colorSegunETA(int eta) { + if (eta <= 5) return const Color(0xFFB71C1C); + if (eta <= 15) return const Color(0xFFE65100); + if (eta <= 30) return const Color(0xFF1B5E20); + return const Color(0xFF1A237E); + } + + String _emojiSegunETA(int eta) { + if (eta <= 5) return '🔴'; + if (eta <= 15) return '🟡'; + if (eta <= 30) return '🟢'; + return '🔵'; + } + + String _textoEstado(int eta) { + if (eta <= 5) return '¡Saca tu basura AHORA!'; + if (eta <= 15) return 'Prepárate, viene pronto'; + if (eta <= 30) return 'En camino a tu zona'; + return 'Aún falta un rato'; + } + + // ================================================================ + // BUILD + // ================================================================ + + @override + Widget build(BuildContext context) { + final baseColor = _etaInfo != null + ? _colorSegunETA(_etaInfo!.etaMinutos) + : const Color(0xFF1B5E20); + + return Scaffold( + body: AnimatedContainer( + duration: const Duration(milliseconds: 800), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [baseColor, baseColor.withValues(alpha: 0.85), const Color(0xFF0D1B0F)], + stops: const [0.0, 0.5, 1.0], + ), + ), + child: SafeArea( + child: _cargando + ? _buildCargando() + : _error != null + ? _buildError() + : FadeTransition(opacity: _fadeAnimation, child: _buildContenido()), + ), + ), + ); + } + + Widget _buildCargando() => const Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + SizedBox(height: 16), + Text('Consultando estado del camión...', style: TextStyle(color: Colors.white60, fontSize: 15)), + ]), + ); + + Widget _buildError() => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.wifi_off_rounded, size: 72, color: Colors.white38), + const SizedBox(height: 16), + Text(_error!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 17)), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _cargarETA, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.red.shade700), + ), + ]), + ), + ); + + Widget _buildContenido() { + if (_etaInfo == null) return const SizedBox.shrink(); + final eta = _etaInfo!; + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + // ── HEADER ────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 8, 0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_nombreUsuario.isNotEmpty) + Text( + 'Hola, ${_nombreUsuario.split(' ').first} 👋', + style: const TextStyle(color: Colors.white60, fontSize: 13), + ), + Row( + children: [ + const Icon(Icons.location_on_rounded, color: Colors.white70, size: 15), + const SizedBox(width: 3), + Text( + eta.colonia, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 17), + ), + ], + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pushNamed(context, '/mapa'), + icon: const Icon(Icons.map_rounded, color: Colors.white60), + tooltip: 'Ver mapa', + ), + IconButton( + onPressed: () => Navigator.pushNamed(context, '/routes'), + icon: const Icon(Icons.local_shipping_rounded, color: Colors.white60), + tooltip: 'Ver rutas', + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white60), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + onSelected: (v) { + if (v == 'password') _mostrarCambioPassword(); + if (v == 'logout') _confirmarCerrarSesion(); + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'password', + child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 10), Text('Cambiar contraseña')]), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'logout', + child: Row(children: [Icon(Icons.logout, size: 18, color: Colors.red), SizedBox(width: 10), Text('Cerrar sesión', style: TextStyle(color: Colors.red))]), + ), + ], + ), + ], + ), + ), + + // ── CHIP DE ESTADO GPS ─────────────────────────────────── + if (!eta.gpsOk) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), + child: _GlassChip( + icon: Icons.gps_off, + label: 'GPS del camión desconectado', + color: Colors.red.shade300, + ), + ), + + // ── CÍRCULO ETA PRINCIPAL ──────────────────────────────── + const SizedBox(height: 24), + ScaleTransition( + scale: _pulseAnimation, + child: Stack( + alignment: Alignment.center, + children: [ + // Anillo exterior difuso + Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.06), + ), + ), + // Anillo con blur + ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all(color: Colors.white.withValues(alpha: 0.4), width: 2), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_emojiSegunETA(eta.etaMinutos), style: const TextStyle(fontSize: 36)), + const SizedBox(height: 2), + Text( + '${eta.etaMinutos}', + style: const TextStyle(fontSize: 58, fontWeight: FontWeight.w900, color: Colors.white, height: 1), + ), + const Text('minutos', style: TextStyle(fontSize: 14, color: Colors.white70, letterSpacing: 1)), + ], + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + Text( + _textoEstado(eta.etaMinutos), + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + eta.etaTexto, + style: const TextStyle(color: Colors.white60, fontSize: 13), + ), + + // ── CARD MENSAJE PREVENTIVO ────────────────────────────── + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _GlassCard( + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + eta.mensajePreventivo, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.4), + ), + ), + ], + ), + ), + ), + + // ── INFO DE RUTA ───────────────────────────────────────── + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _GlassCard( + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.local_shipping_rounded, color: Colors.white, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(eta.rutaNombre, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: eta.rutaStatus == 'EN_RUTA' + ? Colors.green.withValues(alpha: 0.3) + : Colors.blue.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + eta.rutaStatus, + style: TextStyle( + color: eta.rutaStatus == 'EN_RUTA' ? Colors.greenAccent : Colors.lightBlueAccent, + fontSize: 11, fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + + // ── ACCESOS RÁPIDOS ────────────────────────────────────── + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 2, bottom: 12), + child: Text('Accesos rápidos', style: TextStyle(color: Colors.white60, fontSize: 12, letterSpacing: 0.8)), + ), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.55, + children: [ + _AccesoRapido( + icon: Icons.local_shipping_rounded, + label: 'Rutas', + sublabel: 'Estado del camión', + color: const Color(0xFF1565C0), + onTap: () => Navigator.pushNamed(context, '/routes'), + ), + _AccesoRapido( + icon: Icons.analytics_rounded, + label: 'Análisis', + sublabel: 'Reportes y predicción', + color: const Color(0xFF6A1B9A), + onTap: () => Navigator.pushNamed(context, '/analytics'), + ), + _AccesoRapido( + icon: Icons.report_problem_rounded, + label: 'Reportar', + sublabel: 'Enviar un reporte', + color: const Color(0xFFE65100), + onTap: () => Navigator.pushNamed(context, '/reporte'), + ), + _AccesoRapido( + icon: Icons.eco_rounded, + label: 'Información', + sublabel: 'Guía de residuos', + color: const Color(0xFF2E7D32), + onTap: () => Navigator.pushNamed(context, '/info'), + ), + _AccesoRapido( + icon: Icons.map_rounded, + label: 'Mapa', + sublabel: 'Ver rutas en mapa', + color: const Color(0xFF00838F), + onTap: () => Navigator.pushNamed(context, '/mapa'), + ), + _AccesoRapido( + icon: Icons.add_location_alt_rounded, + label: 'Dirección', + sublabel: 'Agregar domicilio', + color: const Color(0xFFC62828), + onTap: _mostrarAgregarDireccionDialog, + ), + ], + ), + ], + ), + ), + + // ── DIRECCIONES ────────────────────────────────────────── + if (_direcciones.isNotEmpty) ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.home_rounded, color: Colors.white70, size: 16), + const SizedBox(width: 8), + const Text('Mis direcciones', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)), + const Spacer(), + GestureDetector( + onTap: _mostrarAgregarDireccionDialog, + child: const Icon(Icons.add_circle_outline, color: Colors.white54, size: 20), + ), + ], + ), + const SizedBox(height: 10), + ..._direcciones.map((d) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 6, height: 6, + decoration: const BoxDecoration(color: Colors.white54, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(d.colonia, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + Text(d.direccion, style: const TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ), + ], + ), + )), + ], + ), + ), + ), + ], + + // ── FOOTER ─────────────────────────────────────────────── + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(bottom: 28), + child: Column(children: [ + const Text('🔄 Actualización automática cada minuto', + style: TextStyle(color: Colors.white30, fontSize: 11)), + const SizedBox(height: 10), + TextButton.icon( + onPressed: _cargarETA, + icon: const Icon(Icons.refresh, color: Colors.white38, size: 16), + label: const Text('Actualizar ahora', style: TextStyle(color: Colors.white38, fontSize: 12)), + ), + ]), + ), + ], + ), + ); + } +} + +// ================================================================ +// WIDGETS AUXILIARES +// ================================================================ + +/// Tarjeta con efecto glassmorphism +class _GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + + const _GlassCard({required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(18), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: double.infinity, + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1), + ), + child: child, + ), + ), + ); + } +} + +/// Chip de estado pequeño +class _GlassChip extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + + const _GlassChip({required this.icon, required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 15), + const SizedBox(width: 6), + Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), + ); + } +} + +/// Tarjeta de acceso rápido en el grid +class _AccesoRapido extends StatelessWidget { + final IconData icon; + final String label; + final String sublabel; + final Color color; + final VoidCallback onTap; + + const _AccesoRapido({ + required this.icon, + required this.label, + required this.sublabel, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withValues(alpha: 0.35), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: Colors.white, size: 20), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + Text(sublabel, style: const TextStyle(color: Colors.white54, fontSize: 10), overflow: TextOverflow.ellipsis), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/info_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/info_screen.dart new file mode 100644 index 0000000..87c8a15 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/info_screen.dart @@ -0,0 +1,808 @@ +// ================================================================ +// lib/screens/info_screen.dart — EcoTrack +// Pantalla de Informacion + Tutorial interactivo +// ================================================================ +// +// ASSETS USADOS: +// assets/images/recycle.jpg → Separacion / reciclaje +// assets/images/bottle.png → Plasticos +// assets/images/planta.png → Composta / Medio ambiente +// assets/images/megafono.png → Residuos peligrosos / Horarios +// +// SECCIONES: +// 1. Tutorial interactivo (swipe de pasos) +// 2. Articulos de informacion por categoria +// 3. Detalle de articulo con imagen +// ================================================================ + +import 'package:flutter/material.dart'; + +// ================================================================ +// MODELOS +// ================================================================ + +class _Subseccion { + final String subtitulo; + final String texto; + const _Subseccion(this.subtitulo, this.texto); +} + +class _Articulo { + final String id; + final String categoria; + final String titulo; + final String resumen; + final String imagenAsset; + final Color color; + final List<_Subseccion> contenido; + final String consejoRapido; + + const _Articulo({ + required this.id, + required this.categoria, + required this.titulo, + required this.resumen, + required this.imagenAsset, + required this.color, + required this.contenido, + required this.consejoRapido, + }); +} + +// ================================================================ +// DATOS +// ================================================================ + +const _articulos = [ + _Articulo( + id: 'separacion', + categoria: 'Separacion', + titulo: 'Como separar correctamente tu basura', + resumen: 'La separacion correcta es el primer paso para reciclar y reducir el impacto ambiental.', + imagenAsset: 'assets/images/recycle.jpg', + color: Color(0xFF2E7D32), + consejoRapido: 'Regla facil: si vino de la naturaleza y se pudre, es organico. Si es artificial y esta limpio, es reciclable.', + contenido: [ + _Subseccion('Residuos Organicos', 'Restos de comida, cascaras de frutas y verduras, posos de cafe, bolsas de te, restos de jardin. Van en bolsa oscura o cafe. Se convierten en composta.'), + _Subseccion('Inorganicos Reciclables', 'Plasticos (botellas PET, envases), papel y carton limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente. Deben estar limpios y secos.'), + _Subseccion('No Reciclables', 'Papel higienico usado, panales, colillas de cigarro, envolturas metalizadas. Van en bolsa negra. No tienen valor de reciclaje.'), + _Subseccion('Residuos Especiales', 'Pilas, medicamentos caducados, electronicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas a puntos de acopio en supermercados.'), + ], + ), + _Articulo( + id: 'horarios', + categoria: 'Horarios', + titulo: 'Cuando sacar tu basura', + resumen: 'Sacar la basura en el momento correcto evita plagas, malos olores y que el camion se la pierda.', + imagenAsset: 'assets/images/megafono.png', + color: Color(0xFF1565C0), + consejoRapido: 'Espera la alerta de EcoTrack antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.', + contenido: [ + _Subseccion('El momento ideal', 'Saca tu basura cuando recibas la alerta de "Camion Cercano" en EcoTrack. Eso significa que el camion esta a menos de 15 minutos de tu domicilio.'), + _Subseccion('Por que no de noche', 'Las bolsas en la acera de noche atraen perros y fauna nocturna que las rompen y dispersan los residuos. Ademas el plastico se deteriora con la humedad nocturna.'), + _Subseccion('Si me lo pierdo', 'Si el camion ya paso, guarda tu basura hasta el siguiente dia. Nunca dejes bolsas en la via publica fuera del horario de recoleccion.'), + _Subseccion('Dias festivos', 'En dias festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de EcoTrack para recibir alertas de retraso o cambio de horario.'), + ], + ), + _Articulo( + id: 'plasticos', + categoria: 'Reciclaje', + titulo: 'Guia de plasticos: cuales si y cuales no', + resumen: 'No todos los plasticos son iguales. Aprende a leer el numero en el triangulo de reciclaje.', + imagenAsset: 'assets/images/bottle.png', + color: Color(0xFF00838F), + consejoRapido: 'Busca el numero dentro del triangulo en el fondo del envase. Los numeros 1 y 2 siempre van al reciclaje.', + contenido: [ + _Subseccion('Plastico #1 PET', 'Botellas de agua y refrescos. El mas reciclado. Aplastalo para ahorrar espacio. Quita la tapa porque es un material diferente.'), + _Subseccion('Plastico #2 HDPE', 'Garrafones, botellas de leche, shampoo. Tambien muy reciclable. Enjuagalo antes de separarlo.'), + _Subseccion('Plastico #5 PP', 'Tapas de botellas, envases de yogur. Si se recicla pero menos centros lo aceptan.'), + _Subseccion('Plasticos 3, 6 y 7', 'PVC, unicel, policarbonato. Dificiles o imposibles de reciclar. Van a basura no reciclable.'), + _Subseccion('Bolsas de plastico', 'No van en el reciclaje de casa porque tapan las maquinas clasificadoras. Lleva tus bolsas a centros de acopio en supermercados.'), + ], + ), + _Articulo( + id: 'composta', + categoria: 'Compostaje', + titulo: 'Haz composta en casa', + resumen: 'Convierte tus residuos organicos en abono natural. Es mas facil de lo que crees.', + imagenAsset: 'assets/images/planta.png', + color: Color(0xFF558B2F), + consejoRapido: 'La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega mas material seco y volteala.', + contenido: [ + _Subseccion('Que necesitas', 'Un contenedor con tapa, residuos organicos, tierra o tierra de hojarasca, y un poco de paciencia.'), + _Subseccion('Que puedes compostar', 'Cascaras de frutas y verduras, restos de comida cocida sin carne, posos de cafe y filtros de papel, cascaras de huevo, hojas secas.'), + _Subseccion('Que NO debes compostar', 'Carnes, pescados, lacteos, aceites ya que atraen plagas, excrementos de mascotas, plasticos ni metales.'), + _Subseccion('El proceso', 'Alterna capas de residuos organicos humedos con capas de material seco. Voltea la mezcla cada semana. En 2 a 3 meses tendras composta lista para tus plantas.'), + ], + ), + _Articulo( + id: 'peligrosos', + categoria: 'Residuos Especiales', + titulo: 'Residuos peligrosos: como deshacerte de ellos', + resumen: 'Pilas, medicamentos y electronicos requieren un manejo especial para no contaminar el suelo y el agua.', + imagenAsset: 'assets/images/megafono.png', + color: Color(0xFFE65100), + consejoRapido: 'Guarda una caja en casa exclusiva para residuos peligrosos. Cuando este llena, busca el punto de acopio mas cercano.', + contenido: [ + _Subseccion('Pilas y baterias', 'Una sola pila AA puede contaminar 600,000 litros de agua. Guardalas en una bolsa y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO.'), + _Subseccion('Medicamentos caducados', 'No los tires al drenaje ni a la basura regular. Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos.'), + _Subseccion('Electronicos RAEE', 'Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electronicos o espera las jornadas municipales.'), + _Subseccion('Aceite de cocina', 'Un litro de aceite contamina hasta 1,000 litros de agua potable. Viertelo en una botella PET con tapa y lleva a centros de acopio.'), + ], + ), + _Articulo( + id: 'impacto', + categoria: 'Medio Ambiente', + titulo: 'El impacto real de reciclar', + resumen: 'Numeros concretos para entender por que vale la pena separar tu basura cada dia.', + imagenAsset: 'assets/images/planta.png', + color: Color(0xFF4527A0), + consejoRapido: 'Cada lata de aluminio que reciclas ahorra energia equivalente a medio litro de gasolina. Si importa.', + contenido: [ + _Subseccion('Papel y carton', 'Reciclar 1 tonelada de papel salva 17 arboles y ahorra 26,000 litros de agua. Una familia promedio genera 500 kg de papel al ano.'), + _Subseccion('Aluminio', 'Reciclar una lata de aluminio ahorra la energia suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces.'), + _Subseccion('Vidrio', 'El vidrio tarda mas de 4,000 anos en degradarse. Reciclarlo reduce en 20% las emisiones de CO2 de su produccion.'), + _Subseccion('Residuos en Mexico', 'Mexico genera 120,000 toneladas de basura al dia. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podria triplicarse.'), + ], + ), +]; + +// ── Pasos del tutorial ───────────────────────────────────────── + +class _PasoTutorial { + final String titulo; + final String descripcion; + final String imagenAsset; + final Color color; + final IconData icono; + + const _PasoTutorial({ + required this.titulo, + required this.descripcion, + required this.imagenAsset, + required this.color, + required this.icono, + }); +} + +const _pasosTutorial = [ + _PasoTutorial( + titulo: 'Bienvenido a EcoTrack', + descripcion: 'EcoTrack te notifica cuando el camion recolector esta cerca de tu domicilio para que saques tu basura en el momento exacto.', + imagenAsset: 'assets/images/recycle.jpg', + color: Color(0xFF2E7D32), + icono: Icons.recycling_rounded, + ), + _PasoTutorial( + titulo: 'Separa tus residuos', + descripcion: 'Separa tu basura en organicos, reciclables y no reciclables. Esto facilita el trabajo del camion y reduce el impacto ambiental.', + imagenAsset: 'assets/images/bottle.png', + color: Color(0xFF00838F), + icono: Icons.category_rounded, + ), + _PasoTutorial( + titulo: 'Espera la alerta', + descripcion: 'Activa las notificaciones de EcoTrack. Te avisaremos cuando el camion este a menos de 15 minutos de tu casa.', + imagenAsset: 'assets/images/megafono.png', + color: Color(0xFF1565C0), + icono: Icons.notifications_active_rounded, + ), + _PasoTutorial( + titulo: 'Saca la basura a tiempo', + descripcion: 'Al recibir la alerta, saca tus bolsas a la acera. Evita sacarlas muy antes para no atraer fauna y mantener limpia la calle.', + imagenAsset: 'assets/images/recycle.jpg', + color: Color(0xFFE65100), + icono: Icons.access_time_filled_rounded, + ), + _PasoTutorial( + titulo: 'Envia reportes', + descripcion: 'Si el camion no paso o detectas alguna irregularidad, usa la seccion de Reportes para informar al municipio. Tu participacion mejora el servicio.', + imagenAsset: 'assets/images/megafono.png', + color: Color(0xFF6A1B9A), + icono: Icons.report_problem_rounded, + ), +]; + +// ================================================================ +// PANTALLA PRINCIPAL +// ================================================================ +class InfoScreen extends StatefulWidget { + const InfoScreen({super.key}); + + @override + State createState() => _InfoScreenState(); +} + +class _InfoScreenState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + String? _categoriaFiltro; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + List get _categorias => + _articulos.map((a) => a.categoria).toSet().toList(); + + List<_Articulo> get _articulosFiltrados => + _categoriaFiltro == null + ? _articulos + : _articulos.where((a) => a.categoria == _categoriaFiltro).toList(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F7F5), + appBar: AppBar( + title: const Text('EcoTrack — Aprende'), + backgroundColor: const Color(0xFF2E7D32), + foregroundColor: Colors.white, + elevation: 0, + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white60, + tabs: const [ + Tab(icon: Icon(Icons.play_circle_outline_rounded), text: 'Tutorial'), + Tab(icon: Icon(Icons.menu_book_rounded), text: 'Guias'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _TutorialView(), + _GuiasView( + categorias: _categorias, + categoriaFiltro: _categoriaFiltro, + articulos: _articulosFiltrados, + onFiltro: (c) => setState(() => _categoriaFiltro = c), + ), + ], + ), + ); + } +} + +// ================================================================ +// TAB 1: TUTORIAL INTERACTIVO +// ================================================================ +class _TutorialView extends StatefulWidget { + @override + State<_TutorialView> createState() => _TutorialViewState(); +} + +class _TutorialViewState extends State<_TutorialView> { + final PageController _pageCtrl = PageController(); + int _paginaActual = 0; + + @override + void dispose() { + _pageCtrl.dispose(); + super.dispose(); + } + + void _siguiente() { + if (_paginaActual < _pasosTutorial.length - 1) { + _pageCtrl.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + } + } + + void _anterior() { + if (_paginaActual > 0) { + _pageCtrl.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Indicador de progreso + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: List.generate(_pasosTutorial.length, (i) { + final activo = i == _paginaActual; + final completado = i < _paginaActual; + return Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 3), + height: 4, + decoration: BoxDecoration( + color: completado + ? const Color(0xFF2E7D32) + : activo + ? _pasosTutorial[_paginaActual].color + : Colors.grey.shade200, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Paso ${_paginaActual + 1} de ${_pasosTutorial.length}', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + Text( + _paginaActual == _pasosTutorial.length - 1 ? 'Completado' : '', + style: const TextStyle(color: Color(0xFF2E7D32), fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ), + + // Páginas del tutorial + Expanded( + child: PageView.builder( + controller: _pageCtrl, + itemCount: _pasosTutorial.length, + onPageChanged: (i) => setState(() => _paginaActual = i), + itemBuilder: (context, i) => _PaginaTutorial(paso: _pasosTutorial[i]), + ), + ), + + // Controles de navegación + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Row( + children: [ + if (_paginaActual > 0) + Expanded( + child: OutlinedButton.icon( + onPressed: _anterior, + icon: const Icon(Icons.arrow_back_rounded, size: 18), + label: const Text('Anterior'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + if (_paginaActual > 0) const SizedBox(width: 12), + Expanded( + flex: _paginaActual == 0 ? 1 : 1, + child: ElevatedButton.icon( + onPressed: _paginaActual == _pasosTutorial.length - 1 ? null : _siguiente, + icon: Icon( + _paginaActual == _pasosTutorial.length - 1 + ? Icons.check_circle_rounded + : Icons.arrow_forward_rounded, + size: 18, + ), + label: Text( + _paginaActual == _pasosTutorial.length - 1 ? 'Listo' : 'Siguiente', + ), + style: ElevatedButton.styleFrom( + backgroundColor: _pasosTutorial[_paginaActual].color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _PaginaTutorial extends StatelessWidget { + final _PasoTutorial paso; + const _PaginaTutorial({required this.paso}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // Imagen con overlay de icono + Stack( + alignment: Alignment.bottomRight, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + paso.imagenAsset, + height: 220, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + height: 220, + decoration: BoxDecoration( + color: paso.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Icon(paso.icono, size: 80, color: paso.color.withValues(alpha: 0.4)), + ), + ), + ), + // Badge del icono + Positioned( + bottom: 16, + right: 16, + child: Container( + width: 52, height: 52, + decoration: BoxDecoration( + color: paso.color, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: paso.color.withValues(alpha: 0.4), blurRadius: 12)], + ), + child: Icon(paso.icono, color: Colors.white, size: 26), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Titulo + Text( + paso.titulo, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: paso.color, height: 1.2), + ), + const SizedBox(height: 12), + + // Descripcion + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)], + ), + child: Text( + paso.descripcion, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15, color: Color(0xFF444444), height: 1.6), + ), + ), + const SizedBox(height: 20), + ], + ), + ); + } +} + +// ================================================================ +// TAB 2: GUIAS DE INFORMACION +// ================================================================ +class _GuiasView extends StatelessWidget { + final List categorias; + final String? categoriaFiltro; + final List<_Articulo> articulos; + final void Function(String?) onFiltro; + + const _GuiasView({ + required this.categorias, + required this.categoriaFiltro, + required this.articulos, + required this.onFiltro, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Filtros de categoria + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _FiltroChip( + label: 'Todas', + seleccionado: categoriaFiltro == null, + color: const Color(0xFF2E7D32), + onTap: () => onFiltro(null), + ), + ...categorias.map((cat) { + final a = _articulos.firstWhere((x) => x.categoria == cat, orElse: () => _articulos.first); + return _FiltroChip( + label: cat, + seleccionado: categoriaFiltro == cat, + color: a.color, + onTap: () => onFiltro(cat), + ); + }), + ], + ), + ), + + // Lista de articulos + Expanded( + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + itemCount: articulos.length, + itemBuilder: (context, i) => _TarjetaArticulo( + articulo: articulos[i], + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => _DetalleArticuloScreen(articulo: articulos[i])), + ), + ), + ), + ), + ], + ); + } +} + +// ================================================================ +// WIDGET: Chip de filtro +// ================================================================ +class _FiltroChip extends StatelessWidget { + final String label; + final bool seleccionado; + final Color color; + final VoidCallback onTap; + + const _FiltroChip({required this.label, required this.seleccionado, required this.color, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: seleccionado ? color : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color, width: 1.5), + ), + child: Text( + label, + style: TextStyle(color: seleccionado ? Colors.white : color, fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + ), + ); + } +} + +// ================================================================ +// WIDGET: Tarjeta de articulo +// ================================================================ +class _TarjetaArticulo extends StatelessWidget { + final _Articulo articulo; + final VoidCallback onTap; + + const _TarjetaArticulo({required this.articulo, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Row( + children: [ + // Imagen lateral + ClipRRect( + borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)), + child: Image.asset( + articulo.imagenAsset, + width: 90, + height: 100, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 90, height: 100, + color: articulo.color.withValues(alpha: 0.1), + child: Icon(Icons.eco_rounded, color: articulo.color, size: 36), + ), + ), + ), + // Contenido + Expanded( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: articulo.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text(articulo.categoria, + style: TextStyle(color: articulo.color, fontSize: 10, fontWeight: FontWeight.w700)), + ), + const SizedBox(height: 6), + Text(articulo.titulo, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A)), + maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text(articulo.resumen, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.3), + maxLines: 2, overflow: TextOverflow.ellipsis), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon(Icons.chevron_right_rounded, color: Colors.grey.shade400), + ), + ], + ), + ), + ), + ); + } +} + +// ================================================================ +// PANTALLA DE DETALLE +// ================================================================ +class _DetalleArticuloScreen extends StatelessWidget { + final _Articulo articulo; + const _DetalleArticuloScreen({required this.articulo}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F7F5), + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 220, + pinned: true, + backgroundColor: articulo.color, + foregroundColor: Colors.white, + flexibleSpace: FlexibleSpaceBar( + title: Text( + articulo.titulo, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), + maxLines: 2, + ), + background: Stack( + fit: StackFit.expand, + children: [ + Image.asset( + articulo.imagenAsset, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container(color: articulo.color), + ), + // Gradiente oscuro para legibilidad del titulo + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, articulo.color.withValues(alpha: 0.85)], + stops: const [0.4, 1.0], + ), + ), + ), + ], + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Badge categoria + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: articulo.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text(articulo.categoria, + style: TextStyle(color: articulo.color, fontWeight: FontWeight.w700, fontSize: 12)), + ), + const SizedBox(height: 12), + + // Resumen + Text(articulo.resumen, + style: const TextStyle(fontSize: 15, color: Color(0xFF333333), height: 1.5)), + const SizedBox(height: 20), + + // Secciones + ...articulo.contenido.map((s) => _SeccionCard(seccion: s, color: articulo.color)), + + const SizedBox(height: 8), + + // Consejo destacado + Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: articulo.color, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline_rounded, color: Colors.white.withValues(alpha: 0.9), size: 20), + const SizedBox(width: 8), + const Text('Consejo rapido', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + ], + ), + const SizedBox(height: 10), + Text(articulo.consejoRapido, + style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.5)), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _SeccionCard extends StatelessWidget { + final _Subseccion seccion; + final Color color; + const _SeccionCard({required this.seccion, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border(left: BorderSide(color: color, width: 4)), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(seccion.subtitulo, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 6), + Text(seccion.texto, + style: const TextStyle(fontSize: 13, color: Color(0xFF444444), height: 1.5)), + ], + ), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/login_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/login_screen.dart new file mode 100644 index 0000000..61ef896 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/login_screen.dart @@ -0,0 +1,402 @@ +// ================================================================ +// lib/screens/login_screen.dart — EcoTrack +// ================================================================ + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/api_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _nameCtrl = TextEditingController(); + final _dirCtrl = TextEditingController(); + + String? _coloniaSeleccionada; + List _colonias = []; + bool _esRegistro = false; + bool _cargandoColonias = true; + String? _errorColonias; + bool _logueando = false; + bool _mostrarPassword = false; + + final ApiService _apiService = ApiService(); + + @override + void initState() { + super.initState(); + _cargarColonias(); + _verificarSesion(); + } + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + _nameCtrl.dispose(); + _dirCtrl.dispose(); + super.dispose(); + } + + Future _verificarSesion() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt('usuario_id'); + if (id != null && mounted) { + Navigator.pushReplacementNamed(context, '/home', arguments: id); + } + } + + Future _cargarColonias() async { + try { + final colonias = await _apiService.obtenerColonias(); + if (mounted) setState(() { _colonias = colonias; _cargandoColonias = false; }); + } catch (e) { + if (mounted) { + setState(() { + _colonias = ['Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes']; + _cargandoColonias = false; + _errorColonias = 'Sin conexión al backend. Usando lista local.'; + }); + } + } + } + + Future _iniciarSesion() async { + final email = _emailCtrl.text.trim(); + final password = _passwordCtrl.text; + if (email.isEmpty) { _error('Ingresa tu correo.'); return; } + if (password.isEmpty) { _error('Ingresa tu contraseña.'); return; } + + setState(() => _logueando = true); + try { + final r = await _apiService.loginConCorreo(email, password); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('usuario_id', r['usuario_id']); + await prefs.setString('nombre', r['nombre'] ?? ''); + await prefs.setString('email', email); + if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: r['usuario_id']); + } catch (e) { + _error(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _logueando = false); + } + } + + Future _registrarse() async { + final nombre = _nameCtrl.text.trim(); + final email = _emailCtrl.text.trim(); + final password = _passwordCtrl.text; + final dir = _dirCtrl.text.trim(); + + if (nombre.isEmpty) { _error('Ingresa tu nombre.'); return; } + if (email.isEmpty) { _error('Ingresa tu correo.'); return; } + if (password.length < 6) { _error('Contraseña de al menos 6 caracteres.'); return; } + if (_coloniaSeleccionada == null) { _error('Selecciona tu colonia.'); return; } + if (dir.isEmpty) { _error('Ingresa tu dirección.'); return; } + + setState(() => _logueando = true); + try { + final id = await _apiService.registrarUsuario(nombre, email, password, dir, _coloniaSeleccionada!); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('usuario_id', id); + await prefs.setString('nombre', nombre); + await prefs.setString('email', email); + if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: id); + } catch (e) { + _error(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _logueando = false); + } + } + + void _error(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + )); + } + + // ================================================================ + // BUILD + // ================================================================ + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + + return Scaffold( + backgroundColor: const Color(0xFFF6FAF6), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + + // ── LOGO ───────────────────────────────────────────── + Center( + child: Container( + width: 84, height: 84, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(22), + boxShadow: [BoxShadow(color: color.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 8))], + ), + child: const Icon(Icons.recycling_rounded, size: 48, color: Colors.white), + ), + ), + const SizedBox(height: 18), + Text('EcoTrack', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 32, fontWeight: FontWeight.w900, color: color, letterSpacing: -0.5), + ), + const SizedBox(height: 4), + const Text('Recolección inteligente de residuos', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 32), + + // ── TABS LOGIN / REGISTRO ───────────────────────────── + Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _Tab(label: 'Iniciar sesión', seleccionado: !_esRegistro, color: color, + onTap: () => setState(() { _esRegistro = false; _passwordCtrl.clear(); })), + _Tab(label: 'Registrarse', seleccionado: _esRegistro, color: color, + onTap: () => setState(() { _esRegistro = true; _passwordCtrl.clear(); })), + ], + ), + ), + const SizedBox(height: 24), + + // ── CAMPOS COMUNES ──────────────────────────────────── + if (_esRegistro) ...[ + _Campo(ctrl: _nameCtrl, label: 'Nombre completo', icon: Icons.person_outline), + const SizedBox(height: 14), + ], + _Campo(ctrl: _emailCtrl, label: 'Correo electrónico', icon: Icons.email_outlined, tipo: TextInputType.emailAddress), + const SizedBox(height: 14), + _Campo( + ctrl: _passwordCtrl, + label: 'Contraseña', + icon: Icons.lock_outline, + obscure: !_mostrarPassword, + sufijo: IconButton( + icon: Icon(_mostrarPassword ? Icons.visibility_off : Icons.visibility, size: 20), + onPressed: () => setState(() => _mostrarPassword = !_mostrarPassword), + ), + ), + + // ── CAMPOS EXTRA REGISTRO ───────────────────────────── + if (_esRegistro) ...[ + const SizedBox(height: 14), + _Campo(ctrl: _dirCtrl, label: 'Dirección', icon: Icons.home_outlined), + const SizedBox(height: 14), + if (_cargandoColonias) + const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator())) + else ...[ + if (_errorColonias != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text(_errorColonias!, style: TextStyle(fontSize: 11, color: Colors.orange.shade700)), + ), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: DropdownButtonFormField( + value: _coloniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.location_city_outlined), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(), + onChanged: (v) => setState(() => _coloniaSeleccionada = v), + ), + ), + ], + const SizedBox(height: 8), + _IndicadorFortaleza(password: _passwordCtrl.text), + ], + + const SizedBox(height: 24), + + // ── BOTÓN PRINCIPAL ─────────────────────────────────── + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _logueando ? null : (_esRegistro ? _registrarse : _iniciarSesion), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: _logueando + ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text(_esRegistro ? 'Crear cuenta' : 'Entrar', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _esRegistro + ? 'Tu contraseña se almacena de forma segura con bcrypt. Minimo 6 caracteres.' + : 'Tus datos son privados y solo se usan para notificarte cuando el camion se acerca.', + style: TextStyle(fontSize: 11, color: color), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} + +// ================================================================ +// WIDGETS AUXILIARES +// ================================================================ + +class _Tab extends StatelessWidget { + final String label; + final bool seleccionado; + final Color color; + final VoidCallback onTap; + + const _Tab({required this.label, required this.seleccionado, required this.color, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: seleccionado ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(9), + boxShadow: seleccionado ? [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 6)] : [], + ), + child: Text(label, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal, + color: seleccionado ? color : Colors.grey.shade500, + fontSize: 13, + ), + ), + ), + ), + ); + } +} + +class _Campo extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final IconData icon; + final bool obscure; + final TextInputType tipo; + final Widget? sufijo; + + const _Campo({ + required this.ctrl, + required this.label, + required this.icon, + this.obscure = false, + this.tipo = TextInputType.text, + this.sufijo, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: ctrl, + obscureText: obscure, + keyboardType: tipo, + textCapitalization: tipo == TextInputType.text ? TextCapitalization.words : TextCapitalization.none, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 20), + suffixIcon: sufijo, + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ); + } +} + +class _IndicadorFortaleza extends StatelessWidget { + final String password; + const _IndicadorFortaleza({required this.password}); + + (int, String, Color) _evaluar() { + if (password.isEmpty) return (0, '', Colors.grey); + int pts = 0; + if (password.length >= 8) pts++; + if (password.contains(RegExp(r'[A-Z]'))) pts++; + if (password.contains(RegExp(r'[0-9]'))) pts++; + if (password.contains(RegExp(r'[!@#\$%^&*]'))) pts++; + if (pts <= 1) return (1, 'Debil', Colors.red); + if (pts == 2) return (2, 'Regular', Colors.orange); + if (pts == 3) return (3, 'Buena', Colors.lightGreen); + return (4, 'Excelente', Colors.green); + } + + @override + Widget build(BuildContext context) { + if (password.isEmpty) return const SizedBox.shrink(); + final (nivel, etiqueta, color) = _evaluar(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: List.generate(4, (i) => Expanded( + child: Container( + margin: const EdgeInsets.only(right: 4), + height: 4, + decoration: BoxDecoration( + color: i < nivel ? color : Colors.grey.shade200, + borderRadius: BorderRadius.circular(2), + ), + ), + ))), + const SizedBox(height: 4), + Text('Contrasena: $etiqueta', style: TextStyle(fontSize: 11, color: color)), + ], + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/mapa_rutas_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/mapa_rutas_screen.dart new file mode 100644 index 0000000..0044f19 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/mapa_rutas_screen.dart @@ -0,0 +1,523 @@ +// ================================================================ +// lib/screens/mapa_rutas_screen.dart (v2 — flutter_map) +// Mapa real de Celaya con rutas estilo metro sobre OpenStreetMap +// ================================================================ +// +// DEPENDENCIAS (agregar en pubspec.yaml): +// flutter_map: ^7.0.2 +// latlong2: ^0.9.1 +// +// SIN API KEY — usa tiles de OpenStreetMap (gratis, libre) +// +// FILTRADO: +// Solo las rutas del usuario se ven en color vivo. +// Las demás se muestran al 20% de opacidad. +// ================================================================ + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/api_service.dart'; + +// ---------------------------------------------------------------- +// DATOS DE RUTAS +// ---------------------------------------------------------------- +class _RutaInfo { + final String routeId; + final String nombre; + final String colonia; + final Color color; + final List puntos; + + const _RutaInfo({ + required this.routeId, + required this.nombre, + required this.colonia, + required this.color, + required this.puntos, + }); +} + +final _todasLasRutas = [ + _RutaInfo( + routeId: 'RUTA-01', + nombre: 'Zona Centro - Las Arboledas', + colonia: 'Zona Centro', + color: const Color(0xFF1565C0), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5185, -100.8450), + LatLng(20.5215, -100.8142), + LatLng(20.5212, -100.8175), + LatLng(20.5210, -100.8210), + LatLng(20.5235, -100.8212), + LatLng(20.5260, -100.8215), + LatLng(20.5111, -100.9037), + ], + ), + _RutaInfo( + routeId: 'RUTA-03', + nombre: 'Sector Poniente - San Juanico', + colonia: 'San Juanico', + color: const Color(0xFF2E7D32), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5250, -100.8510), + LatLng(20.5290, -100.8320), + LatLng(20.5315, -100.8355), + LatLng(20.5340, -100.8390), + LatLng(20.5362, -100.8425), + LatLng(20.5330, -100.8430), + LatLng(20.5111, -100.9037), + ], + ), + _RutaInfo( + routeId: 'RUTA-04', + nombre: 'Oriente - Los Olivos', + colonia: 'Los Olivos', + color: const Color(0xFFE65100), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5260, -100.8010), + LatLng(20.5295, -100.7890), + LatLng(20.5320, -100.7850), + LatLng(20.5350, -100.7790), + LatLng(20.5310, -100.7760), + LatLng(20.5270, -100.7820), + LatLng(20.5111, -100.9037), + ], + ), + _RutaInfo( + routeId: 'RUTA-05', + nombre: 'Sector Sur - Rancho Seco', + colonia: 'Rancho Seco', + color: const Color(0xFF6A1B9A), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5050, -100.8620), + LatLng(20.5020, -100.8350), + LatLng(20.4995, -100.8210), + LatLng(20.4970, -100.8150), + LatLng(20.5010, -100.8120), + LatLng(20.5060, -100.8160), + LatLng(20.5111, -100.9037), + ], + ), + _RutaInfo( + routeId: 'RUTA-12', + nombre: 'Nororiente - Las Insurgentes', + colonia: 'Las Insurgentes', + color: const Color(0xFFC62828), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5280, -100.8080), + LatLng(20.5320, -100.7980), + LatLng(20.5340, -100.7940), + LatLng(20.5360, -100.7900), + LatLng(20.5310, -100.7920), + LatLng(20.5270, -100.8020), + LatLng(20.5111, -100.9037), + ], + ), + _RutaInfo( + routeId: 'RUTA-13', + nombre: 'Sector Norte - Trojes e Irrigación', + colonia: 'Trojes', + color: const Color(0xFF00838F), + puntos: [ + LatLng(20.5111, -100.9037), + LatLng(20.5360, -100.8190), + LatLng(20.5420, -100.8080), + LatLng(20.5440, -100.8040), + LatLng(20.5460, -100.8000), + LatLng(20.5410, -100.8020), + LatLng(20.5370, -100.8120), + LatLng(20.5111, -100.9037), + ], + ), +]; + +const _coloniaARuta = { + 'Zona Centro': 'RUTA-01', + 'Las Arboledas': 'RUTA-01', + 'San Juanico': 'RUTA-03', + 'Los Olivos': 'RUTA-04', + 'Rancho Seco': 'RUTA-05', + 'Las Insurgentes': 'RUTA-12', + 'Trojes': 'RUTA-13', +}; + +// ================================================================ +// PANTALLA +// ================================================================ +class MapaRutasScreen extends StatefulWidget { + const MapaRutasScreen({super.key}); + + @override + State createState() => _MapaRutasScreenState(); +} + +class _MapaRutasScreenState extends State { + Set _rutasDelUsuario = {}; + List _estadosBackend = []; + String? _rutaSeleccionada; + bool _cargando = true; + bool _mostrarTodas = false; + + final _mapController = MapController(); + final ApiService _apiService = ApiService(); + + static const _centro = LatLng(20.5230, -100.8550); + + @override + void initState() { + super.initState(); + _cargarDatos(); + } + + Future _cargarDatos() async { + setState(() => _cargando = true); + final prefs = await SharedPreferences.getInstance(); + final usuarioId = prefs.getInt('usuario_id'); + if (usuarioId == null) { + if (mounted) setState(() => _cargando = false); + return; + } + try { + final rutas = await _apiService.obtenerRutas(usuarioId); + final usuario = await _apiService.obtenerUsuario(usuarioId); + final ids = {}; + for (final d in usuario.direcciones) { + final r = _coloniaARuta[d.colonia]; + if (r != null) ids.add(r); + } + for (final r in rutas) ids.add(r.routeId); + if (mounted) { + setState(() { + _estadosBackend = rutas; + _rutasDelUsuario = ids; + _cargando = false; + }); + } + } catch (_) { + if (mounted) setState(() => _cargando = false); + } + } + + RouteInfo? _estadoRuta(String routeId) { + try { + return _estadosBackend.firstWhere((r) => r.routeId == routeId); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Mapa de rutas — Celaya'), + backgroundColor: const Color(0xFF1B5E20), + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: Icon(_mostrarTodas ? Icons.visibility_off : Icons.visibility), + tooltip: _mostrarTodas ? 'Solo mis rutas' : 'Ver todas', + onPressed: () => setState(() => _mostrarTodas = !_mostrarTodas), + ), + IconButton( + icon: const Icon(Icons.my_location), + onPressed: () => _mapController.move(_centro, 12.5), + ), + IconButton(icon: const Icon(Icons.refresh), onPressed: _cargarDatos), + ], + ), + body: _cargando + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Expanded(flex: 3, child: _buildMapa()), + _buildLeyenda(), + ], + ), + ); + } + + Widget _buildMapa() { + return FlutterMap( + mapController: _mapController, + options: const MapOptions( + initialCenter: _centro, + initialZoom: 12.5, + minZoom: 11, + maxZoom: 17, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.aplicacion_hack', + maxZoom: 19, + ), + PolylineLayer(polylines: _buildPolylines()), + MarkerLayer(markers: _buildMarkers()), + ], + ); + } + + List _buildPolylines() { + final list = []; + for (final ruta in _todasLasRutas) { + final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId); + if (!_mostrarTodas && !esDelUsuario) continue; + + final estado = _estadoRuta(ruta.routeId); + final posActual = estado?.lastPositionId ?? 0; + final seleccionada = _rutaSeleccionada == ruta.routeId; + final grosorBase = esDelUsuario ? 5.0 : 2.0; + final grosor = seleccionada ? grosorBase + 2 : grosorBase; + + if (esDelUsuario && posActual > 1) { + // Tramo recorrido + final recorridos = ruta.puntos.take(posActual).toList(); + if (recorridos.length >= 2) { + list.add(Polyline( + points: recorridos, + color: ruta.color, + strokeWidth: grosor, + )); + } + // Tramo pendiente (punteado) + final pendientes = ruta.puntos.skip(posActual - 1).toList(); + if (pendientes.length >= 2) { + list.add(Polyline( + points: pendientes, + color: ruta.color.withValues(alpha: 0.35), + strokeWidth: grosor - 1, + pattern: StrokePattern.dashed(segments: [12, 8]), + )); + } + } else { + list.add(Polyline( + points: ruta.puntos, + color: esDelUsuario ? ruta.color : ruta.color.withValues(alpha: 0.2), + strokeWidth: grosor, + )); + } + } + return list; + } + + List _buildMarkers() { + final list = []; + + // Base / Relleno Sanitario + list.add(Marker( + point: LatLng(20.5111, -100.9037), + width: 70, + height: 36, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(5), + ), + child: const Text('🏭 Base', + style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ), + Container(width: 2, height: 4, color: Colors.grey.shade800), + Container( + width: 8, height: 8, + decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), + ), + ], + ), + )); + + for (final ruta in _todasLasRutas) { + final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId); + if (!esDelUsuario) continue; + + final estado = _estadoRuta(ruta.routeId); + final posActual = estado?.lastPositionId ?? 0; + + for (int i = 1; i < ruta.puntos.length - 1; i++) { + final punto = ruta.puntos[i]; + final posId = i + 1; + final esCamion = posId == posActual; + + if (esCamion) { + list.add(Marker( + point: punto, + width: 46, + height: 54, + child: GestureDetector( + onTap: () => setState(() => + _rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: ruta.color, + borderRadius: BorderRadius.circular(5), + boxShadow: [BoxShadow(color: ruta.color.withValues(alpha: 0.5), blurRadius: 6)], + ), + child: Text( + ruta.routeId.replaceAll('RUTA-', 'R'), + style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold), + ), + ), + const Text('🚛', style: TextStyle(fontSize: 20)), + ], + ), + ), + )); + } else { + list.add(Marker( + point: punto, + width: 14, + height: 14, + child: GestureDetector( + onTap: () => setState(() => + _rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId), + child: Container( + decoration: BoxDecoration( + color: posId < posActual ? ruta.color : Colors.white, + shape: BoxShape.circle, + border: Border.all(color: ruta.color, width: 2.5), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 2)], + ), + ), + ), + )); + } + } + } + return list; + } + + Widget _buildLeyenda() { + final rutasUsuario = _todasLasRutas + .where((r) => _rutasDelUsuario.contains(r.routeId)) + .toList(); + + if (rutasUsuario.isEmpty) { + return Container( + height: 52, + color: const Color(0xFF1B5E20), + alignment: Alignment.center, + child: const Text('Sin rutas asignadas', + style: TextStyle(color: Colors.white54, fontSize: 13)), + ); + } + + return Container( + height: 96, + color: const Color(0xFF1B5E20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 5, 0, 2), + child: Text( + _mostrarTodas ? 'Todas las rutas • Las tuyas están resaltadas' : 'Tus rutas — toca para centrar', + style: const TextStyle(color: Colors.white54, fontSize: 10), + ), + ), + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + itemCount: rutasUsuario.length, + itemBuilder: (context, i) { + final ruta = rutasUsuario[i]; + final estado = _estadoRuta(ruta.routeId); + final posActual = estado?.lastPositionId ?? 0; + final status = estado?.status ?? 'EN_RUTA'; + final seleccionada = _rutaSeleccionada == ruta.routeId; + + return GestureDetector( + onTap: () { + setState(() => + _rutaSeleccionada = seleccionada ? null : ruta.routeId); + if (!seleccionada && ruta.puntos.length > 1) { + _mapController.move(ruta.puntos[1], 13.5); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + color: seleccionada + ? ruta.color.withValues(alpha: 0.3) + : Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: seleccionada ? ruta.color : Colors.white24, + width: seleccionada ? 2 : 1, + ), + ), + child: ClipRect( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 9, height: 9, + decoration: BoxDecoration(color: ruta.color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text(ruta.routeId, + style: TextStyle(color: ruta.color, fontWeight: FontWeight.bold, fontSize: 11)), + const SizedBox(width: 4), + Text(status == 'COMPLETADO' ? '✅' : '🚛', + style: const TextStyle(fontSize: 10)), + ], + ), + const SizedBox(height: 1), + SizedBox( + width: 115, + child: Text(ruta.colonia, + style: const TextStyle(color: Colors.white70, fontSize: 10), + overflow: TextOverflow.ellipsis), + ), + const SizedBox(height: 1), + SizedBox( + width: 115, + height: 3, + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: (posActual / 8).clamp(0.0, 1.0), + backgroundColor: Colors.white12, + valueColor: AlwaysStoppedAnimation(ruta.color), + ), + ), + ), + const SizedBox(height: 1), + Text( + posActual > 0 ? 'Pos. $posActual / 8' : 'Sin datos', + style: TextStyle(color: ruta.color.withValues(alpha: 0.8), fontSize: 9), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/reporte_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/reporte_screen.dart new file mode 100644 index 0000000..e4005cf --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/reporte_screen.dart @@ -0,0 +1,538 @@ +// ================================================================ +// lib/screens/reporte_screen.dart +// Pantalla para que el ciudadano envíe reportes manuales +// ================================================================ +// +// NAVEGAR DESDE home_screen.dart: +// Navigator.pushNamed(context, '/reporte') +// +// AGREGAR EN main.dart: +// '/reporte': (context) => const ReporteScreen(), +// +// SECCIONES: +// 1. Formulario de nuevo reporte +// 2. Historial de reportes del usuario +// ================================================================ + +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +// ---------------------------------------------------------------- +// MODELOS +// ---------------------------------------------------------------- + +class ReporteInfo { + final int reporteId; + final String fecha; + final String hora; + final String colonia; + final String tipo; + final String? descripcion; + final String estado; + + ReporteInfo({ + required this.reporteId, + required this.fecha, + required this.hora, + required this.colonia, + required this.tipo, + this.descripcion, + required this.estado, + }); + + factory ReporteInfo.fromJson(Map j) => ReporteInfo( + reporteId: j['reporte_id'], + fecha: j['fecha'], + hora: j['hora'], + colonia: j['colonia'], + tipo: j['tipo'], + descripcion: j['descripcion'], + estado: j['estado'], + ); +} + +// ---------------------------------------------------------------- +// DATOS LOCALES +// ---------------------------------------------------------------- + +const _tiposReporte = [ + {'valor': 'CAMION_NO_PASO', 'label': 'El camión no pasó', 'emoji': '🚫', 'color': 0xFFC62828}, + {'valor': 'VOLUMEN_ALTO', 'label': 'Volumen inusualmente alto', 'emoji': '📦', 'color': 0xFFE65100}, + {'valor': 'BASURA_FUERA_HORARIO', 'label': 'Basura fuera de horario', 'emoji': '⏰', 'color': 0xFFF57F17}, + {'valor': 'OTRO', 'label': 'Otro problema', 'emoji': '📝', 'color': 0xFF37474F}, +]; + +const _colonias = [ + 'Zona Centro', 'Las Arboledas', 'Trojes', + 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes', +]; + +// ================================================================ +// PANTALLA PRINCIPAL +// ================================================================ +class ReporteScreen extends StatefulWidget { + const ReporteScreen({super.key}); + + @override + State createState() => _ReporteScreenState(); +} + +class _ReporteScreenState extends State + with SingleTickerProviderStateMixin { + static const _baseUrl = 'http://192.168.198.55:8000'; + + late TabController _tabController; + int? _usuarioId; + + // Formulario + String? _tipoSeleccionado; + String? _coloniaSeleccionada; + final _descController = TextEditingController(); + bool _enviando = false; + + // Historial + List _reportes = []; + bool _cargandoHistorial = true; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _cargarUsuario(); + } + + @override + void dispose() { + _tabController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _cargarUsuario() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt('usuario_id'); + if (id != null && mounted) { + setState(() => _usuarioId = id); + _cargarHistorial(); + } + } + + Future _cargarHistorial() async { + if (_usuarioId == null) return; + setState(() => _cargandoHistorial = true); + try { + final resp = await http + .get(Uri.parse('$_baseUrl/api/reportes/usuario/$_usuarioId')) + .timeout(const Duration(seconds: 10)); + if (resp.statusCode == 200 && mounted) { + final lista = json.decode(resp.body) as List; + setState(() { + _reportes = lista.map((r) => ReporteInfo.fromJson(r)).toList(); + _cargandoHistorial = false; + }); + } else { + if (mounted) setState(() => _cargandoHistorial = false); + } + } catch (_) { + if (mounted) setState(() => _cargandoHistorial = false); + } + } + + Future _enviarReporte() async { + if (_tipoSeleccionado == null) { + _snack('Selecciona el tipo de problema.', error: true); + return; + } + if (_coloniaSeleccionada == null) { + _snack('Selecciona la colonia.', error: true); + return; + } + if (_usuarioId == null) { + _snack('No hay sesión activa.', error: true); + return; + } + + setState(() => _enviando = true); + + try { + final resp = await http.post( + Uri.parse('$_baseUrl/api/reportes?usuario_id=$_usuarioId'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'colonia': _coloniaSeleccionada, + 'tipo': _tipoSeleccionado, + 'descripcion': _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + }), + ).timeout(const Duration(seconds: 10)); + + if (resp.statusCode == 200 && mounted) { + _snack('✅ Reporte enviado correctamente. ¡Gracias!'); + setState(() { + _tipoSeleccionado = null; + _coloniaSeleccionada = null; + }); + _descController.clear(); + _cargarHistorial(); + _tabController.animateTo(1); // ir al historial + } else { + final body = json.decode(resp.body); + _snack(body['detail'] ?? 'Error al enviar el reporte.', error: true); + } + } catch (e) { + _snack('Sin conexión al servidor.', error: true); + } finally { + if (mounted) setState(() => _enviando = false); + } + } + + void _snack(String msg, {bool error = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg), + backgroundColor: error ? Colors.red.shade700 : Colors.green.shade700, + behavior: SnackBarBehavior.floating, + )); + } + + // ================================================================ + // BUILD + // ================================================================ + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F7F5), + appBar: AppBar( + title: const Text('Reportes ciudadanos'), + backgroundColor: const Color(0xFF1B5E20), + foregroundColor: Colors.white, + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white60, + tabs: const [ + Tab(icon: Icon(Icons.add_circle_outline), text: 'Nuevo reporte'), + Tab(icon: Icon(Icons.history), text: 'Mis reportes'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [_buildFormulario(), _buildHistorial()], + ), + ); + } + + // ================================================================ + // TAB 1: FORMULARIO + // ================================================================ + Widget _buildFormulario() { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Encabezado + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF2E7D32).withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF2E7D32).withValues(alpha: 0.2)), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('📋 Envía un reporte', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + SizedBox(height: 6), + Text( + 'Tu reporte se guarda en la base de datos y ayuda a mejorar ' + 'la logística de recolección en tu colonia.', + style: TextStyle(fontSize: 13, color: Colors.black54, height: 1.4), + ), + ], + ), + ), + + const SizedBox(height: 24), + _Label('¿Qué problema ocurrió?'), + const SizedBox(height: 10), + + // Selector de tipo + ...(_tiposReporte.map((tipo) { + final seleccionado = _tipoSeleccionado == tipo['valor']; + final color = Color(tipo['color'] as int); + return GestureDetector( + onTap: () => setState(() => _tipoSeleccionado = tipo['valor'] as String), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: seleccionado ? color.withValues(alpha: 0.1) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: seleccionado ? color : Colors.grey.shade200, + width: seleccionado ? 2 : 1, + ), + boxShadow: seleccionado + ? [BoxShadow(color: color.withValues(alpha: 0.15), blurRadius: 8)] + : [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 4)], + ), + child: Row( + children: [ + Text(tipo['emoji'] as String, style: const TextStyle(fontSize: 22)), + const SizedBox(width: 12), + Expanded( + child: Text( + tipo['label'] as String, + style: TextStyle( + fontSize: 14, + fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal, + color: seleccionado ? color : Colors.black87, + ), + ), + ), + if (seleccionado) + Icon(Icons.check_circle, color: color, size: 20), + ], + ), + ), + ); + })), + + const SizedBox(height: 20), + _Label('¿En qué colonia?'), + const SizedBox(height: 10), + + // Selector de colonia + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: DropdownButtonFormField( + value: _coloniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.location_city_outlined), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ), + items: _colonias + .map((c) => DropdownMenuItem(value: c, child: Text(c))) + .toList(), + onChanged: (v) => setState(() => _coloniaSeleccionada = v), + ), + ), + + const SizedBox(height: 20), + _Label('Descripción adicional (opcional)'), + const SizedBox(height: 10), + + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: TextField( + controller: _descController, + maxLines: 4, + maxLength: 300, + decoration: const InputDecoration( + hintText: 'Describe el problema con más detalle...', + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + ), + ), + + const SizedBox(height: 28), + + // Botón enviar + SizedBox( + width: double.infinity, + height: 54, + child: ElevatedButton.icon( + onPressed: _enviando ? null : _enviarReporte, + icon: _enviando + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.send_rounded), + label: Text(_enviando ? 'Enviando...' : 'Enviar reporte'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2E7D32), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + + const SizedBox(height: 16), + Center( + child: Text( + 'Tu reporte es anónimo para el operador y ayuda\na mejorar el servicio de recolección.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + // ================================================================ + // TAB 2: HISTORIAL + // ================================================================ + Widget _buildHistorial() { + if (_cargandoHistorial) { + return const Center(child: CircularProgressIndicator()); + } + + if (_reportes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 72, color: Colors.grey.shade300), + const SizedBox(height: 16), + const Text('No has enviado reportes aún', + style: TextStyle(fontSize: 16, color: Colors.grey)), + const SizedBox(height: 8), + const Text('Usa la pestaña anterior para reportar un problema.', + style: TextStyle(fontSize: 13, color: Colors.grey)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _cargarHistorial, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _reportes.length, + itemBuilder: (context, i) => _TarjetaReporte(reporte: _reportes[i]), + ), + ); + } +} + +// ================================================================ +// WIDGETS AUXILIARES +// ================================================================ + +class _Label extends StatelessWidget { + final String text; + const _Label(this.text); + + @override + Widget build(BuildContext context) => Text( + text, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ); +} + +class _TarjetaReporte extends StatelessWidget { + final ReporteInfo reporte; + const _TarjetaReporte({required this.reporte}); + + Map get _tipoInfo { + return _tiposReporte.firstWhere( + (t) => t['valor'] == reporte.tipo, + orElse: () => {'label': reporte.tipo, 'emoji': '📝', 'color': 0xFF37474F}, + ); + } + + Color get _colorEstado => reporte.estado == 'ATENDIDO' + ? const Color(0xFF2E7D32) + : const Color(0xFFE65100); + + @override + Widget build(BuildContext context) { + final info = _tipoInfo; + final color = Color(info['color'] as int); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6) + ], + border: Border(left: BorderSide(color: color, width: 4)), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(info['emoji'] as String, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Expanded( + child: Text(info['label'] as String, + style: TextStyle( + fontWeight: FontWeight.bold, color: color, fontSize: 14)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _colorEstado.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + reporte.estado == 'ATENDIDO' ? '✅ Atendido' : '⏳ Pendiente', + style: TextStyle( + color: _colorEstado, fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.location_city_outlined, size: 14, color: Colors.grey.shade500), + const SizedBox(width: 4), + Text(reporte.colonia, + style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + const SizedBox(width: 16), + Icon(Icons.calendar_today_outlined, size: 14, color: Colors.grey.shade500), + const SizedBox(width: 4), + Text('${reporte.fecha} ${reporte.hora.substring(0, 5)}', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ), + if (reporte.descripcion != null && reporte.descripcion!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + reporte.descripcion!, + style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.4), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/screens/route_list_screen.dart b/HackOnLinces_app/aplicacion_hack/lib/screens/route_list_screen.dart new file mode 100644 index 0000000..f8ae98a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/screens/route_list_screen.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/api_service.dart'; + +class RouteListScreen extends StatefulWidget { + const RouteListScreen({super.key}); + + @override + State createState() => _RouteListScreenState(); +} + +class _RouteListScreenState extends State { + final ApiService _apiService = ApiService(); + bool _cargando = true; + bool _avanzando = false; + String? _error; + List _rutas = []; + int? _usuarioId; + + @override + void initState() { + super.initState(); + _cargarUsuarioYRutas(); + } + + Future _cargarUsuarioYRutas() async { + final prefs = await SharedPreferences.getInstance(); + final usuarioId = prefs.getInt('usuario_id'); + + if (usuarioId == null) { + if (mounted) { + setState(() { + _error = + 'No se encontró sesión activa. Por favor inicia sesión de nuevo.'; + _cargando = false; + }); + } + return; + } + + _usuarioId = usuarioId; + await _cargarRutas(); + } + + Future _cargarRutas() async { + setState(() { + _cargando = true; + _error = null; + }); + + if (_usuarioId == null) { + if (mounted) { + setState(() { + _error = + 'No se encontró sesión activa. Por favor inicia sesión de nuevo.'; + _cargando = false; + }); + } + return; + } + + try { + final rutas = await _apiService.obtenerRutas(_usuarioId!); + if (mounted) { + setState(() { + _rutas = rutas; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'No se pudieron cargar las rutas. Verifica el backend.'; + }); + } + } finally { + if (mounted) { + setState(() { + _cargando = false; + }); + } + } + } + + Future _simularAvance(String routeId) async { + setState(() { + _avanzando = true; + }); + + if (_usuarioId == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('No se encontró sesión activa. Inicia sesión de nuevo.'), + backgroundColor: Colors.redAccent, + ), + ); + } + return; + } + + try { + final rutaActualizada = + await _apiService.avanzarRuta(routeId, _usuarioId!); + if (mounted) { + setState(() { + final index = _rutas.indexWhere((r) => r.routeId == routeId); + if (index >= 0) { + _rutas[index] = rutaActualizada; + } + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'La ruta ${rutaActualizada.routeId} avanzó al siguiente tramo.'), + duration: const Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error al simular avance: $e'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _avanzando = false; + }); + } + } + } + + Color _colorEstado(String status) { + switch (status) { + case 'COMPLETADO': + return Colors.blue.shade600; + case 'EN_RUTA': + return Colors.green.shade600; + default: + return Colors.orange.shade600; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Rutas y estado del camión'), + actions: [ + IconButton( + onPressed: () => Navigator.pushNamed(context, '/mapa'), + icon: const Icon(Icons.map_rounded), + tooltip: 'Ver mapa de rutas', + ), + IconButton( + onPressed: _cargarRutas, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: _cargando + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 72, color: Colors.redAccent), + const SizedBox(height: 16), + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _cargarRutas, + child: const Text('Reintentar'), + ), + ], + ), + ), + ) + : RefreshIndicator( + onRefresh: _cargarRutas, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.green.shade200), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Simulación de rutas', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox(height: 10), + Text( + 'Esta pantalla muestra el estado actual de cada camión y permite simular el siguiente tramo de la ruta para pruebas.', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + const SizedBox(height: 16), + ..._rutas.map((ruta) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + ruta.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold), + ), + ), + Chip( + label: Text(ruta.status), + backgroundColor: + _colorEstado(ruta.status) + .withValues(alpha: 0.15), + labelStyle: TextStyle( + color: _colorEstado(ruta.status), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + runSpacing: 6, + spacing: 8, + children: [ + _buildTag('Ruta: ${ruta.routeId}', + Colors.grey.shade200), + _buildTag( + 'Posición: ${ruta.lastPositionId}', + Colors.blue.shade50), + _buildTag( + ruta.gpsOk + ? 'GPS OK' + : 'GPS desconectado', + ruta.gpsOk + ? Colors.green.shade50 + : Colors.red.shade50, + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Último reporte: ${ruta.lastTimestamp}', + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 13), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: ruta.status == 'COMPLETADO' || + _avanzando + ? null + : () => _simularAvance(ruta.routeId), + icon: const Icon(Icons.play_arrow), + label: const Text('Simular avance'), + ), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade300), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Información relevante', + style: TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), + ), + SizedBox(height: 12), + Text( + '• Separa correctamente orgánicos, reciclables y no reciclables.', + style: TextStyle(fontSize: 14)), + SizedBox(height: 8), + Text( + '• No mezcles basura húmeda con envases secos y mantén los líquidos controlados.', + style: TextStyle(fontSize: 14)), + SizedBox(height: 8), + Text( + '• Saca tu basura cuando el camión esté cerca: así evitamos plagas y malos olores.', + style: TextStyle(fontSize: 14)), + SizedBox(height: 8), + Text( + '• Usa bolsas resistentes y cierra bien los residuos antes de ponerlos en la acera.', + style: TextStyle(fontSize: 14)), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildTag(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/lib/services/api_service.dart b/HackOnLinces_app/aplicacion_hack/lib/services/api_service.dart new file mode 100644 index 0000000..d44912e --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/lib/services/api_service.dart @@ -0,0 +1,483 @@ +// ================================================================ +// lib/services/api_service.dart (v2) +// Servicio de comunicación con el backend FastAPI +// ================================================================ +// +// CAMBIOS v2: +// - LoginResponse incluye 'nombre' del usuario +// - ActualizarPassword requiere password_actual + password_nuevo +// - Nuevos métodos: obtenerDashboard, historialPosiciones, +// resumenRutas, estadisticasColonias +// ================================================================ + +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// ---------------------------------------------------------------- +// MODELOS +// ---------------------------------------------------------------- + +class ETAInfo { + final int usuarioId; + final String colonia; + final String rutaNombre; + final String rutaStatus; + final bool gpsOk; + final String etaTexto; + final int etaMinutos; + final String mensajePreventivo; + + ETAInfo({ + required this.usuarioId, + required this.colonia, + required this.rutaNombre, + required this.rutaStatus, + required this.gpsOk, + required this.etaTexto, + required this.etaMinutos, + required this.mensajePreventivo, + }); + + factory ETAInfo.fromJson(Map json) { + return ETAInfo( + usuarioId: json['usuario_id'], + colonia: json['colonia'], + rutaNombre: json['ruta_nombre'] ?? '', + rutaStatus: json['ruta_status'] ?? '', + gpsOk: json['gps_ok'] ?? true, + etaTexto: json['eta_texto'], + etaMinutos: json['eta_minutos'], + mensajePreventivo: json['mensaje_preventivo'], + ); + } +} + +class DireccionInfo { + final String colonia; + final String direccion; + + DireccionInfo({required this.colonia, required this.direccion}); + + factory DireccionInfo.fromJson(Map json) { + return DireccionInfo(colonia: json['colonia'], direccion: json['direccion']); + } +} + +class UsuarioInfo { + final int usuarioId; + final String nombre; + final String email; + final List direcciones; + + UsuarioInfo({ + required this.usuarioId, + required this.nombre, + required this.email, + required this.direcciones, + }); + + factory UsuarioInfo.fromJson(Map json) { + return UsuarioInfo( + usuarioId: json['usuario_id'], + nombre: json['nombre'], + email: json['email'], + direcciones: List>.from(json['direcciones']) + .map(DireccionInfo.fromJson) + .toList(), + ); + } +} + +class RouteInfo { + final String routeId; + final String name; + final String status; + final int lastPositionId; + final String lastTimestamp; + final bool gpsOk; + + RouteInfo({ + required this.routeId, + required this.name, + required this.status, + required this.lastPositionId, + required this.lastTimestamp, + required this.gpsOk, + }); + + factory RouteInfo.fromJson(Map json) { + return RouteInfo( + routeId: json['route_id'], + name: json['name'], + status: json['status'], + lastPositionId: json['last_position_id'], + lastTimestamp: json['last_timestamp'], + gpsOk: json['gps_ok'], + ); + } +} + +/// Posición GPS individual de la ruta de un camión. +class PosicionGPS { + final int positionId; + final double lat; + final double lng; + final int speed; + final String timestamp; + final bool esActual; // true = aquí está el camión ahora + + PosicionGPS({ + required this.positionId, + required this.lat, + required this.lng, + required this.speed, + required this.timestamp, + required this.esActual, + }); + + factory PosicionGPS.fromJson(Map json) { + return PosicionGPS( + positionId: json['position_id'], + lat: (json['lat'] as num).toDouble(), + lng: (json['lng'] as num).toDouble(), + speed: json['speed'], + timestamp: json['timestamp'], + esActual: json['es_actual'] ?? false, + ); + } +} + +/// Detalle de una ruta para el dashboard. +class RutaDetalle { + final String routeId; + final String name; + final String status; + final int truckId; + final int posicionActual; + final int totalPosiciones; + final double porcentajeCompletado; + final int etaMinutos; + final bool gpsOk; + final int usuariosEnRuta; + + RutaDetalle({ + required this.routeId, + required this.name, + required this.status, + required this.truckId, + required this.posicionActual, + required this.totalPosiciones, + required this.porcentajeCompletado, + required this.etaMinutos, + required this.gpsOk, + required this.usuariosEnRuta, + }); + + factory RutaDetalle.fromJson(Map json) { + return RutaDetalle( + routeId: json['route_id'], + name: json['name'], + status: json['status'], + truckId: json['truck_id'], + posicionActual: json['posicion_actual'], + totalPosiciones: json['total_posiciones'], + porcentajeCompletado: (json['porcentaje_completado'] as num).toDouble(), + etaMinutos: json['eta_minutos'], + gpsOk: json['gps_ok'], + usuariosEnRuta: json['usuarios_en_ruta'], + ); + } +} + +/// Respuesta completa del dashboard de operador. +class DashboardInfo { + final int totalRutas; + final int rutasEnProgreso; + final int rutasCompletadas; + final int totalUsuarios; + final int usuariosConToken; + final double coberturaNotificaciones; + final List rutas; + + DashboardInfo({ + required this.totalRutas, + required this.rutasEnProgreso, + required this.rutasCompletadas, + required this.totalUsuarios, + required this.usuariosConToken, + required this.coberturaNotificaciones, + required this.rutas, + }); + + factory DashboardInfo.fromJson(Map json) { + return DashboardInfo( + totalRutas: json['total_rutas'], + rutasEnProgreso: json['rutas_en_progreso'], + rutasCompletadas: json['rutas_completadas'], + totalUsuarios: json['total_usuarios'], + usuariosConToken: json['usuarios_con_token'], + coberturaNotificaciones: (json['cobertura_notificaciones'] as num).toDouble(), + rutas: List>.from(json['rutas']) + .map(RutaDetalle.fromJson) + .toList(), + ); + } +} + +/// Estadísticas de una colonia. +class ColoniaEstadistica { + final String colonia; + final String routeId; + final String rutaNombre; + final String horario; + final int totalUsuarios; + final int usuariosConNotificaciones; + + ColoniaEstadistica({ + required this.colonia, + required this.routeId, + required this.rutaNombre, + required this.horario, + required this.totalUsuarios, + required this.usuariosConNotificaciones, + }); + + factory ColoniaEstadistica.fromJson(Map json) { + return ColoniaEstadistica( + colonia: json['colonia'], + routeId: json['route_id'], + rutaNombre: json['ruta_nombre'], + horario: json['horario'], + totalUsuarios: json['total_usuarios'], + usuariosConNotificaciones: json['usuarios_con_notificaciones'], + ); + } +} + +// ---------------------------------------------------------------- +// CLASE PRINCIPAL: ApiService +// ---------------------------------------------------------------- +class ApiService { + // ============================================================ + // BASE URL — Cambia solo esta línea para apuntar a otro entorno + // Android emulator local: http://10.0.2.2:8000 + // Dispositivo físico (red local): http://192.168.X.X:8000 + // ============================================================ + static const String _baseUrl = 'http://192.168.198.55:8000'; + static const Duration _timeout = Duration(seconds: 10); + + // ---------------------------------------------------------------- + // HELPER PRIVADO: maneja errores HTTP de forma consistente + // ---------------------------------------------------------------- + Never _throwError(http.Response response) { + Map body = {}; + try { + body = json.decode(response.body); + } catch (_) {} + final detail = body['detail'] ?? response.body; + throw Exception(detail); + } + + // ================================================================ + // AUTENTICACIÓN + // ================================================================ + + /// Login con email y contraseña. Retorna [usuarioId, nombre]. + Future> loginConCorreo(String email, String password) async { + final response = await http.post( + Uri.parse('$_baseUrl/api/usuarios/login'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'email': email.trim().toLowerCase(), 'password': password}), + ).timeout(_timeout); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + return {'usuario_id': data['usuario_id'], 'nombre': data['nombre']}; + } + _throwError(response); + } + + Future registrarUsuario( + String nombre, + String email, + String password, + String direccion, + String colonia, + ) async { + final response = await http.post( + Uri.parse('$_baseUrl/api/usuarios/register'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'nombre': nombre.trim(), + 'email': email.trim().toLowerCase(), + 'password': password, + 'colonia': colonia, + 'direccion': direccion.trim(), + }), + ).timeout(_timeout); + + if (response.statusCode == 200) { + return json.decode(response.body)['usuario_id']; + } + _throwError(response); + } + + // ================================================================ + // USUARIOS + // ================================================================ + + Future obtenerETA(int usuarioId) async { + final response = await http + .get(Uri.parse('$_baseUrl/api/eta/$usuarioId')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return ETAInfo.fromJson(json.decode(response.body)); + } else if (response.statusCode == 404) { + throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?'); + } + _throwError(response); + } + + Future> obtenerColonias() async { + final response = await http + .get(Uri.parse('$_baseUrl/api/colonias')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return List.from(json.decode(response.body)['colonias']); + } + _throwError(response); + } + + Future obtenerUsuario(int usuarioId) async { + final response = await http + .get(Uri.parse('$_baseUrl/api/usuarios/$usuarioId')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return UsuarioInfo.fromJson(json.decode(response.body)); + } + _throwError(response); + } + + Future agregarDireccion(int usuarioId, String colonia, String direccion) async { + final response = await http.post( + Uri.parse('$_baseUrl/api/usuarios/$usuarioId/direcciones'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'colonia': colonia, 'direccion': direccion.trim()}), + ).timeout(_timeout); + + if (response.statusCode != 200) _throwError(response); + } + + /// Actualiza contraseña. Requiere la contraseña actual como confirmación. + Future actualizarPassword(int usuarioId, String passwordActual, String passwordNuevo) async { + final response = await http.put( + Uri.parse('$_baseUrl/api/usuarios/$usuarioId/password'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'password_actual': passwordActual, + 'password_nuevo': passwordNuevo, + }), + ).timeout(_timeout); + + if (response.statusCode != 200) _throwError(response); + } + + Future registrarFcmToken(int usuarioId, String fcmToken) async { + final response = await http.put( + Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'fcm_token': fcmToken}), + ).timeout(_timeout); + + if (response.statusCode != 200) { + throw Exception('Error registrando FCM token: ${response.statusCode}'); + } + } + + // ================================================================ + // RUTAS + // ================================================================ + + Future> obtenerRutas(int usuarioId) async { + final response = await http + .get(Uri.parse('$_baseUrl/api/rutas?usuario_id=$usuarioId')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return List>.from(json.decode(response.body)['rutas']) + .map(RouteInfo.fromJson) + .toList(); + } + _throwError(response); + } + + Future avanzarRuta(String routeId, int usuarioId) async { + final response = await http + .post(Uri.parse('$_baseUrl/api/rutas/$routeId/avanzar?usuario_id=$usuarioId')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return RouteInfo.fromJson(json.decode(response.body)); + } + _throwError(response); + } + + // ================================================================ + // VISUALIZACIÓN — NUEVOS EN v2 + // ================================================================ + + /// Dashboard global: estado de todas las rutas + métricas de usuarios. + Future obtenerDashboard() async { + final response = await http + .get(Uri.parse('$_baseUrl/api/dashboard')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return DashboardInfo.fromJson(json.decode(response.body)); + } + _throwError(response); + } + + /// Historial de posiciones GPS de una ruta, con la posición actual marcada. + Future> historialPosiciones(String routeId) async { + final response = await http + .get(Uri.parse('$_baseUrl/api/rutas/$routeId/posiciones')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return List>.from(json.decode(response.body)) + .map(PosicionGPS.fromJson) + .toList(); + } + _throwError(response); + } + + /// Vista rápida y ligera de todas las rutas. Ideal para polling frecuente. + Future>> resumenRutas() async { + final response = await http + .get(Uri.parse('$_baseUrl/api/rutas/resumen')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return List>.from(json.decode(response.body)['rutas']); + } + _throwError(response); + } + + /// Estadísticas por colonia: usuarios y cobertura de notificaciones. + Future> estadisticasColonias() async { + final response = await http + .get(Uri.parse('$_baseUrl/api/estadisticas/colonias')) + .timeout(_timeout); + + if (response.statusCode == 200) { + return List>.from(json.decode(response.body)) + .map(ColoniaEstadistica.fromJson) + .toList(); + } + _throwError(response); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/linux/.gitignore b/HackOnLinces_app/aplicacion_hack/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/HackOnLinces_app/aplicacion_hack/linux/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/linux/CMakeLists.txt new file mode 100644 index 0000000..39af11c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "aplicacion_hack") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.aplicacion_hack") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/HackOnLinces_app/aplicacion_hack/linux/flutter/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/HackOnLinces_app/aplicacion_hack/linux/runner/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/HackOnLinces_app/aplicacion_hack/linux/runner/main.cc b/HackOnLinces_app/aplicacion_hack/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.cc b/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.cc new file mode 100644 index 0000000..0b12c61 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "aplicacion_hack"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "aplicacion_hack"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.h b/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/HackOnLinces_app/aplicacion_hack/macos/.gitignore b/HackOnLinces_app/aplicacion_hack/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Debug.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Release.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.pbxproj b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..58acfd7 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* aplicacion_hack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "aplicacion_hack.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* aplicacion_hack.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* aplicacion_hack.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.aplicacionHack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/aplicacion_hack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/aplicacion_hack"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.aplicacionHack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/aplicacion_hack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/aplicacion_hack"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.aplicacionHack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/aplicacion_hack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/aplicacion_hack"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ec7c5e3 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/contents.xcworkspacedata b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/AppDelegate.swift b/HackOnLinces_app/aplicacion_hack/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Base.lproj/MainMenu.xib b/HackOnLinces_app/aplicacion_hack/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/AppInfo.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..a405ab6 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = aplicacion_hack + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.aplicacionHack + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Debug.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Release.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Warnings.xcconfig b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/DebugProfile.entitlements b/HackOnLinces_app/aplicacion_hack/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Info.plist b/HackOnLinces_app/aplicacion_hack/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/MainFlutterWindow.swift b/HackOnLinces_app/aplicacion_hack/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/HackOnLinces_app/aplicacion_hack/macos/Runner/Release.entitlements b/HackOnLinces_app/aplicacion_hack/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/HackOnLinces_app/aplicacion_hack/macos/RunnerTests/RunnerTests.swift b/HackOnLinces_app/aplicacion_hack/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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. + } + +} diff --git a/HackOnLinces_app/aplicacion_hack/pubspec.yaml b/HackOnLinces_app/aplicacion_hack/pubspec.yaml new file mode 100644 index 0000000..3dbbcd2 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/pubspec.yaml @@ -0,0 +1,64 @@ +name: hack_on_linces_app +description: "Sistema de Notificación Privada de Recolección de Residuos - MVP Hackathon" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_map: ^7.0.2 + latlong2: ^0.9.1 + fl_chart: ^0.69.0 + flutter_local_notifications: ^18.0.0 + + + # ------------------------------------------------------------ + # http: Para llamadas REST al backend FastAPI. + # Elegimos 'http' sobre Dio por simplicidad en hackathon. + # Si necesitas interceptors o cancelación, migra a Dio después. + # ------------------------------------------------------------ + http: ^1.2.0 + + # ------------------------------------------------------------ + # firebase_core: Inicialización base de Firebase. + # REQUERIDO antes de cualquier otro plugin de Firebase. + # Configura con: flutterfire configure (requiere Firebase CLI) + # ------------------------------------------------------------ + firebase_core: ^3.15.2 + + # ------------------------------------------------------------ + # firebase_messaging: Recepción de notificaciones push (FCM). + # Se encarga de pedir permisos al usuario y obtener el FCM token + # que debemos mandar al backend para registrar el dispositivo. + # ------------------------------------------------------------ + firebase_messaging: ^15.0.0 + + # ------------------------------------------------------------ + # shared_preferences: Guardar el usuario_id localmente. + # Simula "sesión persistente" sin un sistema de auth real. + # ATAJO de hackathon: en producción usa JWT + secure storage. + # ------------------------------------------------------------ + shared_preferences: ^2.2.0 + + cupertino_icons: ^1.0.6 + firebase_auth: ^5.7.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + assets: + - assets/images/recycle.jpg + - assets/images/reloj.png + - assets/images/bottle.png + - assets/images/planta.png + - assets/images/megafono.png + # Si agregas assets (imágenes, íconos locales), declararlos aquí: + # assets: + # - assets/images/ diff --git a/HackOnLinces_app/aplicacion_hack/web/favicon.png b/HackOnLinces_app/aplicacion_hack/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/web/favicon.png differ diff --git a/HackOnLinces_app/aplicacion_hack/web/icons/Icon-192.png b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-192.png differ diff --git a/HackOnLinces_app/aplicacion_hack/web/icons/Icon-512.png b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-512.png differ diff --git a/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-192.png b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-192.png differ diff --git a/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-512.png b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/web/icons/Icon-maskable-512.png differ diff --git a/HackOnLinces_app/aplicacion_hack/web/index.html b/HackOnLinces_app/aplicacion_hack/web/index.html new file mode 100644 index 0000000..6931bce --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + aplicacion_hack + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/web/manifest.json b/HackOnLinces_app/aplicacion_hack/web/manifest.json new file mode 100644 index 0000000..7fd23a9 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "aplicacion_hack", + "short_name": "aplicacion_hack", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/HackOnLinces_app/aplicacion_hack/windows/.gitignore b/HackOnLinces_app/aplicacion_hack/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/HackOnLinces_app/aplicacion_hack/windows/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/windows/CMakeLists.txt new file mode 100644 index 0000000..1309a1b --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(aplicacion_hack LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "aplicacion_hack") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/HackOnLinces_app/aplicacion_hack/windows/flutter/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/CMakeLists.txt b/HackOnLinces_app/aplicacion_hack/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/Runner.rc b/HackOnLinces_app/aplicacion_hack/windows/runner/Runner.rc new file mode 100644 index 0000000..2b93d46 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "aplicacion_hack" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "aplicacion_hack" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "aplicacion_hack.exe" "\0" + VALUE "ProductName", "aplicacion_hack" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.cpp b/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.h b/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/main.cpp b/HackOnLinces_app/aplicacion_hack/windows/runner/main.cpp new file mode 100644 index 0000000..eaa7d31 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"aplicacion_hack", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/resource.h b/HackOnLinces_app/aplicacion_hack/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/resources/app_icon.ico b/HackOnLinces_app/aplicacion_hack/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/HackOnLinces_app/aplicacion_hack/windows/runner/resources/app_icon.ico differ diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/runner.exe.manifest b/HackOnLinces_app/aplicacion_hack/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/utils.cpp b/HackOnLinces_app/aplicacion_hack/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/utils.h b/HackOnLinces_app/aplicacion_hack/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.cpp b/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.h b/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/HackOnLinces_app/aplicacion_hack/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/HackOnLinces_app/backend/analytics.py b/HackOnLinces_app/backend/analytics.py new file mode 100644 index 0000000..a128a7a --- /dev/null +++ b/HackOnLinces_app/backend/analytics.py @@ -0,0 +1,398 @@ +""" +=============================================================== + analytics.py — Motor de Reportes y Análisis Predictivo +=============================================================== + Módulo independiente que se importa en main.py. + + FUNCIONALIDADES: + 1. Simulación de registros históricos de recolección + (en producción vendría de una tabla real en DB) + 2. Detección de días con mayor volumen de residuos + 3. Identificación de zonas críticas por colonia + 4. Análisis predictivo simple con regresión lineal + usando numpy (sin sklearn, más ligero) + 5. Recomendaciones automáticas de logística + + INSTALAR: + pip install numpy + + USO EN main.py: + from analytics import generar_reporte_completo, predecir_proxima_semana +=============================================================== +""" + +import numpy as np +import random +from datetime import datetime, timedelta +from typing import List, Dict, Any +import hashlib + +# --------------------------------------------------------------- +# SEMILLA DETERMINISTA +# Los datos simulados son siempre los mismos para el mismo día, +# lo que da consistencia en demos sin necesidad de una DB real. +# --------------------------------------------------------------- +def _semilla_dia(fecha: datetime) -> int: + s = fecha.strftime("%Y-%m-%d") + return int(hashlib.md5(s.encode()).hexdigest(), 16) % (2**32) + + +# --------------------------------------------------------------- +# DATOS DE REFERENCIA +# --------------------------------------------------------------- +COLONIAS = [ + "Zona Centro", "Las Arboledas", "Trojes", + "San Juanico", "Los Olivos", "Rancho Seco", "Las Insurgentes", +] + +RUTAS = { + "Zona Centro": "RUTA-01", + "Las Arboledas": "RUTA-01", + "Trojes": "RUTA-13", + "San Juanico": "RUTA-03", + "Los Olivos": "RUTA-04", + "Rancho Seco": "RUTA-05", + "Las Insurgentes": "RUTA-12", +} + +DIAS_SEMANA = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"] + +# Factores base por colonia (densidad poblacional simulada) +FACTOR_COLONIA = { + "Zona Centro": 1.45, + "Las Arboledas": 1.10, + "Trojes": 0.85, + "San Juanico": 0.90, + "Los Olivos": 1.00, + "Rancho Seco": 0.75, + "Las Insurgentes": 1.20, +} + +# Factores por día de la semana (lunes tras el finde = más basura) +FACTOR_DIA = { + 0: 1.35, # Lunes + 1: 1.05, # Martes + 2: 1.00, # Miércoles + 3: 0.95, # Jueves + 4: 1.15, # Viernes + 5: 1.25, # Sábado + 6: 0.80, # Domingo +} + + +# --------------------------------------------------------------- +# GENERADOR DE HISTÓRICO SIMULADO +# Genera 90 días de datos de recolección por colonia +# --------------------------------------------------------------- +def _generar_historico(dias: int = 90) -> List[Dict]: + registros = [] + hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + for d in range(dias, 0, -1): + fecha = hoy - timedelta(days=d) + rng = random.Random(_semilla_dia(fecha)) + dia_semana = fecha.weekday() + + for colonia in COLONIAS: + # Volumen base en kg: entre 800 y 2500 kg por día + base = rng.uniform(800, 2500) + factor = FACTOR_COLONIA[colonia] * FACTOR_DIA[dia_semana] + + # Ruido aleatorio ±15% + ruido = rng.uniform(0.85, 1.15) + volumen = round(base * factor * ruido, 1) + + # Incidencias: reportes de basura tirada fuera de horario + incidencias = rng.randint(0, int(factor * 5)) + + # Tiempo de recolección en minutos + tiempo_min = round(30 + (volumen / 100) * rng.uniform(0.8, 1.2), 0) + + registros.append({ + "fecha": fecha, + "fecha_str": fecha.strftime("%Y-%m-%d"), + "dia_semana": dia_semana, + "dia_nombre": DIAS_SEMANA[dia_semana], + "colonia": colonia, + "ruta_id": RUTAS[colonia], + "volumen_kg": volumen, + "incidencias": incidencias, + "tiempo_recoleccion_min": tiempo_min, + }) + + return registros + + +# --------------------------------------------------------------- +# ANÁLISIS: Días con más residuos +# --------------------------------------------------------------- +def analisis_por_dia_semana(historico: List[Dict]) -> List[Dict]: + """Agrupa el volumen promedio y total por día de la semana.""" + acumulado = {i: {"total_kg": 0.0, "count": 0, "incidencias": 0} for i in range(7)} + + for r in historico: + d = r["dia_semana"] + acumulado[d]["total_kg"] += r["volumen_kg"] + acumulado[d]["count"] += 1 + acumulado[d]["incidencias"] += r["incidencias"] + + resultado = [] + for dia_idx in range(7): + v = acumulado[dia_idx] + count = v["count"] if v["count"] > 0 else 1 + resultado.append({ + "dia": DIAS_SEMANA[dia_idx], + "dia_idx": dia_idx, + "promedio_kg": round(v["total_kg"] / count, 1), + "total_kg": round(v["total_kg"], 1), + "promedio_incidencias": round(v["incidencias"] / count, 1), + "semanas_registradas": count // len(COLONIAS), + }) + + # Ordenar por promedio descendente para ranking + resultado.sort(key=lambda x: x["promedio_kg"], reverse=True) + return resultado + + +# --------------------------------------------------------------- +# ANÁLISIS: Zonas críticas por colonia +# --------------------------------------------------------------- +def analisis_zonas_criticas(historico: List[Dict]) -> List[Dict]: + """Identifica colonias con mayor volumen e incidencias.""" + acumulado: Dict[str, Dict] = {} + + for r in historico: + c = r["colonia"] + if c not in acumulado: + acumulado[c] = { + "total_kg": 0.0, + "total_incidencias": 0, + "total_tiempo": 0.0, + "count": 0, + } + acumulado[c]["total_kg"] += r["volumen_kg"] + acumulado[c]["total_incidencias"] += r["incidencias"] + acumulado[c]["total_tiempo"] += r["tiempo_recoleccion_min"] + acumulado[c]["count"] += 1 + + resultado = [] + max_vol = max(v["total_kg"] / v["count"] for v in acumulado.values()) + + for colonia, v in acumulado.items(): + count = v["count"] if v["count"] > 0 else 1 + promedio_kg = v["total_kg"] / count + promedio_incidencias = v["total_incidencias"] / count + indice_criticidad = round((promedio_kg / max_vol) * 100, 1) + + # Nivel de criticidad + if indice_criticidad >= 80: + nivel = "CRÍTICO" + elif indice_criticidad >= 60: + nivel = "ALTO" + elif indice_criticidad >= 40: + nivel = "MEDIO" + else: + nivel = "BAJO" + + resultado.append({ + "colonia": colonia, + "ruta_id": RUTAS[colonia], + "promedio_kg_dia": round(promedio_kg, 1), + "total_kg_90dias": round(v["total_kg"], 1), + "promedio_incidencias_dia": round(promedio_incidencias, 2), + "promedio_tiempo_min": round(v["total_tiempo"] / count, 1), + "indice_criticidad": indice_criticidad, + "nivel_criticidad": nivel, + }) + + resultado.sort(key=lambda x: x["indice_criticidad"], reverse=True) + return resultado + + +# --------------------------------------------------------------- +# ANÁLISIS PREDICTIVO: Regresión lineal simple con numpy +# Predice el volumen de los próximos 7 días por colonia +# --------------------------------------------------------------- +def predecir_proxima_semana(historico: List[Dict]) -> List[Dict]: + """ + Regresión lineal por colonia sobre los últimos 30 días. + Retorna predicción para los próximos 7 días. + + MÉTODO: + - X = número de día (0..N) + - Y = volumen_kg + - Ajustamos y = mx + b con numpy.polyfit(deg=1) + - Proyectamos para los días N+1 .. N+7 + - Aplicamos el factor de día de la semana para ajustar estacionalidad + """ + hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + predicciones = [] + + for colonia in COLONIAS: + # Filtrar últimos 30 días de esta colonia + datos_colonia = [ + r for r in historico + if r["colonia"] == colonia + ][-30:] # últimos 30 registros + + if len(datos_colonia) < 7: + continue + + x = np.array(range(len(datos_colonia)), dtype=float) + y = np.array([r["volumen_kg"] for r in datos_colonia], dtype=float) + + # Regresión lineal + coefs = np.polyfit(x, y, 1) # [pendiente, intercepto] + pendiente = coefs[0] + intercepto = coefs[1] + + # R² para medir calidad del ajuste + y_pred = np.polyval(coefs, x) + ss_res = np.sum((y - y_pred) ** 2) + ss_tot = np.sum((y - np.mean(y)) ** 2) + r2 = round(1 - (ss_res / ss_tot) if ss_tot > 0 else 0, 3) + + # Proyección próximos 7 días + n = len(datos_colonia) + dias_pred = [] + for i in range(7): + fecha_pred = hoy + timedelta(days=i + 1) + dia_semana = fecha_pred.weekday() + vol_base = np.polyval(coefs, n + i) + # Ajustar por factor estacional del día + vol_ajustado = max(0, vol_base * FACTOR_DIA[dia_semana]) + + dias_pred.append({ + "fecha": fecha_pred.strftime("%Y-%m-%d"), + "dia": DIAS_SEMANA[dia_semana], + "volumen_predicho_kg": round(float(vol_ajustado), 1), + "confianza": "Alta" if r2 > 0.7 else ("Media" if r2 > 0.4 else "Baja"), + }) + + predicciones.append({ + "colonia": colonia, + "ruta_id": RUTAS[colonia], + "tendencia": "CRECIENTE" if pendiente > 10 else ("DECRECIENTE" if pendiente < -10 else "ESTABLE"), + "pendiente_diaria_kg": round(float(pendiente), 2), + "r2": r2, + "prediccion_7dias": dias_pred, + "volumen_predicho_total_kg": round(sum(d["volumen_predicho_kg"] for d in dias_pred), 1), + }) + + predicciones.sort(key=lambda x: x["volumen_predicho_total_kg"], reverse=True) + return predicciones + + +# --------------------------------------------------------------- +# RECOMENDACIONES AUTOMÁTICAS DE LOGÍSTICA +# --------------------------------------------------------------- +def generar_recomendaciones( + zonas: List[Dict], + dias: List[Dict], + predicciones: List[Dict], +) -> List[Dict]: + """Genera recomendaciones concretas basadas en el análisis.""" + recomendaciones = [] + + # 1. Zonas críticas → refuerzo de camiones + criticas = [z for z in zonas if z["nivel_criticidad"] == "CRÍTICO"] + for z in criticas: + recomendaciones.append({ + "tipo": "REFUERZO_RUTA", + "prioridad": "ALTA", + "emoji": "🚛", + "titulo": f"Refuerzo en {z['colonia']}", + "descripcion": ( + f"{z['colonia']} genera en promedio {z['promedio_kg_dia']:.0f} kg/día " + f"con {z['promedio_incidencias_dia']:.1f} incidencias. " + f"Se recomienda asignar un segundo camión en días pico." + ), + "ruta_afectada": z["ruta_id"], + }) + + # 2. Días pico → ajuste de horarios + dia_pico = dias[0] if dias else None + if dia_pico: + recomendaciones.append({ + "tipo": "HORARIO_PICO", + "prioridad": "MEDIA", + "emoji": "⏰", + "titulo": f"{dia_pico['dia']} es el día con más residuos", + "descripcion": ( + f"El {dia_pico['dia']} se recolectan en promedio " + f"{dia_pico['promedio_kg']:.0f} kg por colonia. " + f"Considera iniciar rutas 30 minutos antes ese día." + ), + "ruta_afectada": None, + }) + + # 3. Colonias con tendencia creciente → alerta temprana + crecientes = [p for p in predicciones if p["tendencia"] == "CRECIENTE"] + for p in crecientes[:2]: # máximo 2 alertas + recomendaciones.append({ + "tipo": "TENDENCIA_CRECIENTE", + "prioridad": "MEDIA", + "emoji": "📈", + "titulo": f"Aumento en {p['colonia']}", + "descripcion": ( + f"{p['colonia']} muestra una tendencia creciente de " + f"+{p['pendiente_diaria_kg']:.1f} kg/día. " + f"Volumen predicho esta semana: {p['volumen_predicho_total_kg']:.0f} kg." + ), + "ruta_afectada": p["ruta_id"], + }) + + # 4. Colonias con muchas incidencias → campaña de concientización + alta_incidencia = sorted(zonas, key=lambda x: x["promedio_incidencias_dia"], reverse=True) + if alta_incidencia: + z = alta_incidencia[0] + recomendaciones.append({ + "tipo": "CONCIENTIZACION", + "prioridad": "BAJA", + "emoji": "📢", + "titulo": f"Campaña en {z['colonia']}", + "descripcion": ( + f"{z['colonia']} registra {z['promedio_incidencias_dia']:.1f} incidencias/día " + f"de basura fuera de horario. Se recomienda campaña de concientización ciudadana." + ), + "ruta_afectada": z["ruta_id"], + }) + + return recomendaciones + + +# --------------------------------------------------------------- +# FUNCIÓN PRINCIPAL: Genera el reporte completo +# --------------------------------------------------------------- +def generar_reporte_completo(dias_historico: int = 90) -> Dict[str, Any]: + """ + Punto de entrada principal. Genera todos los análisis y los empaqueta. + Cachea el resultado por 10 minutos para no recalcular en cada request. + """ + historico = _generar_historico(dias_historico) + + dias_semana = analisis_por_dia_semana(historico) + zonas = analisis_zonas_criticas(historico) + predicciones = predecir_proxima_semana(historico) + recomendaciones = generar_recomendaciones(zonas, dias_semana, predicciones) + + # Resumen ejecutivo + total_kg_periodo = sum(r["volumen_kg"] for r in historico) + promedio_diario = total_kg_periodo / dias_historico if dias_historico > 0 else 0 + + return { + "generado_en": datetime.now().isoformat(), + "periodo_dias": dias_historico, + "resumen": { + "total_kg_recolectados": round(total_kg_periodo, 1), + "promedio_kg_dia": round(promedio_diario, 1), + "colonias_analizadas": len(COLONIAS), + "zonas_criticas": len([z for z in zonas if z["nivel_criticidad"] == "CRÍTICO"]), + "dia_pico": dias_semana[0]["dia"] if dias_semana else "N/A", + "colonia_mayor_volumen": zonas[0]["colonia"] if zonas else "N/A", + }, + "dias_semana": dias_semana, + "zonas_criticas": zonas, + "prediccion_proxima_semana": predicciones, + "recomendaciones": recomendaciones, + } diff --git a/HackOnLinces_app/backend/analytics_real.py b/HackOnLinces_app/backend/analytics_real.py new file mode 100644 index 0000000..99612ae --- /dev/null +++ b/HackOnLinces_app/backend/analytics_real.py @@ -0,0 +1,414 @@ +""" +=============================================================== + analytics.py — v2: Datos REALES de la BD + fallback simulado +=============================================================== + REEMPLAZA el analytics.py anterior completamente. + + CAMBIO PRINCIPAL: + generar_reporte_completo_real(db) lee de RegistroRecoleccion + y ReporteUsuario en SQLite. Si hay pocos datos reales (<7 días), + mezcla con datos simulados para que el dashboard no quede vacío. +=============================================================== +""" + +import numpy as np +import random +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any, Optional +import hashlib + +# --------------------------------------------------------------- +# CONSTANTES +# --------------------------------------------------------------- +COLONIAS = [ + "Zona Centro", "Las Arboledas", "Trojes", + "San Juanico", "Los Olivos", "Rancho Seco", "Las Insurgentes", +] + +RUTAS = { + "Zona Centro": "RUTA-01", + "Las Arboledas": "RUTA-01", + "Trojes": "RUTA-13", + "San Juanico": "RUTA-03", + "Los Olivos": "RUTA-04", + "Rancho Seco": "RUTA-05", + "Las Insurgentes": "RUTA-12", +} + +DIAS_SEMANA = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"] + +FACTOR_COLONIA = { + "Zona Centro": 1.45, + "Las Arboledas": 1.10, + "Trojes": 0.85, + "San Juanico": 0.90, + "Los Olivos": 1.00, + "Rancho Seco": 0.75, + "Las Insurgentes": 1.20, +} + +FACTOR_DIA = {0: 1.35, 1: 1.05, 2: 1.00, 3: 0.95, 4: 1.15, 5: 1.25, 6: 0.80} + + +# --------------------------------------------------------------- +# GENERADOR DE HISTÓRICO SIMULADO (fallback cuando no hay datos) +# --------------------------------------------------------------- +def _semilla_dia(fecha: datetime) -> int: + s = fecha.strftime("%Y-%m-%d") + return int(hashlib.md5(s.encode()).hexdigest(), 16) % (2**32) + + +def _generar_historico_simulado(dias: int = 90) -> List[Dict]: + registros = [] + hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + for d in range(dias, 0, -1): + fecha = hoy - timedelta(days=d) + rng = random.Random(_semilla_dia(fecha)) + dia_semana = fecha.weekday() + for colonia in COLONIAS: + base = rng.uniform(800, 2500) + factor = FACTOR_COLONIA[colonia] * FACTOR_DIA[dia_semana] + ruido = rng.uniform(0.85, 1.15) + volumen = round(base * factor * ruido, 1) + incidencias = rng.randint(0, int(factor * 5)) + tiempo_min = round(30 + (volumen / 100) * rng.uniform(0.8, 1.2), 0) + registros.append({ + "fecha": fecha, + "fecha_str": fecha.strftime("%Y-%m-%d"), + "dia_semana": dia_semana, + "dia_nombre": DIAS_SEMANA[dia_semana], + "colonia": colonia, + "ruta_id": RUTAS[colonia], + "volumen_kg": volumen, + "incidencias": incidencias, + "tiempo_recoleccion_min": tiempo_min, + "fuente": "simulado", + }) + return registros + + +# --------------------------------------------------------------- +# LEER DATOS REALES DE LA BD +# --------------------------------------------------------------- +def _leer_historico_real(db, dias: int = 90) -> List[Dict]: + """Lee RegistroRecoleccion de la BD y los convierte al formato interno.""" + desde = (datetime.now(timezone.utc) - timedelta(days=dias)).strftime("%Y-%m-%d") + + try: + from sqlalchemy import text + rows = db.execute( + text(""" + SELECT fecha, colonia, ruta_id, + SUM(volumen_kg) as volumen_kg, + SUM(incidencias) as incidencias, + AVG(tiempo_min) as tiempo_min, + fuente + FROM registros_recoleccion + WHERE fecha >= :desde + GROUP BY fecha, colonia, ruta_id, fuente + ORDER BY fecha DESC + """), + {"desde": desde} + ).fetchall() + except Exception: + return [] + + registros = [] + for row in rows: + try: + fecha = datetime.strptime(row[0], "%Y-%m-%d") + except Exception: + continue + registros.append({ + "fecha": fecha, + "fecha_str": row[0], + "dia_semana": fecha.weekday(), + "dia_nombre": DIAS_SEMANA[fecha.weekday()], + "colonia": row[1], + "ruta_id": row[2], + "volumen_kg": float(row[3] or 0), + "incidencias": int(row[4] or 0), + "tiempo_recoleccion_min": float(row[5] or 0), + "fuente": row[6], + }) + return registros + + +def _leer_reportes_usuario(db, dias: int = 30) -> List[Dict]: + """Lee los reportes del usuario para mostrarlos en el análisis.""" + desde = (datetime.now(timezone.utc) - timedelta(days=dias)).strftime("%Y-%m-%d") + try: + from sqlalchemy import text + rows = db.execute( + text(""" + SELECT r.fecha, r.colonia, r.tipo_reporte, + COUNT(*) as total, SUM(COALESCE(r.volumen_kg, 0)) as vol_total, + u.nombre + FROM reportes_usuario r + LEFT JOIN usuarios u ON r.usuario_id = u.id + WHERE r.fecha >= :desde + GROUP BY r.fecha, r.colonia, r.tipo_reporte + ORDER BY r.fecha DESC + """), + {"desde": desde} + ).fetchall() + return [ + { + "fecha": row[0], "colonia": row[1], + "tipo": row[2], "total": row[3], + "volumen": float(row[4] or 0), + } + for row in rows + ] + except Exception: + return [] + + +# --------------------------------------------------------------- +# ANÁLISIS (idéntico al anterior, funciona con cualquier historico) +# --------------------------------------------------------------- +def _analisis_por_dia_semana(historico: List[Dict]) -> List[Dict]: + acumulado = {i: {"total_kg": 0.0, "count": 0, "incidencias": 0} for i in range(7)} + for r in historico: + d = r["dia_semana"] + acumulado[d]["total_kg"] += r["volumen_kg"] + acumulado[d]["count"] += 1 + acumulado[d]["incidencias"] += r["incidencias"] + resultado = [] + for dia_idx in range(7): + v = acumulado[dia_idx] + count = max(v["count"], 1) + resultado.append({ + "dia": DIAS_SEMANA[dia_idx], + "dia_idx": dia_idx, + "promedio_kg": round(v["total_kg"] / count, 1), + "total_kg": round(v["total_kg"], 1), + "promedio_incidencias": round(v["incidencias"] / count, 1), + "semanas_registradas": count // max(len(COLONIAS), 1), + }) + resultado.sort(key=lambda x: x["promedio_kg"], reverse=True) + return resultado + + +def _analisis_zonas_criticas(historico: List[Dict]) -> List[Dict]: + acumulado: Dict[str, Dict] = {} + for r in historico: + c = r["colonia"] + if c not in acumulado: + acumulado[c] = {"total_kg": 0.0, "total_incidencias": 0, "total_tiempo": 0.0, "count": 0} + acumulado[c]["total_kg"] += r["volumen_kg"] + acumulado[c]["total_incidencias"] += r["incidencias"] + acumulado[c]["total_tiempo"] += r["tiempo_recoleccion_min"] + acumulado[c]["count"] += 1 + + if not acumulado: + return [] + + max_vol = max(v["total_kg"] / max(v["count"], 1) for v in acumulado.values()) + resultado = [] + for colonia, v in acumulado.items(): + count = max(v["count"], 1) + promedio_kg = v["total_kg"] / count + promedio_inc = v["total_incidencias"] / count + idx = round((promedio_kg / max_vol) * 100, 1) if max_vol > 0 else 0 + + if idx >= 80: nivel = "CRÍTICO" + elif idx >= 60: nivel = "ALTO" + elif idx >= 40: nivel = "MEDIO" + else: nivel = "BAJO" + + resultado.append({ + "colonia": colonia, + "ruta_id": RUTAS.get(colonia, ""), + "promedio_kg_dia": round(promedio_kg, 1), + "total_kg_90dias": round(v["total_kg"], 1), + "promedio_incidencias_dia": round(promedio_inc, 2), + "promedio_tiempo_min": round(v["total_tiempo"] / count, 1), + "indice_criticidad": idx, + "nivel_criticidad": nivel, + }) + resultado.sort(key=lambda x: x["indice_criticidad"], reverse=True) + return resultado + + +def _predecir_proxima_semana(historico: List[Dict]) -> List[Dict]: + hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + predicciones = [] + for colonia in COLONIAS: + datos = [r for r in historico if r["colonia"] == colonia][-30:] + if len(datos) < 5: + continue + x = np.array(range(len(datos)), dtype=float) + y = np.array([r["volumen_kg"] for r in datos], dtype=float) + coefs = np.polyfit(x, y, 1) + y_pred = np.polyval(coefs, x) + ss_res = np.sum((y - y_pred) ** 2) + ss_tot = np.sum((y - np.mean(y)) ** 2) + r2 = round(1 - (ss_res / ss_tot) if ss_tot > 0 else 0, 3) + n = len(datos) + dias_pred = [] + for i in range(7): + fecha_pred = hoy + timedelta(days=i + 1) + dia_semana = fecha_pred.weekday() + vol_base = np.polyval(coefs, n + i) + vol_ajustado = max(0, vol_base * FACTOR_DIA[dia_semana]) + dias_pred.append({ + "fecha": fecha_pred.strftime("%Y-%m-%d"), + "dia": DIAS_SEMANA[dia_semana], + "volumen_predicho_kg": round(float(vol_ajustado), 1), + "confianza": "Alta" if r2 > 0.7 else ("Media" if r2 > 0.4 else "Baja"), + }) + pendiente = float(coefs[0]) + predicciones.append({ + "colonia": colonia, + "ruta_id": RUTAS.get(colonia, ""), + "tendencia": "CRECIENTE" if pendiente > 10 else ("DECRECIENTE" if pendiente < -10 else "ESTABLE"), + "pendiente_diaria_kg": round(pendiente, 2), + "r2": r2, + "prediccion_7dias": dias_pred, + "volumen_predicho_total_kg": round(sum(d["volumen_predicho_kg"] for d in dias_pred), 1), + }) + predicciones.sort(key=lambda x: x["volumen_predicho_total_kg"], reverse=True) + return predicciones + + +def _generar_recomendaciones(zonas, dias, predicciones) -> List[Dict]: + recomendaciones = [] + criticas = [z for z in zonas if z["nivel_criticidad"] == "CRÍTICO"] + for z in criticas: + recomendaciones.append({ + "tipo": "REFUERZO_RUTA", "prioridad": "ALTA", "emoji": "🚛", + "titulo": f"Refuerzo en {z['colonia']}", + "descripcion": ( + f"{z['colonia']} genera {z['promedio_kg_dia']:.0f} kg/día promedio " + f"con {z['promedio_incidencias_dia']:.1f} incidencias. " + f"Se recomienda reforzar el camión en días pico." + ), + "ruta_afectada": z["ruta_id"], + }) + if dias: + dia_pico = dias[0] + recomendaciones.append({ + "tipo": "HORARIO_PICO", "prioridad": "MEDIA", "emoji": "⏰", + "titulo": f"{dia_pico['dia']} es el día con más residuos", + "descripcion": ( + f"El {dia_pico['dia']} se recolectan {dia_pico['promedio_kg']:.0f} kg/colonia en promedio. " + f"Considera iniciar 30 minutos antes ese día." + ), + "ruta_afectada": None, + }) + crecientes = [p for p in predicciones if p["tendencia"] == "CRECIENTE"] + for p in crecientes[:2]: + recomendaciones.append({ + "tipo": "TENDENCIA_CRECIENTE", "prioridad": "MEDIA", "emoji": "📈", + "titulo": f"Aumento en {p['colonia']}", + "descripcion": ( + f"Tendencia creciente de +{p['pendiente_diaria_kg']:.1f} kg/día. " + f"Volumen predicho esta semana: {p['volumen_predicho_total_kg']:.0f} kg." + ), + "ruta_afectada": p["ruta_id"], + }) + if zonas: + z = max(zonas, key=lambda x: x["promedio_incidencias_dia"]) + recomendaciones.append({ + "tipo": "CONCIENTIZACION", "prioridad": "BAJA", "emoji": "📢", + "titulo": f"Campaña en {z['colonia']}", + "descripcion": ( + f"{z['colonia']} tiene {z['promedio_incidencias_dia']:.1f} incidencias/día. " + f"Se recomienda campaña de concientización." + ), + "ruta_afectada": z["ruta_id"], + }) + return recomendaciones + + +# --------------------------------------------------------------- +# FUNCIÓN PRINCIPAL — DATOS REALES + FALLBACK +# --------------------------------------------------------------- +def generar_reporte_completo_real(db) -> Dict[str, Any]: + """ + Lee datos reales de la BD. Si hay menos de 7 días de datos reales, + complementa con datos simulados para que el dashboard no quede vacío. + """ + historico_real = _leer_historico_real(db, dias=90) + reportes_usuario = _leer_reportes_usuario(db, dias=30) + + # Calcular días únicos con datos reales + dias_con_datos = len({r["fecha_str"] for r in historico_real}) + + if dias_con_datos < 7: + # Pocos datos reales — mezclar con simulados + historico_sim = _generar_historico_simulado(dias=90 - dias_con_datos) + historico = historico_real + historico_sim + fuente_datos = f"mixto ({dias_con_datos} días reales + simulados)" + else: + historico = historico_real + fuente_datos = f"real ({dias_con_datos} días)" + + dias_semana = _analisis_por_dia_semana(historico) + zonas = _analisis_zonas_criticas(historico) + predicciones = _predecir_proxima_semana(historico) + recomendaciones = _generar_recomendaciones(zonas, dias_semana, predicciones) + + total_kg = sum(r["volumen_kg"] for r in historico) + n_dias = max(len({r["fecha_str"] for r in historico}), 1) + + # Estadísticas de reportes de usuarios + total_reportes_usuario = sum(r["total"] for r in reportes_usuario) + incidencias_por_colonia = {} + for r in reportes_usuario: + if r["tipo"] == "incidencia": + incidencias_por_colonia[r["colonia"]] = \ + incidencias_por_colonia.get(r["colonia"], 0) + r["total"] + + return { + "generado_en": datetime.now().isoformat(), + "fuente_datos": fuente_datos, + "periodo_dias": 90, + "resumen": { + "total_kg_recolectados": round(total_kg, 1), + "promedio_kg_dia": round(total_kg / n_dias, 1), + "colonias_analizadas": len(COLONIAS), + "zonas_criticas": len([z for z in zonas if z["nivel_criticidad"] == "CRÍTICO"]), + "dia_pico": dias_semana[0]["dia"] if dias_semana else "N/A", + "colonia_mayor_volumen": zonas[0]["colonia"] if zonas else "N/A", + "reportes_ciudadanos": total_reportes_usuario, + "colonias_con_incidencias": len(incidencias_por_colonia), + }, + "dias_semana": dias_semana, + "zonas_criticas": zonas, + "prediccion_proxima_semana": predicciones, + "recomendaciones": recomendaciones, + "reportes_ciudadanos_recientes": reportes_usuario[:10], + } + + +# Mantener compatibilidad con el import anterior +def generar_reporte_completo(db=None, dias_historico: int = 90) -> Dict[str, Any]: + """Versión sin DB (fallback puro simulado).""" + historico = _generar_historico_simulado(dias_historico) + dias_semana = _analisis_por_dia_semana(historico) + zonas = _analisis_zonas_criticas(historico) + predicciones = _predecir_proxima_semana(historico) + recomendaciones = _generar_recomendaciones(zonas, dias_semana, predicciones) + total_kg = sum(r["volumen_kg"] for r in historico) + return { + "generado_en": datetime.now().isoformat(), + "fuente_datos": "simulado", + "periodo_dias": dias_historico, + "resumen": { + "total_kg_recolectados": round(total_kg, 1), + "promedio_kg_dia": round(total_kg / max(dias_historico, 1), 1), + "colonias_analizadas": len(COLONIAS), + "zonas_criticas": len([z for z in zonas if z["nivel_criticidad"] == "CRÍTICO"]), + "dia_pico": dias_semana[0]["dia"] if dias_semana else "N/A", + "colonia_mayor_volumen": zonas[0]["colonia"] if zonas else "N/A", + "reportes_ciudadanos": 0, + "colonias_con_incidencias": 0, + }, + "dias_semana": dias_semana, + "zonas_criticas": zonas, + "prediccion_proxima_semana": predicciones, + "recomendaciones": recomendaciones, + "reportes_ciudadanos_recientes": [], + } diff --git a/HackOnLinces_app/backend/hackathon.db b/HackOnLinces_app/backend/hackathon.db new file mode 100644 index 0000000..d2f00ab Binary files /dev/null and b/HackOnLinces_app/backend/hackathon.db differ diff --git a/HackOnLinces_app/backend/main.py b/HackOnLinces_app/backend/main.py new file mode 100644 index 0000000..7d269da --- /dev/null +++ b/HackOnLinces_app/backend/main.py @@ -0,0 +1,1760 @@ +""" +=============================================================== + SISTEMA DE NOTIFICACIÓN PRIVADA DE RECOLECCIÓN DE RESIDUOS + Backend MVP - Hackathon 24h (v2 — mejorado post-hackathon) +=============================================================== + Stack: FastAPI + SQLite (SQLAlchemy) + Firebase Admin SDK + + MEJORAS v2: + - Passwords hasheadas con bcrypt (antes: sha256 plano) + - Migración automática de hashes legacy sha256 → bcrypt + - Endpoint /api/dashboard: estadísticas globales de rutas + - Endpoint /api/rutas/{route_id}/posiciones: historial GPS + - Endpoint /api/rutas/resumen: vista rápida de todos los camiones + - Endpoint /api/estadisticas/colonias: colonias más activas + + CÓMO CORRER: + pip install fastapi uvicorn sqlalchemy firebase-admin bcrypt + uvicorn main:app --reload --port 8000 +=============================================================== +""" + +from fastapi import FastAPI, HTTPException, Depends, Query +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, inspect, text +from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime, timezone, timedelta +import bcrypt +import hashlib +import logging +from analytics import generar_reporte_completo +from analytics_real import generar_reporte_completo as generar_reporte_real + +# --------------------------------------------------------------- +# LOGGING +# --------------------------------------------------------------- +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# =============================================================== +# SEGURIDAD DE CONTRASEÑAS — bcrypt +# +# POR QUÉ bcrypt en vez de sha256: +# - sha256 es rápido → fácil de atacar con fuerza bruta +# - bcrypt incluye salt automático → dos hashes del mismo +# password son distintos (evita ataques de rainbow table) +# - El "work factor" (12) hace cada verificación ~250ms, +# tolerable para usuarios, costoso para atacantes. +# +# MIGRACIÓN LEGACY: +# Los usuarios registrados antes de esta versión tienen +# password_hash en sha256. Al hacer login, si el hash +# antiguo coincide, re-hasheamos con bcrypt y guardamos. +# =============================================================== + +def hash_password(password: str) -> str: + """Genera un hash bcrypt del password. Incluye salt automático.""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def _es_hash_legacy(password_hash: str) -> bool: + """Detecta si el hash guardado es sha256 (hex de 64 chars) en vez de bcrypt.""" + return len(password_hash) == 64 and not password_hash.startswith("$2b$") + + +def verify_password(password: str, password_hash: str) -> bool: + if not password_hash: # ← hash vacío o None → fallo seguro + return False + if _es_hash_legacy(password_hash): + legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest() + return legacy_hash == password_hash + try: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + except ValueError: # ← salt inválido por cualquier corrupción + return False + + +# --------------------------------------------------------------- +# BASE DE DATOS (SQLite — hackathon mode) +# --------------------------------------------------------------- +DATABASE_URL = "sqlite:///./hackathon.db" +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) +Base = declarative_base() + + +# =============================================================== +# MODELOS ORM +# =============================================================== + +class Usuario(Base): + __tablename__ = "usuarios" + + id = Column(Integer, primary_key=True, index=True) + nombre = Column(String, nullable=False) + email = Column(String, nullable=False, unique=True, index=True) + password_hash = Column(String, nullable=False) + fcm_token = Column(String, nullable=True) + + direcciones = relationship("Domicilio", back_populates="usuario", cascade="all, delete-orphan") + + +class Domicilio(Base): + __tablename__ = "domicilios" + + id = Column(Integer, primary_key=True, index=True) + usuario_id = Column(Integer, ForeignKey("usuarios.id")) + colonia = Column(String, nullable=False) + direccion = Column(String, nullable=False) + route_id = Column(String, nullable=False) # Interno, no se expone al cliente + + usuario = relationship("Usuario", back_populates="direcciones") + +# ================================================================ +# PEGA ESTE BLOQUE EN main.py JUSTO DESPUÉS DE LA CLASE Domicilio +# (después de la línea: usuario = relationship(...)) +# ================================================================ + +class RegistroRecoleccion(Base): + """ + Registro automático cada vez que una ruta avanza o se completa. + Es la fuente de verdad para el módulo de analytics. + """ + __tablename__ = "registros_recoleccion" + + id = Column(Integer, primary_key=True, index=True) + fecha = Column(String, nullable=False) # YYYY-MM-DD + hora = Column(String, nullable=False) # HH:MM:SS + ruta_id = Column(String, nullable=False, index=True) + colonia = Column(String, nullable=False, index=True) + position_id = Column(Integer, nullable=False) # 1..8 + evento = Column(String, nullable=False) # INICIO, AVANCE, COMPLETADO + volumen_kg = Column(Integer, nullable=True) # estimado por posición + tiempo_min = Column(Integer, nullable=True) # minutos desde inicio de ruta + + +class ReporteUsuario(Base): + """ + Reportes manuales enviados por los ciudadanos. + Permiten enriquecer el análisis con datos cualitativos. + """ + __tablename__ = "reportes_usuarios" + + id = Column(Integer, primary_key=True, index=True) + usuario_id = Column(Integer, ForeignKey("usuarios.id"), nullable=False) + fecha = Column(String, nullable=False) # YYYY-MM-DD + hora = Column(String, nullable=False) # HH:MM:SS + colonia = Column(String, nullable=False) + ruta_id = Column(String, nullable=False) + tipo = Column(String, nullable=False) # VOLUMEN_ALTO, CAMION_NO_PASO, BASURA_FUERA_HORARIO, OTRO + descripcion = Column(String, nullable=True) + foto_url = Column(String, nullable=True) # futuro: S3/Firebase Storage + estado = Column(String, nullable=False, default="PENDIENTE") # PENDIENTE, ATENDIDO + + usuario = relationship("Usuario") + +# --------------------------------------------------------------- +# MIGRACIÓN AUTOMÁTICA DE ESQUEMA +# Añade columnas nuevas si la DB existe desde una versión anterior. +# --------------------------------------------------------------- +inspector = inspect(engine) +if inspector.has_table("usuarios"): + cols = [c["name"] for c in inspector.get_columns("usuarios")] + with engine.connect() as conn: + if "email" not in cols: + conn.execute(text("ALTER TABLE usuarios ADD COLUMN email TEXT")) + if "password_hash" not in cols: + conn.execute(text("ALTER TABLE usuarios ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''")) + conn.commit() + +if inspector.has_table("domicilios"): + cols = [c["name"] for c in inspector.get_columns("domicilios")] + with engine.connect() as conn: + if "direccion" not in cols: + conn.execute(text("ALTER TABLE domicilios ADD COLUMN direccion TEXT NOT NULL DEFAULT ''")) + conn.commit() + +Base.metadata.create_all(bind=engine) + + +# =============================================================== +# DATOS MOCKEADOS +# =============================================================== + +# =============================================================== +# PEGA ESTO EN main.py REEMPLAZANDO DESDE "ROUTE_DATA" HASTA +# "ROUTE_STATE" (inclusive el bloque de inicialización de ROUTE_STATE) +# =============================================================== + +ROUTE_DATA = [ + { + "route_id": "RUTA-01", "name": "Zona Centro - Las Arboledas", "truck_id": 101, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"}, + {"positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"}, + {"positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z"}, + {"positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z"}, + {"positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z"}, + {"positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z"}, + {"positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z"}, + ], + }, + { + "route_id": "RUTA-02", "name": "Sector Norte - Av. Tecnológico", "truck_id": 102, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z"}, + {"positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z"}, + {"positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z"}, + {"positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z"}, + {"positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z"}, + ], + }, + { + "route_id": "RUTA-03", "name": "Sector Poniente - San Juanico", "truck_id": 103, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"}, + {"positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z"}, + {"positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z"}, + {"positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z"}, + {"positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z"}, + ], + }, + { + "route_id": "RUTA-04", "name": "Oriente - Los Olivos", "truck_id": 104, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z"}, + {"positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z"}, + {"positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z"}, + ], + }, + { + "route_id": "RUTA-05", "name": "Sector Sur - Rancho Seco", "truck_id": 105, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z"}, + {"positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z"}, + {"positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"}, + {"positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z"}, + {"positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z"}, + ], + }, + { + "route_id": "RUTA-06", "name": "Norte Extremo - Rumbos de Roque", "truck_id": 106, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"}, + {"positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z"}, + {"positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z"}, + {"positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z"}, + ], + }, + { + "route_id": "RUTA-07", "name": "Nororiente - Ciudad Industrial", "truck_id": 107, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"}, + {"positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z"}, + {"positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z"}, + {"positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z"}, + {"positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z"}, + {"positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z"}, + ], + }, + { + "route_id": "RUTA-08", "name": "Suroriente - Universidad Latina", "truck_id": 108, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z"}, + {"positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z"}, + {"positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z"}, + {"positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z"}, + {"positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z"}, + {"positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, + { + "route_id": "RUTA-09", "name": "Poniente - Hospital General", "truck_id": 109, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z"}, + {"positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"}, + {"positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z"}, + {"positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z"}, + {"positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z"}, + ], + }, + { + "route_id": "RUTA-10", "name": "Eje Juan Pablo II - Sede UG Sur", "truck_id": 110, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z"}, + {"positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z"}, + {"positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z"}, + {"positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"}, + {"positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z"}, + {"positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z"}, + ], + }, + { + "route_id": "RUTA-11", "name": "Zona de Oro - Torres Landa", "truck_id": 111, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z"}, + {"positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z"}, + {"positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z"}, + {"positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z"}, + {"positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z"}, + {"positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z"}, + {"positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z"}, + ], + }, + { + "route_id": "RUTA-12", "name": "Nororiente - Las Insurgentes", "truck_id": 112, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z"}, + {"positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z"}, + {"positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z"}, + {"positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z"}, + ], + }, + { + "route_id": "RUTA-13", "name": "Sector Norte - Trojes e Irrigación", "truck_id": 113, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z"}, + {"positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z"}, + {"positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z"}, + {"positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z"}, + {"positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"}, + {"positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, + { + "route_id": "RUTA-14", "name": "Sur Poniente - La Toscana", "truck_id": 114, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z"}, + {"positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z"}, + {"positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z"}, + {"positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z"}, + {"positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"}, + {"positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z"}, + ], + }, + { + "route_id": "RUTA-15", "name": "Norponiente - Camino a San José de Celaya", "truck_id": 115, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z"}, + {"positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z"}, + {"positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z"}, + {"positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z"}, + {"positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z"}, + {"positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z"}, + {"positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, +] + +ROUTAS_POR_ID = {r["route_id"]: r for r in ROUTE_DATA} + +HORARIOS_POR_RUTA = { + "RUTA-01": {"eta_texto": "Llega en aproximadamente 15 minutos", "eta_minutos": 15}, + "RUTA-02": {"eta_texto": "Llega en aproximadamente 30 minutos", "eta_minutos": 30}, + "RUTA-03": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40}, + "RUTA-04": {"eta_texto": "Llega en aproximadamente 60 minutos", "eta_minutos": 60}, + "RUTA-05": {"eta_texto": "Llega en aproximadamente 75 minutos", "eta_minutos": 75}, + "RUTA-06": {"eta_texto": "Llega en aproximadamente 50 minutos", "eta_minutos": 50}, + "RUTA-07": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45}, + "RUTA-08": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55}, + "RUTA-09": {"eta_texto": "Llega en aproximadamente 20 minutos", "eta_minutos": 20}, + "RUTA-10": {"eta_texto": "Llega en aproximadamente 65 minutos", "eta_minutos": 65}, + "RUTA-11": {"eta_texto": "Llega en aproximadamente 25 minutos", "eta_minutos": 25}, + "RUTA-12": {"eta_texto": "Llega en aproximadamente 35 minutos", "eta_minutos": 35}, + "RUTA-13": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40}, + "RUTA-14": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45}, + "RUTA-15": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55}, +} + +HORARIOS_POR_COLONIA = [ + {"colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)"}, + {"colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)"}, + {"colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)"}, + {"colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)"}, + {"colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)"}, + {"colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)"}, + {"colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)"}, +] + +COLONIAS_A_RUTAS = {item["colonia"]: item["routeId"] for item in HORARIOS_POR_COLONIA} + +TRIGGER_NOTIFICATIONS = { + "ROUTE_START": { + "position_id": 2, + "title": "¡Ruta Iniciada!", + "body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos.", + }, + "TRUCK_PROXIMITY": { + "position_id": 4, + "title": "Camión Cercano", + "body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.", + }, + "ROUTE_COMPLETED": { + "position_id": 8, + "title": "Servicio Finalizado", + "body": "El camión de tu sector ha concluido su jornada de recolección diaria.", + }, + "GPS_OUTAGE": { + "title": "Alerta GPS", + "body": "El GPS del camión dejó de reportar su ubicación. Estamos investigando la ruta.", + }, +} + +TIPOS_EVENTO_VALIDOS = ["en_camino", "llegando", "completado", "retrasado"] +INFO_ARTICULOS = [ + { + "id": "separacion-basica", + "categoria": "Separación", + "imagen": "assets/images/recycle.jpg", + "titulo": "Cómo separar correctamente tu basura", + "resumen": "La separación correcta es el primer paso para reciclar y reducir el impacto ambiental.", + "contenido": [ + { + "subtitulo": "Residuos Orgánicos 🟤", + "texto": "Restos de comida, cáscaras de frutas y verduras, posos de café, bolsas de té, restos de jardín. Van en bolsa oscura o café. Se convierten en composta.", + }, + { + "subtitulo": "Residuos Inorgánicos Reciclables 🟡", + "texto": "Plásticos (botellas PET, envases), papel y cartón limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente o amarilla. Deben estar limpios y secos.", + }, + { + "subtitulo": "Residuos No Reciclables 🔴", + "texto": "Papel higiénico usado, pañales, colillas de cigarro, envolturas metalizadas (como papas). Van en bolsa negra. No tienen valor de reciclaje.", + }, + { + "subtitulo": "Residuos Especiales ⚠️", + "texto": "Pilas, medicamentos caducados, electrónicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas y electrónicos a puntos de acopio en supermercados.", + }, + ], + "consejo_rapido": "Regla fácil: si vino de la naturaleza y se pudre → orgánico. Si es artificial y limpio → reciclable.", + }, + { + "id": "cuando-sacar", + "categoria": "Horarios", + "imagen": "assets/images/reloj.png", + "titulo": "¿Cuándo sacar tu basura?", + "resumen": "Sacar la basura en el momento correcto evita plagas, malos olores y que el camión se la pierda.", + "contenido": [ + { + "subtitulo": "El momento ideal", + "texto": "Saca tu basura cuando recibas la alerta de 'Camión Cercano' en la app. Eso significa que el camión está a menos de 15 minutos de tu domicilio.", + }, + { + "subtitulo": "¿Por qué no sacarla de noche?", + "texto": "Las bolsas en la acera de noche atraen perros, gatos y fauna nocturna que las rompen y dispersan los residuos. Además el plástico se deteriora con la humedad nocturna.", + }, + { + "subtitulo": "¿Y si me lo pierdo?", + "texto": "Si el camión ya pasó, guarda tu basura hasta el siguiente día. Nunca dejes bolsas en la vía pública fuera del horario de recolección: es una multa en muchos municipios.", + }, + { + "subtitulo": "Días festivos", + "texto": "En días festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de la app para recibir alertas de retraso o cambio de horario.", + }, + ], + "consejo_rapido": "Espera la alerta de la app antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.", + }, + { + "id": "plasticos-guia", + "categoria": "Reciclaje", + "imagen": "assets/images/bottle.png", + "titulo": "Guía de plásticos: cuáles sí y cuáles no", + "resumen": "No todos los plásticos son iguales. Aprende a leer el número en el triángulo de reciclaje.", + "contenido": [ + { + "subtitulo": "✅ Plástico #1 — PET", + "texto": "Botellas de agua, refrescos, aceite. El más reciclado. Aplástalo para ahorrar espacio. Quita la tapa (es diferente material).", + }, + { + "subtitulo": "✅ Plástico #2 — HDPE", + "texto": "Garrafones, botellas de leche, shampú. También muy reciclable. Enjuágalo antes de separarlo.", + }, + { + "subtitulo": "✅ Plástico #5 — PP", + "texto": "Tapas de botellas, envases de yogur, popotes. Sí se recicla pero menos centros lo aceptan.", + }, + { + "subtitulo": "❌ Plásticos #3, #6, #7", + "texto": "PVC (mangueras, tuberías), poliestireno expandido (unicel), policarbonato. Difíciles o imposibles de reciclar. Van a basura no reciclable.", + }, + { + "subtitulo": "❌ Bolsas de plástico", + "texto": "Las bolsas de supermercado no van en el reciclaje de casa: tapan las máquinas clasificadoras. Lleva tus bolsas a centros de acopio específicos en supermercados.", + }, + ], + "consejo_rapido": "Busca el número dentro del triángulo en el fondo del envase. #1 y #2 siempre al reciclaje.", + }, + { + "id": "composta", + "categoria": "Compostaje", + "imagen": "assets/images/planta.png", + "titulo": "Haz composta en casa", + "resumen": "Convierte tus residuos orgánicos en abono natural. Es más fácil de lo que crees.", + "contenido": [ + { + "subtitulo": "¿Qué necesitas?", + "texto": "Un contenedor con tapa (puede ser una cubeta con tapa o una caja de madera), residuos orgánicos, tierra o tierra de hojarasca, y un poco de paciencia.", + }, + { + "subtitulo": "¿Qué puedes compostar?", + "texto": "Cáscaras de frutas y verduras, restos de comida cocida sin carne, posos de café y filtros de papel, cáscaras de huevo (aplástelas), hojas secas, recortes de jardín.", + }, + { + "subtitulo": "¿Qué NO debes compostar?", + "texto": "Carnes, pescados, lácteos, aceites (atraen plagas), excrementos de mascotas (patógenos), plásticos ni metales.", + }, + { + "subtitulo": "El proceso", + "texto": "Alterna capas de residuos orgánicos húmedos con capas de material seco (tierra, hojas). Voltea la mezcla cada semana. En 2-3 meses tendrás composta lista para tus plantas.", + }, + ], + "consejo_rapido": "La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega más material seco y voltéala.", + }, + { + "id": "residuos-peligrosos", + "categoria": "Residuos Especiales", + "imagen": "assets/images/megafono.png", + "titulo": "Residuos peligrosos: cómo deshacerte de ellos", + "resumen": "Pilas, medicamentos y electrónicos requieren un manejo especial para no contaminar el suelo y el agua.", + "contenido": [ + { + "subtitulo": "Pilas y baterías", + "texto": "Una sola pila AA puede contaminar 600,000 litros de agua. Guárdalas en una bolsa o caja y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO. Nunca al drenaje ni al fuego.", + }, + { + "subtitulo": "Medicamentos caducados", + "texto": "No los tires al drenaje ni a la basura regular. Farmacias como Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos. El municipio también hace jornadas de recolección.", + }, + { + "subtitulo": "Electrónicos (RAEE)", + "texto": "Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electrónicos (Best Buy, Liverpool) o espera las jornadas municipales de recolección.", + }, + { + "subtitulo": "Aceite de cocina", + "texto": "Un litro de aceite contamina hasta 1,000 litros de agua potable. Enfríalo, viértelo en una botella PET con tapa y lleva a centros de acopio o úsalo para hacer jabón casero.", + }, + ], + "consejo_rapido": "Guarda una caja en casa exclusiva para residuos peligrosos. Cuando esté llena, busca el punto de acopio más cercano.", + }, + { + "id": "impacto-ambiental", + "categoria": "Medio Ambiente", + "imagen": "assets/images/globo.png", + "titulo": "El impacto real de reciclar", + "resumen": "Números concretos para entender por qué vale la pena separar tu basura cada día.", + "contenido": [ + { + "subtitulo": "Papel y cartón", + "texto": "Reciclar 1 tonelada de papel salva 17 árboles, ahorra 26,000 litros de agua y evita 4,000 kWh de energía. Una familia promedio genera ~500 kg de papel al año.", + }, + { + "subtitulo": "Aluminio", + "texto": "Reciclar una lata de aluminio ahorra la energía suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces sin perder calidad.", + }, + { + "subtitulo": "Vidrio", + "texto": "El vidrio tarda más de 4,000 años en degradarse. Reciclarlo reduce en 20% las emisiones de CO₂ de su producción. Una botella puede reciclarse indefinidamente.", + }, + { + "subtitulo": "Plástico PET", + "texto": "5 botellas PET recicladas generan fibra suficiente para una camiseta de poliéster. México recicla menos del 20% del PET que consume — hay mucho potencial de mejora.", + }, + { + "subtitulo": "Residuos en México", + "texto": "México genera ~120,000 toneladas de basura al día. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podría triplicarse.", + }, + ], + "consejo_rapido": "Cada lata de aluminio que reciclas ahorra energía equivalente a medio litro de gasolina. Sí importa.", + }, +] +ROUTE_STATE = {} +for _route in ROUTE_DATA: + _pos = _route.get("positions", []) + ROUTE_STATE[_route["route_id"]] = { + "last_position_id": _pos[0]["positionId"] if _pos else 0, + "last_timestamp": datetime.now(timezone.utc), + "gps_ok": True, + "gps_alert_sent": False, + "triggers_sent": {k: False for k in TRIGGER_NOTIFICATIONS if k != "GPS_OUTAGE"}, + "error_message": None, + } + + +# =============================================================== +# FIREBASE ADMIN SDK +# =============================================================== +import firebase_admin +from firebase_admin import credentials, messaging + +try: + cred = credentials.Certificate("firebase-credentials.json") + firebase_admin.initialize_app(cred) + logger.info("✅ Firebase Admin SDK inicializado correctamente") +except Exception as e: + logger.error(f"❌ Error inicializando Firebase: {e}") + +FIREBASE_ACTIVO = True + + +# =============================================================== +# FASTAPI APP +# =============================================================== +app = FastAPI( + title="Sistema de Notificación de Residuos — v2", + description="API privada para notificaciones de recolección de basura", + version="0.2.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# =============================================================== +# SCHEMAS PYDANTIC +# =============================================================== + +class ETAResponse(BaseModel): + usuario_id: int + colonia: str + ruta_nombre: str + ruta_status: str + gps_ok: bool + eta_texto: str + eta_minutos: int + mensaje_preventivo: str + error_message: Optional[str] = None + + +class UsuarioRegisterRequest(BaseModel): + nombre: str + email: str + password: str + colonia: str + direccion: str + + +class UsuarioLoginRequest(BaseModel): + email: str + password: str + + +class DireccionRequest(BaseModel): + colonia: str + direccion: str + + +class DomicilioResponse(BaseModel): + colonia: str + direccion: str + + +class DomicilioAdminResponse(BaseModel): + colonia: str + direccion: str + route_id: str + ruta_nombre: str + + +class UsuarioResponse(BaseModel): + usuario_id: int + nombre: str + email: Optional[str] = None # ← igual aquí + direcciones: List[DomicilioResponse] + + +class UsuarioAdminResponse(BaseModel): + usuario_id: int + nombre: str + email: Optional[str] = None # ← cambia str por Optional[str] + direcciones: List[DomicilioAdminResponse] + + +class RegisterResponse(BaseModel): + usuario_id: int + mensaje: str + + +class LoginResponse(BaseModel): + usuario_id: int + nombre: str + mensaje: str + + +class RouteStatusResponse(BaseModel): + route_id: str + name: str + status: str + last_position_id: int + last_timestamp: str + gps_ok: bool + error_message: Optional[str] = None + + +class RoutePositionUpdateRequest(BaseModel): + position_id: int + lat: float + lng: float + timestamp: str + + +class ActualizarPasswordRequest(BaseModel): + password_actual: str # Requerimos la contraseña actual para cambiarla + password_nuevo: str + + +class ActualizarTokenRequest(BaseModel): + fcm_token: str + + +class SimularEventoRequest(BaseModel): + route_id: str + tipo_evento: str + + +class SimularEventoResponse(BaseModel): + usuarios_notificados: int + route_id: str + tipo_evento: str + detalle: list[str] + + +# --------------------------------------------------------------- +# NUEVOS SCHEMAS PARA VISUALIZACIÓN +# --------------------------------------------------------------- + +class PosicionGPS(BaseModel): + position_id: int + lat: float + lng: float + speed: int + timestamp: str + es_actual: bool # True si esta es la posición donde está el camión ahora + + +class RutaDetalleResponse(BaseModel): + route_id: str + name: str + status: str + truck_id: int + posicion_actual: int + total_posiciones: int + porcentaje_completado: float + eta_minutos: int + gps_ok: bool + usuarios_en_ruta: int # Cuántos usuarios están en esta ruta + + +class DashboardResponse(BaseModel): + total_rutas: int + rutas_en_progreso: int + rutas_completadas: int + total_usuarios: int + usuarios_con_token: int # Cuántos pueden recibir push notifications + cobertura_notificaciones: float # % de usuarios con FCM token + rutas: List[RutaDetalleResponse] + + +class ColoniaEstadisticaResponse(BaseModel): + colonia: str + route_id: str + ruta_nombre: str + horario: str + total_usuarios: int + usuarios_con_notificaciones: int + + +# =============================================================== +# UTILIDADES INTERNAS +# =============================================================== + +def generar_mensaje_preventivo(eta_minutos: int) -> str: + if eta_minutos <= 5: + return "🚛 ¡El camión está muy cerca! Saca tu basura AHORA." + elif eta_minutos <= 15: + return "⏰ Prepárate, el camión llega pronto. No saques tu basura aún." + elif eta_minutos <= 30: + return "🕐 Tienes tiempo. No saques tu basura todavía." + else: + return "😌 Aún falta bastante. Mantén tu basura adentro por ahora." + + +def enviar_notificacion_firebase(fcm_token: str, titulo: str, cuerpo: str) -> bool: + if not FIREBASE_ACTIVO: + logger.info(f"[SIMULADO] Push → Token: {fcm_token[:20]}... | {titulo}: {cuerpo}") + return True + try: + message = messaging.Message( + notification=messaging.Notification(title=titulo, body=cuerpo), + token=fcm_token, + ) + response = messaging.send(message) + logger.info(f"✅ Notificación enviada: {response}") + return True + except Exception as e: + logger.error(f"❌ Error enviando push: {e}") + return False + + +def _calcular_eta_por_ruta(route_id: str) -> Dict: + estado = ROUTE_STATE.get(route_id) + ruta = ROUTAS_POR_ID.get(route_id, {}) + horario = HORARIOS_POR_RUTA.get(route_id) + + if not horario: + return {"eta_texto": "Horario no disponible para esta zona.", "eta_minutos": 60} + + if estado and ruta.get("positions"): + if ruta.get("status") == "DETENIDA": + return {"eta_texto": "Ruta detenida", "eta_minutos": 0} + posiciones = ruta["positions"] + ultimo_id = estado.get("last_position_id", 1) + if ultimo_id >= len(posiciones): + return {"eta_texto": "El servicio ya pasó por tu zona.", "eta_minutos": 0} + pasos_restantes = max(0, len(posiciones) - ultimo_id) + eta = pasos_restantes * 10 + return {"eta_texto": f"Llega en aproximadamente {eta} minutos", "eta_minutos": eta} + + return {"eta_texto": horario["eta_texto"], "eta_minutos": horario["eta_minutos"]} + +def _verificar_gps_outage(route_id: str, db: Session) -> None: + estado = ROUTE_STATE.get(route_id) + if not estado: + return + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + if not gps_ok and not estado.get("gps_alert_sent", False): + logger.warning(f"Alerta GPS outage para {route_id}") + _notificar_ruta(db, route_id, "GPS_OUTAGE") + estado["gps_alert_sent"] = True + +def _obtener_estado_ruta(route_id: str, db: Optional[Session] = None) -> Dict: + ruta = ROUTAS_POR_ID.get(route_id) + estado = ROUTE_STATE.get(route_id, {}) + + if not ruta: + # Ruta en DB pero sin datos en memoria — retornar estado genérico + return { + "route_id": route_id, + "name": f"Ruta {route_id}", + "status": "SIN_DATOS", + "last_position_id": 0, + "last_timestamp": datetime.now(timezone.utc).isoformat(), + "gps_ok": False, + "error_message": "Ruta sin datos de seguimiento disponibles.", + } + + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + if db is not None and not gps_ok: + _verificar_gps_outage(route_id, db) + return { + "route_id": route_id, + "name": ruta.get("name", "Ruta desconocida"), + "status": ruta.get("status", "DESCONOCIDA"), + "last_position_id": estado.get("last_position_id", 0), + "last_timestamp": ultimo.isoformat(), + "gps_ok": gps_ok, + "error_message": estado.get("error_message"), + } + + +def _procesar_trigger_posicion(route_id: str, position_id: int, db: Session) -> list[str]: + estado = ROUTE_STATE.get(route_id) + if not estado: + return [] + mensajes = [] + sent_map = estado.setdefault("triggers_sent", {}) + for trigger_key, trigger in TRIGGER_NOTIFICATIONS.items(): + if trigger_key == "GPS_OUTAGE": + continue + if trigger.get("position_id") == position_id and not sent_map.get(trigger_key, False): + mensajes.extend(_notificar_ruta(db, route_id, trigger_key)) + sent_map[trigger_key] = True + return mensajes + + +def _notificar_ruta(db: Session, route_id: str, trigger_key: str) -> list[str]: + trigger = TRIGGER_NOTIFICATIONS.get(trigger_key) + if not trigger: + return [f"Trigger desconocido: {trigger_key}"] + domicilios = db.query(Domicilio).filter(Domicilio.route_id == route_id).all() + mensajes = [] + for domicilio in domicilios: + usuario = domicilio.usuario + if not usuario or not usuario.fcm_token: + mensajes.append("Usuario sin token.") + continue + enviado = enviar_notificacion_firebase(usuario.fcm_token, trigger["title"], trigger["body"]) + mensajes.append( + f"✅ Push a {usuario.nombre}" if enviado else f"❌ Fallo push a {usuario.nombre}" + ) + return mensajes + + +def _obtener_rutas_usuario(usuario_id: int, db: Session) -> List[str]: + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + return list({d.route_id for d in usuario.direcciones}) + + +# =============================================================== +# ENDPOINTS — USUARIOS +# =============================================================== + +@app.post("/api/usuarios/register", response_model=RegisterResponse, tags=["Usuarios"]) +def registrar_usuario(payload: UsuarioRegisterRequest, db: Session = Depends(get_db)): + existing = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first() + if existing: + raise HTTPException(status_code=400, detail="El correo ya está registrado.") + if not payload.password or not payload.password.strip(): + raise HTTPException(status_code=400, detail="La contraseña no puede estar vacía.") + route_id = COLONIAS_A_RUTAS.get(payload.colonia) + if not route_id: + raise HTTPException(status_code=400, detail=f"Colonia no válida. Opciones: {list(COLONIAS_A_RUTAS.keys())}") + + usuario = Usuario( + nombre=payload.nombre.strip(), + email=payload.email.lower().strip(), + password_hash=hash_password(payload.password), + fcm_token=None, + ) + db.add(usuario) + db.flush() + db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id)) + db.commit() + logger.info(f"✅ Usuario registrado: {usuario.email} (ID {usuario.id})") + return RegisterResponse(usuario_id=usuario.id, mensaje="Usuario registrado correctamente.") + + +@app.post("/api/usuarios/login", response_model=LoginResponse, tags=["Usuarios"]) +def login_usuario(payload: UsuarioLoginRequest, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado. Regístrate primero.") + if not verify_password(payload.password, usuario.password_hash): + raise HTTPException(status_code=401, detail="Contraseña incorrecta.") + + # ── MIGRACIÓN AUTOMÁTICA LEGACY ────────────────────────────── + # Si el hash guardado es sha256 (antiguo), lo re-hasheamos con + # bcrypt ahora que sabemos que el password es correcto. + if _es_hash_legacy(usuario.password_hash): + usuario.password_hash = hash_password(payload.password) + db.commit() + logger.info(f"🔄 Hash migrado sha256→bcrypt para usuario {usuario.id}") + + return LoginResponse(usuario_id=usuario.id, nombre=usuario.nombre, mensaje="Login exitoso.") + + +@app.get("/api/usuarios/{usuario_id}", response_model=UsuarioResponse, tags=["Usuarios"]) +def obtener_usuario(usuario_id: int, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + return UsuarioResponse( + usuario_id=usuario.id, + nombre=usuario.nombre, + email=usuario.email, + direcciones=[DomicilioResponse(colonia=d.colonia, direccion=d.direccion) for d in usuario.direcciones], + ) + + +@app.get("/api/usuarios", response_model=List[UsuarioAdminResponse], tags=["Usuarios"]) +def listar_usuarios(db: Session = Depends(get_db)): + usuarios = db.query(Usuario).all() + return [ + UsuarioAdminResponse( + usuario_id=u.id, + nombre=u.nombre, + email=u.email, + direcciones=[ + DomicilioAdminResponse( + colonia=d.colonia, + direccion=d.direccion, + route_id=d.route_id, + ruta_nombre=ROUTAS_POR_ID.get(d.route_id, {}).get("name", "Desconocida"), + ) + for d in u.direcciones + ], + ) + for u in usuarios + ] + + +@app.post("/api/usuarios/{usuario_id}/direcciones", tags=["Usuarios"]) +def agregar_direccion(usuario_id: int, payload: DireccionRequest, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + route_id = COLONIAS_A_RUTAS.get(payload.colonia) + if not route_id: + raise HTTPException(status_code=400, detail="Colonia no válida.") + db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id)) + db.commit() + return {"mensaje": "Dirección agregada correctamente."} + + +@app.put("/api/usuarios/{usuario_id}/password", tags=["Usuarios"]) +def actualizar_password(usuario_id: int, payload: ActualizarPasswordRequest, db: Session = Depends(get_db)): + """ + Actualiza la contraseña de un usuario. + Requiere la contraseña actual para confirmar identidad. + """ + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + if not verify_password(payload.password_actual, usuario.password_hash): + raise HTTPException(status_code=401, detail="La contraseña actual es incorrecta.") + if not payload.password_nuevo or not payload.password_nuevo.strip(): + raise HTTPException(status_code=400, detail="La nueva contraseña no puede estar vacía.") + if len(payload.password_nuevo) < 6: + raise HTTPException(status_code=400, detail="La nueva contraseña debe tener al menos 6 caracteres.") + usuario.password_hash = hash_password(payload.password_nuevo) + db.commit() + logger.info(f"🔑 Contraseña actualizada para usuario {usuario_id}") + return {"mensaje": "Contraseña actualizada correctamente."} + + +@app.put("/api/usuarios/{usuario_id}/fcm-token", tags=["Utilidades"]) +def actualizar_fcm_token(usuario_id: int, payload: ActualizarTokenRequest, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + usuario.fcm_token = payload.fcm_token + db.commit() + return {"mensaje": f"Token actualizado para usuario {usuario_id}"} + + +# =============================================================== +# ENDPOINTS — RUTAS Y ETA +# =============================================================== + +@app.get("/api/eta/{usuario_id}", response_model=ETAResponse, tags=["Core"]) +def obtener_eta(usuario_id: int, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail=f"Usuario {usuario_id} no encontrado.") + if not usuario.direcciones: + raise HTTPException(status_code=404, detail="El usuario no tiene direcciones registradas.") + + direccion = db.query(Domicilio).filter(Domicilio.usuario_id == usuario_id).order_by(Domicilio.id.desc()).first() + route_id = direccion.route_id + ruta = ROUTAS_POR_ID.get(route_id, {}) + estado_ruta = _obtener_estado_ruta(route_id, db) + calculo = _calcular_eta_por_ruta(route_id) + + return ETAResponse( + usuario_id=usuario_id, + colonia=direccion.colonia, + ruta_nombre=ruta.get("name", "Ruta desconocida"), + ruta_status=estado_ruta["status"], + gps_ok=estado_ruta["gps_ok"], # ← agregar esta línea + eta_texto=calculo["eta_texto"], + eta_minutos=calculo["eta_minutos"], + mensaje_preventivo=generar_mensaje_preventivo(calculo["eta_minutos"]), +) + + +@app.get("/api/rutas", tags=["Rutas"]) +def listar_rutas(usuario_id: int = Query(...), db: Session = Depends(get_db)): + rutas_usuario = _obtener_rutas_usuario(usuario_id, db) + return {"rutas": [_obtener_estado_ruta(r, db) for r in rutas_usuario]} + + +@app.get("/api/rutas/{route_id}/estado", response_model=RouteStatusResponse, tags=["Rutas"]) +def estado_ruta(route_id: str, db: Session = Depends(get_db)): + try: + return RouteStatusResponse(**_obtener_estado_ruta(route_id, db)) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@app.post("/api/rutas/{route_id}/avanzar", response_model=RouteStatusResponse, tags=["Rutas"]) +def avanzar_ruta(route_id: str, usuario_id: int = Query(...), db: Session = Depends(get_db)): + if route_id not in ROUTAS_POR_ID: + raise HTTPException(status_code=404, detail="Ruta no encontrada.") + rutas_usuario = _obtener_rutas_usuario(usuario_id, db) + if route_id not in rutas_usuario: + raise HTTPException(status_code=403, detail="No tienes permiso para avanzar esta ruta.") + + ruta = ROUTAS_POR_ID[route_id] + estado = ROUTE_STATE.get(route_id) + posiciones = ruta.get("positions", []) + if not estado or not posiciones: + raise HTTPException(status_code=500, detail="Estado de ruta no disponible.") + + actual = estado.get("last_position_id", 0) + if actual < len(posiciones): + siguiente = actual + 1 + estado["last_position_id"] = siguiente + estado["last_timestamp"] = datetime.now(timezone.utc) + estado["gps_ok"] = True + estado["gps_alert_sent"] = False + mensajes = _procesar_trigger_posicion(route_id, siguiente, db) + ruta["status"] = "COMPLETADO" if siguiente >= len(posiciones) else "EN_RUTA" + if mensajes: + logger.info(f"Triggers en {route_id}: {mensajes}") + colonia = db.query(Domicilio).filter( + Domicilio.route_id == route_id + ).first() + colonia_nombre = colonia.colonia if colonia else "Desconocida" + evento = "COMPLETADO" if siguiente >= len(posiciones) else "AVANCE" + _registrar_evento_ruta(db, route_id, siguiente, evento, colonia_nombre) + return RouteStatusResponse(**_obtener_estado_ruta(route_id, db)) + + +@app.post("/api/rutas/{route_id}/posicion", tags=["Rutas"]) +def actualizar_posicion_ruta(route_id: str, payload: RoutePositionUpdateRequest, db: Session = Depends(get_db)): + if route_id not in ROUTAS_POR_ID: + raise HTTPException(status_code=404, detail="Ruta no encontrada.") + estado = ROUTE_STATE.get(route_id) + if not estado: + raise HTTPException(status_code=500, detail="Estado de ruta no inicializado.") + try: + timestamp = datetime.fromisoformat(payload.timestamp.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(status_code=400, detail="Timestamp inválido. Usa formato ISO 8601 UTC.") + + mensajes = [] + if payload.position_id > estado["last_position_id"]: + mensajes = _procesar_trigger_posicion(route_id, payload.position_id, db) + + estado.update({ + "last_position_id": payload.position_id, + "last_timestamp": timestamp, + "gps_ok": True, + "gps_alert_sent": False, + }) + if payload.position_id == 8: + ROUTAS_POR_ID[route_id]["status"] = "COMPLETADO" + + return {"route_id": route_id, "position_id": payload.position_id, "timestamp": payload.timestamp, "gps_ok": True, "mensajes": mensajes} + + +# =============================================================== +# ENDPOINTS — VISUALIZACIÓN Y ESTADÍSTICAS (NUEVOS EN v2) +# =============================================================== + +@app.get("/api/dashboard", response_model=DashboardResponse, tags=["Visualización"]) +def obtener_dashboard(db: Session = Depends(get_db)): + """ + Vista global del sistema para monitoreo. + Muestra el estado de todas las rutas + métricas de usuarios. + + Útil para: + - Panel de control del operador municipal + - Demo en hackathon (muestra todo de un vistazo) + """ + todos_usuarios = db.query(Usuario).all() + total_usuarios = len(todos_usuarios) + usuarios_con_token = sum(1 for u in todos_usuarios if u.fcm_token) + cobertura = (usuarios_con_token / total_usuarios * 100) if total_usuarios > 0 else 0.0 + + rutas_detalle = [] + rutas_en_progreso = 0 + rutas_completadas = 0 + + for route_id, ruta in ROUTAS_POR_ID.items(): + estado = ROUTE_STATE.get(route_id, {}) + calculo = _calcular_eta_por_ruta(route_id) + posiciones = ruta.get("positions", []) + total_pos = len(posiciones) + actual_pos = estado.get("last_position_id", 0) + porcentaje = round((actual_pos / total_pos * 100) if total_pos > 0 else 0.0, 1) + + status = ruta.get("status", "DESCONOCIDA") + if status == "EN_RUTA": + rutas_en_progreso += 1 + elif status == "COMPLETADO": + rutas_completadas += 1 + + # Contar usuarios asignados a esta ruta + usuarios_en_ruta = db.query(Domicilio).filter(Domicilio.route_id == route_id).count() + + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + + rutas_detalle.append(RutaDetalleResponse( + route_id=route_id, + name=ruta.get("name", "Desconocida"), + status=status, + truck_id=ruta.get("truck_id", 0), + posicion_actual=actual_pos, + total_posiciones=total_pos, + porcentaje_completado=porcentaje, + eta_minutos=calculo["eta_minutos"], + gps_ok=gps_ok, + usuarios_en_ruta=usuarios_en_ruta, + )) + + return DashboardResponse( + total_rutas=len(ROUTAS_POR_ID), + rutas_en_progreso=rutas_en_progreso, + rutas_completadas=rutas_completadas, + total_usuarios=total_usuarios, + usuarios_con_token=usuarios_con_token, + cobertura_notificaciones=round(cobertura, 1), + rutas=rutas_detalle, + ) + + +@app.get("/api/rutas/{route_id}/posiciones", response_model=List[PosicionGPS], tags=["Visualización"]) +def historial_posiciones(route_id: str): + """ + Devuelve el historial completo de posiciones GPS de una ruta, + marcando cuál es la posición actual del camión. + + Útil para dibujar el recorrido en un mapa en la app. + """ + ruta = ROUTAS_POR_ID.get(route_id) + if not ruta: + raise HTTPException(status_code=404, detail="Ruta no encontrada.") + + estado = ROUTE_STATE.get(route_id, {}) + posicion_actual = estado.get("last_position_id", 0) + + resultado = [] + for pos in ruta.get("positions", []): + resultado.append(PosicionGPS( + position_id=pos["positionId"], + lat=pos["lat"], + lng=pos["lng"], + speed=pos["speed"], + timestamp=pos["timestamp"], + es_actual=(pos["positionId"] == posicion_actual), + )) + return resultado + + +@app.get("/api/rutas/resumen", tags=["Visualización"]) +def resumen_rutas(db: Session = Depends(get_db)): + """ + Vista rápida y ligera de todas las rutas activas. + Diseñada para refrescarse cada 30s en la app sin sobrecargar. + """ + resumen = [] + for route_id, ruta in ROUTAS_POR_ID.items(): + estado = ROUTE_STATE.get(route_id, {}) + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + calculo = _calcular_eta_por_ruta(route_id) + resumen.append({ + "route_id": route_id, + "name": ruta.get("name"), + "status": ruta.get("status"), + "eta_minutos": calculo["eta_minutos"], + "gps_ok": gps_ok, + "posicion_actual": estado.get("last_position_id", 0), + "total_posiciones": len(ruta.get("positions", [])), + }) + return {"rutas": resumen, "timestamp": datetime.now(timezone.utc).isoformat()} + + +@app.get("/api/estadisticas/colonias", response_model=List[ColoniaEstadisticaResponse], tags=["Visualización"]) +def estadisticas_por_colonia(db: Session = Depends(get_db)): + """ + Estadísticas por colonia: cuántos usuarios hay y cuántos + tienen notificaciones activas. Útil para el operador. + """ + resultado = [] + for item in HORARIOS_POR_COLONIA: + colonia = item["colonia"] + route_id = item["routeId"] + + domicilios = db.query(Domicilio).filter(Domicilio.colonia == colonia).all() + total = len(domicilios) + con_notif = sum(1 for d in domicilios if d.usuario and d.usuario.fcm_token) + + resultado.append(ColoniaEstadisticaResponse( + colonia=colonia, + route_id=route_id, + ruta_nombre=ROUTAS_POR_ID.get(route_id, {}).get("name", "Desconocida"), + horario=item.get("horarioEstimado", ""), + total_usuarios=total, + usuarios_con_notificaciones=con_notif, + )) + return resultado + + +# =============================================================== +# ENDPOINTS — UTILIDADES +# =============================================================== + +@app.get("/api/colonias", tags=["Utilidades"]) +def listar_colonias(): + return {"colonias": [item["colonia"] for item in HORARIOS_POR_COLONIA]} + + +@app.post("/api/simular-evento", response_model=SimularEventoResponse, tags=["Core"]) +def simular_evento(payload: SimularEventoRequest, db: Session = Depends(get_db)): + if payload.tipo_evento not in TIPOS_EVENTO_VALIDOS: + raise HTTPException(status_code=400, detail=f"tipo_evento inválido. Opciones: {TIPOS_EVENTO_VALIDOS}") + if payload.route_id not in HORARIOS_POR_RUTA: + raise HTTPException(status_code=404, detail=f"Ruta {payload.route_id} no encontrada.") + + domicilios = db.query(Domicilio).filter(Domicilio.route_id == payload.route_id).all() + if not domicilios: + return SimularEventoResponse(usuarios_notificados=0, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=["No hay usuarios registrados en esta ruta."]) + + mensajes_por_evento = { + "en_camino": ("🚛 Camión en camino", "El camión de recolección está en ruta hacia tu zona."), + "llegando": ("⚠️ ¡El camión está cerca!", "Saca tu basura ahora, el camión llega en minutos."), + "completado": ("✅ Recolección completada", "El camión ya pasó por tu zona. Nos vemos mañana."), + "retrasado": ("🕐 Retraso en ruta", "El camión se ha retrasado. Te avisaremos cuando esté cerca."), + } + titulo, cuerpo = mensajes_por_evento[payload.tipo_evento] + + detalle_log = [] + notificados = 0 + for domicilio in domicilios: + usuario = domicilio.usuario + if not usuario: + detalle_log.append(f"Domicilio ID {domicilio.id}: sin usuario asociado.") + continue + if not usuario.fcm_token: + detalle_log.append(f"Usuario '{usuario.nombre}': sin FCM token.") + continue + if enviar_notificacion_firebase(usuario.fcm_token, titulo, cuerpo): + notificados += 1 + detalle_log.append(f"✅ Push a {usuario.nombre} ({domicilio.colonia})") + else: + detalle_log.append(f"❌ Fallo push a {usuario.nombre}") + + return SimularEventoResponse(usuarios_notificados=notificados, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=detalle_log) + + +@app.post("/api/seed", tags=["Utilidades"]) +def seed_datos(db: Session = Depends(get_db)): + if db.query(Usuario).count() > 0: + return {"mensaje": "Ya hay datos en la DB. No se hizo nada."} + usuarios_seed = [ + {"nombre": "Ana García", "email": "ana@example.com", "colonia": "Zona Centro", "direccion": "Calle Principal 123", "password": "123456"}, + {"nombre": "Carlos López", "email": "carlos@example.com", "colonia": "Las Arboledas", "direccion": "Av. Hidalgo 45", "password": "123456"}, + {"nombre": "María Torres", "email": "maria@example.com", "colonia": "San Juanico", "direccion": "Calle Luna 12", "password": "123456"}, + {"nombre": "Pedro Ruiz", "email": "pedro@example.com", "colonia": "Los Olivos", "direccion": "Calle Sol 78", "password": "123456"}, + ] + for u in usuarios_seed: + route_id = COLONIAS_A_RUTAS.get(u["colonia"], "RUTA-01") + usuario = Usuario(nombre=u["nombre"], email=u["email"], password_hash=hash_password(u["password"])) + db.add(usuario) + db.flush() + db.add(Domicilio(usuario_id=usuario.id, colonia=u["colonia"], direccion=u["direccion"], route_id=route_id)) + db.commit() + logger.info("✅ Seed completado: 4 usuarios creados con bcrypt") + return {"mensaje": "Seed exitoso. Passwords: 123456 para todos."} + +# Schema Pydantic para la respuesta +# (agregar junto a los otros schemas en main.py) + +class SubseccionInfo(BaseModel): + subtitulo: str + texto: str + +class ArticuloInfo(BaseModel): + id: str + categoria: str + emoji: str + titulo: str + resumen: str + contenido: List[SubseccionInfo] + consejo_rapido: str + + +# --------------------------------------------------------------- +# ENDPOINTS DE INFORMACIÓN +# (agregar en la sección de endpoints de main.py) +# --------------------------------------------------------------- + +@app.get("/api/info", tags=["Información"]) +def listar_articulos(): + """Lista todos los artículos de información relevante (solo metadatos).""" + return { + "articulos": [ + { + "id": a["id"], + "categoria": a["categoria"], + "emoji": a.get("emoji", "♻️"), + "titulo": a["titulo"], + "resumen": a["resumen"], + } + for a in INFO_ARTICULOS + ] + } + + +@app.get("/api/info/{articulo_id}", tags=["Información"]) +def obtener_articulo(articulo_id: str): + """Devuelve el contenido completo de un artículo por su ID.""" + articulo = next((a for a in INFO_ARTICULOS if a["id"] == articulo_id), None) + if not articulo: + raise HTTPException(status_code=404, detail="Artículo no encontrado.") + return articulo +# --------------------------------------------------------------- +# ================================================================ +# PEGA ESTE BLOQUE EN main.py JUSTO ANTES DE: +# if __name__ == "__main__": +# +# Y agrega al inicio de main.py: +# from analytics import generar_reporte_completo +# import time as _time +# ================================================================ + +# Cache simple en memoria para no recalcular en cada request +_reporte_cache: dict = {"data": None, "ts": 0} +_CACHE_TTL = 600 # 10 minutos + + +@app.get("/api/analytics/reporte", tags=["Analytics"]) +def obtener_reporte(): + """ + Reporte completo: días pico, zonas críticas, predicción 7 días + y recomendaciones de logística. + + Cacheado 10 minutos — pesado de calcular, ligero de servir. + """ + import time as _time + ahora = _time.time() + if _reporte_cache["data"] is None or (ahora - _reporte_cache["ts"]) > _CACHE_TTL: + _reporte_cache["data"] = generar_reporte_completo(dias_historico=90) + _reporte_cache["ts"] = ahora + return _reporte_cache["data"] + + +@app.get("/api/analytics/prediccion/{colonia}", tags=["Analytics"]) +def prediccion_colonia(colonia: str): + """ + Predicción de los próximos 7 días solo para una colonia específica. + Más rápido que el reporte completo para consultas frecuentes. + """ + colonias_validas = [ + "Zona Centro", "Las Arboledas", "Trojes", + "San Juanico", "Los Olivos", "Rancho Seco", "Las Insurgentes", + ] + if colonia not in colonias_validas: + raise HTTPException( + status_code=400, + detail=f"Colonia no válida. Opciones: {colonias_validas}" + ) + + reporte = generar_reporte_completo(90) + pred = next( + (p for p in reporte["prediccion_proxima_semana"] if p["colonia"] == colonia), + None + ) + if not pred: + raise HTTPException(status_code=404, detail="Sin datos para esta colonia.") + return pred + + +@app.get("/api/analytics/resumen-ejecutivo", tags=["Analytics"]) +def resumen_ejecutivo(): + """ + Vista rápida: solo el resumen + recomendaciones. + Ideal para mostrar en el dashboard sin cargar todos los datos. + """ + reporte = generar_reporte_completo(90) + return { + "resumen": reporte["resumen"], + "recomendaciones": reporte["recomendaciones"], + "dia_pico": reporte["dias_semana"][0] if reporte["dias_semana"] else None, + "zona_mas_critica": reporte["zonas_criticas"][0] if reporte["zonas_criticas"] else None, + } + +# ================================================================ +# PEGA ESTE BLOQUE EN main.py JUSTO ANTES DE: +# if __name__ == "__main__": +# +# TAMBIÉN agrega estos imports al inicio de main.py: +# from analytics_real import generar_reporte_real +# ================================================================ + +# --------------------------------------------------------------- +# SCHEMAS PARA REPORTES DE USUARIO +# --------------------------------------------------------------- + +class ReporteUsuarioRequest(BaseModel): + colonia: str + tipo: str # VOLUMEN_ALTO | CAMION_NO_PASO | BASURA_FUERA_HORARIO | OTRO + descripcion: Optional[str] = None + +class ReporteUsuarioResponse(BaseModel): + reporte_id: int + fecha: str + colonia: str + tipo: str + descripcion: Optional[str] + estado: str + mensaje: str + +class ReporteListResponse(BaseModel): + reporte_id: int + fecha: str + hora: str + colonia: str + tipo: str + descripcion: Optional[str] + estado: str + +# --------------------------------------------------------------- +# HELPER: Estimar volumen por posición de ruta +# Cada posición representa un tramo; el volumen aumenta gradualmente. +# --------------------------------------------------------------- +def _estimar_volumen_kg(position_id: int, total_positions: int = 8) -> int: + """Estima los kg recolectados hasta esta posición (lineal).""" + base_por_ruta = 1200 # kg promedio total por ruta + return int(base_por_ruta * (position_id / total_positions)) + + +def _registrar_evento_ruta( + db: Session, + route_id: str, + position_id: int, + evento: str, + colonia: str, +) -> None: + """Inserta un registro de recolección en la DB.""" + ahora = datetime.now(timezone.utc) + ruta = ROUTAS_POR_ID.get(route_id, {}) + total_pos = len(ruta.get("positions", [])) or 8 + estado = ROUTE_STATE.get(route_id, {}) + inicio_ts = estado.get("inicio_timestamp") + tiempo_min = None + if inicio_ts: + tiempo_min = int((ahora - inicio_ts).total_seconds() / 60) + + db.add(RegistroRecoleccion( + fecha=ahora.strftime("%Y-%m-%d"), + hora=ahora.strftime("%H:%M:%S"), + ruta_id=route_id, + colonia=colonia, + position_id=position_id, + evento=evento, + volumen_kg=_estimar_volumen_kg(position_id, total_pos), + tiempo_min=tiempo_min, + )) + db.commit() + + +# --------------------------------------------------------------- +# ENDPOINTS — REPORTES DE USUARIO +# --------------------------------------------------------------- + +@app.post("/api/reportes", response_model=ReporteUsuarioResponse, tags=["Reportes"]) +def crear_reporte( + usuario_id: int, + payload: ReporteUsuarioRequest, + db: Session = Depends(get_db), +): + """ + El ciudadano envía un reporte manual: + - Camión no pasó + - Volumen inusualmente alto + - Basura tirada fuera de horario + - Otro (con descripción libre) + """ + tipos_validos = ["VOLUMEN_ALTO", "CAMION_NO_PASO", "BASURA_FUERA_HORARIO", "OTRO"] + if payload.tipo not in tipos_validos: + raise HTTPException( + status_code=400, + detail=f"Tipo inválido. Opciones: {tipos_validos}" + ) + + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + + route_id = COLONIAS_A_RUTAS.get(payload.colonia, "RUTA-01") + ahora = datetime.now(timezone.utc) + + reporte = ReporteUsuario( + usuario_id=usuario_id, + fecha=ahora.strftime("%Y-%m-%d"), + hora=ahora.strftime("%H:%M:%S"), + colonia=payload.colonia, + ruta_id=route_id, + tipo=payload.tipo, + descripcion=payload.descripcion, + estado="PENDIENTE", + ) + db.add(reporte) + db.commit() + db.refresh(reporte) + + logger.info(f"📋 Reporte #{reporte.id} creado por usuario {usuario_id}: {payload.tipo}") + + return ReporteUsuarioResponse( + reporte_id=reporte.id, + fecha=reporte.fecha, + colonia=reporte.colonia, + tipo=reporte.tipo, + descripcion=reporte.descripcion, + estado=reporte.estado, + mensaje="Reporte enviado correctamente. Gracias por contribuir.", + ) + + +@app.get("/api/reportes/usuario/{usuario_id}", response_model=List[ReporteListResponse], tags=["Reportes"]) +def listar_reportes_usuario(usuario_id: int, db: Session = Depends(get_db)): + """Historial de reportes enviados por un usuario específico.""" + reportes = ( + db.query(ReporteUsuario) + .filter(ReporteUsuario.usuario_id == usuario_id) + .order_by(ReporteUsuario.id.desc()) + .all() + ) + return [ + ReporteListResponse( + reporte_id=r.id, + fecha=r.fecha, + hora=r.hora, + colonia=r.colonia, + tipo=r.tipo, + descripcion=r.descripcion, + estado=r.estado, + ) + for r in reportes + ] + + +@app.get("/api/reportes", tags=["Reportes"]) +def listar_todos_reportes( + estado: Optional[str] = None, + colonia: Optional[str] = None, + db: Session = Depends(get_db), +): + """Vista de operador: todos los reportes, filtrables por estado o colonia.""" + query = db.query(ReporteUsuario) + if estado: + query = query.filter(ReporteUsuario.estado == estado) + if colonia: + query = query.filter(ReporteUsuario.colonia == colonia) + reportes = query.order_by(ReporteUsuario.id.desc()).limit(100).all() + return { + "total": len(reportes), + "reportes": [ + { + "reporte_id": r.id, + "usuario_id": r.usuario_id, + "fecha": r.fecha, + "hora": r.hora, + "colonia": r.colonia, + "ruta_id": r.ruta_id, + "tipo": r.tipo, + "descripcion": r.descripcion, + "estado": r.estado, + } + for r in reportes + ], + } + + +@app.put("/api/reportes/{reporte_id}/atender", tags=["Reportes"]) +def atender_reporte(reporte_id: int, db: Session = Depends(get_db)): + """Marca un reporte como atendido (uso del operador).""" + reporte = db.query(ReporteUsuario).filter(ReporteUsuario.id == reporte_id).first() + if not reporte: + raise HTTPException(status_code=404, detail="Reporte no encontrado.") + reporte.estado = "ATENDIDO" + db.commit() + return {"mensaje": f"Reporte #{reporte_id} marcado como atendido."} + + +# --------------------------------------------------------------- +# ENDPOINT: Analytics con datos reales de la DB +# --------------------------------------------------------------- + +@app.get("/api/analytics/reporte-real", tags=["Analytics"]) +def reporte_real(db: Session = Depends(get_db)): + return generar_reporte_real(db) + + +@app.get("/api/analytics/registros", tags=["Analytics"]) +def listar_registros( + ruta_id: Optional[str] = None, + colonia: Optional[str] = None, + fecha_inicio: Optional[str] = None, + db: Session = Depends(get_db), +): + """Lista los registros de recolección reales guardados en la DB.""" + query = db.query(RegistroRecoleccion) + if ruta_id: + query = query.filter(RegistroRecoleccion.ruta_id == ruta_id) + if colonia: + query = query.filter(RegistroRecoleccion.colonia == colonia) + if fecha_inicio: + query = query.filter(RegistroRecoleccion.fecha >= fecha_inicio) + registros = query.order_by(RegistroRecoleccion.id.desc()).limit(500).all() + return { + "total": len(registros), + "registros": [ + { + "id": r.id, + "fecha": r.fecha, + "hora": r.hora, + "ruta_id": r.ruta_id, + "colonia": r.colonia, + "position_id": r.position_id, + "evento": r.evento, + "volumen_kg": r.volumen_kg, + "tiempo_min": r.tiempo_min, + } + for r in registros + ], + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/HackOnLinces_app/hackathon.db b/HackOnLinces_app/hackathon.db new file mode 100644 index 0000000..9df5177 Binary files /dev/null and b/HackOnLinces_app/hackathon.db differ