Initial commit: SIBU 2.0 MISSION

This commit is contained in:
2026-02-21 09:53:31 -05:00
commit 0c7aa53c8b
400 changed files with 67708 additions and 0 deletions

47
old/.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
pubspec.lock
# 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
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/web/
# 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

30
old/.metadata Normal file
View File

@ -0,0 +1,30 @@
# 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: "19074d12f7eaf6a8180cd4036a430c1d76de904e"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
- platform: web
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
# 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'

View File

@ -0,0 +1,116 @@
# Quick Start: PostgreSQL Setup
## ✅ What's Been Configured
1. **Backend Database Connection**: Configured to use `postgresql+asyncpg://sibu:sibu@localhost:5432/sibu`
2. **Environment File**: Created `backend/.env.development` with database settings
3. **API Client**: Created Flutter `ApiClient` service for backend communication
4. **Run Scripts**: Created helper scripts for easy execution
## 🚀 Quick Start
### 1. Ensure PostgreSQL is Running
```bash
# Check if PostgreSQL is running
psql -h localhost -p 5432 -U sibu -d sibu -c "SELECT version();"
```
If it fails, start PostgreSQL:
```bash
# macOS with Homebrew
brew services start postgresql@14 # or your version
```
### 2. Create Database (if needed)
```bash
psql -h localhost -p 5432 -U postgres -c "CREATE DATABASE sibu;"
psql -h localhost -p 5432 -U postgres -c "CREATE USER sibu WITH PASSWORD 'sibu';"
psql -h localhost -p 5432 -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE sibu TO sibu;"
```
### 3. Apply Database Schema
You have migrations in `supabase/migrations/`. Apply them:
```bash
cd old
# Apply all migrations
for migration in supabase/migrations/*.sql; do
psql -h localhost -p 5432 -U sibu -d sibu -f "$migration"
done
```
Or use Alembic (if backend models match):
```bash
cd backend
uv run alembic upgrade head
```
### 4. Start Backend API
```bash
cd backend
uv run fastapi dev app/main.py
```
Backend will be available at: `http://localhost:8000`
### 5. Run Flutter App
In a new terminal:
```bash
cd old
./scripts/run-flutter-backend.sh
```
Or manually:
```bash
flutter run -d chrome --dart-define=API_BASE_URL=http://localhost:8000
```
## 📋 Configuration Summary
| Component | Configuration |
|-----------|--------------|
| **Database** | `sibu:sibu@localhost:5432/sibu` |
| **Backend API** | `http://localhost:8000` |
| **Flutter API Client** | Configured via `--dart-define=API_BASE_URL` |
## 🔍 Verify Setup
### Test Database Connection
```bash
psql -h localhost -p 5432 -U sibu -d sibu -c "\dt" # List tables
```
### Test Backend API
```bash
curl http://localhost:8000/health
curl http://localhost:8000/api/routes
```
### Test Flutter Connection
The app will automatically try to connect to the backend API on startup.
## 📚 Next Steps
- See `README-POSTGRESQL-SETUP.md` for detailed documentation
- Update Flutter services to use `ApiClient` instead of `SupabaseService` (currently still using Supabase)
- Add authentication if needed
- Configure CORS properly for production
## 🐛 Troubleshooting
**Backend can't connect:**
- Check PostgreSQL is running: `lsof -i :5432`
- Verify credentials: `psql -h localhost -p 5432 -U sibu -d sibu`
- Check `.env.development` file exists
**Flutter can't connect:**
- Verify backend is running: `curl http://localhost:8000/health`
- Check API_BASE_URL is set correctly
- Check browser console for CORS errors

View File

@ -0,0 +1,210 @@
# PostgreSQL Direct Connection Setup
This guide explains how to use the PostgreSQL database directly instead of Supabase.
## Architecture
Since Flutter web cannot connect directly to PostgreSQL from the browser (security restrictions), we use a two-tier architecture:
```
Flutter App (Web) → FastAPI Backend → PostgreSQL Database
```
The backend acts as an API layer that connects to PostgreSQL and exposes REST endpoints.
## Database Configuration
The PostgreSQL database is configured as:
- **Host**: localhost
- **Port**: 5432
- **Database**: sibu
- **Username**: sibu
- **Password**: sibu
Connection string: `postgresql+asyncpg://sibu:sibu@localhost:5432/sibu`
## Setup Steps
### 1. Ensure PostgreSQL is Running
Make sure your PostgreSQL database is running and accessible:
```bash
# Test connection (if you have psql installed)
psql -h localhost -p 5432 -U sibu -d sibu
```
### 2. Apply Database Migrations
The database schema needs to be created. You have two options:
#### Option A: Use Supabase Migrations
If you have the Supabase migrations in `supabase/migrations/`, you can apply them directly:
```bash
# Connect to PostgreSQL and run migrations
psql -h localhost -p 5432 -U sibu -d sibu -f supabase/migrations/20241019215951_sibu_transportation_system.sql
# ... apply other migrations
```
#### Option B: Use Alembic (Backend Migrations)
The backend uses Alembic for migrations:
```bash
cd backend
uv run alembic upgrade head
```
### 3. Start the Backend API
```bash
cd backend
uv run fastapi dev app/main.py
```
The API will be available at `http://localhost:8000`
### 4. Run Flutter App
```bash
cd old
./scripts/run-flutter-backend.sh
```
Or manually:
```bash
flutter run -d chrome --dart-define=API_BASE_URL=http://localhost:8000
```
## Backend API Endpoints
The backend provides the following endpoints:
- `GET /api/routes` - Get all routes
- `GET /api/routes/{route_id}` - Get specific route
- `GET /api/bus-stops` - Get all bus stops
- `GET /api/bus-stops/{stop_id}` - Get specific bus stop
- `GET /api/schedules` - Get schedules (with optional route_id, stop_id filters)
- `GET /api/coupons` - Get coupons (with optional category, is_active filters)
- `GET /api/taxis` - Get taxis (with optional filters)
- `GET /health` - Health check
## Configuration Files
### Backend Configuration
**File**: `backend/.env.development`
```env
DATABASE_URL=postgresql+asyncpg://sibu:sibu@localhost:5432/sibu
ENVIRONMENT=development
DEBUG=true
```
**File**: `backend/app/core/config.py`
- Default database URL is set to your PostgreSQL connection
### Flutter Configuration
The Flutter app uses `ApiClient` service which:
- Defaults to `http://localhost:8000`
- Can be configured via `--dart-define=API_BASE_URL=<url>`
## Switching Between Supabase and PostgreSQL
### Use PostgreSQL (via Backend API)
```bash
cd old
./scripts/run-flutter-backend.sh
```
### Use Supabase
```bash
cd old
./scripts/run-flutter-local.sh # For local Supabase
# OR
flutter run -d chrome \
--dart-define=SUPABASE_URL=<url> \
--dart-define=SUPABASE_ANON_KEY=<key>
```
## Troubleshooting
### Backend Can't Connect to PostgreSQL
1. **Check PostgreSQL is running:**
```bash
# macOS
brew services list | grep postgresql
# Or check if port is listening
lsof -i :5432
```
2. **Verify credentials:**
```bash
psql -h localhost -p 5432 -U sibu -d sibu
```
3. **Check database exists:**
```sql
\l -- List databases
```
### Flutter Can't Connect to Backend
1. **Check backend is running:**
```bash
curl http://localhost:8000/health
```
2. **Check CORS settings** in `backend/app/main.py`
3. **Verify API URL** in Flutter:
```dart
// Check ApiClient base URL
print(ApiClient.instance._baseUrl);
```
### Database Schema Issues
If tables don't exist:
1. **Apply migrations:**
```bash
cd backend
uv run alembic upgrade head
```
2. **Or manually create from Supabase migrations:**
```bash
psql -h localhost -p 5432 -U sibu -d sibu < supabase/migrations/20241019215951_sibu_transportation_system.sql
```
## Development Workflow
1. **Start PostgreSQL** (if not running as a service)
2. **Start Backend API:**
```bash
cd backend
uv run fastapi dev app/main.py
```
3. **Run Flutter App:**
```bash
cd old
./scripts/run-flutter-backend.sh
```
## Next Steps
The Flutter app services need to be updated to use `ApiClient` instead of `SupabaseService`. Currently, the app still uses Supabase. To fully migrate:
1. Update `TransportationService` to use `ApiClient`
2. Update `CouponService` to use `ApiClient`
3. Update `TaxiService` to use `ApiClient`
4. Remove or make optional `SupabaseService`
This migration can be done incrementally.

View File

@ -0,0 +1,196 @@
# Local Supabase Setup Guide
This guide will help you set up and run Supabase locally for development.
## Prerequisites
1. **Docker Desktop** - Supabase runs on Docker
- Download: https://docs.docker.com/desktop
- Make sure Docker Desktop is running before proceeding
2. **Supabase CLI** - Already installed via Homebrew
## Quick Start
### 1. Start Local Supabase
```bash
cd old
./scripts/setup-local-supabase.sh
```
This script will:
- Check if Docker is running
- Initialize Supabase (if not already done)
- Start all Supabase services locally
- Apply all database migrations
- Display your local credentials
### 2. Run Flutter App with Local Supabase
```bash
./scripts/run-flutter-local.sh
```
Or specify a device:
```bash
./scripts/run-flutter-local.sh chrome
./scripts/run-flutter-local.sh web-server
```
### 3. Get Credentials Manually
If you need to see the credentials again:
```bash
./scripts/get-local-credentials.sh
```
Or use the Supabase CLI directly:
```bash
supabase status
```
## Manual Setup
If you prefer to set up manually:
### Step 1: Start Supabase
```bash
cd old
supabase start
```
### Step 2: Get Credentials
After starting, `supabase status` will show:
- **API URL**: Your local Supabase URL (usually `http://127.0.0.1:54321`)
- **anon key**: Your local anon key
### Step 3: Run Flutter with Credentials
```bash
flutter run -d chrome \
--dart-define=SUPABASE_URL="http://127.0.0.1:54321" \
--dart-define=SUPABASE_ANON_KEY="<your-anon-key>"
```
## Useful Commands
### Supabase Management
```bash
# Start Supabase
supabase start
# Stop Supabase
supabase stop
# View Supabase status and credentials
supabase status
# View logs
supabase logs
# Reset database (applies all migrations fresh)
supabase db reset
# Apply new migrations
supabase db reset
```
### Database Migrations
Migrations are automatically applied when you run `supabase start` or `supabase db reset`.
To create a new migration:
```bash
supabase migration new <migration_name>
```
## Local Supabase Services
When running locally, Supabase provides:
- **API**: `http://127.0.0.1:54321`
- **Studio (Admin UI)**: `http://127.0.0.1:54323`
- **Database**: `postgresql://postgres:postgres@127.0.0.1:54322/postgres`
- **Auth**: Handled by the API
- **Storage**: Handled by the API
## Accessing Supabase Studio
Supabase Studio is a web-based admin interface for managing your local database:
1. Start Supabase: `supabase start`
2. Open Studio URL from `supabase status` output (usually `http://127.0.0.1:54323`)
3. Use it to:
- View and edit tables
- Run SQL queries
- Manage authentication
- View API documentation
## Troubleshooting
### Docker Not Running
```
Error: Cannot connect to the Docker daemon
```
**Solution**: Start Docker Desktop and wait for it to fully start, then try again.
### Port Already in Use
If ports 54321, 54322, or 54323 are already in use:
1. Stop the conflicting service
2. Or modify `supabase/config.toml` to use different ports
### Database Reset Issues
If migrations fail:
```bash
# Stop Supabase
supabase stop
# Reset everything
supabase db reset
# Start again
supabase start
```
### Flutter Can't Connect
Make sure:
1. Supabase is running (`supabase status`)
2. You're using the correct URL and anon key
3. The `--dart-define` flags are correctly formatted
## Switching Between Local and Production
### Use Local Supabase
```bash
./scripts/run-flutter-local.sh
```
### Use Production Supabase
Update `env.json` with production credentials and run:
```bash
flutter run -d chrome \
--dart-define-from-file=env.json
```
Note: The current app uses `String.fromEnvironment`, so you need `--dart-define` flags.
## Next Steps
1. ✅ Start Docker Desktop
2. ✅ Run `./scripts/setup-local-supabase.sh`
3. ✅ Run `./scripts/run-flutter-local.sh`
4. ✅ Open Supabase Studio to view your database
5. ✅ Start developing!

View File

