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

View File

@ -0,0 +1,170 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// API Client for connecting to the FastAPI backend
class ApiClient {
static ApiClient? _instance;
static ApiClient get instance => _instance ??= ApiClient._();
late Dio _dio;
String _baseUrl = 'http://localhost:8000';
ApiClient._() {
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
// Add interceptors for logging in debug mode
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
error: true,
));
}
// Error handling interceptor
_dio.interceptors.add(InterceptorsWrapper(
onError: (error, handler) {
if (error.response != null) {
debugPrint('API Error: ${error.response?.statusCode} - ${error.response?.data}');
} else {
debugPrint('API Error: ${error.message}');
}
handler.next(error);
},
));
}
/// Initialize with custom base URL
void initialize({String? baseUrl}) {
final url = baseUrl ?? getBaseUrl();
_baseUrl = url;
_dio.options.baseUrl = _baseUrl;
}
/// Get base URL from environment or use default
static String getBaseUrl() {
const url = String.fromEnvironment('API_BASE_URL');
return url.isNotEmpty ? url : 'http://localhost:8000';
}
/// Get current base URL
String get baseUrl => _baseUrl;
/// GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
return await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
);
} catch (e) {
if (e is DioException) {
rethrow;
}
throw DioException(
requestOptions: RequestOptions(path: path),
error: e,
);
}
}
/// POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
return await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
} catch (e) {
if (e is DioException) {
rethrow;
}
throw DioException(
requestOptions: RequestOptions(path: path),
error: e,
);
}
}
/// PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
return await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
} catch (e) {
if (e is DioException) {
rethrow;
}
throw DioException(
requestOptions: RequestOptions(path: path),
error: e,
);
}
}
/// DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
return await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
} catch (e) {
if (e is DioException) {
rethrow;
}
throw DioException(
requestOptions: RequestOptions(path: path),
error: e,
);
}
}
/// Check if API is available
Future<bool> checkConnection() async {
try {
final response = await _dio.get('/health');
return response.statusCode == 200;
} catch (e) {
return false;
}
}
}

View File

