Initial commit: SIBU 2.0 MISSION
This commit is contained in:
170
old/lib/services/api_client.dart
Normal file
170
old/lib/services/api_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
207
old/lib/services/app_state_service.dart
Normal file
207
old/lib/services/app_state_service.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
old/lib/services/coupon_service.dart
Normal file
130
old/lib/services/coupon_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
255
old/lib/services/supabase_service.dart
Normal file
255
old/lib/services/supabase_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
243
old/lib/services/taxi_service.dart
Normal file
243
old/lib/services/taxi_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
446
old/lib/services/transportation_service.dart
Normal file
446
old/lib/services/transportation_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user