diff --git a/.gitignore b/.gitignore
index f4c3b0c..28bede4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
# ---> Flutter
# Miscellaneous
*.class
@@ -153,3 +154,50 @@ google-services.json
# Android Profiling
*.hprof
+=======
+# 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
+>>>>>>> 8e51b9c (initial commit 2)
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..768732a
--- /dev/null
+++ b/.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/README.md b/README.md
index d7fa947..fa7fa69 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,23 @@
+<<<<<<< HEAD
# ProxyTrash
-Una aplicacion para saber si el camion mas sercano es Organico o inorganico junto con su distancia y otras funciones mas.
\ No newline at end of file
+Una aplicacion para saber si el camion mas sercano es Organico o inorganico junto con su distancia y otras funciones mas.
+=======
+# flutter_application_1
+
+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.
+>>>>>>> 8e51b9c (initial commit 2)
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/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/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/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/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..b686dc7
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.example.flutter_application_1"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.flutter_application_1"
+ // 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 = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f2daf87
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt
new file mode 100644
index 0000000..aa48e52
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.flutter_application_1
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+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)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/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/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2d428bf
--- /dev/null
+++ b/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/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..c21f0c5
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.0.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.3.20" apply false
+}
+
+include(":app")
diff --git a/backend/.env b/backend/.env
new file mode 100644
index 0000000..3b3d333
--- /dev/null
+++ b/backend/.env
@@ -0,0 +1,4 @@
+DATABASE_URL=postgresql+psycopg://postgres:password@127.0.0.1:5432/hackaton
+SECRET_KEY=change-this-in-production
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+CORS_ORIGINS=http://localhost:3000,http://10.0.2.2:3000
diff --git a/backend/.env.copy-paste.example b/backend/.env.copy-paste.example
new file mode 100644
index 0000000..d4e056e
--- /dev/null
+++ b/backend/.env.copy-paste.example
@@ -0,0 +1,5 @@
+# Copia este contenido a backend/.env y reemplaza los valores entre < >
+DATABASE_URL=postgresql+psycopg://:@:5432/
+SECRET_KEY=
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+CORS_ORIGINS=http://localhost:3000,http://10.0.2.2:3000,http://:8000
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..f07443e
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,4 @@
+DATABASE_URL=postgresql+psycopg://postgres:TU_PASSWORD@10.77.234.29:5432/hackaton
+SECRET_KEY=pon-una-clave-larga-y-segura-aqui
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+CORS_ORIGINS=http://localhost:3000,http://10.0.2.2:3000,http://10.77.234.6:8000
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..975d09b
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,128 @@
+# Backend Python para Flutter
+
+Backend en Python con FastAPI para autenticar usuarios y guardar direcciones en PostgreSQL. No usa HTTPS; expone HTTP en el puerto 8000 por defecto.
+
+## Qué debes cambiar
+
+### 1. Configuración del backend
+Crea el archivo `backend/.env` copiando `backend/.env.example` y reemplaza estos valores:
+
+```env
+DATABASE_URL=postgresql+psycopg://postgres:TU_PASSWORD@10.77.234.29:5432/hackaton
+SECRET_KEY=pon-una-clave-larga-y-segura-aqui
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+CORS_ORIGINS=http://localhost:3000,http://10.0.2.2:3000,http://10.77.234.6:8000
+```
+
+### 2. Configuración de Flutter
+En Flutter, la URL base del backend está en [lib/app_config.dart](../lib/app_config.dart). Debe apuntar al backend HTTP, por ejemplo:
+
+```dart
+static const String apiBaseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://10.77.234.6:8000',
+);
+```
+
+No pongas la cadena `postgresql://...` en Flutter. Esa cadena solo va en el backend.
+
+## Variables que usa el backend
+
+- `DATABASE_URL`: conexión a PostgreSQL.
+- `SECRET_KEY`: clave para firmar el token JWT.
+- `ACCESS_TOKEN_EXPIRE_MINUTES`: tiempo de vida del token.
+- `CORS_ORIGINS`: orígenes permitidos para la app Flutter o pruebas web.
+
+## Estructura de PostgreSQL
+
+El backend crea las tablas automáticamente al arrancar con `Base.metadata.create_all(...)`, pero si quieres crearlas manualmente, este es el esquema esperado.
+
+### Tabla `users`
+
+```sql
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ last_login_at TIMESTAMPTZ NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+```
+
+### Tabla `addresses`
+
+```sql
+CREATE TABLE IF NOT EXISTS addresses (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ house_number VARCHAR(50) NOT NULL,
+ colonia VARCHAR(120) NOT NULL,
+ street VARCHAR(160) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+```
+
+## Datos que se envían desde Flutter
+
+### Registro
+Se envía al endpoint `POST /auth/register`:
+
+```json
+{
+ "name": "Juan Perez",
+ "email": "juan@mail.com",
+ "password": "123456"
+}
+```
+
+### Inicio de sesión
+Se envía al endpoint `POST /auth/login`:
+
+```json
+{
+ "email": "juan@mail.com",
+ "password": "123456"
+}
+```
+
+### Dirección
+Se envía al endpoint `POST /addresses` con token Bearer:
+
+```json
+{
+ "email": "juan@mail.com",
+ "houseNumber": "12A",
+ "colonia": "Centro",
+ "street": "Reforma"
+}
+```
+
+## Endpoints disponibles
+
+- `POST /auth/register`
+- `POST /auth/login`
+- `GET /me`
+- `POST /addresses`
+- `GET /health`
+
+## Instalar y ejecutar
+
+```bash
+cd backend
+python -m venv .venv
+.venv\Scripts\activate
+pip install -r requirements.txt
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+## Cómo funciona la conexión
+
+1. Flutter llama a `http://IP_DEL_BACKEND:8000`.
+2. El backend recibe login/registro y crea el token JWT.
+3. El backend conecta a PostgreSQL usando `DATABASE_URL`.
+4. Al guardar la dirección, el backend asocia la dirección al usuario autenticado.
+
+## Nota importante
+
+Si pruebas desde un celular físico, `10.0.2.2` no sirve para la API. Debes usar la IP real de la computadora donde corre el backend.
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/auth.py b/backend/app/auth.py
new file mode 100644
index 0000000..be9e0ea
--- /dev/null
+++ b/backend/app/auth.py
@@ -0,0 +1,22 @@
+from datetime import datetime, timedelta, timezone
+
+from jose import jwt
+from passlib.context import CryptContext
+
+from .core.config import settings
+
+pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ return pwd_context.verify(plain_password, hashed_password)
+
+
+def create_access_token(*, user_id: int, email: str) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
+ payload = {'sub': str(user_id), 'email': email, 'exp': expire}
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..7129c50
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,25 @@
+from functools import lru_cache
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
+
+ database_url: str = 'postgresql+psycopg://postgres:password@127.0.0.1:5432/hackaton'
+ secret_key: str = 'change-this-in-production'
+ algorithm: str = 'HS256'
+ access_token_expire_minutes: int = 60 * 24 * 7
+ cors_origins: str = 'http://localhost:3000,http://10.0.2.2:3000'
+
+ @property
+ def cors_origin_list(self) -> list[str]:
+ return [origin.strip() for origin in self.cors_origins.split(',') if origin.strip()]
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/app/crud.py b/backend/app/crud.py
new file mode 100644
index 0000000..c4dc5a6
--- /dev/null
+++ b/backend/app/crud.py
@@ -0,0 +1,56 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .auth import hash_password, verify_password
+from .models import Address, User
+from .schemas import AddressCreate, UserCreate
+
+
+def get_user_by_id(db: Session, user_id: int) -> User | None:
+ return db.get(User, user_id)
+
+
+def get_user_by_email(db: Session, email: str) -> User | None:
+ statement = select(User).where(User.email == email)
+ return db.scalar(statement)
+
+
+def create_user(db: Session, user_in: UserCreate) -> User:
+ user = User(
+ name=user_in.name,
+ email=user_in.email,
+ password_hash=hash_password(user_in.password),
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return user
+
+
+def authenticate_user(db: Session, email: str, password: str) -> User | None:
+ user = get_user_by_email(db, email)
+ if user is None:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+
+ user.last_login_at = datetime.now(timezone.utc)
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return user
+
+
+def create_address(db: Session, user: User, address_in: AddressCreate) -> Address:
+ address = Address(
+ user_id=user.id,
+ house_number=address_in.house_number,
+ colonia=address_in.colonia,
+ street=address_in.street,
+ )
+ db.add(address)
+ db.commit()
+ db.refresh(address)
+ return address
diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/db/session.py b/backend/app/db/session.py
new file mode 100644
index 0000000..9b5137c
--- /dev/null
+++ b/backend/app/db/session.py
@@ -0,0 +1,22 @@
+from collections.abc import Generator
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+from ..core.config import settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+engine = create_engine(settings.database_url, pool_pre_ping=True)
+SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
+
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py
new file mode 100644
index 0000000..be590fd
--- /dev/null
+++ b/backend/app/dependencies.py
@@ -0,0 +1,31 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from sqlalchemy.orm import Session
+
+from .core.config import settings
+from .crud import get_user_by_id
+from .db.session import get_db
+from .models import User
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/auth/login')
+
+
+def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
+ credentials_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail='Token inválido o expirado',
+ headers={'WWW-Authenticate': 'Bearer'},
+ )
+ try:
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
+ user_id = payload.get('sub')
+ if user_id is None:
+ raise credentials_exception
+ except JWTError as exc:
+ raise credentials_exception from exc
+
+ user = get_user_by_id(db, int(user_id))
+ if user is None:
+ raise credentials_exception
+ return user
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..0446077
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,79 @@
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.middleware.cors import CORSMiddleware
+from sqlalchemy.orm import Session
+
+from .auth import create_access_token
+from .core.config import settings
+from .crud import authenticate_user, create_address, create_user, get_user_by_email
+from .db.session import Base, engine, get_db
+from .dependencies import get_current_user
+from .models import Address, User
+from .schemas import AddressCreate, AddressRead, TokenResponse, UserCreate, UserLogin, UserRead
+
+app = FastAPI(title='Flutter Auth API', version='1.0.0')
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origin_list,
+ allow_credentials=True,
+ allow_methods=['*'],
+ allow_headers=['*'],
+)
+
+
+@app.on_event('startup')
+def on_startup() -> None:
+ Base.metadata.create_all(bind=engine)
+
+
+@app.get('/health')
+def health_check() -> dict[str, str]:
+ return {'status': 'ok'}
+
+
+@app.post('/auth/register', response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
+def register(payload: UserCreate, db: Session = Depends(get_db)) -> TokenResponse:
+ existing_user = get_user_by_email(db, payload.email)
+ if existing_user is not None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Ya existe un usuario con ese correo')
+
+ user = create_user(db, payload)
+ token = create_access_token(user_id=user.id, email=user.email)
+ return TokenResponse(token=token, user=UserRead.model_validate(user))
+
+
+@app.post('/auth/login', response_model=TokenResponse)
+def login(payload: UserLogin, db: Session = Depends(get_db)) -> TokenResponse:
+ user = authenticate_user(db, payload.email, payload.password)
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Correo o contraseña inválidos')
+
+ token = create_access_token(user_id=user.id, email=user.email)
+ return TokenResponse(token=token, user=UserRead.model_validate(user))
+
+
+@app.get('/me', response_model=UserRead)
+def read_me(current_user: User = Depends(get_current_user)) -> UserRead:
+ return UserRead.model_validate(current_user)
+
+
+@app.post('/addresses', response_model=AddressRead, status_code=status.HTTP_201_CREATED)
+def save_address(
+ payload: AddressCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> AddressRead:
+ if payload.email is not None and payload.email != current_user.email:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='El correo no coincide con el usuario autenticado')
+
+ address = create_address(db, current_user, payload)
+ return AddressRead.model_validate(address)
+
+
+@app.get('/addresses/me', response_model=list[AddressRead])
+def read_my_addresses(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> list[AddressRead]:
+ addresses = sorted(current_user.addresses, key=lambda address: address.created_at, reverse=True)
+ return [AddressRead.model_validate(address) for address in addresses]
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..b0774dd
--- /dev/null
+++ b/backend/app/models.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from .db.session import Base
+
+
+class User(Base):
+ __tablename__ = 'users'
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ name: Mapped[str] = mapped_column(String(120), nullable=False)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ addresses: Mapped[list['Address']] = relationship(back_populates='user', cascade='all, delete-orphan')
+
+
+class Address(Base):
+ __tablename__ = 'addresses'
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey('users.id', ondelete='CASCADE'), index=True, nullable=False)
+ house_number: Mapped[str] = mapped_column(String(50), nullable=False)
+ colonia: Mapped[str] = mapped_column(String(120), nullable=False)
+ street: Mapped[str] = mapped_column(String(160), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ user: Mapped['User'] = relationship(back_populates='addresses')
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..f77e257
--- /dev/null
+++ b/backend/app/schemas.py
@@ -0,0 +1,50 @@
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, EmailStr, Field
+
+
+class UserBase(BaseModel):
+ name: str
+ email: EmailStr
+
+
+class UserCreate(UserBase):
+ password: str
+
+
+class UserLogin(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class UserRead(UserBase):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ last_login_at: datetime | None = None
+ created_at: datetime
+
+
+class TokenResponse(BaseModel):
+ token: str
+ user: UserRead
+
+
+class AddressCreate(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ house_number: str = Field(alias='houseNumber')
+ colonia: str
+ street: str
+ email: EmailStr | None = None
+
+
+class AddressRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ house_number: str
+ colonia: str
+ street: str
+ user_id: int
+ created_at: datetime
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..9842d1e
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,9 @@
+fastapi==0.115.0
+uvicorn[standard]==0.32.0
+SQLAlchemy==2.0.36
+psycopg[binary]==3.2.3
+pydantic-settings==2.6.0
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.12
+email-validator==2.2.0
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/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/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/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/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8988472
--- /dev/null
+++ b/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.flutterApplication1;
+ 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.flutterApplication1.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.flutterApplication1.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.flutterApplication1.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.flutterApplication1;
+ 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.flutterApplication1;
+ 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/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..c3fedb2
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..c30b367
--- /dev/null
+++ b/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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..7353c41
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..6ed2d93
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cd7b00
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..fe73094
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..321773c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..502f463
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e9f5fea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..8953cba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/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/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/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/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..5323135
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,70 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Flutter Application 1
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ flutter_application_1
+ 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/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/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/lib/app.dart b/lib/app.dart
new file mode 100644
index 0000000..0f7cbab
--- /dev/null
+++ b/lib/app.dart
@@ -0,0 +1,106 @@
+import 'package:flutter/material.dart';
+
+import 'models/auth_session.dart';
+import 'screens/auth_screen.dart';
+import 'screens/dashboard_screen.dart';
+import 'services/address_repository.dart';
+import 'services/auth_repository.dart';
+
+class MyApp extends StatelessWidget {
+ const MyApp({
+ super.key,
+ AuthRepository? authRepository,
+ AddressRepository? addressRepository,
+ this.enableLiveFeatures = true,
+ }) : _authRepository = authRepository ?? const HttpAuthRepository(),
+ _addressRepository = addressRepository ?? const HttpAddressRepository();
+
+ final AuthRepository _authRepository;
+ final AddressRepository _addressRepository;
+ final bool enableLiveFeatures;
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ debugShowCheckedModeBanner: false,
+ title: 'Acceso',
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0F766E)),
+ useMaterial3: true,
+ ),
+ home: AuthBootstrap(
+ authRepository: _authRepository,
+ addressRepository: _addressRepository,
+ enableLiveFeatures: enableLiveFeatures,
+ ),
+ );
+ }
+}
+
+class AuthBootstrap extends StatefulWidget {
+ const AuthBootstrap({
+ super.key,
+ required this.authRepository,
+ required this.addressRepository,
+ this.enableLiveFeatures = true,
+ });
+
+ final AuthRepository authRepository;
+ final AddressRepository addressRepository;
+ final bool enableLiveFeatures;
+
+ @override
+ State createState() => _AuthBootstrapState();
+}
+
+class _AuthBootstrapState extends State {
+ late final Future _sessionFuture;
+
+ @override
+ void initState() {
+ super.initState();
+ _sessionFuture = widget.authRepository.restoreSession();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return FutureBuilder(
+ future: _sessionFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState != ConnectionState.done) {
+ return const _LoadingView();
+ }
+
+ final session = snapshot.data;
+ if (session != null) {
+ return DashboardScreen(
+ authRepository: widget.authRepository,
+ addressRepository: widget.addressRepository,
+ session: session,
+ savedAddress: null,
+ enableLiveFeatures: widget.enableLiveFeatures,
+ );
+ }
+
+ return AuthScreen(
+ authRepository: widget.authRepository,
+ addressRepository: widget.addressRepository,
+ enableLiveFeatures: widget.enableLiveFeatures,
+ );
+ },
+ );
+ }
+}
+
+class _LoadingView extends StatelessWidget {
+ const _LoadingView();
+
+ @override
+ Widget build(BuildContext context) {
+ return const Scaffold(
+ body: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+}
diff --git a/lib/app_config.dart b/lib/app_config.dart
new file mode 100644
index 0000000..04c03d1
--- /dev/null
+++ b/lib/app_config.dart
@@ -0,0 +1,6 @@
+class AppConfig {
+ static const String apiBaseUrl = String.fromEnvironment(
+ 'API_BASE_URL',
+ defaultValue: 'http://10.77.234.29:3000',
+ );
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..7773b62
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,9 @@
+export 'app.dart';
+
+import 'package:flutter/material.dart';
+
+import 'app.dart';
+
+void main() {
+ runApp(const MyApp());
+}
diff --git a/lib/models/address_entry.dart b/lib/models/address_entry.dart
new file mode 100644
index 0000000..2c8ef2b
--- /dev/null
+++ b/lib/models/address_entry.dart
@@ -0,0 +1,19 @@
+class AddressEntry {
+ const AddressEntry({
+ required this.houseNumber,
+ required this.colonia,
+ required this.street,
+ });
+
+ final String houseNumber;
+ final String colonia;
+ final String street;
+
+ Map toJson() {
+ return {
+ 'houseNumber': houseNumber,
+ 'colonia': colonia,
+ 'street': street,
+ };
+ }
+}
\ No newline at end of file
diff --git a/lib/models/address_record.dart b/lib/models/address_record.dart
new file mode 100644
index 0000000..abf1518
--- /dev/null
+++ b/lib/models/address_record.dart
@@ -0,0 +1,22 @@
+class AddressRecord {
+ const AddressRecord({
+ required this.id,
+ required this.houseNumber,
+ required this.colonia,
+ required this.street,
+ });
+
+ final int id;
+ final String houseNumber;
+ final String colonia;
+ final String street;
+
+ factory AddressRecord.fromJson(Map json) {
+ return AddressRecord(
+ id: (json['id'] as num).toInt(),
+ houseNumber: json['house_number']?.toString() ?? json['houseNumber']?.toString() ?? '',
+ colonia: json['colonia']?.toString() ?? '',
+ street: json['street']?.toString() ?? '',
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/models/auth_session.dart b/lib/models/auth_session.dart
new file mode 100644
index 0000000..462ed3f
--- /dev/null
+++ b/lib/models/auth_session.dart
@@ -0,0 +1,11 @@
+class AuthSession {
+ const AuthSession({
+ required this.token,
+ required this.email,
+ required this.displayName,
+ });
+
+ final String token;
+ final String email;
+ final String displayName;
+}
\ No newline at end of file
diff --git a/lib/screens/address_screen.dart b/lib/screens/address_screen.dart
new file mode 100644
index 0000000..deedaa2
--- /dev/null
+++ b/lib/screens/address_screen.dart
@@ -0,0 +1,255 @@
+import 'package:flutter/material.dart';
+
+import '../app_config.dart';
+import '../models/address_entry.dart';
+import '../models/auth_session.dart';
+import '../services/auth_repository.dart';
+import '../services/address_repository.dart';
+import 'dashboard_screen.dart';
+
+class AddressScreen extends StatefulWidget {
+ const AddressScreen({
+ super.key,
+ required this.authRepository,
+ required this.addressRepository,
+ required this.session,
+ this.enableLiveFeatures = true,
+ });
+
+ final AuthRepository authRepository;
+ final AddressRepository addressRepository;
+ final AuthSession session;
+ final bool enableLiveFeatures;
+
+ @override
+ State createState() => _AddressScreenState();
+}
+
+class _AddressScreenState extends State {
+ final GlobalKey _formKey = GlobalKey();
+ final TextEditingController _houseNumberController = TextEditingController();
+ final TextEditingController _coloniaController = TextEditingController();
+ final TextEditingController _streetController = TextEditingController();
+
+ bool _isSaving = false;
+ String? _errorMessage;
+
+ @override
+ void dispose() {
+ _houseNumberController.dispose();
+ _coloniaController.dispose();
+ _streetController.dispose();
+ super.dispose();
+ }
+
+ Future _saveAddress() async {
+ if (!(_formKey.currentState?.validate() ?? false)) {
+ return;
+ }
+
+ setState(() {
+ _isSaving = true;
+ _errorMessage = null;
+ });
+
+ try {
+ await widget.addressRepository.saveAddress(
+ session: widget.session,
+ address: AddressEntry(
+ houseNumber: _houseNumberController.text.trim(),
+ colonia: _coloniaController.text.trim(),
+ street: _streetController.text.trim(),
+ ),
+ );
+
+ if (!mounted) {
+ return;
+ }
+
+ Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(
+ builder: (_) => DashboardScreen(
+ authRepository: widget.authRepository,
+ addressRepository: widget.addressRepository,
+ session: widget.session,
+ savedAddress: AddressEntry(
+ houseNumber: _houseNumberController.text.trim(),
+ colonia: _coloniaController.text.trim(),
+ street: _streetController.text.trim(),
+ ),
+ enableLiveFeatures: widget.enableLiveFeatures,
+ ),
+ ),
+ (route) => false,
+ );
+ } on AddressException catch (error) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _errorMessage = error.message;
+ });
+ } catch (_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _errorMessage = 'No se pudo guardar la dirección. Revisa el backend.';
+ });
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isSaving = false;
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Container(
+ decoration: const BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Color(0xFFF8FAFC), Color(0xFFE2E8F0), Color(0xFFCCFBF1)],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ child: SafeArea(
+ child: Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(20),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 520),
+ child: Card(
+ elevation: 12,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Text(
+ 'Dirección',
+ style: TextStyle(fontSize: 30, fontWeight: FontWeight.w800),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Ingresa la dirección de tu casa y se enviará al backend para guardarla en PostgreSQL.',
+ style: TextStyle(color: Colors.grey.shade700, height: 1.4),
+ ),
+ const SizedBox(height: 20),
+ if (_errorMessage != null) ...[
+ _AddressStatusBanner(message: _errorMessage!),
+ const SizedBox(height: 16),
+ ],
+ Form(
+ key: _formKey,
+ child: Column(
+ children: [
+ TextFormField(
+ controller: _houseNumberController,
+ keyboardType: TextInputType.text,
+ decoration: const InputDecoration(
+ labelText: 'Número de casa',
+ prefixIcon: Icon(Icons.home_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa el número de casa';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _coloniaController,
+ decoration: const InputDecoration(
+ labelText: 'Colonia',
+ prefixIcon: Icon(Icons.location_city_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa la colonia';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _streetController,
+ decoration: const InputDecoration(
+ labelText: 'Calle',
+ prefixIcon: Icon(Icons.signpost_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa la calle';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 20),
+ SizedBox(
+ width: double.infinity,
+ height: 52,
+ child: FilledButton(
+ onPressed: _isSaving ? null : _saveAddress,
+ child: _isSaving
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
+ )
+ : const Text('Guardar dirección'),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'Base URL configurada: ${AppConfig.apiBaseUrl}',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _AddressStatusBanner extends StatelessWidget {
+ const _AddressStatusBanner({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFEE2E2),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFFCA5A5)),
+ ),
+ child: Text(
+ message,
+ style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart
new file mode 100644
index 0000000..487045d
--- /dev/null
+++ b/lib/screens/auth_screen.dart
@@ -0,0 +1,461 @@
+import 'package:flutter/material.dart';
+
+import '../app_config.dart';
+import '../models/auth_session.dart';
+import '../services/address_repository.dart';
+import '../services/auth_repository.dart';
+import 'address_screen.dart';
+
+class AuthScreen extends StatefulWidget {
+ const AuthScreen({
+ super.key,
+ required this.authRepository,
+ required this.addressRepository,
+ this.enableLiveFeatures = true,
+ });
+
+ final AuthRepository authRepository;
+ final AddressRepository addressRepository;
+ final bool enableLiveFeatures;
+
+ @override
+ State createState() => _AuthScreenState();
+}
+
+class _AuthScreenState extends State {
+ final GlobalKey _loginFormKey = GlobalKey();
+ final GlobalKey _registerFormKey = GlobalKey();
+ final TextEditingController _loginEmailController = TextEditingController();
+ final TextEditingController _loginPasswordController = TextEditingController();
+ final TextEditingController _registerNameController = TextEditingController();
+ final TextEditingController _registerEmailController = TextEditingController();
+ final TextEditingController _registerPasswordController = TextEditingController();
+ final TextEditingController _registerConfirmPasswordController = TextEditingController();
+
+ bool _isLoading = false;
+ String? _errorMessage;
+
+ @override
+ void dispose() {
+ _loginEmailController.dispose();
+ _loginPasswordController.dispose();
+ _registerNameController.dispose();
+ _registerEmailController.dispose();
+ _registerPasswordController.dispose();
+ _registerConfirmPasswordController.dispose();
+ super.dispose();
+ }
+
+ Future _signIn() async {
+ if (!(_loginFormKey.currentState?.validate() ?? false)) {
+ return;
+ }
+
+ await _submit(() {
+ return widget.authRepository.signIn(
+ email: _loginEmailController.text.trim(),
+ password: _loginPasswordController.text,
+ );
+ });
+ }
+
+ Future _signUp() async {
+ if (!(_registerFormKey.currentState?.validate() ?? false)) {
+ return;
+ }
+
+ await _submit(() {
+ return widget.authRepository.signUp(
+ name: _registerNameController.text.trim(),
+ email: _registerEmailController.text.trim(),
+ password: _registerPasswordController.text,
+ );
+ });
+ }
+
+ Future _submit(Future Function() action) async {
+ setState(() {
+ _isLoading = true;
+ _errorMessage = null;
+ });
+
+ try {
+ final session = await action();
+ if (!mounted) {
+ return;
+ }
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(
+ builder: (_) => AddressScreen(
+ authRepository: widget.authRepository,
+ addressRepository: widget.addressRepository,
+ session: session,
+ enableLiveFeatures: widget.enableLiveFeatures,
+ ),
+ ),
+ );
+ } on AuthException catch (error) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _errorMessage = error.message;
+ });
+ } catch (_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _errorMessage = 'No se pudo completar la operación. Verifica el backend y vuelve a intentar.';
+ });
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Container(
+ decoration: const BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Color(0xFF06141B), Color(0xFF0F766E), Color(0xFFE2E8F0)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ child: SafeArea(
+ child: Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(20),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 440),
+ child: Card(
+ elevation: 18,
+ color: Colors.white.withValues(alpha: 0.94),
+ shadowColor: Colors.black26,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: DefaultTabController(
+ length: 2,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 72,
+ height: 72,
+ decoration: BoxDecoration(
+ color: const Color(0xFF0F766E).withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: const Icon(Icons.lock_outline, size: 36, color: Color(0xFF0F766E)),
+ ),
+ const SizedBox(height: 20),
+ const Text(
+ 'Bienvenido',
+ style: TextStyle(fontSize: 30, fontWeight: FontWeight.w800),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Inicia sesión o crea una cuenta para continuar. Luego irás a la pantalla Dirección.',
+ style: TextStyle(color: Colors.grey.shade700, height: 1.4),
+ ),
+ const SizedBox(height: 20),
+ Container(
+ decoration: BoxDecoration(
+ color: const Color(0xFFF1F5F9),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: TabBar(
+ onTap: (_) {
+ setState(() {
+ _errorMessage = null;
+ });
+ },
+ indicatorSize: TabBarIndicatorSize.tab,
+ dividerColor: Colors.transparent,
+ indicator: BoxDecoration(
+ color: const Color(0xFF0F766E),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ labelColor: Colors.white,
+ unselectedLabelColor: Colors.grey.shade700,
+ tabs: const [
+ Tab(text: 'Entrar'),
+ Tab(text: 'Crear cuenta'),
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+ if (_errorMessage != null) ...[
+ _AuthStatusBanner(message: _errorMessage!),
+ const SizedBox(height: 16),
+ ],
+ SizedBox(
+ height: 200,
+ child: TabBarView(
+ children: [
+ _LoginForm(
+ formKey: _loginFormKey,
+ emailController: _loginEmailController,
+ passwordController: _loginPasswordController,
+ onSubmit: _signIn,
+ isLoading: _isLoading,
+ ),
+ _RegisterForm(
+ formKey: _registerFormKey,
+ nameController: _registerNameController,
+ emailController: _registerEmailController,
+ passwordController: _registerPasswordController,
+ confirmPasswordController: _registerConfirmPasswordController,
+ onSubmit: _signUp,
+ isLoading: _isLoading,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'Base URL configurada: ${AppConfig.apiBaseUrl}',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _LoginForm extends StatelessWidget {
+ const _LoginForm({
+ required this.formKey,
+ required this.emailController,
+ required this.passwordController,
+ required this.onSubmit,
+ required this.isLoading,
+ });
+
+ final GlobalKey formKey;
+ final TextEditingController emailController;
+ final TextEditingController passwordController;
+ final Future Function() onSubmit;
+ final bool isLoading;
+
+ @override
+ Widget build(BuildContext context) {
+ return Form(
+ key: formKey,
+ child: Column(
+ children: [
+ TextFormField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: 'Correo electrónico',
+ prefixIcon: Icon(Icons.email_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa tu correo';
+ }
+ if (!value.contains('@')) {
+ return 'Ingresa un correo válido';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: passwordController,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Contraseña',
+ prefixIcon: Icon(Icons.lock_outline),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Ingresa tu contraseña';
+ }
+ if (value.length < 6) {
+ return 'Usa al menos 6 caracteres';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 20),
+ SizedBox(
+ width: double.infinity,
+ height: 52,
+ child: FilledButton(
+ onPressed: isLoading ? null : onSubmit,
+ child: isLoading
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
+ )
+ : const Text('Ingresar'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _RegisterForm extends StatelessWidget {
+ const _RegisterForm({
+ required this.formKey,
+ required this.nameController,
+ required this.emailController,
+ required this.passwordController,
+ required this.confirmPasswordController,
+ required this.onSubmit,
+ required this.isLoading,
+ });
+
+ final GlobalKey formKey;
+ final TextEditingController nameController;
+ final TextEditingController emailController;
+ final TextEditingController passwordController;
+ final TextEditingController confirmPasswordController;
+ final Future Function() onSubmit;
+ final bool isLoading;
+
+ @override
+ Widget build(BuildContext context) {
+ return Form(
+ key: formKey,
+ child: ListView(
+ children: [
+ TextFormField(
+ controller: nameController,
+ textCapitalization: TextCapitalization.words,
+ decoration: const InputDecoration(
+ labelText: 'Nombre',
+ prefixIcon: Icon(Icons.person_outline),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa tu nombre';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: emailController,
+ keyboardType: TextInputType.emailAddress,
+ decoration: const InputDecoration(
+ labelText: 'Correo electrónico',
+ prefixIcon: Icon(Icons.email_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Ingresa tu correo';
+ }
+ if (!value.contains('@')) {
+ return 'Ingresa un correo válido';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: passwordController,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Contraseña',
+ prefixIcon: Icon(Icons.lock_outline),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Ingresa una contraseña';
+ }
+ if (value.length < 6) {
+ return 'Usa al menos 6 caracteres';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: confirmPasswordController,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Confirmar contraseña',
+ prefixIcon: Icon(Icons.lock_reset_outlined),
+ border: OutlineInputBorder(),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Confirma tu contraseña';
+ }
+ if (value != passwordController.text) {
+ return 'Las contraseñas no coinciden';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 20),
+ SizedBox(
+ width: double.infinity,
+ height: 52,
+ child: FilledButton(
+ onPressed: isLoading ? null : onSubmit,
+ child: isLoading
+ ? const SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white),
+ )
+ : const Text('Registrarme'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _AuthStatusBanner extends StatelessWidget {
+ const _AuthStatusBanner({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFEE2E2),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFFCA5A5)),
+ ),
+ child: Text(
+ message,
+ style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart
new file mode 100644
index 0000000..6d74dff
--- /dev/null
+++ b/lib/screens/dashboard_screen.dart
@@ -0,0 +1,943 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:table_calendar/table_calendar.dart';
+
+import '../models/address_entry.dart';
+import '../models/address_record.dart';
+import '../models/auth_session.dart';
+import '../services/address_repository.dart';
+import '../services/auth_repository.dart';
+import 'auth_screen.dart';
+
+class DashboardScreen extends StatefulWidget {
+ const DashboardScreen({
+ super.key,
+ required this.authRepository,
+ required this.addressRepository,
+ required this.session,
+ this.savedAddress,
+ this.enableLiveFeatures = true,
+ });
+
+ final AuthRepository authRepository;
+ final AddressRepository addressRepository;
+ final AuthSession session;
+ final AddressEntry? savedAddress;
+ final bool enableLiveFeatures;
+
+ @override
+ State createState() => _DashboardScreenState();
+}
+
+class _DashboardScreenState extends State {
+ final MapController _mapController = MapController();
+ final TextEditingController _calendarNoteController = TextEditingController();
+ final TextEditingController _newHouseNumberController = TextEditingController();
+ final TextEditingController _newColoniaController = TextEditingController();
+ final TextEditingController _newStreetController = TextEditingController();
+
+ final List<_AppNotification> _notifications = <_AppNotification>[];
+ final Map _calendarNotes = {};
+ final Random _random = Random();
+
+ int _selectedIndex = 0;
+ LatLng _center = const LatLng(19.4326, -99.1332);
+ bool _isLoadingLocation = true;
+ bool _showLiveFeatures = true;
+ String? _locationError;
+ Timer? _truckTimer;
+ LatLng? _truckPosition;
+ bool _truckVisible = false;
+
+ bool _loadingAddresses = true;
+ String? _addressesError;
+ List _addresses = [];
+
+ DateTime _focusedDay = DateTime.now();
+ DateTime? _selectedDay;
+ CalendarFormat _calendarFormat = CalendarFormat.month;
+
+ @override
+ void initState() {
+ super.initState();
+ _showLiveFeatures = widget.enableLiveFeatures;
+ if (!_showLiveFeatures) {
+ _isLoadingLocation = false;
+ _loadingAddresses = false;
+ _seedTruckSimulation();
+ return;
+ }
+
+ unawaited(_loadLocation());
+ unawaited(_loadAddresses());
+ _startTruckSimulation();
+ }
+
+ @override
+ void dispose() {
+ _truckTimer?.cancel();
+ _calendarNoteController.dispose();
+ _newHouseNumberController.dispose();
+ _newColoniaController.dispose();
+ _newStreetController.dispose();
+ super.dispose();
+ }
+
+ DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day);
+
+ void _addNotification(String message) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _notifications.insert(0, _AppNotification(message: message, timestamp: DateTime.now()));
+ });
+ }
+
+ void _seedTruckSimulation() {
+ final visible = _random.nextBool();
+ _truckVisible = visible;
+ _truckPosition = _truckOffsetFromCenter(_random.nextDouble() * 20.0);
+ _notifications.add(
+ _AppNotification(
+ message: visible ? 'El camión de basura apareció cerca de tu ubicación.' : 'El camión de basura está fuera de rango.',
+ timestamp: DateTime.now(),
+ ),
+ );
+ }
+
+ Future _loadLocation() async {
+ try {
+ final permission = await Geolocator.checkPermission();
+ var currentPermission = permission;
+ if (currentPermission == LocationPermission.denied) {
+ currentPermission = await Geolocator.requestPermission();
+ }
+
+ if (currentPermission == LocationPermission.denied || currentPermission == LocationPermission.deniedForever) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _locationError = 'Permiso de ubicación no concedido. Mostrando mapa por defecto.';
+ _isLoadingLocation = false;
+ });
+ return;
+ }
+
+ final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
+ final newCenter = LatLng(position.latitude, position.longitude);
+
+ if (!mounted) {
+ return;
+ }
+
+ setState(() {
+ _center = newCenter;
+ _isLoadingLocation = false;
+ _locationError = null;
+ });
+
+ _mapController.move(newCenter, 15);
+ } catch (_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _locationError = 'No se pudo obtener la ubicación actual. Se usó una referencia por defecto.';
+ _isLoadingLocation = false;
+ });
+ }
+ }
+
+ Future _loadAddresses() async {
+ try {
+ final addresses = await widget.addressRepository.getMyAddresses(session: widget.session);
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _addresses = addresses;
+ _loadingAddresses = false;
+ _addressesError = null;
+ });
+ } catch (error) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _addressesError = error.toString();
+ _loadingAddresses = false;
+ });
+ }
+ }
+
+ void _startTruckSimulation() {
+ _truckTimer?.cancel();
+ _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) {
+ final distanceMeters = _random.nextDouble() * 40.0;
+ final newTruckPosition = _truckOffsetFromCenter(distanceMeters);
+ final wasVisible = _truckVisible;
+ final isVisible = distanceMeters <= 20.0;
+
+ if (!mounted) {
+ return;
+ }
+
+ setState(() {
+ _truckPosition = newTruckPosition;
+ _truckVisible = isVisible;
+ });
+
+ if (isVisible != wasVisible) {
+ _addNotification(
+ isVisible
+ ? 'El camión de basura apareció a ${distanceMeters.toStringAsFixed(1)} m.'
+ : 'El camión de basura salió del rango visible.',
+ );
+ }
+ });
+ }
+
+ LatLng _truckOffsetFromCenter(double distanceMeters) {
+ final angle = _random.nextDouble() * 2 * pi;
+ final metersPerDegreeLat = 111320.0;
+ final metersPerDegreeLng = 111320.0 * cos(_center.latitude * pi / 180.0).abs().clamp(0.2, 1.0);
+ final deltaLat = (distanceMeters * cos(angle)) / metersPerDegreeLat;
+ final deltaLng = (distanceMeters * sin(angle)) / metersPerDegreeLng;
+ return LatLng(_center.latitude + deltaLat, _center.longitude + deltaLng);
+ }
+
+ Future _logOut(BuildContext context) async {
+ await widget.authRepository.signOut();
+ if (!context.mounted) {
+ return;
+ }
+ Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(
+ builder: (_) => AuthScreen(
+ authRepository: widget.authRepository,
+ addressRepository: widget.addressRepository,
+ ),
+ ),
+ (route) => false,
+ );
+ }
+
+ Future _saveCalendarNote() async {
+ final note = _calendarNoteController.text.trim();
+ if (note.isEmpty || _selectedDay == null) {
+ return;
+ }
+
+ final normalizedDay = _normalizeDay(_selectedDay!);
+ setState(() {
+ _calendarNotes[normalizedDay] = note;
+ });
+ _calendarNoteController.clear();
+ _addNotification('Se guardó texto en el calendario para ${_selectedDay!.day}/${_selectedDay!.month}.');
+ }
+
+ Future _saveNewAddress() async {
+ if (_newHouseNumberController.text.trim().isEmpty ||
+ _newColoniaController.text.trim().isEmpty ||
+ _newStreetController.text.trim().isEmpty) {
+ _addNotification('Completa la nueva dirección antes de guardarla.');
+ return;
+ }
+
+ final newAddress = AddressEntry(
+ houseNumber: _newHouseNumberController.text.trim(),
+ colonia: _newColoniaController.text.trim(),
+ street: _newStreetController.text.trim(),
+ );
+
+ try {
+ await widget.addressRepository.saveAddress(session: widget.session, address: newAddress);
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _addresses = [
+ AddressRecord(
+ id: DateTime.now().millisecondsSinceEpoch,
+ houseNumber: newAddress.houseNumber,
+ colonia: newAddress.colonia,
+ street: newAddress.street,
+ ),
+ ..._addresses,
+ ];
+ });
+ _newHouseNumberController.clear();
+ _newColoniaController.clear();
+ _newStreetController.clear();
+ _addNotification('Se agregó una nueva dirección a tu perfil.');
+ } catch (error) {
+ _addNotification('No se pudo guardar la nueva dirección: $error');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final pages = [
+ _MapSection(
+ center: _center,
+ truckPosition: _truckPosition,
+ truckVisible: _truckVisible,
+ isLoadingLocation: _isLoadingLocation,
+ locationError: _locationError,
+ mapController: _mapController,
+ showTiles: _showLiveFeatures,
+ address: widget.savedAddress,
+ ),
+ _CalendarSection(
+ focusedDay: _focusedDay,
+ selectedDay: _selectedDay,
+ calendarFormat: _calendarFormat,
+ notes: _calendarNotes,
+ noteController: _calendarNoteController,
+ onSelectedDay: (day, focusedDay) {
+ setState(() {
+ _selectedDay = day;
+ _focusedDay = focusedDay;
+ });
+ },
+ onFormatChanged: (format) {
+ setState(() {
+ _calendarFormat = format;
+ });
+ },
+ onPageChanged: (focusedDay) {
+ _focusedDay = focusedDay;
+ },
+ onSaveNote: _saveCalendarNote,
+ ),
+ _NotificationsSection(notifications: _notifications),
+ _DataSection(
+ session: widget.session,
+ loadingAddresses: _loadingAddresses,
+ addressesError: _addressesError,
+ addresses: _addresses,
+ houseNumberController: _newHouseNumberController,
+ coloniaController: _newColoniaController,
+ streetController: _newStreetController,
+ onSaveAddress: _saveNewAddress,
+ ),
+ ];
+
+ return Scaffold(
+ body: SafeArea(
+ child: Column(
+ children: [
+ _UserHeader(
+ session: widget.session,
+ onLogout: () => _logOut(context),
+ ),
+ Expanded(
+ child: IndexedStack(
+ index: _selectedIndex,
+ children: pages,
+ ),
+ ),
+ ],
+ ),
+ ),
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: _selectedIndex,
+ onDestinationSelected: (index) {
+ setState(() {
+ _selectedIndex = index;
+ });
+ },
+ destinations: const [
+ NavigationDestination(icon: Icon(Icons.my_location_outlined), selectedIcon: Icon(Icons.my_location), label: 'Mapa'),
+ NavigationDestination(icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: 'Calendario'),
+ NavigationDestination(icon: Icon(Icons.notifications_outlined), selectedIcon: Icon(Icons.notifications), label: 'Avisos'),
+ NavigationDestination(icon: Icon(Icons.input_outlined), selectedIcon: Icon(Icons.input), label: 'Datos'),
+ ],
+ ),
+ );
+ }
+}
+
+class _UserHeader extends StatelessWidget {
+ const _UserHeader({required this.session, required this.onLogout});
+
+ final AuthSession session;
+ final VoidCallback onLogout;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Colors.teal.shade900, Colors.teal.shade700],
+ ),
+ ),
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 24,
+ backgroundColor: Colors.white.withValues(alpha: 0.18),
+ child: Text(
+ session.displayName.isNotEmpty ? session.displayName[0].toUpperCase() : 'U',
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(session.displayName, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
+ Text(session.email, style: TextStyle(color: Colors.white.withValues(alpha: 0.85))),
+ ],
+ ),
+ ),
+ IconButton(
+ onPressed: onLogout,
+ icon: const Icon(Icons.logout, color: Colors.white),
+ tooltip: 'Cerrar sesión',
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _MapSection extends StatelessWidget {
+ const _MapSection({
+ required this.center,
+ required this.truckPosition,
+ required this.truckVisible,
+ required this.isLoadingLocation,
+ required this.locationError,
+ required this.mapController,
+ required this.showTiles,
+ required this.address,
+ });
+
+ final LatLng center;
+ final LatLng? truckPosition;
+ final bool truckVisible;
+ final bool isLoadingLocation;
+ final String? locationError;
+ final MapController mapController;
+ final bool showTiles;
+ final AddressEntry? address;
+
+ @override
+ Widget build(BuildContext context) {
+ final markers = [
+ Marker(
+ width: 60,
+ height: 60,
+ point: center,
+ child: const Icon(Icons.person_pin_circle, size: 56, color: Colors.blue),
+ ),
+ if (truckVisible && truckPosition != null)
+ Marker(
+ width: 60,
+ height: 60,
+ point: truckPosition!,
+ child: const Icon(Icons.local_shipping, size: 50, color: Colors.red),
+ ),
+ ];
+
+ return Container(
+ color: const Color(0xFFF8FAFC),
+ child: Column(
+ children: [
+ if (locationError != null)
+ Padding(
+ padding: const EdgeInsets.all(12),
+ child: _NoticeBanner(message: locationError!),
+ ),
+ Expanded(
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(24),
+ child: Stack(
+ children: [
+ if (showTiles)
+ FlutterMap(
+ mapController: mapController,
+ options: MapOptions(
+ initialCenter: center,
+ initialZoom: 15,
+ interactionOptions: const InteractionOptions(flags: InteractiveFlag.all),
+ ),
+ children: [
+ TileLayer(
+ urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ userAgentPackageName: 'flutter_application_1',
+ ),
+ MarkerLayer(markers: markers),
+ ],
+ )
+ else
+ _MapPlaceholder(
+ center: center,
+ truckVisible: truckVisible,
+ truckPosition: truckPosition,
+ ),
+ Positioned(
+ top: 16,
+ left: 16,
+ right: 16,
+ child: Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ child: Padding(
+ padding: const EdgeInsets.all(14),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('Mapa y camión', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800)),
+ const SizedBox(height: 4),
+ Text(
+ address == null
+ ? 'No hay una dirección guardada todavía.'
+ : 'Dirección: ${address!.houseNumber}, ${address!.colonia}, ${address!.street}',
+ style: TextStyle(color: Colors.grey.shade700),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ truckVisible ? 'El camión está dentro de 20 m.' : 'El camión está fuera de rango.',
+ style: const TextStyle(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ if (isLoadingLocation)
+ const Center(
+ child: CircularProgressIndicator(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _MapPlaceholder extends StatelessWidget {
+ const _MapPlaceholder({
+ required this.center,
+ required this.truckVisible,
+ required this.truckPosition,
+ });
+
+ final LatLng center;
+ final bool truckVisible;
+ final LatLng? truckPosition;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: const Color(0xFFE2E8F0),
+ alignment: Alignment.center,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.map_outlined, size: 72, color: Colors.teal),
+ const SizedBox(height: 12),
+ const Text('Mapa centrado en tu ubicación', style: TextStyle(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 4),
+ Text('Lat: ${center.latitude.toStringAsFixed(5)}, Lng: ${center.longitude.toStringAsFixed(5)}'),
+ const SizedBox(height: 12),
+ Text(
+ truckVisible && truckPosition != null
+ ? 'Camión visible cerca de ti'
+ : 'Camión fuera del rango de 20 m',
+ style: const TextStyle(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CalendarSection extends StatelessWidget {
+ const _CalendarSection({
+ required this.focusedDay,
+ required this.selectedDay,
+ required this.calendarFormat,
+ required this.notes,
+ required this.noteController,
+ required this.onSelectedDay,
+ required this.onFormatChanged,
+ required this.onPageChanged,
+ required this.onSaveNote,
+ });
+
+ final DateTime focusedDay;
+ final DateTime? selectedDay;
+ final CalendarFormat calendarFormat;
+ final Map notes;
+ final TextEditingController noteController;
+ final void Function(DateTime day, DateTime focusedDay) onSelectedDay;
+ final void Function(CalendarFormat format) onFormatChanged;
+ final void Function(DateTime focusedDay) onPageChanged;
+ final Future Function() onSaveNote;
+
+ DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day);
+
+ @override
+ Widget build(BuildContext context) {
+ final selectedNormalized = selectedDay == null ? null : _normalizeDay(selectedDay!);
+
+ return Container(
+ color: const Color(0xFFF8FAFC),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ TableCalendar(
+ firstDay: DateTime.utc(2020, 1, 1),
+ lastDay: DateTime.utc(2035, 12, 31),
+ focusedDay: focusedDay,
+ calendarFormat: calendarFormat,
+ selectedDayPredicate: (day) => isSameDay(selectedDay, day),
+ eventLoader: (day) => notes.containsKey(_normalizeDay(day)) ? [notes[_normalizeDay(day)]!] : [],
+ onDaySelected: onSelectedDay,
+ onFormatChanged: onFormatChanged,
+ onPageChanged: onPageChanged,
+ calendarBuilders: CalendarBuilders(
+ defaultBuilder: (context, day, focusedDay) {
+ final note = notes[_normalizeDay(day)];
+ if (note == null) {
+ return null;
+ }
+ return _CalendarDayCell(day: day, note: note, selected: false);
+ },
+ selectedBuilder: (context, day, focusedDay) {
+ final note = notes[_normalizeDay(day)];
+ return _CalendarDayCell(day: day, note: note, selected: true);
+ },
+ todayBuilder: (context, day, focusedDay) {
+ final note = notes[_normalizeDay(day)];
+ return _CalendarDayCell(day: day, note: note, selected: false, today: true);
+ },
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: noteController,
+ maxLines: 2,
+ decoration: const InputDecoration(
+ labelText: 'Texto del día',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 12),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton(
+ onPressed: onSaveNote,
+ child: const Text('Guardar texto y resaltar casilla'),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ selectedNormalized == null
+ ? 'Selecciona un día para escribir.'
+ : 'Día seleccionado: ${selectedNormalized.day}/${selectedNormalized.month}/${selectedNormalized.year}',
+ style: TextStyle(color: Colors.grey.shade700),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CalendarDayCell extends StatelessWidget {
+ const _CalendarDayCell({
+ required this.day,
+ required this.note,
+ required this.selected,
+ this.today = false,
+ });
+
+ final DateTime day;
+ final String? note;
+ final bool selected;
+ final bool today;
+
+ @override
+ Widget build(BuildContext context) {
+ final backgroundColor = selected
+ ? Colors.teal.shade700
+ : today
+ ? Colors.teal.shade100
+ : Colors.white;
+
+ return Container(
+ margin: const EdgeInsets.all(4),
+ padding: const EdgeInsets.all(6),
+ decoration: BoxDecoration(
+ color: backgroundColor,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: note == null ? Colors.grey.shade300 : Colors.teal),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ '${day.day}',
+ style: TextStyle(
+ color: selected ? Colors.white : Colors.black,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ if (note != null) ...[
+ const SizedBox(height: 4),
+ Text(
+ note!,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 10,
+ color: selected ? Colors.white : Colors.teal.shade900,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _NotificationsSection extends StatelessWidget {
+ const _NotificationsSection({required this.notifications});
+
+ final List<_AppNotification> notifications;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: const Color(0xFFF8FAFC),
+ child: ListView.builder(
+ padding: const EdgeInsets.all(16),
+ itemCount: notifications.isEmpty ? 1 : notifications.length,
+ itemBuilder: (context, index) {
+ if (notifications.isEmpty) {
+ return const Center(
+ child: Padding(
+ padding: EdgeInsets.only(top: 48),
+ child: Text('Aquí aparecerán las notificaciones.'),
+ ),
+ );
+ }
+
+ final notification = notifications[index];
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: ListTile(
+ leading: const Icon(Icons.notifications_active, color: Colors.teal),
+ title: Text(notification.message),
+ subtitle: Text(
+ '${notification.timestamp.hour.toString().padLeft(2, '0')}:${notification.timestamp.minute.toString().padLeft(2, '0')}',
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _DataSection extends StatelessWidget {
+ const _DataSection({
+ required this.session,
+ required this.loadingAddresses,
+ required this.addressesError,
+ required this.addresses,
+ required this.houseNumberController,
+ required this.coloniaController,
+ required this.streetController,
+ required this.onSaveAddress,
+ });
+
+ final AuthSession session;
+ final bool loadingAddresses;
+ final String? addressesError;
+ final List addresses;
+ final TextEditingController houseNumberController;
+ final TextEditingController coloniaController;
+ final TextEditingController streetController;
+ final Future Function() onSaveAddress;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: const Color(0xFFF8FAFC),
+ padding: const EdgeInsets.all(16),
+ child: ListView(
+ children: [
+ Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Datos de la cuenta', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
+ const SizedBox(height: 12),
+ _ProfileRow(label: 'Nombre', value: session.displayName),
+ _ProfileRow(label: 'Correo', value: session.email),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Agregar otra dirección', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
+ const SizedBox(height: 12),
+ TextField(
+ controller: houseNumberController,
+ decoration: const InputDecoration(labelText: 'Número de casa', border: OutlineInputBorder()),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: coloniaController,
+ decoration: const InputDecoration(labelText: 'Colonia', border: OutlineInputBorder()),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: streetController,
+ decoration: const InputDecoration(labelText: 'Calle', border: OutlineInputBorder()),
+ ),
+ const SizedBox(height: 12),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton.icon(
+ onPressed: onSaveAddress,
+ icon: const Icon(Icons.add_location_alt_outlined),
+ label: const Text('Guardar nueva dirección'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Direcciones registradas', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
+ const SizedBox(height: 12),
+ if (loadingAddresses) const Center(child: CircularProgressIndicator()),
+ if (addressesError != null) Text(addressesError!, style: const TextStyle(color: Colors.red)),
+ if (!loadingAddresses && addresses.isEmpty)
+ const Text('Todavía no hay direcciones registradas.'),
+ if (addresses.isNotEmpty)
+ ...addresses.map(
+ (address) => Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: ListTile(
+ leading: const Icon(Icons.home_work_outlined, color: Colors.teal),
+ title: Text('Casa ${address.houseNumber}'),
+ subtitle: Text('${address.colonia}, ${address.street}'),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ProfileRow extends StatelessWidget {
+ const _ProfileRow({required this.label, required this.value});
+
+ final String label;
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(width: 90, child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700))),
+ Expanded(child: Text(value)),
+ ],
+ ),
+ );
+ }
+}
+
+class _AppNotification {
+ _AppNotification({required this.message, required this.timestamp});
+
+ final String message;
+ final DateTime timestamp;
+}
+
+class _NoticeBanner extends StatelessWidget {
+ const _NoticeBanner({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: const Color(0xFFE0F2FE),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFF7DD3FC)),
+ ),
+ child: Text(message, style: const TextStyle(color: Color(0xFF075985))),
+ );
+ }
+}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
new file mode 100644
index 0000000..67de4b8
--- /dev/null
+++ b/lib/screens/home_screen.dart
@@ -0,0 +1,155 @@
+import 'package:flutter/material.dart';
+
+import '../models/address_entry.dart';
+import '../models/auth_session.dart';
+import '../services/auth_repository.dart';
+import '../services/address_repository.dart';
+import 'auth_screen.dart';
+
+class HomeScreen extends StatelessWidget {
+ const HomeScreen({
+ super.key,
+ required this.authRepository,
+ required this.addressRepository,
+ required this.session,
+ this.savedAddress,
+ });
+
+ final AuthRepository authRepository;
+ final AddressRepository addressRepository;
+ final AuthSession session;
+ final AddressEntry? savedAddress;
+
+ Future _logOut(BuildContext context) async {
+ await authRepository.signOut();
+ if (!context.mounted) {
+ return;
+ }
+ Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(
+ builder: (_) => AuthScreen(
+ authRepository: authRepository,
+ addressRepository: addressRepository,
+ ),
+ ),
+ (route) => false,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Container(
+ decoration: const BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Color(0xFFF8FAFC), Color(0xFFE2E8F0), Color(0xFFCCFBF1)],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ child: SafeArea(
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 520),
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Card(
+ elevation: 12,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ CircleAvatar(
+ radius: 34,
+ backgroundColor: const Color(0xFF0F766E).withValues(alpha: 0.12),
+ child: Text(
+ session.displayName.isNotEmpty ? session.displayName[0].toUpperCase() : 'U',
+ style: const TextStyle(
+ fontSize: 28,
+ fontWeight: FontWeight.w800,
+ color: Color(0xFF0F766E),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Hola, ${session.displayName}',
+ style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w800),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ session.email,
+ style: TextStyle(color: Colors.grey.shade700),
+ ),
+ const SizedBox(height: 20),
+ _InfoTile(
+ icon: Icons.dns_outlined,
+ title: 'Dirección guardada',
+ subtitle: savedAddress == null
+ ? 'Todavía no hay una dirección registrada para esta sesión.'
+ : 'Casa ${savedAddress!.houseNumber}, Colonia ${savedAddress!.colonia}, Calle ${savedAddress!.street}',
+ ),
+ const SizedBox(height: 12),
+ _InfoTile(
+ icon: Icons.storage_outlined,
+ title: 'Persistencia en PostgreSQL',
+ subtitle: 'La dirección se envió al backend con el token de sesión para almacenarla en la base de datos.',
+ ),
+ const SizedBox(height: 24),
+ FilledButton.icon(
+ onPressed: () => _logOut(context),
+ icon: const Icon(Icons.logout),
+ label: const Text('Cerrar sesión'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _InfoTile extends StatelessWidget {
+ const _InfoTile({required this.icon, required this.title, required this.subtitle});
+
+ final IconData icon;
+ final String title;
+ final String subtitle;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF8FAFC),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: const Color(0xFFE2E8F0)),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(icon, color: const Color(0xFF0F766E)),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 4),
+ Text(subtitle, style: TextStyle(color: Colors.grey.shade700, height: 1.35)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/services/address_repository.dart b/lib/services/address_repository.dart
new file mode 100644
index 0000000..21b4749
--- /dev/null
+++ b/lib/services/address_repository.dart
@@ -0,0 +1,96 @@
+import 'dart:convert';
+import 'package:http/http.dart' as http;
+import '../app_config.dart';
+import '../models/address_entry.dart';
+import '../models/address_record.dart';
+import '../models/auth_session.dart';
+
+abstract class AddressRepository {
+ Future saveAddress({
+ required AuthSession session,
+ required AddressEntry address,
+ });
+
+ Future> getMyAddresses({required AuthSession session});
+}
+
+class HttpAddressRepository implements AddressRepository {
+ const HttpAddressRepository({http.Client? client}) : _client = client;
+
+ final http.Client? _client;
+
+ @override
+ Future saveAddress({
+ required AuthSession session,
+ required AddressEntry address,
+ }) async {
+ final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses');
+
+ late final http.Response response;
+ try {
+ response = await (_client ?? http.Client()).post(
+ uri,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': 'Bearer ${session.token}',
+ },
+ body: jsonEncode(address.toJson()),
+ );
+ } catch (_) {
+ throw AddressException(
+ 'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.',
+ );
+ }
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ final payload = jsonDecode(response.body);
+ final message = payload['message']?.toString() ?? 'Error al guardar la dirección.';
+ throw AddressException(message);
+ }
+ }
+
+ @override
+ Future> getMyAddresses({required AuthSession session}) async {
+ final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses/me');
+
+ late final http.Response response;
+ try {
+ response = await (_client ?? http.Client()).get(
+ uri,
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': 'Bearer ${session.token}',
+ },
+ );
+ } catch (_) {
+ throw AddressException(
+ 'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.',
+ );
+ }
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ final payload = jsonDecode(response.body);
+ final message = payload['message']?.toString() ?? 'Error al obtener las direcciones.';
+ throw AddressException(message);
+ }
+
+ final decoded = jsonDecode(response.body);
+ if (decoded is! List) {
+ return [];
+ }
+
+ return decoded
+ .whereType