483 lines
15 KiB
Dart
483 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../api/types.dart';
|
|
import '../api/client.dart';
|
|
import '../api/grpc_client.dart';
|
|
import '../api/grpc_config.dart';
|
|
import '../api/openapi_config.dart';
|
|
import '../services/auth_service.dart';
|
|
import '../models/user_profile.dart';
|
|
import '../models/delivery_route.dart';
|
|
import '../models/delivery.dart';
|
|
|
|
// ============================================================================
|
|
// API Mode Configuration - Feature Flag for HTTP/gRPC Transport
|
|
// ============================================================================
|
|
|
|
/// Enum representing the available API transport modes.
|
|
enum ApiMode {
|
|
/// Use HTTP/REST-based CQRS API client
|
|
http,
|
|
|
|
/// Use gRPC-based CQRS API client
|
|
grpc,
|
|
}
|
|
|
|
/// Configuration for API transport mode selection.
|
|
///
|
|
/// This class allows switching between HTTP and gRPC transports
|
|
/// for API calls. Following the pattern from [ApiClientConfig].
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// // To switch to gRPC, override the apiModeConfigProvider:
|
|
/// ProviderScope(
|
|
/// overrides: [
|
|
/// apiModeConfigProvider.overrideWithValue(ApiModeConfig.developmentGrpc),
|
|
/// ],
|
|
/// child: MyApp(),
|
|
/// )
|
|
/// ```
|
|
class ApiModeConfig {
|
|
/// The transport mode to use for API calls.
|
|
final ApiMode mode;
|
|
|
|
/// Whether to fall back to HTTP on gRPC failures.
|
|
/// Only applicable when [mode] is [ApiMode.grpc].
|
|
final bool fallbackToHttpOnError;
|
|
|
|
const ApiModeConfig({
|
|
required this.mode,
|
|
this.fallbackToHttpOnError = true,
|
|
});
|
|
|
|
/// Development configuration - defaults to HTTP for stability.
|
|
/// Use this for safe development when gRPC backend may be unavailable.
|
|
static const ApiModeConfig development = ApiModeConfig(
|
|
mode: ApiMode.http,
|
|
fallbackToHttpOnError: true,
|
|
);
|
|
|
|
/// gRPC-first development configuration for testing gRPC integration.
|
|
/// Use this when actively developing/testing gRPC functionality.
|
|
static const ApiModeConfig developmentGrpc = ApiModeConfig(
|
|
mode: ApiMode.grpc,
|
|
fallbackToHttpOnError: true,
|
|
);
|
|
|
|
/// Production configuration - uses HTTP until gRPC is verified stable.
|
|
static const ApiModeConfig production = ApiModeConfig(
|
|
mode: ApiMode.http,
|
|
fallbackToHttpOnError: false,
|
|
);
|
|
|
|
/// Production gRPC configuration for when gRPC is production-ready.
|
|
static const ApiModeConfig productionGrpc = ApiModeConfig(
|
|
mode: ApiMode.grpc,
|
|
fallbackToHttpOnError: false,
|
|
);
|
|
|
|
/// Whether the current mode is gRPC.
|
|
bool get isGrpc => mode == ApiMode.grpc;
|
|
|
|
/// Whether the current mode is HTTP.
|
|
bool get isHttp => mode == ApiMode.http;
|
|
}
|
|
|
|
/// Provider for API mode configuration.
|
|
///
|
|
/// Override this provider to switch between HTTP and gRPC:
|
|
/// ```dart
|
|
/// ProviderScope(
|
|
/// overrides: [
|
|
/// apiModeConfigProvider.overrideWithValue(ApiModeConfig.developmentGrpc),
|
|
/// ],
|
|
/// child: MyApp(),
|
|
/// )
|
|
/// ```
|
|
final apiModeConfigProvider = Provider<ApiModeConfig>((ref) {
|
|
// Default to HTTP for safety during transition
|
|
return ApiModeConfig.development;
|
|
});
|
|
|
|
// ============================================================================
|
|
// Core Service Providers
|
|
// ============================================================================
|
|
|
|
final authServiceProvider = Provider<AuthService>((ref) {
|
|
return AuthService(config: AuthConfig.development);
|
|
});
|
|
|
|
final apiClientProvider = Provider<CqrsApiClient>((ref) {
|
|
final authService = ref.watch(authServiceProvider);
|
|
return CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
authService: authService,
|
|
);
|
|
});
|
|
|
|
/// Provider for the gRPC-based CQRS API client.
|
|
///
|
|
/// Uses auto-dispose to properly shut down the gRPC channel when the provider
|
|
/// is no longer being watched. This ensures network resources are cleaned up.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final grpcClient = ref.watch(grpcClientProvider);
|
|
/// final result = await grpcClient.getDeliveryRoutes();
|
|
/// ```
|
|
final grpcClientProvider = Provider.autoDispose<GrpcCqrsApiClient>((ref) {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final client = GrpcCqrsApiClient(
|
|
config: GrpcConfig.development,
|
|
authService: authService,
|
|
);
|
|
|
|
// Register disposal callback to clean up gRPC channel resources
|
|
ref.onDispose(() {
|
|
client.shutdown();
|
|
});
|
|
|
|
return client;
|
|
});
|
|
|
|
final isAuthenticatedProvider = FutureProvider<bool>((ref) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
return await authService.isAuthenticated();
|
|
});
|
|
|
|
final userProfileProvider = FutureProvider<UserProfile?>((ref) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final token = await authService.getToken();
|
|
if (token == null) return null;
|
|
return authService.decodeToken(token);
|
|
});
|
|
|
|
final authTokenProvider = FutureProvider<String?>((ref) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
return await authService.getToken();
|
|
});
|
|
|
|
/// Internal HTTP-based delivery routes provider.
|
|
/// Use [deliveryRoutesProvider] instead, which respects the API mode configuration.
|
|
final _httpDeliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final isAuthenticated = await authService.isAuthenticated();
|
|
|
|
if (!isAuthenticated) {
|
|
throw Exception('User not authenticated');
|
|
}
|
|
|
|
// Create a new client with auth service for automatic token refresh
|
|
final authClient = CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
authService: authService,
|
|
);
|
|
|
|
final result = await authClient.executeQuery<List<DeliveryRoute>>(
|
|
endpoint: 'simpleDeliveryRouteQueryItems',
|
|
query: _EmptyQuery(),
|
|
fromJson: (json) {
|
|
// API returns data wrapped in object with "data" field
|
|
final data = json['data'];
|
|
if (data is List<dynamic>) {
|
|
return data.map((r) => DeliveryRoute.fromJson(r as Map<String, dynamic>)).toList();
|
|
}
|
|
return [];
|
|
},
|
|
);
|
|
|
|
return result.whenSuccess((routes) => routes) ?? [];
|
|
});
|
|
|
|
/// Unified delivery routes provider that respects the API mode configuration.
|
|
///
|
|
/// Automatically switches between HTTP and gRPC based on [apiModeConfigProvider].
|
|
/// When gRPC mode is enabled and [ApiModeConfig.fallbackToHttpOnError] is true,
|
|
/// falls back to HTTP on gRPC failures.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final routes = ref.watch(deliveryRoutesProvider);
|
|
/// routes.when(
|
|
/// data: (data) => displayRoutes(data),
|
|
/// loading: () => showLoading(),
|
|
/// error: (error, stack) => showError(error),
|
|
/// );
|
|
/// ```
|
|
final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
|
final apiModeConfig = ref.watch(apiModeConfigProvider);
|
|
|
|
if (apiModeConfig.isGrpc) {
|
|
try {
|
|
return await ref.watch(grpcDeliveryRoutesProvider.future);
|
|
} catch (e) {
|
|
if (apiModeConfig.fallbackToHttpOnError) {
|
|
debugPrint('gRPC failed, falling back to HTTP: $e');
|
|
return await ref.watch(_httpDeliveryRoutesProvider.future);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
return await ref.watch(_httpDeliveryRoutesProvider.future);
|
|
});
|
|
|
|
/// Provider for delivery routes using gRPC.
|
|
///
|
|
/// This is the gRPC-based alternative to [deliveryRoutesProvider].
|
|
/// Uses [GrpcCqrsApiClient] for improved performance and type safety.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final routes = ref.watch(grpcDeliveryRoutesProvider);
|
|
/// routes.when(
|
|
/// data: (data) => displayRoutes(data),
|
|
/// loading: () => showLoading(),
|
|
/// error: (error, stack) => showError(error),
|
|
/// );
|
|
/// ```
|
|
final grpcDeliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final isAuthenticated = await authService.isAuthenticated();
|
|
|
|
if (!isAuthenticated) {
|
|
throw Exception('User not authenticated');
|
|
}
|
|
|
|
final grpcClient = ref.watch(grpcClientProvider);
|
|
final result = await grpcClient.getDeliveryRoutes();
|
|
|
|
return result.when(
|
|
success: (routes) => routes,
|
|
onError: (error) {
|
|
debugPrint('ERROR fetching delivery routes via gRPC: ${error.message}');
|
|
if (error.originalException != null) {
|
|
debugPrint('Original exception: ${error.originalException}');
|
|
}
|
|
throw Exception(error.message);
|
|
},
|
|
);
|
|
});
|
|
|
|
/// Provider for deliveries using gRPC.
|
|
///
|
|
/// This is the gRPC-based alternative to [deliveriesProvider].
|
|
/// Uses [GrpcCqrsApiClient] for improved performance and type safety.
|
|
/// Takes a [routeFragmentId] parameter to fetch deliveries for a specific route.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final deliveries = ref.watch(grpcDeliveriesProvider(routeFragmentId));
|
|
/// deliveries.when(
|
|
/// data: (data) => displayDeliveries(data),
|
|
/// loading: () => showLoading(),
|
|
/// error: (error, stack) => showError(error),
|
|
/// );
|
|
/// ```
|
|
final grpcDeliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, routeFragmentId) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final isAuthenticated = await authService.isAuthenticated();
|
|
|
|
if (!isAuthenticated) {
|
|
throw Exception('User not authenticated');
|
|
}
|
|
|
|
final grpcClient = ref.watch(grpcClientProvider);
|
|
final result = await grpcClient.getDeliveries(routeFragmentId: routeFragmentId);
|
|
|
|
final deliveries = result.when(
|
|
success: (deliveries) => deliveries,
|
|
onError: (error) {
|
|
debugPrint('ERROR fetching deliveries for route $routeFragmentId via gRPC: ${error.message}');
|
|
if (error.originalException != null) {
|
|
debugPrint('Original exception: ${error.originalException}');
|
|
}
|
|
throw Exception(error.message);
|
|
},
|
|
);
|
|
|
|
// Always append the warehouse delivery at the end
|
|
return [...deliveries, Delivery.createWarehouseDelivery()];
|
|
});
|
|
|
|
/// Internal HTTP-based deliveries provider.
|
|
/// Use [deliveriesProvider] instead, which respects the API mode configuration.
|
|
final _httpDeliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, routeFragmentId) async {
|
|
final authService = ref.watch(authServiceProvider);
|
|
final isAuthenticated = await authService.isAuthenticated();
|
|
|
|
if (!isAuthenticated) {
|
|
throw Exception('User not authenticated');
|
|
}
|
|
|
|
final authClient = CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
authService: authService,
|
|
);
|
|
|
|
final result = await authClient.executeQuery<List<Delivery>>(
|
|
endpoint: 'simpleDeliveriesQueryItems',
|
|
query: _DeliveriesQuery(routeFragmentId: routeFragmentId),
|
|
fromJson: (json) {
|
|
// API returns data wrapped in object with "data" field
|
|
final data = json['data'];
|
|
if (data is List<dynamic>) {
|
|
return data.map((d) => Delivery.fromJson(d as Map<String, dynamic>)).toList();
|
|
}
|
|
return [];
|
|
},
|
|
);
|
|
|
|
// Log error if API call failed
|
|
result.whenError((error) {
|
|
debugPrint('ERROR fetching deliveries for route $routeFragmentId: ${error.message}');
|
|
if (error.originalException != null) {
|
|
debugPrint('Original exception: ${error.originalException}');
|
|
}
|
|
});
|
|
|
|
final deliveries = result.whenSuccess((deliveries) => deliveries) ?? [];
|
|
|
|
// Always append the warehouse delivery at the end
|
|
return [...deliveries, Delivery.createWarehouseDelivery()];
|
|
});
|
|
|
|
/// Unified deliveries provider that respects the API mode configuration.
|
|
///
|
|
/// Automatically switches between HTTP and gRPC based on [apiModeConfigProvider].
|
|
/// When gRPC mode is enabled and [ApiModeConfig.fallbackToHttpOnError] is true,
|
|
/// falls back to HTTP on gRPC failures.
|
|
///
|
|
/// Takes a [routeFragmentId] parameter to fetch deliveries for a specific route.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final deliveries = ref.watch(deliveriesProvider(routeFragmentId));
|
|
/// deliveries.when(
|
|
/// data: (data) => displayDeliveries(data),
|
|
/// loading: () => showLoading(),
|
|
/// error: (error, stack) => showError(error),
|
|
/// );
|
|
/// ```
|
|
final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, routeFragmentId) async {
|
|
final apiModeConfig = ref.watch(apiModeConfigProvider);
|
|
|
|
if (apiModeConfig.isGrpc) {
|
|
try {
|
|
return await ref.watch(grpcDeliveriesProvider(routeFragmentId).future);
|
|
} catch (e) {
|
|
if (apiModeConfig.fallbackToHttpOnError) {
|
|
debugPrint('gRPC failed for route $routeFragmentId, falling back to HTTP: $e');
|
|
return await ref.watch(_httpDeliveriesProvider(routeFragmentId).future);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
return await ref.watch(_httpDeliveriesProvider(routeFragmentId).future);
|
|
});
|
|
|
|
/// Provider to get all deliveries from all routes
|
|
final allDeliveriesProvider = FutureProvider<List<Delivery>>((ref) async {
|
|
final routes = await ref.read(deliveryRoutesProvider.future);
|
|
|
|
if (routes.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
// Fetch deliveries for all routes in parallel using Future.wait
|
|
final deliveriesFutures = routes.map((route) {
|
|
return ref.read(deliveriesProvider(route.id).future);
|
|
}).toList();
|
|
|
|
// Wait for all futures to complete
|
|
final deliveriesLists = await Future.wait(deliveriesFutures);
|
|
|
|
// Combine all deliveries into a single list
|
|
final allDeliveries = <Delivery>[];
|
|
for (final deliveries in deliveriesLists) {
|
|
allDeliveries.addAll(deliveries);
|
|
}
|
|
|
|
return allDeliveries;
|
|
});
|
|
|
|
// Language notifier for state management with SharedPreferences persistence
|
|
class LanguageNotifier extends AsyncNotifier<String> {
|
|
static const String _languageKey = 'app_language';
|
|
|
|
@override
|
|
Future<String> build() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString(_languageKey) ?? 'system';
|
|
}
|
|
|
|
Future<void> setLanguage(String lang) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_languageKey, lang);
|
|
state = AsyncValue.data(lang);
|
|
}
|
|
}
|
|
|
|
final languageProvider = AsyncNotifierProvider<LanguageNotifier, String>(() {
|
|
return LanguageNotifier();
|
|
});
|
|
|
|
// Theme mode notifier for manual theme switching
|
|
class ThemeModeNotifier extends Notifier<ThemeMode> {
|
|
@override
|
|
ThemeMode build() => ThemeMode.dark;
|
|
|
|
void setThemeMode(ThemeMode mode) => state = mode;
|
|
}
|
|
|
|
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(() {
|
|
return ThemeModeNotifier();
|
|
});
|
|
|
|
// Collapse state notifier for sidebar persistence
|
|
class CollapseStateNotifier extends Notifier<bool> {
|
|
@override
|
|
bool build() => true; // Default: expanded
|
|
|
|
void toggle() => state = !state;
|
|
void setExpanded(bool expanded) => state = expanded;
|
|
}
|
|
|
|
final collapseStateProvider = NotifierProvider<CollapseStateNotifier, bool>(() {
|
|
return CollapseStateNotifier();
|
|
});
|
|
|
|
// Mobile deliveries list toggle state notifier for mobile overlay
|
|
class MobileDeliveriesListOpenNotifier extends Notifier<bool> {
|
|
@override
|
|
bool build() => false; // Default: closed
|
|
|
|
void toggle() => state = !state;
|
|
void setOpen(bool open) => state = open;
|
|
}
|
|
|
|
final mobileDeliveriesListOpenProvider = NotifierProvider<MobileDeliveriesListOpenNotifier, bool>(() {
|
|
return MobileDeliveriesListOpenNotifier();
|
|
});
|
|
|
|
class _EmptyQuery implements Serializable {
|
|
@override
|
|
Map<String, Object?> toJson() => {};
|
|
}
|
|
|
|
class _DeliveriesQuery implements Serializable {
|
|
final int routeFragmentId;
|
|
|
|
_DeliveriesQuery({required this.routeFragmentId});
|
|
|
|
@override
|
|
Map<String, Object?> toJson() => {
|
|
'params': {
|
|
'routeFragmentId': routeFragmentId,
|
|
},
|
|
};
|
|
}
|