@ -0,0 +1,103 @@
# Supabase Production Setup (No Docker)
This guide shows how to use Supabase production instance (no local Docker required).
## Quick Start
### Run Flutter with Supabase
```bash
cd old
./scripts/run-flutter-supabase.sh
```
This script:
1. Reads `env.json` for Supabase credentials
2. Extracts `SUPABASE_URL` and `SUPABASE_ANON_KEY`
3. Runs Flutter with `--dart-define` flags
### Manual Run
If you prefer to run manually:
```bash
flutter run -d chrome \
--dart-define=SUPABASE_URL="https://yeqdxdocspsuexamljen.supabase.co" \
--dart-define=SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
## Configuration
Your Supabase credentials are stored in `env.json`:
```json
{
"SUPABASE_URL": "https://yeqdxdocspsuexamljen.supabase.co",
"SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
## How It Works
1. **Script reads env.json** → Extracts Supabase credentials
2. **Flutter receives credentials** → Via `--dart-define` flags
3. **App initializes Supabase** → Uses `String.fromEnvironment()` to read credentials
4. **Services use Supabase** → All data operations go through Supabase client
## Troubleshooting
### "Missing SUPABASE_URL or SUPABASE_ANON_KEY"
**Solution**: Make sure you're running with the script:
```bash
./scripts/run-flutter-supabase.sh
```
Or manually pass the flags:
```bash
flutter run -d chrome \
--dart-define=SUPABASE_URL="<your-url>" \
--dart-define=SUPABASE_ANON_KEY="<your-key>"
```
### Script Can't Read env.json
Make sure:
1. `env.json` exists in the `old/` directory
2. File contains valid JSON
3. Has `SUPABASE_URL` and `SUPABASE_ANON_KEY` keys
### Supabase Connection Errors
1. **Check your Supabase project is active:**
- Go to https://supabase.com/dashboard
- Verify your project is running
2. **Verify credentials:**
- Check `env.json` has correct values
- Make sure anon key hasn't been rotated
3. **Check network:**
- Ensure you can access `https://yeqdxdocspsuexamljen.supabase.co`
## Database Access
Your Supabase project has a PostgreSQL database. You can:
1. **Access via Supabase Dashboard:**
- Go to https://supabase.com/dashboard
- Select your project
- Use SQL Editor or Table Editor
2. **Direct PostgreSQL connection:**
- Connection string available in Supabase Dashboard → Settings → Database
- Use with psql or any PostgreSQL client
## Next Steps
- ✅ Supabase is configured and ready
- ✅ Run `./scripts/run-flutter-supabase.sh` to start the app
- ✅ App will connect to your production Supabase instance
No Docker needed! 🎉

142
old/README.md Normal file
View File

@ -0,0 +1,142 @@
# Flutter
A modern Flutter-based mobile application utilizing the latest mobile development technologies and tools for building responsive cross-platform applications.
## 📋 Prerequisites
- Flutter SDK (^3.29.2)
- Dart SDK
- Android Studio / VS Code with Flutter extensions
- Android SDK / Xcode (for iOS development)
## 🛠️ Installation
1. Install dependencies:
```bash
flutter pub get
```
2. Run the application:
To run the app with environment variables defined in an env.json file, follow the steps mentioned below:
1. Through CLI
```bash
flutter run --dart-define-from-file=env.json
```
2. For VSCode
- Open .vscode/launch.json (create it if it doesn't exist).
- Add or modify your launch configuration to include --dart-define-from-file:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--dart-define-from-file",
"env.json"
]
}
]
}
```
3. For IntelliJ / Android Studio
- Go to Run > Edit Configurations.
- Select your Flutter configuration or create a new one.
- Add the following to the "Additional arguments" field:
```bash
--dart-define-from-file=env.json
```
## 📁 Project Structure
```
flutter_app/
├── android/ # Android-specific configuration
├── ios/ # iOS-specific configuration
├── lib/
│ ├── core/ # Core utilities and services
│ │ └── utils/ # Utility classes
│ ├── presentation/ # UI screens and widgets
│ │ └── splash_screen/ # Splash screen implementation
│ ├── routes/ # Application routing
│ ├── theme/ # Theme configuration
│ ├── widgets/ # Reusable UI components
│ └── main.dart # Application entry point
├── assets/ # Static assets (images, fonts, etc.)
├── pubspec.yaml # Project dependencies and configuration
└── README.md # Project documentation
```
## 🧩 Adding Routes
To add new routes to the application, update the `lib/routes/app_routes.dart` file:
```dart
import 'package:flutter/material.dart';
import 'package:package_name/presentation/home_screen/home_screen.dart';
class AppRoutes {
static const String initial = '/';
static const String home = '/home';
static Map<String, WidgetBuilder> routes = {
initial: (context) => const SplashScreen(),
home: (context) => const HomeScreen(),
// Add more routes as needed
}
}
```
## 🎨 Theming
This project includes a comprehensive theming system with both light and dark themes:
```dart
// Access the current theme
ThemeData theme = Theme.of(context);
// Use theme colors
Color primaryColor = theme.colorScheme.primary;
```
The theme configuration includes:
- Color schemes for light and dark modes
- Typography styles
- Button themes
- Input decoration themes
- Card and dialog themes
## 📱 Responsive Design
The app is built with responsive design using the Sizer package:
```dart
// Example of responsive sizing
Container(
width: 50.w, // 50% of screen width
height: 20.h, // 20% of screen height
child: Text('Responsive Container'),
)
```
## 📦 Deployment
Build the application for production:
```bash
# For Android
flutter build apk --release
# For iOS
flutter build ios --release
```
## 🙏 Acknowledgments
- Built with [Rocket.new](https://rocket.new)
- Powered by [Flutter](https://flutter.dev) & [Dart](https://dart.dev)
- Styled with Material Design
Built with ❤️ on Rocket.new

28
old/analysis_options.yaml Normal file
View File

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

13
old/android/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,56 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "com.sibu.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.sibu.app"
// 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
multiDexEnabled true
minSdkVersion 23
}
buildTypes {
release {
minifyEnabled false
shrinkResources false
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation "androidx.multidex:multidex:2.0.1"
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.concurrent:concurrent-futures:1.1.0'
}

7
old/android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,7 @@
-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivity$g
-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter$Args
-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter$Error
-dontwarn com.stripe.android.pushProvisioning.PushProvisioningActivityStarter
-dontwarn com.stripe.android.pushProvisioning.PushProvisioningEphemeralKeyProvider
# Keep Stripe classes
-keep class com.stripe.** { *; }

View File

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

View File

@ -0,0 +1,58 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="SIBU"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<!-- Google Maps API Key -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyAwcCgnmNvsTDyELAlBPWjdzXb1D2rosq8" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,7 @@
package com.sibu.app
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.plugins.GeneratedPluginRegistrant
class MainActivity: FlutterFragmentActivity() {
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">sibu</string>
</resources>

View File

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

View File

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

18
old/android/build.gradle Normal file
View File

@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=false

View File

@ -0,0 +1,6 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip

View File

@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return 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 "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="512px" height="512px" viewBox="-30.5 0 317 317" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<defs>
<linearGradient x1="3.9517088%" y1="26.9930287%" x2="75.8970734%" y2="52.9192657%" id="linearGradient-1">
<stop stop-color="#000000" offset="0%">
</stop>
<stop stop-color="#000000" stop-opacity="0" offset="100%">
</stop>
</linearGradient>
</defs>
<g>
<polygon fill="#47C5FB" points="157.665785 0.000549356223 0.000549356223 157.665785 48.8009614 206.466197 255.267708 0.000549356223">
</polygon>
<polygon fill="#47C5FB" points="156.567183 145.396793 72.1487107 229.815265 121.132608 279.530905 169.842925 230.820587 255.267818 145.396793">
</polygon>
<polygon fill="#00569E" points="121.133047 279.531124 158.214592 316.61267 255.267159 316.61267 169.842266 230.820807">
</polygon>
<polygon fill="#00B5F8" points="71.5995742 230.364072 120.401085 181.562561 169.842046 230.821136 121.132827 279.531454">
</polygon>
<polygon fill-opacity="0.8" fill="url(#linearGradient-1)" points="121.132827 279.531454 161.692896 266.072227 165.721875 234.941308">
</polygon>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="33" viewBox="0 0 32 33" fill="none">
<path d="M16 28.5C22.6274 28.5 28 23.1274 28 16.5C28 9.87258 22.6274 4.5 16 4.5C9.37258 4.5 4 9.87258 4 16.5C4 23.1274 9.37258 28.5 16 28.5Z" stroke="#343330" stroke-width="2" stroke-miterlimit="10"/>
<path d="M11.5 15.5C12.3284 15.5 13 14.8284 13 14C13 13.1716 12.3284 12.5 11.5 12.5C10.6716 12.5 10 13.1716 10 14C10 14.8284 10.6716 15.5 11.5 15.5Z" fill="#343330"/>
<path d="M20.5 15.5C21.3284 15.5 22 14.8284 22 14C22 13.1716 21.3284 12.5 20.5 12.5C19.6716 12.5 19 13.1716 19 14C19 14.8284 19.6716 15.5 20.5 15.5Z" fill="#343330"/>
<path d="M21 22.5C19.9625 20.7062 18.2213 19.5 16 19.5C13.7787 19.5 12.0375 20.7062 11 22.5" stroke="#343330" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

10
old/env.json Normal file
View File

@ -0,0 +1,10 @@
{
"SUPABASE_URL": "https://yeqdxdocspsuexamljen.supabase.co",
"SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllcWR4ZG9jc3BzdWV4YW1samVuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA4Njk4MzksImV4cCI6MjA3NjQ0NTgzOX0.CoHnreH3DPB4Ut2dCuIjGylQJzsOx_4qF1mAgGuf_Yk",
"OPENAI_API_KEY": "your-openai-api-key-here",
"GEMINI_API_KEY": "your-gemini-api-key-here",
"ANTHROPIC_API_KEY": "your-anthropic-api-key-here",
"PERPLEXITY_API_KEY": "your-perplexity-api-key-here",
"GOOGLE_WEB_CLIENT_ID": "your_google_web_client_id",
"GOOGLE_MAPS_API_KEY": "your-google-maps-api-key"
}

34
old/ios/.gitignore vendored Normal file
View File

@ -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

View File

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

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
old/ios/Podfile Normal file
View File

@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@ -0,0 +1,617 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
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 */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807E294A63A400263BE5 /* Frameworks */,
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;
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 = 1430;
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;
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
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;
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 = 11.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)";
DEVELOPMENT_TEAM = 8GZ776NSU2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sibu.app.testProject;
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;
baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */;
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.sibu.app.testProject.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;
baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */;
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.sibu.app.testProject.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;
baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */;
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.sibu.app.testProject.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;
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;
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 = 11.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;
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;
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 = 11.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)";
DEVELOPMENT_TEAM = 8GZ776NSU2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sibu.app.testProject;
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)";
DEVELOPMENT_TEAM = 8GZ776NSU2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sibu.app.testProject;
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 */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import UIKit
import Flutter
import GoogleMaps
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Initialize Google Maps with API key
GMSServices.provideAPIKey(AIzaSyAwcCgnmNvsTDyELAlBPWjdzXb1D2rosq8)
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

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

View File

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

55
old/ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>SIBU</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>SIBU</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Location Permissions -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Necesitamos tu ubicación para mostrarte las paradas de bus cercanas</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Necesitamos tu ubicación para mostrarte las paradas de bus cercanas</string>
</dict>
</plist>

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export 'package:connectivity_plus/connectivity_plus.dart';
export 'package:google_fonts/google_fonts.dart';
export '../routes/app_routes.dart';
export '../widgets/custom_icon_widget.dart';
export '../widgets/custom_image_widget.dart';
export '../theme/app_theme.dart';

75
old/lib/main.dart Normal file
View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import './services/app_state_service.dart';
import './services/supabase_service.dart';
import './services/api_client.dart';
import 'core/app_export.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
// Initialize Supabase (required for app functionality)
try {
await SupabaseService.initialize();
debugPrint('✅ Supabase initialized successfully');
} catch (e) {
debugPrint('❌ Supabase initialization failed: $e');
debugPrint(' Make sure to run with: ./scripts/run-flutter-supabase.sh');
// Continue - app will show error UI
}
// Initialize API Client (optional, for PostgreSQL backend)
try {
final apiBaseUrl = ApiClient.getBaseUrl();
ApiClient.instance.initialize(baseUrl: apiBaseUrl);
debugPrint('✅ API Client initialized: $apiBaseUrl');
} catch (e) {
debugPrint('⚠️ API Client initialization skipped: $e');
}
// Initialize global app state
await AppStateService().initialize();
debugPrint('✅ App state initialized successfully');
} catch (e) {
debugPrint('❌ Initialization failed: $e');
// Continue running the app even if initialization fails
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Sizer(
builder: (context, orientation, deviceType) {
return AnimatedBuilder(
animation: AppStateService(),
builder: (context, child) {
return MaterialApp(
title: 'SIBU - Sistema de Transporte',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child!,
);
},
routes: AppRoutes.routes,
initialRoute: AppRoutes.splash,
);
},
);
},
);
}
}

View File

@ -0,0 +1,203 @@
class BusStopModel {
final String id;
final String name;
final double lat;
final double lng;
final String? city;
final String? address;
final String? parentId;
final String? side;
final String stopType;
final bool hasShelter;
final bool hasSeating;
final bool isAccessible;
final DateTime? createdAt;
final DateTime? updatedAt;
// Route-specific fields (from route_stops junction table)
final int? stopOrder;
final int? travelTimeMinutes;
final bool? isPickupPoint;
final bool? isDropoffPoint;
BusStopModel({
required this.id,
required this.name,
required this.lat,
required this.lng,
this.city,
this.address,
this.parentId,
this.side,
this.stopType = 'regular',
this.hasShelter = false,
this.hasSeating = false,
this.isAccessible = false,
this.createdAt,
this.updatedAt,
this.stopOrder,
this.travelTimeMinutes,
this.isPickupPoint,
this.isDropoffPoint,
});
factory BusStopModel.fromJson(Map<String, dynamic> json) {
return BusStopModel(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
lat: double.tryParse(json['lat']?.toString() ?? '0') ?? 0.0,
lng: double.tryParse(json['lng']?.toString() ?? '0') ?? 0.0,
city: json['city']?.toString(),
address: json['address']?.toString(),
parentId: json['parent_id']?.toString(),
side: json['side']?.toString(),
stopType: json['stop_type']?.toString() ?? 'regular',
hasShelter: json['has_shelter'] == true,
hasSeating: json['has_seating'] == true,
isAccessible: json['is_accessible'] == true,
createdAt:
json['created_at'] != null
? DateTime.tryParse(json['created_at'].toString())
: null,
updatedAt:
json['updated_at'] != null
? DateTime.tryParse(json['updated_at'].toString())
: null,
stopOrder:
json['stop_order'] != null
? int.tryParse(json['stop_order'].toString())
: null,
travelTimeMinutes:
json['travel_time_minutes'] != null
? int.tryParse(json['travel_time_minutes'].toString())
: null,
isPickupPoint: json['is_pickup_point'] == true,
isDropoffPoint: json['is_dropoff_point'] == true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'lat': lat,
'lng': lng,
'city': city,
'address': address,
'parent_id': parentId,
'side': side,
'stop_type': stopType,
'has_shelter': hasShelter,
'has_seating': hasSeating,
'is_accessible': isAccessible,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
'stop_order': stopOrder,
'travel_time_minutes': travelTimeMinutes,
'is_pickup_point': isPickupPoint,
'is_dropoff_point': isDropoffPoint,
};
}
// Helper getters
String get displayName => name;
String get fullAddress {
if (address != null && address!.isNotEmpty) {
return city != null ? '$address, $city' : address!;
}
return city ?? 'Ubicación desconocida';
}
String get stopTypeDisplay {
switch (stopType) {
case 'terminal':
return 'Terminal';
case 'express_only':
return 'Solo Express';
case 'regular':
default:
return 'Parada Regular';
}
}
List<String> get amenities {
List<String> amenityList = [];
if (hasShelter) amenityList.add('Refugio');
if (hasSeating) amenityList.add('Asientos');
if (isAccessible) amenityList.add('Accesible');
return amenityList;
}
String get amenitiesText {
final amenityList = amenities;
if (amenityList.isEmpty) return 'Sin servicios especiales';
return amenityList.join(', ');
}
bool get isTerminal => stopType == 'terminal';
bool get isExpressOnly => stopType == 'express_only';
String get travelTimeText {
if (travelTimeMinutes != null && travelTimeMinutes! > 0) {
return '${travelTimeMinutes} min';
}
return 'N/A';
}
BusStopModel copyWith({
String? id,
String? name,
double? lat,
double? lng,
String? city,
String? address,
String? parentId,
String? side,
String? stopType,
bool? hasShelter,
bool? hasSeating,
bool? isAccessible,
DateTime? createdAt,
DateTime? updatedAt,
int? stopOrder,
int? travelTimeMinutes,
bool? isPickupPoint,
bool? isDropoffPoint,
}) {
return BusStopModel(
id: id ?? this.id,
name: name ?? this.name,
lat: lat ?? this.lat,
lng: lng ?? this.lng,
city: city ?? this.city,
address: address ?? this.address,
parentId: parentId ?? this.parentId,
side: side ?? this.side,
stopType: stopType ?? this.stopType,
hasShelter: hasShelter ?? this.hasShelter,
hasSeating: hasSeating ?? this.hasSeating,
isAccessible: isAccessible ?? this.isAccessible,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
stopOrder: stopOrder ?? this.stopOrder,
travelTimeMinutes: travelTimeMinutes ?? this.travelTimeMinutes,
isPickupPoint: isPickupPoint ?? this.isPickupPoint,
isDropoffPoint: isDropoffPoint ?? this.isDropoffPoint,
);
}
@override
String toString() {
return 'BusStopModel(id: $id, name: $name, city: $city, stopType: $stopType)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BusStopModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@ -0,0 +1,113 @@
class CouponModel {
final String id;
final String businessName;
final String title;
final String description;
final DateTime? validUntil;
final String? imageUrl;
final String category;
final bool isActive;
final DateTime createdAt;
CouponModel({
required this.id,
required this.businessName,
required this.title,
required this.description,
this.validUntil,
this.imageUrl,
required this.category,
required this.isActive,
required this.createdAt,
});
factory CouponModel.fromMap(Map<String, dynamic> map) {
return CouponModel(
id: map['id'] as String,
businessName: map['business_name'] as String,
title: map['title'] as String,
description: map['description'] as String? ?? '',
validUntil: map['valid_until'] != null
? DateTime.parse(map['valid_until'])
: null,
imageUrl: map['image_url'] as String?,
category: map['category'] as String,
isActive: map['is_active'] as bool? ?? true,
createdAt: DateTime.parse(map['created_at']),
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'business_name': businessName,
'title': title,
'description': description,
'valid_until': validUntil?.toIso8601String(),
'image_url': imageUrl,
'category': category,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
};
}
bool get isExpired {
if (validUntil == null) return false;
return DateTime.now().isAfter(validUntil!);
}
bool get isExpiringSoon {
if (validUntil == null) return false;
final now = DateTime.now();
final difference = validUntil!.difference(now).inDays;
return difference <= 3 && difference >= 0;
}
String get categoryDisplayName {
switch (category.toLowerCase()) {
case 'restaurantes':
return 'Restaurantes';
case 'tiendas':
return 'Tiendas';
case 'servicios':
return 'Servicios';
case 'entretenimiento':
return 'Entretenimiento';
case 'salud':
return 'Salud';
case 'belleza':
return 'Belleza';
default:
return 'Otros';
}
}
String get validUntilFormatted {
if (validUntil == null) return 'Sin fecha de vencimiento';
return '${validUntil!.day.toString().padLeft(2, '0')}/${validUntil!.month.toString().padLeft(2, '0')}/${validUntil!.year}';
}
CouponModel copyWith({
String? id,
String? businessName,
String? title,
String? description,
DateTime? validUntil,
String? imageUrl,
String? category,
bool? isActive,
DateTime? createdAt,
}) {
return CouponModel(
id: id ?? this.id,
businessName: businessName ?? this.businessName,
title: title ?? this.title,
description: description ?? this.description,
validUntil: validUntil ?? this.validUntil,
imageUrl: imageUrl ?? this.imageUrl,
category: category ?? this.category,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@ -0,0 +1,154 @@
class RouteModel {
final String id;
final String name;
final String? description;
final String color;
final String direction;
final String? originCity;
final String? destinationCity;
final double? distanceKm;
final int? estimatedDurationMinutes;
final String status;
final DateTime? createdAt;
final DateTime? updatedAt;
RouteModel({
required this.id,
required this.name,
this.description,
required this.color,
required this.direction,
this.originCity,
this.destinationCity,
this.distanceKm,
this.estimatedDurationMinutes,
this.status = 'active',
this.createdAt,
this.updatedAt,
});
factory RouteModel.fromJson(Map<String, dynamic> json) {
return RouteModel(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
description: json['description']?.toString(),
color: json['color']?.toString() ?? '#FEE715',
direction: json['direction']?.toString() ?? 'outbound',
originCity: json['origin_city']?.toString(),
destinationCity: json['destination_city']?.toString(),
distanceKm: json['distance_km'] != null
? double.tryParse(json['distance_km'].toString())
: null,
estimatedDurationMinutes: json['estimated_duration_minutes'] != null
? int.tryParse(json['estimated_duration_minutes'].toString())
: null,
status: json['status']?.toString() ?? 'active',
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'].toString())
: null,
updatedAt: json['updated_at'] != null
? DateTime.tryParse(json['updated_at'].toString())
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'color': color,
'direction': direction,
'origin_city': originCity,
'destination_city': destinationCity,
'distance_km': distanceKm,
'estimated_duration_minutes': estimatedDurationMinutes,
'status': status,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
// Helper getters
String get displayName {
if (name.isNotEmpty) return name;
final od = [originCity, destinationCity]
.where((e) => e != null && e.trim().isNotEmpty)
.map((e) => e!.trim())
.join(' ');
return od.isNotEmpty ? od : 'Route';
}
String get routeDescription {
if (description != null && description!.isNotEmpty) {
return description!;
}
return 'Ruta $displayName';
}
String get durationText {
if (estimatedDurationMinutes != null) {
if (estimatedDurationMinutes! >= 60) {
final hours = estimatedDurationMinutes! ~/ 60;
final minutes = estimatedDurationMinutes! % 60;
return minutes > 0 ? '${hours}h ${minutes}min' : '${hours}h';
}
return '${estimatedDurationMinutes}min';
}
return 'N/A';
}
String get distanceText {
if (distanceKm != null) {
return '${distanceKm!.toStringAsFixed(1)} km';
}
return 'N/A';
}
bool get isActive => status == 'active';
RouteModel copyWith({
String? id,
String? name,
String? description,
String? color,
String? direction,
String? originCity,
String? destinationCity,
double? distanceKm,
int? estimatedDurationMinutes,
String? status,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return RouteModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
color: color ?? this.color,
direction: direction ?? this.direction,
originCity: originCity ?? this.originCity,
destinationCity: destinationCity ?? this.destinationCity,
distanceKm: distanceKm ?? this.distanceKm,
estimatedDurationMinutes:
estimatedDurationMinutes ?? this.estimatedDurationMinutes,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
String toString() {
return 'RouteModel(id: $id, name: $name, direction: $direction, status: $status)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is RouteModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@ -0,0 +1,72 @@
class RouteStopModel {
final String id;
final String routeId;
final String stopId;
final int stopOrder;
final int? travelTimeMinutes;
final bool isPickupPoint;
final bool isDropoffPoint;
final DateTime createdAt;
// Populated from joined data
final String? stopName;
final double? latitude;
final double? longitude;
final String? city;
RouteStopModel({
required this.id,
required this.routeId,
required this.stopId,
required this.stopOrder,
this.travelTimeMinutes,
required this.isPickupPoint,
required this.isDropoffPoint,
required this.createdAt,
this.stopName,
this.latitude,
this.longitude,
this.city,
});
factory RouteStopModel.fromJson(Map<String, dynamic> json) {
return RouteStopModel(
id: json['id'] as String,
routeId: json['route_id'] as String,
stopId: json['stop_id'] as String,
stopOrder: json['stop_order'] as int,
travelTimeMinutes: json['travel_time_minutes'] as int?,
isPickupPoint: json['is_pickup_point'] as bool,
isDropoffPoint: json['is_dropoff_point'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
stopName: json['stop_name'] as String?,
latitude: json['latitude']?.toDouble(),
longitude: json['longitude']?.toDouble(),
city: json['city'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'route_id': routeId,
'stop_id': stopId,
'stop_order': stopOrder,
'travel_time_minutes': travelTimeMinutes,
'is_pickup_point': isPickupPoint,
'is_dropoff_point': isDropoffPoint,
'created_at': createdAt.toIso8601String(),
if (stopName != null) 'stop_name': stopName,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (city != null) 'city': city,
};
}
String get operationType {
if (isPickupPoint && isDropoffPoint) return 'Subida/Bajada';
if (isPickupPoint) return 'Solo Subida';
if (isDropoffPoint) return 'Solo Bajada';
return 'Sin servicio';
}
}

View File

@ -0,0 +1,140 @@
import 'package:flutter/foundation.dart';
/// Model representing a taxi service with contact and location information
@immutable
class TaxiModel {
final String id;
final String name;
final String phone;
final String corregimiento;
final String shift;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
const TaxiModel({
required this.id,
required this.name,
required this.phone,
required this.corregimiento,
required this.shift,
required this.isActive,
required this.createdAt,
required this.updatedAt,
});
/// Create TaxiModel from Supabase JSON response
factory TaxiModel.fromJson(Map<String, dynamic> json) {
return TaxiModel(
id: json['id'] as String,
name: json['name'] as String,
phone: json['phone'] as String,
corregimiento: json['corregimiento'] as String,
shift: json['shift'] as String,
isActive: json['is_active'] as bool? ?? true,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
/// Convert TaxiModel to JSON for Supabase operations
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'phone': phone,
'corregimiento': corregimiento,
'shift': shift,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
/// Create a copy with modified properties
TaxiModel copyWith({
String? id,
String? name,
String? phone,
String? corregimiento,
String? shift,
bool? isActive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return TaxiModel(
id: id ?? this.id,
name: name ?? this.name,
phone: phone ?? this.phone,
corregimiento: corregimiento ?? this.corregimiento,
shift: shift ?? this.shift,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TaxiModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() {
return 'TaxiModel(id: $id, name: $name, phone: $phone, corregimiento: $corregimiento, shift: $shift, isActive: $isActive)';
}
}
/// Model representing a user's favorite taxi
@immutable
class FavoriteTaxiModel {
final String id;
final String userId;
final String taxiId;
final DateTime createdAt;
const FavoriteTaxiModel({
required this.id,
required this.userId,
required this.taxiId,
required this.createdAt,
});
/// Create FavoriteTaxiModel from Supabase JSON response
factory FavoriteTaxiModel.fromJson(Map<String, dynamic> json) {
return FavoriteTaxiModel(
id: json['id'] as String,
userId: json['user_id'] as String,
taxiId: json['taxi_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
/// Convert FavoriteTaxiModel to JSON for Supabase operations
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'taxi_id': taxiId,
'created_at': createdAt.toIso8601String(),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is FavoriteTaxiModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() {
return 'FavoriteTaxiModel(id: $id, userId: $userId, taxiId: $taxiId)';
}
}

View File

@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../core/app_export.dart';
import './widgets/bus_route_card_widget.dart';
import './widgets/nearby_landmarks_widget.dart';
import './widgets/report_issue_widget.dart';
import './widgets/stop_amenities_widget.dart';
import './widgets/user_comments_widget.dart';
class BusStopDetails extends StatefulWidget {
const BusStopDetails({super.key});
@override
State<BusStopDetails> createState() => _BusStopDetailsState();
}
class _BusStopDetailsState extends State<BusStopDetails> {
bool _isLoading = true;
DateTime _lastUpdated = DateTime.now();
// Mock data for bus stop details
final Map<String, dynamic> _busStopData = {
"stopId": "BS001",
"stopName": "Parada Central Boquete",
"address": "Av. Central, frente al Parque José Domingo de Obaldía",
"coordinates": {"lat": 8.7833, "lng": -82.4333},
"lastUpdated": "2025-10-19 18:45:00",
};
final List<Map<String, dynamic>> _routesData = [
{
"routeName": "Boquete - David",
"nextBusMinutes": 12,
"upcomingTimes": ["19:15", "19:45", "20:15", "20:45"],
"isDelayed": false,
},
{
"routeName": "David - Boquete",
"nextBusMinutes": 25,
"upcomingTimes": ["19:30", "20:00", "20:30", "21:00"],
"isDelayed": true,
},
{
"routeName": "Boquete - Caldera",
"nextBusMinutes": 45,
"upcomingTimes": ["19:50", "20:50", "21:50"],
"isDelayed": false,
},
];
final Map<String, dynamic> _amenitiesData = {
"hasShelter": true,
"hasBench": true,
"isAccessible": false,
"hasLighting": true,
"hasTrashCan": true,
};
final List<Map<String, dynamic>> _landmarksData = [
{
"name": "Parque José Domingo de Obaldía",
"distance": "50m",
"type": "park",
},
{
"name": "Banco Nacional de Panamá",
"distance": "120m",
"type": "bank",
},
{
"name": "Supermercado El Mandado",
"distance": "200m",
"type": "store",
},
{
"name": "Hospital Regional de Boquete",
"distance": "350m",
"type": "hospital",
},
];
final List<Map<String, dynamic>> _commentsData = [
{
"userName": "María González",
"userAvatar":
"https://images.unsplash.com/photo-1687757660301-7aac1198ed63",
"semanticLabel":
"Profile photo of a woman with long brown hair wearing a blue blouse, smiling at the camera",
"comment":
"Muy buena parada, siempre está limpia y los buses llegan a tiempo. El refugio protege bien de la lluvia.",
"timestamp": "Hace 2 horas",
"rating": 5,
},
{
"userName": "Carlos Rodríguez",
"userAvatar":
"https://images.unsplash.com/photo-1735651705945-64bc6d18d555",
"semanticLabel":
"Profile photo of a middle-aged man with short gray hair and glasses wearing a white shirt",
"comment":
"La parada está bien ubicada pero necesita mejor iluminación en las noches. Los horarios son confiables.",
"timestamp": "Hace 1 día",
"rating": 4,
},
{
"userName": "Ana Morales",
"userAvatar":
"https://images.unsplash.com/photo-1722291493584-9e75986c6c5c",
"semanticLabel":
"Profile photo of a young woman with curly black hair wearing a red top, smiling outdoors",
"comment":
"Excelente ubicación cerca del parque. Los buses de la ruta Boquete-David son muy puntuales.",
"timestamp": "Hace 3 días",
"rating": 5,
},
];
@override
void initState() {
super.initState();
_loadBusStopData();
}
Future<void> _loadBusStopData() async {
// Simulate loading data
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() {
_isLoading = false;
_lastUpdated = DateTime.now();
});
}
}
Future<void> _refreshData() async {
setState(() {
_isLoading = true;
});
await _loadBusStopData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Información actualizada'),
backgroundColor: AppTheme.successGreen,
duration: Duration(seconds: 2),
),
);
}
}
void _shareStopInfo() {
HapticFeedback.lightImpact();
final stopName = _busStopData['stopName'] as String;
final address = _busStopData['address'] as String;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Compartiendo información de: $stopName'),
backgroundColor: AppTheme.primaryBlack,
duration: const Duration(seconds: 2),
),
);
}
void _handleNotificationToggle() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notificación configurada'),
backgroundColor: AppTheme.accentYellow,
duration: Duration(seconds: 2),
),
);
}
void _handleAddComment() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Función de comentarios próximamente'),
backgroundColor: AppTheme.primaryBlack,
duration: Duration(seconds: 2),
),
);
}
void _handleReportSubmitted() {
HapticFeedback.lightImpact();
}
String _formatLastUpdated() {
final now = DateTime.now();
final difference = now.difference(_lastUpdated);
if (difference.inMinutes < 1) {
return 'Actualizado hace unos segundos';
} else if (difference.inMinutes < 60) {
return 'Actualizado hace ${difference.inMinutes} min';
} else {
return 'Actualizado hace ${difference.inHours}h';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stopName = _busStopData['stopName'] as String? ?? '';
final address = _busStopData['address'] as String? ?? '';
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: theme.colorScheme.surface,
elevation: 0,
leading: GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context);
},
child: Container(
margin: EdgeInsets.all(2.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: CustomIconWidget(
iconName: 'close',
color: theme.colorScheme.onSurface,
size: 24,
),
),
),
),
actions: [
GestureDetector(
onTap: _shareStopInfo,
child: Container(
margin: EdgeInsets.all(2.w),
padding: EdgeInsets.all(2.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: CustomIconWidget(
iconName: 'share',
color: theme.colorScheme.onSurface,
size: 24,
),
),
),
],
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: theme.brightness == Brightness.light
? Brightness.dark
: Brightness.light,
),
),
body: _isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
color: AppTheme.accentYellow,
),
SizedBox(height: 2.h),
Text(
'Cargando información de la parada...',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
)
: RefreshIndicator(
onRefresh: _refreshData,
color: AppTheme.accentYellow,
backgroundColor: theme.colorScheme.surface,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stop header information
Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stopName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 1.h),
Row(
children: [
CustomIconWidget(
iconName: 'location_on',
color: AppTheme.primaryBlack,
size: 16,
),
SizedBox(width: 1.w),
Expanded(
child: Text(
address,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.7),
),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
],
),
SizedBox(height: 2.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 3.w, vertical: 1.h),
decoration: BoxDecoration(
color: AppTheme.successGreen
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CustomIconWidget(
iconName: 'access_time',
color: AppTheme.successGreen,
size: 14,
),
SizedBox(width: 1.w),
Text(
_formatLastUpdated(),
style: theme.textTheme.labelSmall?.copyWith(
color: AppTheme.successGreen,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
SizedBox(height: 3.h),
// Real-time arrival predictions
Text(
'Llegadas en tiempo real',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 2.h),
// Route cards
..._routesData
.map((routeData) => BusRouteCardWidget(
routeData: routeData,
onNotificationToggle: _handleNotificationToggle,
))
.toList(),
SizedBox(height: 4.h),
// Stop amenities
StopAmenitiesWidget(amenitiesData: _amenitiesData),
SizedBox(height: 3.h),
// Nearby landmarks
NearbyLandmarksWidget(landmarks: _landmarksData),
SizedBox(height: 3.h),
// User comments section
UserCommentsWidget(
comments: _commentsData,
onAddComment: _handleAddComment,
),
SizedBox(height: 3.h),
// Report issue section
ReportIssueWidget(
onReportSubmitted: _handleReportSubmitted,
),
SizedBox(height: 4.h),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../../core/app_export.dart';
class BusRouteCardWidget extends StatefulWidget {
final Map<String, dynamic> routeData;
final VoidCallback? onNotificationToggle;
const BusRouteCardWidget({
super.key,
required this.routeData,
this.onNotificationToggle,
});
@override
State<BusRouteCardWidget> createState() => _BusRouteCardWidgetState();
}
class _BusRouteCardWidgetState extends State<BusRouteCardWidget> {
bool _isNotificationEnabled = false;
void _toggleNotification() {
setState(() {
_isNotificationEnabled = !_isNotificationEnabled;
});
if (widget.onNotificationToggle != null) {
widget.onNotificationToggle!();
}
}
String _formatTime(int minutes) {
if (minutes < 60) {
return '${minutes}min';
} else {
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
return remainingMinutes > 0
? '${hours}h ${remainingMinutes}min'
: '${hours}h';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final routeName = widget.routeData['routeName'] as String? ?? '';
final nextBusMinutes = widget.routeData['nextBusMinutes'] as int? ?? 0;
final upcomingTimes =
(widget.routeData['upcomingTimes'] as List?)?.cast<String>() ?? [];
final isDelayed = widget.routeData['isDelayed'] as bool? ?? false;
return Container(
margin: EdgeInsets.symmetric(horizontal: 4.w, vertical: 1.h),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Route header with notification toggle
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
routeName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
if (isDelayed) ...[
SizedBox(height: 0.5.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 2.w, vertical: 0.5.h),
decoration: BoxDecoration(
color:
AppTheme.warningOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Retraso reportado',
style: theme.textTheme.labelSmall?.copyWith(
color: AppTheme.warningOrange,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
GestureDetector(
onTap: _toggleNotification,
child: Container(
padding: EdgeInsets.all(2.w),
decoration: BoxDecoration(
color: _isNotificationEnabled
? AppTheme.accentYellow.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: CustomIconWidget(
iconName: _isNotificationEnabled
? 'notifications'
: 'notifications_none',
color: _isNotificationEnabled
? AppTheme.accentYellow
: theme.colorScheme.onSurface.withValues(alpha: 0.6),
size: 24,
),
),
),
],
),
SizedBox(height: 3.h),
// Next bus countdown
Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: AppTheme.accentYellow.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.accentYellow.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
children: [
Text(
'Próximo bus en:',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
SizedBox(height: 1.h),
Text(
_formatTime(nextBusMinutes),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppTheme.primaryBlack,
),
),
],
),
),
// Upcoming times
if (upcomingTimes.isNotEmpty) ...[
SizedBox(height: 3.h),
Text(
'Próximas salidas:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 1.h),
Wrap(
spacing: 2.w,
runSpacing: 1.h,
children: upcomingTimes
.take(4)
.map((time) => Container(
padding: EdgeInsets.symmetric(
horizontal: 3.w, vertical: 1.h),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: theme.colorScheme.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
))
.toList(),
),
],
],
),
),
);
}
}

View File

@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../../core/app_export.dart';
class NearbyLandmarksWidget extends StatelessWidget {
final List<Map<String, dynamic>> landmarks;
const NearbyLandmarksWidget({
super.key,
required this.landmarks,
});
Widget _buildLandmarkItem(
BuildContext context, Map<String, dynamic> landmark) {
final theme = Theme.of(context);
final name = landmark['name'] as String? ?? '';
final distance = landmark['distance'] as String? ?? '';
final type = landmark['type'] as String? ?? '';
String iconName = 'place';
switch (type.toLowerCase()) {
case 'restaurant':
iconName = 'restaurant';
break;
case 'hospital':
iconName = 'local_hospital';
break;
case 'school':
iconName = 'school';
break;
case 'bank':
iconName = 'account_balance';
break;
case 'store':
iconName = 'store';
break;
case 'gas_station':
iconName = 'local_gas_station';
break;
default:
iconName = 'place';
}
return Container(
margin: EdgeInsets.only(bottom: 2.h),
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Container(
width: 10.w,
height: 5.h,
decoration: BoxDecoration(
color: AppTheme.primaryBlack.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: CustomIconWidget(
iconName: iconName,
color: AppTheme.primaryBlack,
size: 20,
),
),
),
SizedBox(width: 3.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 0.5.h),
Text(
distance,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (landmarks.isEmpty) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
CustomIconWidget(
iconName: 'location_off',
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
size: 32,
),
SizedBox(height: 2.h),
Text(
'No hay puntos de referencia cercanos',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
);
}
return Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Puntos de referencia cercanos',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 3.h),
...landmarks
.map((landmark) => _buildLandmarkItem(context, landmark))
.toList(),
],
),
);
}
}

View File

@ -0,0 +1,396 @@
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sizer/sizer.dart';
import '../../../../core/app_export.dart';
class ReportIssueWidget extends StatefulWidget {
final VoidCallback? onReportSubmitted;
const ReportIssueWidget({
super.key,
this.onReportSubmitted,
});
@override
State<ReportIssueWidget> createState() => _ReportIssueWidgetState();
}
class _ReportIssueWidgetState extends State<ReportIssueWidget> {
final TextEditingController _issueController = TextEditingController();
String _selectedIssueType = 'Limpieza';
XFile? _capturedImage;
CameraController? _cameraController;
List<CameraDescription> _cameras = [];
bool _isCameraInitialized = false;
bool _isSubmitting = false;
final List<String> _issueTypes = [
'Limpieza',
'Daños en la estructura',
'Falta de iluminación',
'Problemas de accesibilidad',
'Vandalismo',
'Otro',
];
@override
void initState() {
super.initState();
_initializeCamera();
}
@override
void dispose() {
_issueController.dispose();
_cameraController?.dispose();
super.dispose();
}
Future<bool> _requestCameraPermission() async {
if (kIsWeb) return true;
return (await Permission.camera.request()).isGranted;
}
Future<void> _initializeCamera() async {
try {
if (!await _requestCameraPermission()) return;
_cameras = await availableCameras();
if (_cameras.isEmpty) return;
final camera = kIsWeb
? _cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.front,
orElse: () => _cameras.first)
: _cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => _cameras.first);
_cameraController = CameraController(
camera, kIsWeb ? ResolutionPreset.medium : ResolutionPreset.high);
await _cameraController!.initialize();
if (!kIsWeb) {
try {
await _cameraController!.setFocusMode(FocusMode.auto);
await _cameraController!.setFlashMode(FlashMode.auto);
} catch (e) {}
}
if (mounted) {
setState(() {
_isCameraInitialized = true;
});
}
} catch (e) {
// Silent fail - camera not available
}
}
Future<void> _capturePhoto() async {
if (_cameraController == null || !_cameraController!.value.isInitialized)
return;
try {
final XFile photo = await _cameraController!.takePicture();
setState(() {
_capturedImage = photo;
});
} catch (e) {
// Silent fail
}
}
Future<void> _pickImageFromGallery() async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
_capturedImage = image;
});
}
} catch (e) {
// Silent fail
}
}
Future<void> _submitReport() async {
if (_issueController.text.trim().isEmpty) return;
setState(() {
_isSubmitting = true;
});
// Simulate report submission
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isSubmitting = false;
_issueController.clear();
_capturedImage = null;
_selectedIssueType = 'Limpieza';
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reporte enviado exitosamente'),
backgroundColor: AppTheme.successGreen,
),
);
if (widget.onReportSubmitted != null) {
widget.onReportSubmitted!();
}
}
}
void _showReportDialog() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: 85.h,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Padding(
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 12.w,
height: 0.5.h,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
SizedBox(height: 3.h),
// Header
Row(
children: [
Expanded(
child: Text(
'Reportar problema',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: CustomIconWidget(
iconName: 'close',
color: Theme.of(context).colorScheme.onSurface,
size: 24,
),
),
],
),
SizedBox(height: 4.h),
// Issue type dropdown
Text(
'Tipo de problema',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 1.h),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 4.w),
decoration: BoxDecoration(
border:
Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedIssueType,
isExpanded: true,
items: _issueTypes
.map((type) => DropdownMenuItem(
value: type,
child: Text(type),
))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedIssueType = value;
});
}
},
),
),
),
SizedBox(height: 3.h),
// Description field
Text(
'Descripción del problema',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 1.h),
TextField(
controller: _issueController,
maxLines: 4,
decoration: const InputDecoration(
hintText:
'Describe el problema que encontraste en esta parada...',
),
),
SizedBox(height: 3.h),
// Photo section
Text(
'Agregar foto (opcional)',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 1.h),
if (_capturedImage != null) ...[
Container(
width: double.infinity,
height: 20.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: kIsWeb
? Image.network(_capturedImage!.path, fit: BoxFit.cover)
: Image.network(_capturedImage!.path,
fit: BoxFit.cover),
),
),
SizedBox(height: 2.h),
],
Row(
children: [
if (_isCameraInitialized) ...[
Expanded(
child: ElevatedButton.icon(
onPressed: _capturePhoto,
icon: CustomIconWidget(
iconName: 'camera_alt',
color: AppTheme.primaryBlack,
size: 20,
),
label: const Text('Tomar foto'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentYellow,
foregroundColor: AppTheme.primaryBlack,
),
),
),
SizedBox(width: 2.w),
],
Expanded(
child: OutlinedButton.icon(
onPressed: _pickImageFromGallery,
icon: CustomIconWidget(
iconName: 'photo_library',
color: Theme.of(context).colorScheme.primary,
size: 20,
),
label: const Text('Galería'),
),
),
],
),
const Spacer(),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitReport,
child: _isSubmitting
? const CircularProgressIndicator()
: const Text('Enviar reporte'),
),
),
SizedBox(height: 2.h),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Reportar problema',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 2.h),
Text(
'¿Encontraste algún problema en esta parada? Ayúdanos a mejorar reportándolo.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
SizedBox(height: 3.h),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _showReportDialog,
icon: CustomIconWidget(
iconName: 'report_problem',
color: AppTheme.primaryBlack,
size: 20,
),
label: const Text('Reportar problema'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.warningOrange,
foregroundColor: AppTheme.primaryBlack,
padding: EdgeInsets.symmetric(vertical: 3.h),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../../core/app_export.dart';
class StopAmenitiesWidget extends StatelessWidget {
final Map<String, dynamic> amenitiesData;
const StopAmenitiesWidget({
super.key,
required this.amenitiesData,
});
Widget _buildAmenityItem(
BuildContext context, String iconName, String label, bool isAvailable) {
final theme = Theme.of(context);
return Column(
children: [
Container(
width: 12.w,
height: 6.h,
decoration: BoxDecoration(
color: isAvailable
? AppTheme.successGreen.withValues(alpha: 0.1)
: theme.colorScheme.surface.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isAvailable
? AppTheme.successGreen.withValues(alpha: 0.3)
: theme.colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Center(
child: CustomIconWidget(
iconName: iconName,
color: isAvailable
? AppTheme.successGreen
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
size: 24,
),
),
),
SizedBox(height: 1.h),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: isAvailable
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
],
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasShelter = amenitiesData['hasShelter'] as bool? ?? false;
final hasBench = amenitiesData['hasBench'] as bool? ?? false;
final isAccessible = amenitiesData['isAccessible'] as bool? ?? false;
final hasLighting = amenitiesData['hasLighting'] as bool? ?? false;
final hasTrashCan = amenitiesData['hasTrashCan'] as bool? ?? false;
return Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Comodidades de la parada',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
SizedBox(height: 3.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: _buildAmenityItem(
context,
'home',
'Refugio',
hasShelter,
),
),
Expanded(
child: _buildAmenityItem(
context,
'chair',
'Asiento',
hasBench,
),
),
Expanded(
child: _buildAmenityItem(
context,
'accessible',
'Accesible',
isAccessible,
),
),
Expanded(
child: _buildAmenityItem(
context,
'lightbulb',
'Iluminación',
hasLighting,
),
),
Expanded(
child: _buildAmenityItem(
context,
'delete',
'Basura',
hasTrashCan,
),
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../../core/app_export.dart';
class UserCommentsWidget extends StatefulWidget {
final List<Map<String, dynamic>> comments;
final VoidCallback? onAddComment;
const UserCommentsWidget({
super.key,
required this.comments,
this.onAddComment,
});
@override
State<UserCommentsWidget> createState() => _UserCommentsWidgetState();
}
class _UserCommentsWidgetState extends State<UserCommentsWidget> {
bool _isExpanded = false;
Widget _buildCommentItem(BuildContext context, Map<String, dynamic> comment) {
final theme = Theme.of(context);
final userName = comment['userName'] as String? ?? '';
final userAvatar = comment['userAvatar'] as String? ?? '';
final semanticLabel = comment['semanticLabel'] as String? ?? '';
final commentText = comment['comment'] as String? ?? '';
final timestamp = comment['timestamp'] as String? ?? '';
final rating = comment['rating'] as int? ?? 0;
return Container(
margin: EdgeInsets.only(bottom: 3.h),
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// User info and rating
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CustomImageWidget(
imageUrl: userAvatar,
width: 40,
height: 40,
fit: BoxFit.cover,
semanticLabel: semanticLabel,
),
),
SizedBox(width: 3.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
timestamp,
style: theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
// Rating stars
Row(
children: List.generate(
5,
(index) => CustomIconWidget(
iconName: index < rating ? 'star' : 'star_border',
color: index < rating
? AppTheme.accentYellow
: theme.colorScheme.onSurface
.withValues(alpha: 0.3),
size: 16,
)),
),
],
),
SizedBox(height: 2.h),
// Comment text
Text(
commentText,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with expand/collapse
GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: Row(
children: [
Expanded(
child: Text(
'Comentarios de usuarios (${widget.comments.length})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
),
CustomIconWidget(
iconName: _isExpanded ? 'expand_less' : 'expand_more',
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
size: 24,
),
],
),
),
if (_isExpanded) ...[
SizedBox(height: 3.h),
// Add comment button
GestureDetector(
onTap: widget.onAddComment,
child: Container(
width: double.infinity,
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: AppTheme.accentYellow.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.accentYellow.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
CustomIconWidget(
iconName: 'add_comment',
color: AppTheme.primaryBlack,
size: 20,
),
SizedBox(width: 2.w),
Text(
'Agregar comentario',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: AppTheme.primaryBlack,
),
),
],
),
),
),
SizedBox(height: 3.h),
// Comments list
if (widget.comments.isEmpty)
Container(
width: double.infinity,
padding: EdgeInsets.all(6.w),
child: Column(
children: [
CustomIconWidget(
iconName: 'comment',
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
size: 32,
),
SizedBox(height: 2.h),
Text(
'Aún no hay comentarios',
style: theme.textTheme.bodyMedium?.copyWith(
color:
theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
SizedBox(height: 1.h),
Text(
'¡Sé el primero en compartir tu experiencia!',
style: theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
textAlign: TextAlign.center,
),
],
),
)
else
...widget.comments
.map((comment) => _buildCommentItem(context, comment))
.toList(),
],
],
),
);
}
}

View File

@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../core/app_export.dart';
import '../../models/coupon_model.dart';
import '../../services/coupon_service.dart';
import '../../theme/app_theme.dart';
import '../../widgets/custom_bottom_bar.dart';
import './widgets/category_filter_chips.dart';
import './widgets/coupon_card_widget.dart';
import './widgets/coupon_detail_modal.dart';
import './widgets/empty_state_widget.dart';
import './widgets/sort_dropdown.dart';
class CouponsScreen extends StatefulWidget {
const CouponsScreen({super.key});
@override
State<CouponsScreen> createState() => _CouponsScreenState();
}
class _CouponsScreenState extends State<CouponsScreen> {
final ScrollController _scrollController = ScrollController();
bool _isLoading = true;
bool _isRefreshing = false;
String _selectedCategory = 'Todos';
String _selectedSort = 'Más recientes';
List<CouponModel> _coupons = [];
@override
void initState() {
super.initState();
_loadCoupons();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// Load coupons with silent error handling - no user error messages
Future<void> _loadCoupons() async {
try {
setState(() {
_isLoading = true;
});
final coupons = await CouponService.getCoupons(
selectedCategory: _selectedCategory,
sort: _selectedSort,
);
setState(() {
_coupons = coupons;
_isLoading = false;
});
} catch (e) {
// Silent failure - show empty state but don't show error to user
setState(() {
_coupons = [];
_isLoading = false;
});
}
}
/// Refresh coupons with silent error handling
Future<void> _refreshCoupons() async {
setState(() {
_isRefreshing = true;
});
HapticFeedback.lightImpact();
try {
final coupons = await CouponService.getCoupons(
selectedCategory: _selectedCategory,
sort: _selectedSort,
);
setState(() {
_coupons = coupons;
});
} catch (e) {
// Silent failure - keep existing coupons, don't show error
} finally {
setState(() {
_isRefreshing = false;
});
}
}
/// Handle category change and auto-refresh data
void _onCategoryChanged(String category) {
if (_selectedCategory != category) {
setState(() {
_selectedCategory = category;
});
_loadCoupons(); // Automatically refresh when filters change
}
}
/// Handle sort change and auto-refresh data
void _onSortChanged(String sort) {
if (_selectedSort != sort) {
setState(() {
_selectedSort = sort;
});
_loadCoupons(); // Automatically refresh when sorting changes
}
}
void _showCouponDetail(CouponModel coupon) {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (context) => CouponDetailModal(coupon: coupon),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(
'Cupones',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
backgroundColor: theme.colorScheme.surface,
elevation: 0,
),
body: Column(
children: [
// Category Filter Chips (Spanish UI)
CategoryFilterChips(
selectedCategory: _selectedCategory,
onCategorySelected: _onCategoryChanged,
),
// Sort Dropdown (Spanish UI)
Container(
padding: EdgeInsets.symmetric(vertical: 1.h),
color: theme.colorScheme.surface,
child: SortDropdown(
selectedSort: _selectedSort,
onSortChanged: _onSortChanged,
),
),
// Content
Expanded(child: _buildContent()),
],
),
bottomNavigationBar: CustomBottomBar(
currentIndex: 2,
onTap: (index) {
switch (index) {
case 0:
Navigator.pushReplacementNamed(context, '/map-screen');
break;
case 1:
Navigator.pushReplacementNamed(context, '/schedules-screen');
break;
case 2:
// Already on coupons screen
break;
}
},
),
);
}
Widget _buildContent() {
final theme = Theme.of(context);
if (_isLoading) {
return Center(
child: CircularProgressIndicator(color: AppTheme.accentYellow),
);
}
// No error states shown to user - only empty states
if (_coupons.isEmpty) {
final isFiltered = _selectedCategory != 'Todos';
return EmptyStateWidget(
title:
isFiltered
? 'No hay cupones disponibles para esta categoría.'
: 'Aún no hay cupones registrados.',
subtitle:
isFiltered
? 'Intenta seleccionando otra categoría'
: 'Vuelve pronto para ver nuevas ofertas',
actionText: isFiltered ? 'Ver todos' : null,
onActionPressed: isFiltered ? () => _onCategoryChanged('Todos') : null,
);
}
return RefreshIndicator(
onRefresh: _refreshCoupons,
color: AppTheme.accentYellow,
backgroundColor: theme.colorScheme.surface,
child: GridView.builder(
controller: _scrollController,
padding: EdgeInsets.all(4.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _getGridCrossAxisCount(),
crossAxisSpacing: 4.w,
mainAxisSpacing: 4.w,
childAspectRatio: 0.75,
),
itemCount: _coupons.length + (_isRefreshing ? 2 : 0),
itemBuilder: (context, index) {
if (index >= _coupons.length) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
),
child: Center(
child: CircularProgressIndicator(
color: AppTheme.accentYellow,
strokeWidth: 2,
),
),
),
);
}
final coupon = _coupons[index];
return CouponCardWidget(
coupon: coupon,
onTap: () => _showCouponDetail(coupon),
);
},
),
);
}
int _getGridCrossAxisCount() {
if (MediaQuery.of(context).size.width > 768) {
return 3; // Tablet
}
return 2; // Phone
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../services/coupon_service.dart';
import '../../../theme/app_theme.dart';
class CategoryFilterChips extends StatelessWidget {
final String selectedCategory;
final ValueChanged<String> onCategorySelected;
const CategoryFilterChips({
super.key,
required this.selectedCategory,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final categories = CouponService.getCategoryOptions();
return Container(
height: 6.h,
padding: EdgeInsets.symmetric(vertical: 1.h),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 4.w),
itemCount: categories.length,
separatorBuilder: (context, index) => SizedBox(width: 2.w),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = selectedCategory == category;
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
onCategorySelected(category);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 4.w,
vertical: 1.h,
),
decoration: BoxDecoration(
color: isSelected
? AppTheme.accentYellow
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? AppTheme.accentYellow
: theme.colorScheme.outline,
width: 1,
),
),
child: Text(
category,
style: theme.textTheme.labelMedium?.copyWith(
color: isSelected
? AppTheme.primaryBlack
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../models/coupon_model.dart';
class CouponCardWidget extends StatelessWidget {
final CouponModel coupon;
final VoidCallback onTap;
const CouponCardWidget({
super.key,
required this.coupon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Section (16:9 aspect ratio)
Expanded(
flex: 4,
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: coupon.imageUrl != null
? CustomImageWidget(
imageUrl: coupon.imageUrl!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
semanticLabel: 'Imagen de ${coupon.businessName}',
)
: Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomIconWidget(
iconName: 'image',
color: theme.colorScheme.onSurfaceVariant,
size: 24,
),
SizedBox(height: 1.h),
Text(
'Sin imagen',
style:
theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
),
// Content Section
Expanded(
flex: 3,
child: Padding(
padding: EdgeInsets.all(3.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Business Name (bold)
Text(
coupon.businessName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 0.5.h),
// Title (subtitle style)
Text(
coupon.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 0.5.h),
// Description (2 lines max, small text)
Expanded(
child: Text(
coupon.description,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: 1.h),
// Valid until text
Text(
coupon.validUntil != null
? 'Válido hasta: ${coupon.validUntilFormatted}'
: 'Sin fecha de vencimiento',
style: theme.textTheme.bodySmall?.copyWith(
color: coupon.isExpired
? AppTheme.errorRed
: coupon.isExpiringSoon
? Colors.orange
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../models/coupon_model.dart';
class CouponDetailModal extends StatelessWidget {
final CouponModel coupon;
final VoidCallback? onUseCoupon;
const CouponDetailModal({
super.key,
required this.coupon,
this.onUseCoupon,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.all(4.w),
child: Container(
constraints: BoxConstraints(
maxHeight: 85.h,
),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Close Button
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.all(2.w),
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: CustomIconWidget(
iconName: 'close',
color: theme.colorScheme.onSurface,
size: 24,
),
),
),
),
// Content
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(6.w, 0, 6.w, 6.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full Image
Container(
width: double.infinity,
height: 25.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surfaceContainerHighest,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: coupon.imageUrl != null
? CustomImageWidget(
imageUrl: coupon.imageUrl!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
semanticLabel:
'Imagen de ${coupon.businessName}',
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomIconWidget(
iconName: 'image',
color: theme.colorScheme.onSurfaceVariant,
size: 32,
),
SizedBox(height: 1.h),
Text(
'Sin imagen',
style:
theme.textTheme.bodyMedium?.copyWith(
color:
theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
SizedBox(height: 4.w),
// Business Name
Text(
coupon.businessName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 2.w),
// Title
Text(
coupon.title,
style: theme.textTheme.titleMedium?.copyWith(
color: AppTheme.accentYellow,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 3.w),
// Description
Text(
coupon.description,
style: theme.textTheme.bodyMedium,
),
SizedBox(height: 3.w),
// Valid Until
Container(
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
CustomIconWidget(
iconName: 'access_time',
color: theme.colorScheme.onSurfaceVariant,
size: 20,
),
SizedBox(width: 2.w),
Text(
coupon.validUntil != null
? 'Válido hasta: ${coupon.validUntilFormatted}'
: 'Sin fecha de vencimiento',
style: theme.textTheme.bodyMedium?.copyWith(
color: coupon.isExpired
? AppTheme.errorRed
: coupon.isExpiringSoon
? Colors.orange
: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
),
SizedBox(height: 4.w),
// Action Buttons Row
Row(
children: [
// Call Button (placeholder)
Expanded(
child: OutlinedButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
// TODO: Implement call functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Función de llamada próximamente'),
),
);
},
icon: CustomIconWidget(
iconName: 'call',
color: theme.colorScheme.primary,
size: 20,
),
label: const Text('Llamar'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
side:
BorderSide(color: theme.colorScheme.outline),
),
),
),
SizedBox(width: 3.w),
// WhatsApp Button (placeholder)
Expanded(
child: OutlinedButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
// TODO: Implement WhatsApp functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Función de WhatsApp próximamente'),
),
);
},
icon: CustomIconWidget(
iconName: 'chat',
color: Colors.green,
size: 20,
),
label: const Text('WhatsApp'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.green,
side:
BorderSide(color: theme.colorScheme.outline),
),
),
),
SizedBox(width: 3.w),
// Location Button (placeholder)
Expanded(
child: OutlinedButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
// TODO: Implement location functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Función de ubicación próximamente'),
),
);
},
icon: CustomIconWidget(
iconName: 'location_on',
color: AppTheme.errorRed,
size: 20,
),
label: const Text('Ubicación'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorRed,
side:
BorderSide(color: theme.colorScheme.outline),
),
),
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
class EmptyStateWidget extends StatelessWidget {
final String title;
final String subtitle;
final String? actionText;
final VoidCallback? onActionPressed;
const EmptyStateWidget({
super.key,
required this.title,
required this.subtitle,
this.actionText,
this.onActionPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: EdgeInsets.all(8.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Empty state icon
Container(
padding: EdgeInsets.all(6.w),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: CustomIconWidget(
iconName: 'local_offer',
color: theme.colorScheme.onSurfaceVariant,
size: 48,
),
),
SizedBox(height: 4.h),
// Title
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
SizedBox(height: 2.h),
// Subtitle
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
if (actionText != null && onActionPressed != null) ...[
SizedBox(height: 4.h),
// Action button
ElevatedButton(
onPressed: onActionPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentYellow,
foregroundColor: AppTheme.primaryBlack,
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 1.5.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
actionText!,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
);
}
}

View File

@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../theme/app_theme.dart';
class FilterBottomSheet extends StatefulWidget {
final Map<String, dynamic> currentFilters;
final Function(Map<String, dynamic>) onFiltersChanged;
const FilterBottomSheet({
super.key,
required this.currentFilters,
required this.onFiltersChanged,
});
@override
State<FilterBottomSheet> createState() => _FilterBottomSheetState();
}
class _FilterBottomSheetState extends State<FilterBottomSheet> {
late Map<String, dynamic> _filters;
final List<String> _categories = [
'Todos',
'Restaurantes',
'Tiendas',
'Servicios',
'Entretenimiento',
'Salud',
'Belleza',
];
final List<String> _sortOptions = [
'Más recientes',
'Por vencer',
'Mayor descuento',
'Distancia',
];
@override
void initState() {
super.initState();
_filters = Map<String, dynamic>.from(widget.currentFilters);
}
void _applyFilters() {
HapticFeedback.lightImpact();
widget.onFiltersChanged(_filters);
Navigator.of(context).pop();
}
void _resetFilters() {
HapticFeedback.lightImpact();
setState(() {
_filters = {
'category': 'Todos',
'sortBy': 'Más recientes',
'showUsed': false,
'showExpired': false,
};
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
margin: EdgeInsets.only(top: 2.h),
width: 12.w,
height: 0.5.h,
decoration: BoxDecoration(
color: theme.colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: EdgeInsets.all(4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filtros',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
TextButton(
onPressed: _resetFilters,
child: Text(
'Limpiar',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
Divider(height: 1, color: theme.colorScheme.outline),
// Content
Flexible(
child: SingleChildScrollView(
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category Filter
Text(
'Categoría',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 2.h),
Wrap(
spacing: 2.w,
runSpacing: 1.h,
children: _categories.map((category) {
final isSelected = _filters['category'] == category;
return FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
HapticFeedback.lightImpact();
setState(() {
_filters['category'] = category;
});
},
backgroundColor: theme.colorScheme.surface,
selectedColor:
AppTheme.accentYellow.withValues(alpha: 0.2),
checkmarkColor: AppTheme.primaryBlack,
labelStyle: theme.textTheme.bodyMedium?.copyWith(
color: isSelected
? AppTheme.primaryBlack
: theme.colorScheme.onSurface,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
side: BorderSide(
color: isSelected
? AppTheme.accentYellow
: theme.colorScheme.outline,
),
);
}).toList(),
),
SizedBox(height: 3.h),
// Sort Options
Text(
'Ordenar por',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 2.h),
Column(
children: _sortOptions.map((option) {
final isSelected = _filters['sortBy'] == option;
return RadioListTile<String>(
title: Text(
option,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
value: option,
groupValue: _filters['sortBy'],
onChanged: (value) {
HapticFeedback.lightImpact();
setState(() {
_filters['sortBy'] = value;
});
},
activeColor: AppTheme.primaryBlack,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
SizedBox(height: 2.h),
// Additional Options
Text(
'Mostrar',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 1.h),
SwitchListTile(
title: Text(
'Cupones usados',
style: theme.textTheme.bodyMedium,
),
value: _filters['showUsed'] ?? false,
onChanged: (value) {
HapticFeedback.lightImpact();
setState(() {
_filters['showUsed'] = value;
});
},
activeColor: AppTheme.accentYellow,
activeTrackColor: AppTheme.primaryBlack,
contentPadding: EdgeInsets.zero,
),
SwitchListTile(
title: Text(
'Cupones vencidos',
style: theme.textTheme.bodyMedium,
),
value: _filters['showExpired'] ?? false,
onChanged: (value) {
HapticFeedback.lightImpact();
setState(() {
_filters['showExpired'] = value;
});
},
activeColor: AppTheme.accentYellow,
activeTrackColor: AppTheme.primaryBlack,
contentPadding: EdgeInsets.zero,
),
],
),
),
),
// Apply Button
Padding(
padding: EdgeInsets.all(4.w),
child: SizedBox(
width: double.infinity,
height: 6.h,
child: ElevatedButton(
onPressed: _applyFilters,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentYellow,
foregroundColor: AppTheme.primaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Aplicar Filtros',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../services/coupon_service.dart';
import '../../../widgets/custom_icon_widget.dart';
class SortDropdown extends StatelessWidget {
final String selectedSort;
final ValueChanged<String> onSortChanged;
const SortDropdown({
super.key,
required this.selectedSort,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final sortOptions = CouponService.getSortOptions();
return Container(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Ordenar por:',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 3.w),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedSort,
isDense: true,
icon: CustomIconWidget(
iconName: 'keyboard_arrow_down',
color: theme.colorScheme.onSurface,
size: 20,
),
items: sortOptions.map((String option) {
return DropdownMenuItem<String>(
value: option,
child: Text(
option,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
HapticFeedback.lightImpact();
onSortChanged(newValue);
}
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,760 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../../models/bus_stop_model.dart';
import '../../models/route_model.dart';
import '../../services/transportation_service.dart';
import '../../services/app_state_service.dart';
import '../../services/supabase_service.dart';
import '../../widgets/custom_bottom_bar.dart';
import '../../widgets/route_selection_bottom_sheet.dart';
import '../../widgets/debug_banner_widget.dart';
import './widgets/bus_arrival_bottom_sheet.dart';
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
final TransportationService _transportationService = TransportationService();
final AppStateService _appStateService = AppStateService();
GoogleMapController? _mapController;
// State variables
List<BusStopModel> _routeStops = [];
BusStopModel? _selectedStop;
Set<Marker> _markers = {};
bool _isLoading = false;
String? _error;
String? _nextBusMessage;
String? _lastRoutesError;
int _routeCount = 0;
// New connection check state variables
bool _isConnected = false;
String _connectionStatus = 'Not checked';
// Panama coordinates (centered around David/Boquete area)
static const LatLng _initialPosition = LatLng(8.4177, -82.4270);
@override
void initState() {
super.initState();
_appStateService.addListener(_onGlobalStateChanged);
_performSupabaseConnectionCheck();
}
@override
void dispose() {
_appStateService.removeListener(_onGlobalStateChanged);
_mapController?.dispose();
super.dispose();
}
void _onGlobalStateChanged() {
// React to global route selection changes
if (mounted) {
_loadStopsForSelectedRoute();
}
}
/// Perform Supabase connection check as specified in requirements
Future<void> _performSupabaseConnectionCheck() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_error = null;
});
// Step 1 & 2: Validate credentials and initialize Supabase client
final connectionResult = await SupabaseService.performConnectionCheck();
if (connectionResult['success']) {
// Connection successful - show "Connected ✓" and count
if (mounted) {
setState(() {
_isConnected = true;
_connectionStatus = 'Connected ✓';
_routeCount = connectionResult['count'] ?? 0;
_lastRoutesError = null;
_isLoading = false;
});
}
// Load initial data after successful connection
if (mounted) {
await _loadInitialData();
}
} else {
// Connection failed - show error
final errorMessage = connectionResult['error'] ?? 'Unknown error';
if (mounted) {
setState(() {
_isConnected = false;
_connectionStatus = 'Connection Failed';
_lastRoutesError = errorMessage;
_error = errorMessage;
_isLoading = false;
});
}
// Show red toast for credential issues
if (errorMessage.contains('Missing') ||
errorMessage.contains('SUPABASE_URL') ||
errorMessage.contains('SUPABASE_ANON_KEY')) {
Fluttertoast.showToast(
msg: "Missing SUPABASE_URL or SUPABASE_ANON_KEY",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
}
}
}
Future<void> _loadInitialData() async {
setState(() {
_isLoading = true;
_error = null;
_lastRoutesError = null;
});
try {
// Check if we have routes loaded globally
if (_appStateService.allRoutes.isEmpty &&
!_appStateService.isLoadingRoutes) {
await _appStateService.loadRoutes();
}
final routes = _appStateService.allRoutes;
if (mounted) {
setState(() {
_routeCount = routes.length;
_lastRoutesError = null; // Clear any previous errors when successful
_isConnected = true; // Update connection status on successful data load
_connectionStatus = 'Connected ✓';
});
}
// Show toast if no routes found as specified in requirements
if (routes.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Fluttertoast.showToast(
msg: "No routes available",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.orange,
textColor: Colors.white,
fontSize: 16.0,
);
});
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
// Auto-pick the first route if no route is selected (Step 6 from requirements)
if (mounted && _appStateService.selectedRouteId == null && routes.isNotEmpty) {
final firstRoute = routes.first;
await _appStateService.selectRoute(firstRoute.id);
}
// Load stops for currently selected route
if (mounted) {
await _loadStopsForSelectedRoute();
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Error loading routes: ${e.toString()}';
_lastRoutesError = e.toString();
_isLoading = false;
_isConnected = false;
_connectionStatus = 'Connection Failed';
});
}
// Show Supabase connection error as per requirements
if (e.toString().contains('connection') ||
e.toString().contains('credentials')) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Fluttertoast.showToast(
msg: "Could not connect to Supabase. Please check credentials.",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
});
}
}
}
Future<void> _loadStopsForSelectedRoute() async {
final selectedRouteId = _appStateService.selectedRouteId;
if (selectedRouteId == null) {
setState(() {
_routeStops = [];
_markers = {};
_selectedStop = null;
_nextBusMessage = null;
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_error = null;
_selectedStop = null;
_nextBusMessage = null;
});
try {
// Use the exact query from requirements (Step 7)
final stops = await _transportationService.getRouteStopsOrderedBySeq(
selectedRouteId,
);
if (mounted) {
setState(() {
_routeStops = stops;
_isLoading = false;
});
}
// Clear and re-render markers
if (mounted) {
await _updateMapMarkers();
}
// Move camera to show all stops
if (mounted && stops.isNotEmpty && _mapController != null) {
_fitCameraToStops(stops);
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Error loading stops: ${e.toString()}';
_isLoading = false;
_routeStops = [];
_markers = {};
});
}
}
}
Future<void> _updateMapMarkers() async {
Set<Marker> markers = {};
for (int i = 0; i < _routeStops.length; i++) {
final stop = _routeStops[i];
final isSelected = _selectedStop?.id == stop.id;
markers.add(
Marker(
markerId: MarkerId(stop.id),
position: LatLng(stop.lat, stop.lng),
onTap: () => _onStopTapped(stop),
icon: await _createStopMarkerIcon(
isSelected: isSelected,
stopNumber: (i + 1).toString(),
),
infoWindow: InfoWindow(title: stop.name, snippet: 'Parada ${i + 1}'),
),
);
}
if (mounted) {
setState(() {
_markers = markers;
});
}
}
Future<BitmapDescriptor> _createStopMarkerIcon({
required bool isSelected,
required String stopNumber,
}) async {
if (isSelected) {
return BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen);
} else {
return BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueYellow);
}
}
Future<void> _onStopTapped(BusStopModel stop) async {
if (!mounted) return;
setState(() {
_selectedStop = stop;
_nextBusMessage = null;
});
if (mounted) {
await _updateMapMarkers();
}
// Calculate next bus time for this stop
if (mounted) {
final selectedRouteId = _appStateService.selectedRouteId;
if (selectedRouteId != null) {
await _calculateNextBusTime(stop, selectedRouteId);
}
}
}
Future<void> _calculateNextBusTime(BusStopModel stop, String routeId) async {
try {
final nextBusInfo = await _transportationService.getNextBusTime(
routeId,
stop.id,
);
if (nextBusInfo != null) {
if (nextBusInfo['minutes_until_arrival'] != null) {
final minutes = nextBusInfo['minutes_until_arrival'] ?? 0;
final scheduleType = nextBusInfo['schedule_type'] ?? 'weekday';
String scheduleDisplay = '';
switch (scheduleType) {
case 'weekday':
scheduleDisplay = 'Lunes-Viernes';
break;
case 'saturday':
scheduleDisplay = 'Sábado';
break;
case 'sunday':
scheduleDisplay = 'Domingo';
break;
}
if (mounted) {
setState(() {
_nextBusMessage = 'Próximo bus en: $minutes min ($scheduleDisplay)';
});
}
} else if (nextBusInfo['first_tomorrow'] != null) {
if (mounted) {
setState(() {
_nextBusMessage =
'No hay más buses hoy. Primer bus mañana: ${nextBusInfo['first_tomorrow']}';
});
}
}
} else {
if (mounted) {
setState(() {
_nextBusMessage = 'No hay más buses programados';
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_nextBusMessage = 'Error calculando próximo bus';
});
}
}
}
void _showBusArrivalInfo(BusStopModel stop, RouteModel route) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.3,
builder: (context, scrollController) =>
BusArrivalBottomSheet(busStop: stop, route: route),
),
);
}
void _showRouteSelector() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => RouteSelectionBottomSheet(
title: 'Seleccionar Ruta',
onRouteChanged: () {
// The global state listener will handle the reload
},
),
);
}
void _fitCameraToStops(List<BusStopModel> stops) {
if (stops.isEmpty || _mapController == null) return;
double minLat = stops.first.lat;
double maxLat = stops.first.lat;
double minLng = stops.first.lng;
double maxLng = stops.first.lng;
for (final stop in stops) {
minLat = math.min(minLat, stop.lat);
maxLat = math.max(maxLat, stop.lat);
minLng = math.min(minLng, stop.lng);
maxLng = math.max(maxLng, stop.lng);
}
_mapController!.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
southwest: LatLng(minLat - 0.01, minLng - 0.01),
northeast: LatLng(maxLat + 0.01, maxLng + 0.01),
),
100.0,
),
);
}
void _onMapCreated(GoogleMapController controller) {
_mapController = controller;
if (_routeStops.isNotEmpty) {
_fitCameraToStops(_routeStops);
}
}
@override
Widget build(BuildContext context) {
final selectedRoute = _appStateService.getSelectedRoute();
final selectedRouteName = _appStateService.selectedRouteName;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: const Color(0xFFFEE715),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.menu, color: Color(0xFF101820)),
onPressed: () {
// TODO: Implement menu functionality
},
),
title: const Text(
'SIBU',
style: TextStyle(
color: Color(0xFF101820),
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.feedback, color: Color(0xFF101820)),
onPressed: () {
// TODO: Implement feedback functionality
},
),
],
),
body: RefreshIndicator(
onRefresh: _performSupabaseConnectionCheck,
child: Stack(
children: [
// Google Map
GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: const CameraPosition(
target: _initialPosition,
zoom: 11.0,
),
markers: _markers,
myLocationEnabled: true,
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
mapToolbarEnabled: false,
),
// Debug banner with updated connection status
DebugBannerWidget(
lastError: _lastRoutesError,
routeCount: _routeCount,
isConnected: _isConnected,
connectionStatus: _connectionStatus,
),
// Route selector card (always visible when route is selected)
if (selectedRoute != null && selectedRouteName != null)
Positioned(
top: 80,
left: 16,
right: 16,
child: GestureDetector(
onTap: _showRouteSelector,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(26),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Icon(
Icons.route,
color: const Color(0xFF101820),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Route: $selectedRouteName',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF101820),
),
),
Text(
'${_routeStops.length} stops',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.keyboard_arrow_down,
color: Colors.grey[600],
size: 20,
),
],
),
),
),
),
// Next bus info (when stop is selected)
if (_selectedStop != null && selectedRoute != null)
Positioned(
bottom: 100,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEE715),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(26),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_selectedStop!.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF101820),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_nextBusMessage ?? 'Calculando próximo bus...',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF101820).withAlpha(204),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _showBusArrivalInfo(
_selectedStop!,
selectedRoute,
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF101820),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'CONSULTAR SIGUIENTE BUS',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
// Loading indicator
if (_isLoading)
Container(
color: Colors.black.withAlpha(77),
child: const Center(
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFFFEE715)),
),
),
),
// Error message (Step 4: red error card)
if (_error != null)
Positioned(
top: 150,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red[300]!),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: Colors.red[600], size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
_error!,
style:
TextStyle(color: Colors.red[600], fontSize: 14),
),
),
IconButton(
onPressed: () {
setState(() => _error = null);
_performSupabaseConnectionCheck();
},
icon: Icon(
Icons.refresh,
color: Colors.red[600],
size: 20,
),
),
],
),
),
),
// No routes found message (Step 4)
if (_routeCount == 0 && !_isLoading && _error == null)
Positioned(
top: 150,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange[300]!),
),
child: Row(
children: [
Icon(
Icons.warning_outlined,
color: Colors.orange[600],
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'No routes available',
style: TextStyle(fontSize: 14),
),
),
IconButton(
onPressed: _performSupabaseConnectionCheck,
icon: Icon(
Icons.refresh,
color: Colors.orange[600],
size: 20,
),
),
],
),
),
),
// Route selector floating button (when no route selected)
if (selectedRoute == null &&
!_appStateService.isLoadingRoutes &&
_routeCount > 0)
Positioned(
top: 80,
right: 16,
child: FloatingActionButton(
mini: true,
backgroundColor: const Color(0xFFFEE715),
onPressed: _showRouteSelector,
child: const Icon(Icons.route, color: Color(0xFF101820)),
),
),
// Small refresh icon with updated refresh method
if (selectedRoute != null)
Positioned(
top: 80,
right: 16,
child: FloatingActionButton(
mini: true,
backgroundColor: const Color(0xFFFEE715),
onPressed:
_performSupabaseConnectionCheck, // Updated to use connection check
child: const Icon(Icons.refresh, color: Color(0xFF101820)),
),
),
],
),
),
bottomNavigationBar: CustomBottomBar(
currentIndex: 0,
onTap: (int index) {
// Handle navigation tap
},
),
);
}
}

View File

@ -0,0 +1,461 @@
import 'package:flutter/material.dart';
import '../../../models/bus_stop_model.dart';
import '../../../models/route_model.dart';
import '../../../services/transportation_service.dart';
class BusArrivalBottomSheet extends StatefulWidget {
final BusStopModel busStop;
final RouteModel route;
const BusArrivalBottomSheet({
super.key,
required this.busStop,
required this.route,
});
@override
State<BusArrivalBottomSheet> createState() => _BusArrivalBottomSheetState();
}
class _BusArrivalBottomSheetState extends State<BusArrivalBottomSheet> {
final TransportationService _transportationService = TransportationService();
Map<String, dynamic>? _arrivalInfo;
List<Map<String, dynamic>> _schedules = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadArrivalInfo();
}
Future<void> _loadArrivalInfo() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
// Load next bus arrival info using the new method with schedule_type
final arrivalInfo = await _transportationService.getNextBusTime(
widget.route.id,
widget.busStop.id,
);
// Load all schedules for this route from timetable (current day's schedule)
final schedules = await _transportationService.getRouteTimetables(
widget.route.id,
);
setState(() {
_arrivalInfo = arrivalInfo;
_schedules = schedules;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Error cargando información: ${e.toString()}';
_isLoading = false;
});
}
}
String _formatTime(String timeStr) {
try {
final parts = timeStr.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final period = hour >= 12 ? 'PM' : 'AM';
final displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour);
return '${displayHour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')} $period';
} catch (e) {
return timeStr;
}
}
String _formatMinutesUntil(int minutes) {
if (minutes <= 0) return 'Llegando ahora';
if (minutes == 1) return 'En 1 minuto';
if (minutes < 60) return 'En $minutes minutos';
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
if (remainingMinutes == 0) {
return hours == 1 ? 'En 1 hora' : 'En $hours horas';
}
return 'En ${hours}h ${remainingMinutes}min';
}
String _getScheduleTypeDisplay(String scheduleType) {
switch (scheduleType) {
case 'weekday':
return 'Lunes-Viernes';
case 'saturday':
return 'Sábado';
case 'sunday':
return 'Domingo';
default:
return scheduleType;
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 12),
height: 4,
width: 40,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFEE715),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.directions_bus,
color: Color(0xFF101820),
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.busStop.displayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF101820),
),
),
Text(
widget.route.displayName,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
color: const Color(0xFF101820),
),
],
),
],
),
),
// Content
Flexible(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
if (_isLoading) {
return const Padding(
padding: EdgeInsets.all(40),
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFFEE715)),
),
),
);
}
if (_error != null) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.red[600]),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _loadArrivalInfo,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFEE715),
foregroundColor: const Color(0xFF101820),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Reintentar'),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Next bus info
if (_arrivalInfo != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFEE715).withAlpha(26),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFEE715), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Próximo Bus',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF101820),
),
),
const SizedBox(height: 8),
Text(
_formatMinutesUntil(
_arrivalInfo!['minutes_until_arrival'] ?? 0,
),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF101820),
),
),
if (_arrivalInfo!['next_departure'] != null) ...[
const SizedBox(height: 4),
Text(
'Salida: ${_formatTime(_arrivalInfo!['next_departure'])}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
if (_arrivalInfo!['schedule_type'] != null) ...[
const SizedBox(height: 2),
Text(
'Horario: ${_getScheduleTypeDisplay(_arrivalInfo!['schedule_type'])}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
],
],
),
),
const SizedBox(height: 24),
],
// Next bus button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loadArrivalInfo,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF101820),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
'Next bus',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 24),
// Bus stop info
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Información de la Parada',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF101820),
),
),
const SizedBox(height: 12),
if (widget.busStop.fullAddress.isNotEmpty) ...[
Row(
children: [
Icon(
Icons.location_on,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.busStop.fullAddress,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
),
],
),
const SizedBox(height: 8),
],
Row(
children: [
Icon(Icons.category, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
widget.busStop.stopTypeDisplay,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
if (widget.busStop.amenities.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.star, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.busStop.amenitiesText,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
),
],
),
],
],
),
),
// Schedule list
if (_schedules.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
'Horarios de Salida',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF101820),
),
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _schedules.length > 6 ? 6 : _schedules.length,
separatorBuilder:
(context, index) =>
Divider(height: 1, color: Colors.grey[200]),
itemBuilder: (context, index) {
final schedule = _schedules[index];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Icon(Icons.schedule, size: 16, color: Colors.grey[600]),
const SizedBox(width: 12),
Text(
_formatTime(schedule['departure_time'] ?? ''),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF101820),
),
),
const Spacer(),
if (schedule['frequency_minutes'] != null)
Text(
'Cada ${schedule['frequency_minutes']} min',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
);
},
),
),
if (_schedules.length > 6) ...[
const SizedBox(height: 8),
Center(
child: Text(
'Y ${_schedules.length - 6} horarios más...',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
],
const SizedBox(height: 24),
],
),
);
}
}

View File

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
class BusStopMarkerWidget extends StatelessWidget {
final Map<String, dynamic> busStop;
final bool isSelected;
final VoidCallback onTap;
const BusStopMarkerWidget({
super.key,
required this.busStop,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isSelected ? 48.w : 40.w,
height: isSelected ? 48.w : 40.w,
decoration: BoxDecoration(
color: isSelected
? AppTheme.accentYellow
: AppTheme.accentYellow.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: AppTheme.primaryBlack,
width: isSelected ? 3 : 2,
),
boxShadow: [
BoxShadow(
color: AppTheme.primaryBlack.withValues(alpha: 0.3),
blurRadius: isSelected ? 8 : 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: CustomIconWidget(
iconName: 'directions_bus',
color: AppTheme.primaryBlack,
size: isSelected ? 24 : 20,
),
),
),
);
}
}

View File

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
import '../../../theme/app_theme.dart';
class LoadingOverlayWidget extends StatefulWidget {
final bool isVisible;
final String message;
const LoadingOverlayWidget({
super.key,
required this.isVisible,
this.message = 'Cargando...',
});
@override
State<LoadingOverlayWidget> createState() => _LoadingOverlayWidgetState();
}
class _LoadingOverlayWidgetState extends State<LoadingOverlayWidget>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void didUpdateWidget(LoadingOverlayWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isVisible != oldWidget.isVisible) {
if (widget.isVisible) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.isVisible && _animationController.isDismissed) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Container(
width: double.infinity,
height: double.infinity,
color: AppTheme.primaryBlack.withValues(alpha: 0.3),
child: Center(
child: Container(
padding: EdgeInsets.all(6.w),
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.primaryBlack.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12.w,
height: 12.w,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentYellow,
),
strokeWidth: 3,
),
),
SizedBox(height: 3.h),
Text(
widget.message,
style: AppTheme.lightTheme.textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryBlack,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
},
);
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
class MapControlsWidget extends StatelessWidget {
final VoidCallback onLocationPressed;
final VoidCallback onZoomIn;
final VoidCallback onZoomOut;
final bool isLocationEnabled;
const MapControlsWidget({
super.key,
required this.onLocationPressed,
required this.onZoomIn,
required this.onZoomOut,
this.isLocationEnabled = true,
});
@override
Widget build(BuildContext context) {
return Positioned(
right: 4.w,
bottom: 25.h,
child: Column(
children: [
// Zoom In Button
_buildControlButton(
icon: 'add',
onPressed: () {
HapticFeedback.lightImpact();
onZoomIn();
},
tooltip: 'Acercar',
),
SizedBox(height: 1.h),
// Zoom Out Button
_buildControlButton(
icon: 'remove',
onPressed: () {
HapticFeedback.lightImpact();
onZoomOut();
},
tooltip: 'Alejar',
),
SizedBox(height: 2.h),
// Location Button
_buildControlButton(
icon: 'my_location',
onPressed: isLocationEnabled
? () {
HapticFeedback.mediumImpact();
onLocationPressed();
}
: null,
tooltip: 'Mi ubicación',
isLocationButton: true,
isEnabled: isLocationEnabled,
),
],
),
);
}
Widget _buildControlButton({
required String icon,
required VoidCallback? onPressed,
required String tooltip,
bool isLocationButton = false,
bool isEnabled = true,
}) {
return Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: isEnabled
? AppTheme.lightTheme.colorScheme.surface
: AppTheme.lightTheme.colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: AppTheme.primaryBlack.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Center(
child: CustomIconWidget(
iconName: icon,
color: isEnabled
? (isLocationButton
? AppTheme.accentYellow
: AppTheme.primaryBlack)
: AppTheme.textSecondary,
size: 24,
),
),
),
),
);
}
}

View File

@ -0,0 +1,632 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../core/app_export.dart';
import '../../models/route_model.dart';
import '../../services/app_state_service.dart';
import '../../services/transportation_service.dart';
import '../../widgets/custom_app_bar.dart';
import '../../widgets/custom_bottom_bar.dart';
import '../../widgets/route_selection_bottom_sheet.dart';
import './widgets/empty_state_widget.dart';
import './widgets/notification_badge_widget.dart';
import './widgets/route_selection_card.dart';
import './widgets/schedule_card.dart';
import './widgets/search_bar_widget.dart';
class SchedulesScreen extends StatefulWidget {
const SchedulesScreen({super.key});
@override
State<SchedulesScreen> createState() => _SchedulesScreenState();
}
class _SchedulesScreenState extends State<SchedulesScreen>
with TickerProviderStateMixin {
int _currentBottomIndex = 1; // Schedules tab
String _searchQuery = '';
final ScrollController _scrollController = ScrollController();
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
GlobalKey<RefreshIndicatorState>();
// Animation controllers
late AnimationController _fadeAnimationController;
late Animation<double> _fadeAnimation;
// Service and data
final TransportationService _transportationService = TransportationService();
final AppStateService _appStateService = AppStateService();
List<RouteModel> _routes = [];
List<Map<String, dynamic>> _schedules = [];
bool _isLoadingSchedules = false;
String _currentScheduleType = 'weekday';
// Notification state
final Map<String, bool> _notificationStates = {};
int _activeNotifications = 0;
@override
void initState() {
super.initState();
_initializeAnimations();
_appStateService.addListener(_onGlobalStateChanged);
_loadInitialData();
_determineCurrentScheduleType();
}
@override
void dispose() {
_appStateService.removeListener(_onGlobalStateChanged);
_fadeAnimationController.dispose();
_scrollController.dispose();
super.dispose();
}
void _initializeAnimations() {
_fadeAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeAnimationController,
curve: Curves.easeInOut,
));
}
void _onGlobalStateChanged() {
// React to global route selection changes
if (mounted) {
_loadSchedulesForSelectedRoute();
}
}
void _determineCurrentScheduleType() {
// Use Panama timezone as specified in requirements
final panamaNow =
DateTime.now().toUtc().add(Duration(hours: -5)); // Panama is UTC-5
final dayOfWeek = panamaNow.weekday;
// Monday = 1, Sunday = 7
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
_currentScheduleType = 'weekday';
} else if (dayOfWeek == 6) {
_currentScheduleType = 'saturday';
} else {
_currentScheduleType = 'sunday';
}
}
Future<void> _loadInitialData() async {
// Check if we have routes loaded globally
if (_appStateService.allRoutes.isEmpty &&
!_appStateService.isLoadingRoutes) {
await _appStateService.loadRoutes();
}
// Show toast if no routes found
if (_appStateService.allRoutes.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_appStateService.showNoRoutesToast(context);
});
}
// Update local routes reference
setState(() {
_routes = _appStateService.allRoutes;
});
// Load schedules for currently selected route
await _loadSchedulesForSelectedRoute();
}
Future<void> _loadSchedulesForSelectedRoute() async {
final selectedRouteId = _appStateService.selectedRouteId;
if (selectedRouteId == null) {
setState(() {
_schedules = [];
_isLoadingSchedules = false;
});
return;
}
setState(() {
_isLoadingSchedules = true;
});
try {
final schedules =
await _transportationService.getRouteTimetablesByScheduleType(
selectedRouteId,
_currentScheduleType,
);
setState(() {
_schedules = schedules;
_isLoadingSchedules = false;
});
_fadeAnimationController.forward();
} catch (e) {
setState(() {
_isLoadingSchedules = false;
_schedules = [];
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading schedules: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
List<RouteModel> get _filteredRoutes {
if (_searchQuery.isEmpty) return _routes;
return _routes.where((route) {
final name = route.name.toLowerCase();
final query = _searchQuery.toLowerCase();
return name.contains(query);
}).toList();
}
List<Map<String, dynamic>> get _filteredSchedules {
if (_searchQuery.isEmpty) return _schedules;
return _schedules.where((schedule) {
final departureTime =
(schedule['departure_time'] as String).toLowerCase();
final query = _searchQuery.toLowerCase();
return departureTime.contains(query);
}).toList();
}
void _onBottomNavTap(int index) {
setState(() {
_currentBottomIndex = index;
});
}
Future<void> _onRouteSelected(String routeId) async {
await _appStateService.selectRoute(routeId);
setState(() {
_searchQuery = '';
});
}
void _onSearchChanged(String query) {
setState(() {
_searchQuery = query;
});
}
void _onSearchClear() {
setState(() {
_searchQuery = '';
});
}
void _toggleNotification(String scheduleId) {
setState(() {
final currentState = _notificationStates[scheduleId] ?? false;
_notificationStates[scheduleId] = !currentState;
if (!currentState) {
_activeNotifications++;
} else {
_activeNotifications--;
}
});
}
Future<void> _onRefresh() async {
await _appStateService.refreshRoutes();
setState(() {
_routes = _appStateService.allRoutes;
});
await _loadSchedulesForSelectedRoute();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Horarios actualizados'),
backgroundColor: AppTheme.lightTheme.colorScheme.tertiary,
duration: const Duration(seconds: 2),
),
);
}
}
void _showRouteSelector() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => RouteSelectionBottomSheet(
title: 'Seleccionar Ruta',
onRouteChanged: () {
// The global state listener will handle the reload
},
),
);
}
void _showScheduleContextMenu(String scheduleId, String departureTime) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12.w,
height: 0.5.h,
margin: EdgeInsets.symmetric(vertical: 2.h),
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Text(
'Opciones para $departureTime',
style: AppTheme.lightTheme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: 2.h),
_buildContextMenuItem(
icon: 'alarm_add',
title: 'Configurar Recordatorio',
onTap: () {
Navigator.pop(context);
_showReminderDialog(scheduleId, departureTime);
},
),
_buildContextMenuItem(
icon: 'share',
title: 'Compartir Horario',
onTap: () {
Navigator.pop(context);
_shareSchedule(departureTime);
},
),
_buildContextMenuItem(
icon: 'report_problem',
title: 'Reportar Problema',
onTap: () {
Navigator.pop(context);
_reportIssue(scheduleId);
},
),
SizedBox(height: 2.h),
],
),
),
),
);
}
Widget _buildContextMenuItem({
required String icon,
required String title,
required VoidCallback onTap,
}) {
return ListTile(
leading: CustomIconWidget(
iconName: icon,
color: AppTheme.lightTheme.colorScheme.onSurface,
size: 24,
),
title: Text(
title,
style: AppTheme.lightTheme.textTheme.bodyLarge,
),
onTap: onTap,
);
}
void _showReminderDialog(String scheduleId, String departureTime) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Configurar Recordatorio'),
content: Text(
'¿Deseas recibir una notificación antes de la salida de las $departureTime?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_toggleNotification(scheduleId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recordatorio configurado'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Confirmar'),
),
],
),
);
}
void _shareSchedule(String departureTime) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Compartiendo horario de $departureTime'),
duration: const Duration(seconds: 2),
),
);
}
void _reportIssue(String scheduleId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reporte enviado. Gracias por tu feedback.'),
duration: Duration(seconds: 2),
),
);
}
String _getScheduleTypeDisplayName() {
switch (_currentScheduleType) {
case 'weekday':
return 'Lunes a Viernes';
case 'saturday':
return 'Sábado';
case 'sunday':
return 'Domingo';
default:
return 'Horario';
}
}
Widget _buildRouteSelectionView() {
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 1.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomIconWidget(
iconName: 'schedule',
color: AppTheme.lightTheme.colorScheme.secondary,
size: 20,
),
SizedBox(width: 2.w),
Text(
'Horarios para ${_getScheduleTypeDisplayName()}',
style: AppTheme.lightTheme.textTheme.bodyMedium?.copyWith(
color: AppTheme.lightTheme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
),
SearchBarWidget(
hintText: 'Buscar rutas...',
onChanged: _onSearchChanged,
onClear: _onSearchClear,
),
Expanded(
child: _appStateService.isLoadingRoutes
? const Center(child: CircularProgressIndicator())
: _filteredRoutes.isEmpty
? const EmptyStateWidget(
title: 'No se encontraron rutas',
subtitle: 'Intenta con otros términos de búsqueda',
)
: ListView.builder(
controller: _scrollController,
itemCount: _filteredRoutes.length,
itemBuilder: (context, index) {
final route = _filteredRoutes[index];
return RouteSelectionCard(
routeName: route.displayName,
duration: route.direction,
onTap: () => _onRouteSelected(route.id),
);
},
),
),
],
);
}
Widget _buildScheduleView() {
final selectedRoute = _appStateService.getSelectedRoute();
final selectedRouteName = _appStateService.selectedRouteName;
if (selectedRoute == null || selectedRouteName == null) {
return const Center(
child: Text('No route selected'),
);
}
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.h),
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: AppTheme.lightTheme.colorScheme.outline,
width: 1,
),
),
),
child: Row(
children: [
GestureDetector(
onTap: () {
_appStateService.clearSelectedRoute();
setState(() {
_searchQuery = '';
_schedules.clear();
});
},
child: Container(
padding: EdgeInsets.all(2.w),
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.secondary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: CustomIconWidget(
iconName: 'arrow_back',
color: AppTheme.lightTheme.colorScheme.primary,
size: 20,
),
),
),
SizedBox(width: 3.w),
Expanded(
child: GestureDetector(
onTap: _showRouteSelector,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedRouteName,
style:
AppTheme.lightTheme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
'${_getScheduleTypeDisplayName()}${selectedRoute.direction}',
style:
AppTheme.lightTheme.textTheme.bodySmall?.copyWith(
color:
AppTheme.lightTheme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
],
),
),
SearchBarWidget(
hintText: 'Buscar horarios...',
onChanged: _onSearchChanged,
onClear: _onSearchClear,
),
Expanded(
child: RefreshIndicator(
key: _refreshIndicatorKey,
onRefresh: _onRefresh,
color: AppTheme.lightTheme.colorScheme.secondary,
child: _isLoadingSchedules
? const Center(child: CircularProgressIndicator())
: _filteredSchedules.isEmpty
? const EmptyStateWidget(
title: 'No hay horarios disponibles',
subtitle: 'Intenta actualizar o selecciona otra ruta',
)
: FadeTransition(
opacity: _fadeAnimation,
child: ListView.builder(
controller: _scrollController,
itemCount: _filteredSchedules.length,
itemBuilder: (context, index) {
final schedule = _filteredSchedules[index];
final scheduleId = schedule['id'].toString();
final departureTime =
schedule['departure_time'] as String;
final timeParts = departureTime.split(':');
final hour = timeParts[0];
final minute = timeParts[1];
final formattedTime = '$hour:$minute';
return ScheduleCard(
departureTime: formattedTime,
duration: selectedRoute.direction,
arrivalTime: '',
isNotificationEnabled:
_notificationStates[scheduleId] ?? false,
onNotificationToggle: () =>
_toggleNotification(scheduleId),
onLongPress: () => _showScheduleContextMenu(
scheduleId,
formattedTime,
),
);
},
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final hasSelectedRoute = _appStateService.hasSelectedRoute;
return Scaffold(
backgroundColor: AppTheme.lightTheme.scaffoldBackgroundColor,
appBar: CustomAppBar(
title: 'Horarios',
actions: [
NotificationBadgeWidget(
count: _activeNotifications,
child: IconButton(
icon: CustomIconWidget(
iconName: 'notifications',
color: AppTheme.lightTheme.colorScheme.onSurface,
size: 24,
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Tienes $_activeNotifications notificaciones activas'),
duration: const Duration(seconds: 2),
),
);
},
),
),
],
),
body: SafeArea(
child: !hasSelectedRoute
? _buildRouteSelectionView()
: _buildScheduleView(),
),
bottomNavigationBar: CustomBottomBar(
currentIndex: _currentBottomIndex,
onTap: _onBottomNavTap,
),
);
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:sizer/sizer.dart';
import '../../../core/app_export.dart';
class EmptyStateWidget extends StatelessWidget {
final String title;
final String subtitle;
final String? actionText;
final VoidCallback? onActionPressed;
const EmptyStateWidget({
super.key,
required this.title,
required this.subtitle,
this.actionText,
this.onActionPressed,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 30.w,
height: 30.w,
decoration: BoxDecoration(
color: AppTheme.lightTheme.colorScheme.secondary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(15.w),
),
child: Center(
child: CustomIconWidget(
iconName: 'schedule',
color: AppTheme.lightTheme.colorScheme.secondary,
size: 15.w,
),
),
),
SizedBox(height: 4.h),
Text(
title,
style: AppTheme.lightTheme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: AppTheme.lightTheme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
SizedBox(height: 2.h),
Text(
subtitle,
style: AppTheme.lightTheme.textTheme.bodyMedium?.copyWith(
color: AppTheme.lightTheme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (actionText != null && onActionPressed != null) ...[
SizedBox(height: 4.h),
ElevatedButton(
onPressed: onActionPressed,
child: Text(actionText!),
),
],
],
),
);
}
}

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