@ -0,0 +1,207 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../models/route_model.dart';
import './transportation_service.dart';
/// Global app state service to manage selected route across the entire app
class AppStateService extends ChangeNotifier {
static final AppStateService _instance = AppStateService._internal();
factory AppStateService() => _instance;
AppStateService._internal();
final TransportationService _transportationService = TransportationService();
// Global state
String? _selectedRouteId;
String? _selectedRouteName;
List<RouteModel> _allRoutes = [];
bool _isLoadingRoutes = false;
String? _error;
// Getters
String? get selectedRouteId => _selectedRouteId;
String? get selectedRouteName => _selectedRouteName;
List<RouteModel> get allRoutes => List.unmodifiable(_allRoutes);
bool get isLoadingRoutes => _isLoadingRoutes;
String? get error => _error;
bool get hasSelectedRoute =>
_selectedRouteId != null && _selectedRouteName != null;
/// Initialize app state - call this on app start
Future<void> initialize() async {
await loadRoutes();
}
/// Load all routes from Supabase
Future<void> loadRoutes() async {
if (_isLoadingRoutes) return;
_isLoadingRoutes = true;
notifyListeners();
try {
final result = await _transportationService.getRoutesWithCount();
final routes = result['data'] as List<RouteModel>;
final count = result['count'] as int;
final error = result['error'] as String?;
if (error != null) {
print('Error loading routes: $error');
// Handle error but continue with empty list
_allRoutes = [];
} else {
_allRoutes = routes;
// Auto-select first route if none selected and routes available
if (routes.isNotEmpty && _selectedRouteId == null) {
await selectRoute(routes.first.id);
}
}
_isLoadingRoutes = false;
notifyListeners();
// Show toast if no routes found as specified in requirements
if (_allRoutes.isEmpty) {
_showNoRoutesToast();
}
} catch (e) {
print('Exception loading routes: $e');
_allRoutes = [];
_isLoadingRoutes = false;
notifyListeners();
// Show error toast for exceptions
Fluttertoast.showToast(
msg: "Error loading routes: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
}
}
void _showNoRoutesToast() {
Fluttertoast.showToast(
msg: "No routes found",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.orange,
textColor: Colors.white,
fontSize: 16.0,
);
}
/// Select a route and update global state
Future<void> selectRoute(String routeId) async {
if (routeId == _selectedRouteId) return;
try {
// Find the route in our cached routes
final route = _allRoutes.firstWhere(
(r) => r.id == routeId,
orElse: () => throw Exception('Route not found: $routeId'),
);
_selectedRouteId = routeId;
_selectedRouteName = route.displayName;
_error = null;
debugPrint('✅ Route selected: $routeId - $_selectedRouteName');
notifyListeners();
} catch (e) {
_error = 'Error selecting route: $e';
debugPrint('❌ Error selecting route: $e');
notifyListeners();
}
}
/// Clear selected route
void clearSelectedRoute() {
_selectedRouteId = null;
_selectedRouteName = null;
notifyListeners();
}
/// Get selected route model
RouteModel? getSelectedRoute() {
if (_selectedRouteId == null) return null;
try {
return _allRoutes.firstWhere((r) => r.id == _selectedRouteId!);
} catch (e) {
return null;
}
}
/// Private method to select first available route with stops
Future<void> _selectFirstAvailableRoute() async {
for (final route in _allRoutes) {
try {
final stops =
await _transportationService.getRouteStopsOrderedBySeq(route.id);
if (stops.isNotEmpty) {
_selectedRouteId = route.id;
_selectedRouteName = route.displayName;
debugPrint(
'✅ Auto-selected route: ${route.id} - ${route.displayName}');
return;
}
} catch (e) {
debugPrint('⚠️ Error checking stops for route ${route.id}: $e');
}
}
// Fallback: select first route even if no stops
if (_allRoutes.isNotEmpty) {
final firstRoute = _allRoutes.first;
_selectedRouteId = firstRoute.id;
_selectedRouteName = firstRoute.displayName;
debugPrint(
'✅ Fallback: selected first route: ${firstRoute.id} - ${firstRoute.displayName}');
}
}
/// Refresh routes and maintain selection if possible
Future<void> refreshRoutes() async {
final previousSelectedId = _selectedRouteId;
await loadRoutes();
// Try to restore previous selection
if (previousSelectedId != null &&
_allRoutes.any((r) => r.id == previousSelectedId)) {
await selectRoute(previousSelectedId);
}
}
/// Get route by ID
RouteModel? getRouteById(String routeId) {
try {
return _allRoutes.firstWhere((r) => r.id == routeId);
} catch (e) {
return null;
}
}
/// Check if route exists
bool hasRoute(String routeId) {
return _allRoutes.any((r) => r.id == routeId);
}
/// Show toast message for no routes found
void showNoRoutesToast(BuildContext context) {
if (_allRoutes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No routes found'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 3),
),
);
}
}
}

View File

@ -0,0 +1,130 @@
import '../models/coupon_model.dart';
import './supabase_service.dart';
class CouponService {
/// Fetches coupons with auto-reconnection and exact query logic from requirements
static Future<List<CouponModel>> getCoupons({
String? selectedCategory,
String? sort,
}) async {
return await SupabaseService.withAutoReconnect(() async {
final client = SupabaseService.client;
// Exact query from requirements specification
var query = client
.from('coupons')
.select(
'id, business_name, title, description, valid_until, image_url, category, is_active, created_at',
);
// Apply the exact WHERE conditions from requirements
query = query.eq('is_active', true);
// Handle date filter: (valid_until IS NULL OR valid_until >= CURRENT_DATE)
final currentDate = DateTime.now().toIso8601String().split('T')[0];
query = query.or('valid_until.is.null,valid_until.gte.$currentDate');
// Category filter: ignore when 'Todos' is selected
if (selectedCategory != null && selectedCategory != 'Todos') {
final categoryValue = _mapDisplayCategoryToDbValue(selectedCategory);
query = query.eq('category', categoryValue);
}
// Apply sorting based on requirements
final dynamic response;
if (sort == 'Por vencer') {
// Sort by expiring first (null values last)
response = await query.order('valid_until', ascending: true, nullsFirst: false);
} else {
// Default: "Más recientes" - sort by created_at descending
response = await query.order('created_at', ascending: false);
}
return (response as List)
.map((data) => CouponModel.fromMap(data))
.toList();
});
}
/// Maps display category names to database enum values
static String _mapDisplayCategoryToDbValue(String displayCategory) {
switch (displayCategory.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 'restaurantes'; // fallback
}
}
/// Gets available category options for filtering (Spanish UI)
static List<String> getCategoryOptions() {
return [
'Todos',
'Restaurantes',
'Tiendas',
'Servicios',
'Entretenimiento',
'Salud',
'Belleza',
];
}
/// Gets available sort options (Spanish UI)
static List<String> getSortOptions() {
return ['Más recientes', 'Por vencer'];
}
/// Gets a single coupon by ID with auto-reconnection
static Future<CouponModel?> getCouponById(String id) async {
try {
return await SupabaseService.withAutoReconnect(() async {
final client = SupabaseService.client;
final response =
await client
.from('coupons')
.select(
'id, business_name, title, description, valid_until, image_url, category, is_active, created_at',
)
.eq('id', id)
.eq('is_active', true)
.single();
return CouponModel.fromMap(response);
});
} catch (e) {
// Silent failure - return null
return null;
}
}
/// Counts total coupons for a category with auto-reconnection
static Future<int> getCouponCount({String? category}) async {
try {
return await SupabaseService.withAutoReconnect(() async {
final client = SupabaseService.client;
var query = client.from('coupons').select('id').eq('is_active', true);
if (category != null && category.toLowerCase() != 'todos') {
final categoryValue = _mapDisplayCategoryToDbValue(category);
query = query.eq('category', categoryValue);
}
final response = await query.count();
return response.count ?? 0;
});
} catch (e) {
// Silent failure - return 0
return 0;
}
}
}

View File

@ -0,0 +1,255 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:fluttertoast/fluttertoast.dart';
/// Enhanced Supabase service with auto-connection and silent retry logic
class SupabaseService {
static SupabaseService? _instance;
static SupabaseService get instance => _instance ??= SupabaseService._();
static Timer? _retryTimer;
static bool _isInitializing = false;
SupabaseService._();
/// Get the Supabase client instance with auto-initialization
static SupabaseClient get client {
_ensureInitialized();
return Supabase.instance.client;
}
/// Auto-initialize with environment variables and silent retry
static void _ensureInitialized() {
if (!_isInitialized() && !_isInitializing) {
_initializeWithRetry();
}
}
/// Check if Supabase is properly initialized
static bool _isInitialized() {
try {
final url = const String.fromEnvironment('SUPABASE_URL');
final key = const String.fromEnvironment('SUPABASE_ANON_KEY');
if (url.isEmpty || key.isEmpty) {
return false;
}
// Check if current client matches the environment variables
final currentClient = Supabase.instance.client;
return currentClient.headers['Authorization']?.contains(key) ?? false;
} catch (e) {
return false;
}
}
/// Initialize with automatic retry every 2 seconds until success
static void _initializeWithRetry() {
if (_isInitializing) return;
_isInitializing = true;
_retryTimer?.cancel();
_retryTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
try {
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
const supabaseAnonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
// Stop and reinitialize once envs are available
if (supabaseUrl.isNotEmpty && supabaseAnonKey.isNotEmpty) {
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
debug: false,
);
timer.cancel();
_isInitializing = false;
_retryTimer = null;
}
} catch (e) {
// Silent retry - continue timer
}
});
}
/// Initialize Supabase with environment variables
static Future<void> initialize() async {
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
const supabaseAnonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
if (supabaseUrl.isEmpty || supabaseAnonKey.isEmpty) {
throw Exception('Missing Supabase credentials');
}
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
debug: false,
);
}
/// Auto-reconnection wrapper for database operations
static Future<T> withAutoReconnect<T>(Future<T> Function() operation) async {
try {
return await operation();
} catch (e) {
final errorMessage = e.toString().toLowerCase();
// Check for connection errors
if (errorMessage.contains('failed to connect') ||
errorMessage.contains('networkerror') ||
errorMessage.contains('invalid key') ||
errorMessage.contains('connection') ||
errorMessage.contains('timeout')) {
// Wait 500-1000ms debounce before retry
await Future.delayed(const Duration(milliseconds: 750));
try {
// Reinitialize client
await initialize();
// Retry the operation once
return await operation();
} catch (retryError) {
// Silent failure - operation will return empty results
throw retryError;
}
}
// Re-throw non-connection errors
throw e;
}
}
/// Validate Supabase credentials and show toast if missing
static bool validateCredentials(BuildContext? context) {
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
const supabaseAnonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
if (supabaseUrl.isEmpty || supabaseAnonKey.isEmpty) {
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,
);
return false;
}
return true;
}
/// Get truncated Supabase URL for debug display (first/last 8 chars)
static String getTruncatedUrl() {
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
if (supabaseUrl.isEmpty) return 'Not configured';
if (supabaseUrl.length <= 16) return supabaseUrl;
final first8 = supabaseUrl.substring(0, 8);
final last8 = supabaseUrl.substring(supabaseUrl.length - 8);
return '$first8...$last8';
}
/// Get masked Supabase anon key for debug display (first/last 6 chars, masked middle)
static String getMaskedAnonKey() {
const supabaseAnonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
if (supabaseAnonKey.isEmpty) return 'Not configured';
if (supabaseAnonKey.length <= 12)
return '${supabaseAnonKey.substring(0, 3)}***${supabaseAnonKey.substring(supabaseAnonKey.length - 3)}';
final first6 = supabaseAnonKey.substring(0, 6);
final last6 = supabaseAnonKey.substring(supabaseAnonKey.length - 6);
return '$first6***$last6';
}
/// Connection check with lightweight test query
static Future<Map<String, dynamic>> performConnectionCheck() async {
try {
// 1) Read env vars and validate
if (!validateCredentials(null)) {
return {
'success': false,
'error': 'Missing SUPABASE_URL or SUPABASE_ANON_KEY',
'count': null,
};
}
// 2) Initialize Supabase client if needed
try {
await initialize();
} catch (e) {
// Client might already be initialized, continue
}
// 3) Run lightweight test query exactly as specified:
// const { data, error } = await supabase.from('routes').select('id', { head: true, count: 'exact' });
final response = await Supabase.instance.client
.from('routes')
.select('id')
.limit(1); // Using limit instead of head for Flutter supabase client
// Get count separately for exact count
final countResponse =
await Supabase.instance.client.from('routes').select('*');
final count = (countResponse as List).length;
return {'success': true, 'error': null, 'count': count};
} catch (e) {
return {'success': false, 'error': e.toString(), 'count': null};
}
}
/// Check if Supabase is initialized
bool get isInitialized {
try {
return Supabase.instance.client.auth.currentUser != null ||
Supabase.instance.client.auth.currentSession != null ||
true; // Supabase is initialized even without user
} catch (e) {
return false;
}
}
/// Force reconnect to Supabase
static Future<bool> forceReconnect(BuildContext? context) async {
try {
if (!validateCredentials(context)) {
return false;
}
// Re-initialize Supabase
await initialize();
// Test connection with the lightweight query
final result = await performConnectionCheck();
if (!result['success']) {
Fluttertoast.showToast(
msg: "Connection failed: ${result['error']}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return false;
}
return true;
} catch (e) {
Fluttertoast.showToast(
msg: "Failed to connect to Supabase: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return false;
}
}
}

View File

@ -0,0 +1,243 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/taxi_model.dart';
import './supabase_service.dart';
/// Service for managing taxi data and favorites functionality
class TaxiService {
static TaxiService? _instance;
static TaxiService get instance => _instance ??= TaxiService._();
TaxiService._();
final SupabaseClient _client = SupabaseService.client;
static const String _favoritesKey = 'favorite_taxi_ids';
/// Get distinct corregimientos from active taxis
Future<List<String>> getCorregimientos() async {
try {
final response = await _client
.from('taxis')
.select('corregimiento')
.eq('is_active', true)
.order('corregimiento');
if (response.isEmpty) return [];
// Extract unique corregimientos
final Set<String> uniqueCorregimientos = {};
for (final item in response) {
final corregimiento = item['corregimiento'] as String?;
if (corregimiento != null && corregimiento.isNotEmpty) {
uniqueCorregimientos.add(corregimiento);
}
}
return uniqueCorregimientos.toList()..sort();
} catch (e) {
throw Exception('Failed to fetch corregimientos: $e');
}
}
/// Get available shifts
List<String> getShifts() {
return ['Day', 'Evening', 'Night'];
}
/// Search taxis with optional corregimiento, shift, and text filters
Future<List<TaxiModel>> searchTaxis({
String? selectedCorregimiento,
String? selectedShift,
String? searchText,
}) async {
try {
var query = _client.from('taxis').select('*').eq('is_active', true);
// Apply corregimiento filter
if (selectedCorregimiento != null && selectedCorregimiento.isNotEmpty) {
query = query.eq('corregimiento', selectedCorregimiento);
}
// Apply shift filter
if (selectedShift != null && selectedShift.isNotEmpty) {
// Convert display name to database value
String dbShift = selectedShift.toLowerCase();
query = query.eq('shift', dbShift);
}
// Apply text search filter
if (searchText != null && searchText.isNotEmpty) {
query = query.or('name.ilike.%$searchText%,phone.ilike.%$searchText%');
}
final response = await query.order('name');
return response
.map<TaxiModel>((json) => TaxiModel.fromJson(json))
.toList();
} catch (e) {
throw Exception('Failed to search taxis: $e');
}
}
/// Get user's favorite taxi IDs (from Supabase or local storage)
Future<List<String>> getFavoriteTaxiIds() async {
try {
// Check if user is authenticated
final user = _client.auth.currentUser;
if (user != null) {
// Get favorites from Supabase
final response = await _client
.from('favorite_taxis')
.select('taxi_id')
.eq('user_id', user.id);
return response
.map<String>((item) => item['taxi_id'] as String)
.toList();
} else {
// Get favorites from local storage
return await _getLocalFavorites();
}
} catch (e) {
// Fallback to local storage if Supabase fails
return await _getLocalFavorites();
}
}
/// Toggle favorite status for a taxi
Future<bool> toggleFavorite(String taxiId) async {
try {
final user = _client.auth.currentUser;
if (user != null) {
// Handle Supabase favorites
return await _toggleSupabaseFavorite(user.id, taxiId);
} else {
// Handle local storage favorites
return await _toggleLocalFavorite(taxiId);
}
} catch (e) {
// Fallback to local storage
return await _toggleLocalFavorite(taxiId);
}
}
/// Check if taxi is favorited
Future<bool> isFavorite(String taxiId) async {
final favorites = await getFavoriteTaxiIds();
return favorites.contains(taxiId);
}
/// Handle Supabase favorite toggle
Future<bool> _toggleSupabaseFavorite(String userId, String taxiId) async {
try {
// Check if favorite exists
final existing =
await _client
.from('favorite_taxis')
.select('id')
.eq('user_id', userId)
.eq('taxi_id', taxiId)
.maybeSingle();
if (existing != null) {
// Remove favorite
await _client
.from('favorite_taxis')
.delete()
.eq('user_id', userId)
.eq('taxi_id', taxiId);
return false;
} else {
// Add favorite
await _client.from('favorite_taxis').insert({
'user_id': userId,
'taxi_id': taxiId,
});
return true;
}
} catch (e) {
throw Exception('Failed to toggle Supabase favorite: $e');
}
}
/// Handle local storage favorite toggle
Future<bool> _toggleLocalFavorite(String taxiId) async {
try {
final prefs = await SharedPreferences.getInstance();
final favorites = prefs.getStringList(_favoritesKey) ?? [];
if (favorites.contains(taxiId)) {
favorites.remove(taxiId);
await prefs.setStringList(_favoritesKey, favorites);
return false;
} else {
favorites.add(taxiId);
await prefs.setStringList(_favoritesKey, favorites);
return true;
}
} catch (e) {
throw Exception('Failed to toggle local favorite: $e');
}
}
/// Get favorites from local storage
Future<List<String>> _getLocalFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_favoritesKey) ?? [];
} catch (e) {
return [];
}
}
/// Clear all local favorites (for testing/reset)
Future<void> clearLocalFavorites() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_favoritesKey);
} catch (e) {
// Silent fail for clearing favorites
}
}
/// Sync local favorites to Supabase when user authenticates
Future<void> syncLocalFavoritesToSupabase() async {
try {
final user = _client.auth.currentUser;
if (user == null) return;
final localFavorites = await _getLocalFavorites();
if (localFavorites.isEmpty) return;
// Get existing Supabase favorites
final supabaseFavorites = await _client
.from('favorite_taxis')
.select('taxi_id')
.eq('user_id', user.id);
final existingIds =
supabaseFavorites
.map<String>((item) => item['taxi_id'] as String)
.toSet();
// Add local favorites that don't exist in Supabase
final toAdd = localFavorites.where((id) => !existingIds.contains(id));
if (toAdd.isNotEmpty) {
final insertData =
toAdd
.map((taxiId) => {'user_id': user.id, 'taxi_id': taxiId})
.toList();
await _client.from('favorite_taxis').insert(insertData);
}
// Clear local favorites after successful sync
await clearLocalFavorites();
} catch (e) {
// Silent fail for sync operation
}
}
}

View File

@ -0,0 +1,446 @@
import 'dart:math' as math;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:flutter/material.dart';
import '../models/bus_stop_model.dart';
import '../models/route_model.dart';
import './supabase_service.dart';
class TransportationService {
final SupabaseService _supabaseService = SupabaseService.instance;
/// Determines the current schedule type based on Panama timezone
String _getCurrentScheduleType() {
// Use Panama timezone for schedule type detection
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) {
return 'weekday';
} else if (dayOfWeek == 6) {
return 'saturday';
} else {
return 'sunday';
}
}
// Get all available routes using exact query pattern from requirements
Future<Map<String, dynamic>> getRoutesWithCount() async {
try {
// Use exact query: supabase.from('routes').select('id,name', { count: 'exact' }).order('name', {ascending: true})
final response = await SupabaseService.client
.from('routes')
.select('id,name')
.order('name', ascending: true);
final routes =
(response as List)
.map((route) => RouteModel.fromJson(route))
.toList();
return {'data': routes, 'count': routes.length, 'error': null};
} catch (e) {
print('Error fetching routes: $e');
// Show toast on exception as specified in requirements
Fluttertoast.showToast(
msg: "Error loading routes: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return {'data': <RouteModel>[], 'count': 0, 'error': e.toString()};
}
}
// Get all available routes (legacy method updated to use new pattern)
Future<List<RouteModel>> getRoutes() async {
final result = await getRoutesWithCount();
return result['data'] as List<RouteModel>;
}
// HEAD count request for just getting route count as specified in requirements
Future<int> getRoutesCount() async {
try {
// Use HEAD count request: supabase.from('routes').select('*', { count: 'exact', head: true })
final response = await SupabaseService.client.from('routes').select('*');
return (response as List).length;
} catch (e) {
print('Error getting routes count: $e');
Fluttertoast.showToast(
msg: "Error getting routes count: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return 0;
}
}
// Get first available route that has stops (for default selection)
Future<String?> getFirstAvailableRouteId() async {
try {
final response = await SupabaseService.client
.from('routes')
.select('id')
.order('name')
.limit(1);
if ((response as List).isNotEmpty) {
// Verify the route has stops
final routeId = response[0]['id'];
final stopsResponse = await SupabaseService.client
.from('route_stops')
.select('route_id')
.eq('route_id', routeId)
.limit(1);
if ((stopsResponse as List).isNotEmpty) {
return routeId;
}
}
return null;
} catch (e) {
print('Error getting first available route: $e');
Fluttertoast.showToast(
msg: "Error getting default route: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return null;
}
}
// Get next bus time using Panama timezone and exact query from requirements
Future<Map<String, dynamic>?> getNextBusTime(
String routeId,
String stopId,
) async {
try {
// Get current schedule type automatically using Panama time
final scheduleType = _getCurrentScheduleType();
// Use exact "Next bus" query from requirements:
// SELECT departure_time FROM timetable
// WHERE route_id=:selectedRouteId AND schedule_type=:todayType
// AND departure_time > (now() AT TIME ZONE 'America/Panama')::time
// ORDER BY departure_time LIMIT 1
final response = await SupabaseService.client
.from('timetable')
.select('departure_time')
.eq('route_id', routeId)
.eq('schedule_type', scheduleType)
.filter(
'departure_time',
'gt',
'extract(time from (now() AT TIME ZONE \'America/Panama\'))',
)
.order('departure_time', ascending: true)
.limit(1);
if ((response as List).isNotEmpty) {
final nextDeparture = response[0]['departure_time'];
// Parse the departure time
final parts = nextDeparture.split(':');
final departureHour = int.parse(parts[0]);
final departureMinute = int.parse(parts[1]);
// Calculate minutes until departure using Panama time
final panamaNow = DateTime.now().toUtc().add(Duration(hours: -5));
final departureDateTime = DateTime(
panamaNow.year,
panamaNow.month,
panamaNow.day,
departureHour,
departureMinute,
);
int minutesUntil = departureDateTime.difference(panamaNow).inMinutes;
// If negative, it means it's for tomorrow
if (minutesUntil < 0) {
minutesUntil =
departureDateTime
.add(const Duration(days: 1))
.difference(panamaNow)
.inMinutes;
}
return {
'next_departure': nextDeparture,
'minutes_until_arrival': minutesUntil,
'estimated_arrival_time': departureDateTime.toIso8601String(),
'schedule_type': scheduleType,
};
}
// No more buses today, check for tomorrow's first departure
final nextDayScheduleType = _getNextDayScheduleType();
final tomorrowResponse = await SupabaseService.client
.from('timetable')
.select('departure_time')
.eq('route_id', routeId)
.eq('schedule_type', nextDayScheduleType)
.order('departure_time', ascending: true)
.limit(1);
if ((tomorrowResponse as List).isNotEmpty) {
final firstTomorrowDeparture = tomorrowResponse[0]['departure_time'];
return {
'next_departure': null,
'first_tomorrow': firstTomorrowDeparture,
'minutes_until_arrival': null,
'message': 'No more buses today',
'schedule_type': scheduleType,
};
}
return null;
} catch (e) {
print('Error getting next bus time: $e');
Fluttertoast.showToast(
msg: "Error calculating next bus: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return null;
}
}
String _getNextDayScheduleType() {
// Use Panama timezone for next day calculation
final tomorrow = DateTime.now().toUtc().add(Duration(hours: -5, days: 1));
final dayOfWeek = tomorrow.weekday;
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
return 'weekday';
} else if (dayOfWeek == 6) {
return 'saturday';
} else {
return 'sunday';
}
}
// Get all timetables for a route with current day's schedule_type
Future<List<Map<String, dynamic>>> getRouteTimetables(String routeId) async {
try {
final scheduleType = _getCurrentScheduleType();
final response = await SupabaseService.client
.from('timetable')
.select('*')
.eq('route_id', routeId)
.eq('schedule_type', scheduleType)
.order('departure_time');
return List<Map<String, dynamic>>.from(response as List);
} catch (e) {
print('Error fetching route timetables: $e');
Fluttertoast.showToast(
msg: "Error loading timetables: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return [];
}
}
// Get all timetables for a specific schedule type using exact query from requirements
Future<List<Map<String, dynamic>>> getRouteTimetablesByScheduleType(
String routeId,
String scheduleType,
) async {
try {
// Use EXACT query as specified in requirements:
// SELECT departure_time FROM timetable
// WHERE route_id = :selectedRouteId AND schedule_type = :todayType
// ORDER BY departure_time
final response = await SupabaseService.client
.from('timetable')
.select('departure_time')
.eq('route_id', routeId)
.eq('schedule_type', scheduleType)
.order('departure_time');
return List<Map<String, dynamic>>.from(response as List);
} catch (e) {
print('Error fetching route timetables by schedule type: $e');
Fluttertoast.showToast(
msg: "Error loading schedule: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return [];
}
}
// Get stops for a specific route - using EXACT SQL query from requirements
Future<List<BusStopModel>> getRouteStopsOrderedBySeq(String routeId) async {
try {
// Use EXACT SQL query as specified in requirements:
// SELECT s.id, s.name, s.lat, s.lng, rs.seq
// FROM route_stops rs JOIN stops s ON s.id = rs.stop_id
// WHERE rs.route_id = :selectedRouteId ORDER BY rs.seq
// REMOVED RPC CALL - using direct SQL table queries instead
final response = await SupabaseService.client
.from('route_stops')
.select('''
seq,
stops:stop_id (
id,
name,
lat,
lng
)
''')
.eq('route_id', routeId)
.order('seq', ascending: true);
List<BusStopModel> stops = [];
for (var item in response as List) {
if (item['stops'] != null) {
final stopData = Map<String, dynamic>.from(item['stops']);
stopData['seq'] = item['seq'];
stops.add(BusStopModel.fromJson(stopData));
}
}
// Show "No stops found for this route" message if empty as per requirements
if (stops.isEmpty) {
Fluttertoast.showToast(
msg: "No stops found for this route",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.orange,
textColor: Colors.white,
fontSize: 16.0,
);
}
return stops;
} catch (e) {
print('Error fetching route stops: $e');
// Show Supabase connection error message as per requirements
if (e.toString().contains('connection') ||
e.toString().contains('network')) {
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,
);
} else {
Fluttertoast.showToast(
msg: "Error loading route stops: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
}
rethrow; // Re-throw to let calling code handle it
}
}
// Get route by ID
Future<RouteModel?> getRouteById(String routeId) async {
try {
final response =
await SupabaseService.client
.from('routes')
.select('*')
.eq('id', routeId)
.single();
return RouteModel.fromJson(response);
} catch (e) {
print('Error fetching route: $e');
Fluttertoast.showToast(
msg: "Error loading route details: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return null;
}
}
// Get stop by ID
Future<BusStopModel?> getStopById(String stopId) async {
try {
final response =
await SupabaseService.client
.from('stops')
.select('*')
.eq('id', stopId)
.single();
return BusStopModel.fromJson(response);
} catch (e) {
print('Error fetching stop: $e');
Fluttertoast.showToast(
msg: "Error loading stop details: ${e.toString()}",
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.CENTER,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0,
);
return null;
}
}
// Simple distance calculation (Haversine formula)
double _calculateDistance(
double lat1,
double lng1,
double lat2,
double lng2,
) {
const double earthRadius = 6371; // km
double dLat = _toRadians(lat2 - lat1);
double dLng = _toRadians(lng2 - lng1);
double a =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_toRadians(lat1)) *
math.cos(_toRadians(lat2)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
double c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c;
}
double _toRadians(double degrees) {
return degrees * (math.pi / 180);
}
}