checkpoint
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:http_interceptor/http_interceptor.dart';
|
||||
import 'types.dart';
|
||||
import 'openapi_config.dart';
|
||||
import '../utils/logging_interceptor.dart';
|
||||
import '../utils/http_client_factory.dart';
|
||||
|
||||
class CqrsApiClient {
|
||||
final ApiClientConfig config;
|
||||
@@ -16,6 +17,9 @@ class CqrsApiClient {
|
||||
}) {
|
||||
_httpClient = httpClient ?? InterceptedClient.build(
|
||||
interceptors: [LoggingInterceptor()],
|
||||
client: HttpClientFactory.createClient(
|
||||
allowSelfSigned: config.allowSelfSignedCertificate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@ class ApiClientConfig {
|
||||
final String baseUrl;
|
||||
final Duration timeout;
|
||||
final Map<String, String> defaultHeaders;
|
||||
final bool allowSelfSignedCertificate;
|
||||
|
||||
const ApiClientConfig({
|
||||
required this.baseUrl,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
this.defaultHeaders = const {},
|
||||
this.allowSelfSignedCertificate = false,
|
||||
});
|
||||
|
||||
static const ApiClientConfig development = ApiClientConfig(
|
||||
baseUrl: 'https://api-route.goutezplanb.com',
|
||||
baseUrl: 'https://localhost:7182',
|
||||
timeout: Duration(seconds: 30),
|
||||
allowSelfSignedCertificate: true,
|
||||
);
|
||||
|
||||
static const ApiClientConfig production = ApiClientConfig(
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/delivery_route.dart';
|
||||
import '../theme/spacing_system.dart';
|
||||
import '../theme/size_system.dart';
|
||||
import '../theme/animation_system.dart';
|
||||
import '../theme/color_system.dart';
|
||||
import '../utils/breakpoints.dart';
|
||||
import '../providers/providers.dart';
|
||||
import 'route_list_item.dart';
|
||||
|
||||
|
||||
class CollapsibleRoutesSidebar extends StatefulWidget {
|
||||
class CollapsibleRoutesSidebar extends ConsumerStatefulWidget {
|
||||
final List<DeliveryRoute> routes;
|
||||
final DeliveryRoute? selectedRoute;
|
||||
final ValueChanged<DeliveryRoute> onRouteSelected;
|
||||
@@ -21,14 +23,13 @@ class CollapsibleRoutesSidebar extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<CollapsibleRoutesSidebar> createState() =>
|
||||
ConsumerState<CollapsibleRoutesSidebar> createState() =>
|
||||
_CollapsibleRoutesSidebarState();
|
||||
}
|
||||
|
||||
class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
class _CollapsibleRoutesSidebarState extends ConsumerState<CollapsibleRoutesSidebar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
bool _isExpanded = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,9 +38,15 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
if (_isExpanded) {
|
||||
_animationController.forward();
|
||||
}
|
||||
// Set initial animation state based on provider value
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final isExpanded = ref.read(collapseStateProvider);
|
||||
if (isExpanded) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,10 +56,11 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
}
|
||||
|
||||
void _toggleSidebar() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
if (_isExpanded) {
|
||||
// Use shared provider state
|
||||
ref.read(collapseStateProvider.notifier).toggle();
|
||||
final isExpanded = ref.read(collapseStateProvider);
|
||||
|
||||
if (isExpanded) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
@@ -63,6 +71,7 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = context.isMobile;
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final isExpanded = ref.watch(collapseStateProvider);
|
||||
|
||||
// On mobile, always show as collapsible
|
||||
if (isMobile) {
|
||||
@@ -84,19 +93,15 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (_isExpanded)
|
||||
if (isExpanded)
|
||||
Text(
|
||||
'Routes',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: _isExpanded ? 0 : -0.25,
|
||||
duration: Duration(
|
||||
milliseconds: AppAnimations.durationFast.inMilliseconds,
|
||||
),
|
||||
child: const Icon(Icons.chevron_right),
|
||||
),
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
onPressed: _toggleSidebar,
|
||||
iconSize: AppSizes.iconMd,
|
||||
),
|
||||
@@ -104,18 +109,18 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
),
|
||||
),
|
||||
// Collapsible content
|
||||
if (_isExpanded)
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: _buildRoutesList(context),
|
||||
child: _buildRoutesList(context, isExpanded),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// On tablet/desktop, show full sidebar with toggle (expanded: 420px, collapsed: 80px for badge)
|
||||
// On tablet/desktop, show full sidebar with toggle (expanded: 300px, collapsed: 80px for badge)
|
||||
return Container(
|
||||
width: _isExpanded ? 420 : 80,
|
||||
width: isExpanded ? 300 : 80,
|
||||
color: isDarkMode ? SvrntyColors.almostBlack : Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -132,13 +137,15 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: _isExpanded ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center,
|
||||
mainAxisAlignment: isExpanded ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isExpanded)
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Routes',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -146,13 +153,7 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
width: AppSizes.buttonHeightMd,
|
||||
height: AppSizes.buttonHeightMd,
|
||||
child: IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: _isExpanded ? 0 : -0.5,
|
||||
duration: Duration(
|
||||
milliseconds: AppAnimations.durationFast.inMilliseconds,
|
||||
),
|
||||
child: const Icon(Icons.chevron_right),
|
||||
),
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
onPressed: _toggleSidebar,
|
||||
iconSize: AppSizes.iconMd,
|
||||
),
|
||||
@@ -161,15 +162,15 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
),
|
||||
),
|
||||
// Routes list
|
||||
Expanded(
|
||||
child: _buildRoutesList(context),
|
||||
Flexible(
|
||||
child: _buildRoutesList(context, isExpanded),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoutesList(BuildContext context) {
|
||||
Widget _buildRoutesList(BuildContext context, bool isExpanded) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 8),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
@@ -183,7 +184,7 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
|
||||
isSelected: isSelected,
|
||||
onTap: () => widget.onRouteSelected(route),
|
||||
animationIndex: index,
|
||||
isCollapsed: !_isExpanded,
|
||||
isCollapsed: !isExpanded,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
String _loadingMessage = 'Initializing...';
|
||||
Brightness? _lastBrightness;
|
||||
bool _isMapViewReady = false;
|
||||
bool _isDisposed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -41,6 +42,13 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
_initializeNavigation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_navigationController = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -49,7 +57,8 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
final currentBrightness = Theme.of(context).brightness;
|
||||
if (_lastBrightness != null &&
|
||||
_lastBrightness != currentBrightness &&
|
||||
_navigationController != null) {
|
||||
_navigationController != null &&
|
||||
!_isDisposed) {
|
||||
_applyDarkModeStyle();
|
||||
}
|
||||
_lastBrightness = currentBrightness;
|
||||
@@ -135,39 +144,13 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
|
||||
Future<void> _applyDarkModeStyle() async {
|
||||
// Check if widget is still mounted and controller exists
|
||||
if (!mounted || _navigationController == null) return;
|
||||
if (!mounted || _navigationController == null || _isDisposed || !_isMapViewReady) return;
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDisposed) return;
|
||||
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
if (isDarkMode) {
|
||||
// Dark mode style - Note: Currently only supported on Android
|
||||
const simpleDarkStyle = '''[
|
||||
{
|
||||
"elementType": "geometry",
|
||||
"stylers": [{"color": "#242424"}]
|
||||
},
|
||||
{
|
||||
"elementType": "labels.text.fill",
|
||||
"stylers": [{"color": "#746855"}]
|
||||
},
|
||||
{
|
||||
"elementType": "labels.text.stroke",
|
||||
"stylers": [{"color": "#242424"}]
|
||||
},
|
||||
{
|
||||
"featureType": "water",
|
||||
"elementType": "geometry",
|
||||
"stylers": [{"color": "#17263c"}]
|
||||
}
|
||||
]''';
|
||||
|
||||
await _navigationController!.setMapStyle(simpleDarkStyle);
|
||||
} else {
|
||||
// Reset to default light style
|
||||
await _navigationController!.setMapStyle(null);
|
||||
}
|
||||
// Always use default (light) map style
|
||||
await _navigationController!.setMapStyle(null);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
debugPrint('Error applying map style: $e');
|
||||
@@ -358,14 +341,20 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
// This is critical for iOS to display turn-by-turn directions, ETA, distance
|
||||
initialNavigationUIEnabledPreference: NavigationUIEnabledPreference.automatic,
|
||||
onViewCreated: (controller) async {
|
||||
// Early exit if widget is already disposed
|
||||
if (_isDisposed || !mounted) return;
|
||||
|
||||
_navigationController = controller;
|
||||
|
||||
// Wait longer for the map to be fully initialized on Android
|
||||
// This helps prevent crashes when the view is disposed during initialization
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// Safety check: ensure widget is still mounted before proceeding
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDisposed) {
|
||||
_navigationController = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark map as ready only after the delay
|
||||
_isMapViewReady = true;
|
||||
@@ -373,34 +362,68 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
|
||||
// Enable navigation UI elements (header with turn directions, footer with ETA/distance)
|
||||
// This is required for iOS to show trip info, duration, and ETA
|
||||
try {
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.setNavigationUIEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.setNavigationHeaderEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.setNavigationFooterEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.setNavigationTripProgressBarEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
// Disable report incident button
|
||||
await controller.setReportIncidentButtonEnabled(false);
|
||||
debugPrint('Navigation UI elements enabled');
|
||||
|
||||
// Configure map settings to reduce GPU load for devices with limited graphics capabilities
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.settings.setTrafficEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.settings.setRotateGesturesEnabled(true);
|
||||
if (!mounted || _isDisposed) return;
|
||||
await controller.settings.setTiltGesturesEnabled(false);
|
||||
if (!mounted || _isDisposed) return;
|
||||
debugPrint('Map settings configured for performance');
|
||||
} catch (e) {
|
||||
debugPrint('Error enabling navigation UI: $e');
|
||||
debugPrint('Error configuring map: $e');
|
||||
if (_isDisposed || !mounted) return;
|
||||
}
|
||||
|
||||
if (!mounted || _isDisposed) return;
|
||||
await _applyDarkModeStyle();
|
||||
|
||||
// Wrap camera animation in try-catch to handle "No valid view found" errors
|
||||
// This can happen on Android when the view isn't fully ready
|
||||
try {
|
||||
if (mounted && _navigationController != null && _isMapViewReady) {
|
||||
if (mounted && _navigationController != null && _isMapViewReady && !_isDisposed) {
|
||||
await controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(initialPosition, 12),
|
||||
);
|
||||
|
||||
// Auto-recenter to current location after initial setup
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted && _navigationController != null && !_isDisposed) {
|
||||
await _recenterMap();
|
||||
debugPrint('Auto-recentered map to current location on initialization');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Camera animation error (view may not be ready): $e');
|
||||
if (_isDisposed || !mounted) return;
|
||||
// Retry once after a longer delay
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
if (mounted && _navigationController != null && _isMapViewReady) {
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (mounted && _navigationController != null && _isMapViewReady && !_isDisposed) {
|
||||
try {
|
||||
await controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(initialPosition, 12),
|
||||
);
|
||||
|
||||
// Auto-recenter to current location after retry
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted && _navigationController != null && !_isDisposed) {
|
||||
await _recenterMap();
|
||||
debugPrint('Auto-recentered map to current location on initialization (retry)');
|
||||
}
|
||||
} catch (e2) {
|
||||
debugPrint('Camera animation retry failed: $e2');
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class _DeliveryListItemState extends State<DeliveryListItem>
|
||||
}
|
||||
});
|
||||
|
||||
_slideAnimation = Tween<double>(begin: 20, end: 0).animate(
|
||||
_slideAnimation = Tween<double>(begin: 0, end: 0).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
@@ -154,16 +154,14 @@ class _DeliveryListItemState extends State<DeliveryListItem>
|
||||
child: AnimatedContainer(
|
||||
duration: AppAnimations.durationFast,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
horizontal: 2,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: widget.delivery.delivered
|
||||
? Colors.green.withValues(alpha: 0.15)
|
||||
: (_isHovered || widget.isSelected
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Colors.transparent),
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
boxShadow: (_isHovered || widget.isSelected) && !widget.delivery.delivered
|
||||
? [
|
||||
BoxShadow(
|
||||
@@ -176,7 +174,7 @@ class _DeliveryListItemState extends State<DeliveryListItem>
|
||||
]
|
||||
: [],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main delivery info row
|
||||
@@ -185,63 +183,63 @@ class _DeliveryListItemState extends State<DeliveryListItem>
|
||||
children: [
|
||||
// Order number badge (left of status bar)
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${widget.delivery.deliveryIndex + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
// Left accent bar (vertical status bar)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 80,
|
||||
width: 4,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 10),
|
||||
// Delivery info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Customer Name (20% larger - 24px)
|
||||
// Customer Name
|
||||
Text(
|
||||
widget.delivery.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 24,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Address (20% larger - 18px)
|
||||
const SizedBox(height: 4),
|
||||
// Address
|
||||
Text(
|
||||
widget.delivery.deliveryAddress
|
||||
?.formattedAddress ??
|
||||
'No address',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
fontSize: 18,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -149,11 +149,11 @@ class _RouteListItemState extends State<RouteListItem>
|
||||
child: AnimatedContainer(
|
||||
duration: AppAnimations.durationFast,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
horizontal: 2,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: widget.route.completed
|
||||
? Colors.green.withValues(alpha: 0.15)
|
||||
: (_isHovered || widget.isSelected
|
||||
@@ -171,7 +171,7 @@ class _RouteListItemState extends State<RouteListItem>
|
||||
]
|
||||
: [],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main route info row
|
||||
@@ -180,61 +180,61 @@ class _RouteListItemState extends State<RouteListItem>
|
||||
children: [
|
||||
// Route number badge (left of status bar)
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
width: 45,
|
||||
height: 45,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${(widget.animationIndex ?? 0) + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
// Left accent bar (vertical status bar)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 80,
|
||||
width: 4,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 10),
|
||||
// Route info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Route Name (24px)
|
||||
// Route Name
|
||||
Text(
|
||||
widget.route.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 24,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Route details (18px)
|
||||
const SizedBox(height: 4),
|
||||
// Route details
|
||||
Text(
|
||||
'${widget.route.deliveredCount}/${widget.route.deliveriesCount} deliveries',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
fontSize: 18,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
+2
-2
@@ -42,8 +42,8 @@ class PlanBLogisticApp extends ConsumerWidget {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('en', ''),
|
||||
Locale('fr', ''),
|
||||
Locale('en', 'CA'),
|
||||
Locale('fr', 'CA'),
|
||||
],
|
||||
home: const AppHome(),
|
||||
);
|
||||
|
||||
@@ -13,11 +13,17 @@ import '../components/delivery_list_item.dart';
|
||||
class DeliveriesPage extends ConsumerStatefulWidget {
|
||||
final int routeFragmentId;
|
||||
final String routeName;
|
||||
final VoidCallback? onBack;
|
||||
final bool showAsEmbedded;
|
||||
final ValueChanged<Delivery?>? onDeliverySelected;
|
||||
|
||||
const DeliveriesPage({
|
||||
super.key,
|
||||
required this.routeFragmentId,
|
||||
required this.routeName,
|
||||
this.onBack,
|
||||
this.showAsEmbedded = false,
|
||||
this.onDeliverySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -60,10 +66,114 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deliveriesData = ref.watch(deliveriesProvider(widget.routeFragmentId));
|
||||
final routesData = ref.watch(deliveryRoutesProvider);
|
||||
final tokenAsync = ref.watch(authTokenProvider);
|
||||
final token = tokenAsync.hasValue ? tokenAsync.value : null;
|
||||
|
||||
// When embedded in sidebar, show only the delivery list with back button
|
||||
// This is a responsive sidebar that collapses like routes
|
||||
if (widget.showAsEmbedded) {
|
||||
final isExpanded = ref.watch(collapseStateProvider);
|
||||
|
||||
return deliveriesData.when(
|
||||
data: (deliveries) {
|
||||
// Auto-scroll to first pending delivery when page loads or route changes
|
||||
if (_lastRouteFragmentId != widget.routeFragmentId) {
|
||||
_lastRouteFragmentId = widget.routeFragmentId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_autoScrollToFirstPending(deliveries);
|
||||
});
|
||||
}
|
||||
|
||||
// Responsive sidebar that changes width when collapsed (420px expanded, 80px collapsed)
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: isExpanded ? 420 : 80,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with back button
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isExpanded ? 12 : 8,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: isExpanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isExpanded) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: widget.onBack,
|
||||
tooltip: 'Back to routes',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.routeName,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
onPressed: () {
|
||||
ref.read(collapseStateProvider.notifier).toggle();
|
||||
},
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Delivery list
|
||||
Expanded(
|
||||
child: UnifiedDeliveryListView(
|
||||
deliveries: deliveries,
|
||||
selectedDelivery: _selectedDelivery,
|
||||
scrollController: _listScrollController,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
// Notify parent about delivery selection
|
||||
widget.onDeliverySelected?.call(delivery);
|
||||
},
|
||||
onItemAction: (delivery, action) {
|
||||
_handleDeliveryAction(context, delivery, action, token);
|
||||
_autoScrollToFirstPending(deliveries);
|
||||
},
|
||||
isCollapsed: !isExpanded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: Text('Error: $error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// When not embedded, show full page with map
|
||||
final routesData = ref.watch(deliveryRoutesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.routeName),
|
||||
|
||||
+56
-45
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/delivery.dart';
|
||||
import '../models/delivery_route.dart';
|
||||
import '../providers/providers.dart';
|
||||
import '../utils/breakpoints.dart';
|
||||
@@ -18,6 +19,8 @@ class RoutesPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _RoutesPageState extends ConsumerState<RoutesPage> {
|
||||
late LocationPermissionService _permissionService;
|
||||
DeliveryRoute? _selectedRoute;
|
||||
Delivery? _selectedDelivery;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,15 +54,17 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToDeliveries(BuildContext context, DeliveryRoute route) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DeliveriesPage(
|
||||
routeFragmentId: route.id,
|
||||
routeName: route.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
void _selectRoute(DeliveryRoute route) {
|
||||
setState(() {
|
||||
_selectedRoute = route;
|
||||
});
|
||||
}
|
||||
|
||||
void _backToRoutes() {
|
||||
setState(() {
|
||||
_selectedRoute = null;
|
||||
_selectedDelivery = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -73,6 +78,14 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
|
||||
title: const Text('Delivery Routes'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.refresh(deliveryRoutesProvider);
|
||||
ref.refresh(allDeliveriesProvider);
|
||||
},
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
userProfile.when(
|
||||
data: (profile) => PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
@@ -134,43 +147,41 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
|
||||
// ignore: unused_result
|
||||
ref.refresh(allDeliveriesProvider);
|
||||
},
|
||||
child: context.isDesktop
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DarkModeMapComponent(
|
||||
deliveries: allDeliveries,
|
||||
selectedDelivery: null,
|
||||
onDeliverySelected: null,
|
||||
),
|
||||
),
|
||||
CollapsibleRoutesSidebar(
|
||||
routes: routes,
|
||||
selectedRoute: null,
|
||||
onRouteSelected: (route) {
|
||||
_navigateToDeliveries(context, route);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DarkModeMapComponent(
|
||||
deliveries: allDeliveries,
|
||||
selectedDelivery: null,
|
||||
onDeliverySelected: null,
|
||||
),
|
||||
),
|
||||
CollapsibleRoutesSidebar(
|
||||
routes: routes,
|
||||
selectedRoute: null,
|
||||
onRouteSelected: (route) {
|
||||
_navigateToDeliveries(context, route);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DarkModeMapComponent(
|
||||
deliveries: allDeliveries,
|
||||
selectedDelivery: _selectedDelivery,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
_selectedRoute == null
|
||||
? CollapsibleRoutesSidebar(
|
||||
routes: routes,
|
||||
selectedRoute: null,
|
||||
onRouteSelected: _selectRoute,
|
||||
)
|
||||
: SizedBox(
|
||||
width: 300,
|
||||
child: DeliveriesPage(
|
||||
routeFragmentId: _selectedRoute!.id,
|
||||
routeName: _selectedRoute!.name,
|
||||
onBack: _backToRoutes,
|
||||
showAsEmbedded: true,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
|
||||
@@ -9,11 +9,11 @@ import '../models/delivery_route.dart';
|
||||
import '../models/delivery.dart';
|
||||
|
||||
final authServiceProvider = Provider<AuthService>((ref) {
|
||||
return AuthService();
|
||||
return AuthService(config: AuthConfig.development);
|
||||
});
|
||||
|
||||
final apiClientProvider = Provider<CqrsApiClient>((ref) {
|
||||
return CqrsApiClient(config: ApiClientConfig.production);
|
||||
return CqrsApiClient(config: ApiClientConfig.development);
|
||||
});
|
||||
|
||||
final isAuthenticatedProvider = FutureProvider<bool>((ref) async {
|
||||
@@ -43,8 +43,9 @@ final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
||||
// Create a new client with auth token
|
||||
final authClient = CqrsApiClient(
|
||||
config: ApiClientConfig(
|
||||
baseUrl: ApiClientConfig.production.baseUrl,
|
||||
baseUrl: ApiClientConfig.development.baseUrl,
|
||||
defaultHeaders: {'Authorization': 'Bearer $token'},
|
||||
allowSelfSignedCertificate: ApiClientConfig.development.allowSelfSignedCertificate,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -75,8 +76,9 @@ final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, rout
|
||||
|
||||
final authClient = CqrsApiClient(
|
||||
config: ApiClientConfig(
|
||||
baseUrl: ApiClientConfig.production.baseUrl,
|
||||
baseUrl: ApiClientConfig.development.baseUrl,
|
||||
defaultHeaders: {'Authorization': 'Bearer $token'},
|
||||
allowSelfSignedCertificate: ApiClientConfig.development.allowSelfSignedCertificate,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -106,18 +108,17 @@ final allDeliveriesProvider = FutureProvider<List<Delivery>>((ref) async {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch deliveries for all routes
|
||||
// Fetch deliveries for all routes in parallel using Future.wait
|
||||
final deliveriesFutures = routes.map((route) {
|
||||
return ref.watch(deliveriesProvider(route.id)).when(
|
||||
data: (deliveries) => deliveries,
|
||||
loading: () => <Delivery>[],
|
||||
error: (_, __) => <Delivery>[],
|
||||
);
|
||||
});
|
||||
return ref.read(deliveriesProvider(route.id).future);
|
||||
}).toList();
|
||||
|
||||
// Combine all deliveries
|
||||
// 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 deliveriesFutures) {
|
||||
for (final deliveries in deliveriesLists) {
|
||||
allDeliveries.addAll(deliveries);
|
||||
}
|
||||
|
||||
@@ -148,6 +149,19 @@ 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();
|
||||
});
|
||||
|
||||
class _EmptyQuery implements Serializable {
|
||||
@override
|
||||
Map<String, Object?> toJson() => {};
|
||||
|
||||
@@ -5,20 +5,51 @@ import 'package:http_interceptor/http_interceptor.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import '../utils/logging_interceptor.dart';
|
||||
import '../utils/http_client_factory.dart';
|
||||
|
||||
class AuthConfig {
|
||||
final String realm;
|
||||
final String authServerUrl;
|
||||
final String clientId;
|
||||
final bool allowSelfSignedCertificate;
|
||||
|
||||
const AuthConfig({
|
||||
required this.realm,
|
||||
required this.authServerUrl,
|
||||
required this.clientId,
|
||||
this.allowSelfSignedCertificate = false,
|
||||
});
|
||||
|
||||
static const AuthConfig development = AuthConfig(
|
||||
realm: 'dev',
|
||||
authServerUrl: 'https://auth.goutezplanb.com',
|
||||
clientId: 'delivery-mobile-app',
|
||||
allowSelfSignedCertificate: true,
|
||||
);
|
||||
|
||||
static const AuthConfig production = AuthConfig(
|
||||
realm: 'planb-internal',
|
||||
authServerUrl: 'https://auth.goutezplanb.com',
|
||||
clientId: 'delivery-mobile-app',
|
||||
);
|
||||
|
||||
String get tokenEndpoint => '$authServerUrl/realms/$realm/protocol/openid-connect/token';
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _tokenEndpoint = 'https://auth.goutezplanb.com/realms/planb-internal/protocol/openid-connect/token';
|
||||
static const String _clientId = 'delivery-mobile-app';
|
||||
|
||||
final AuthConfig _config;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final http.Client _httpClient;
|
||||
|
||||
AuthService({
|
||||
AuthConfig config = AuthConfig.development,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
http.Client? httpClient,
|
||||
}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(
|
||||
}) : _config = config,
|
||||
_secureStorage = secureStorage ?? const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
@@ -31,6 +62,9 @@ class AuthService {
|
||||
),
|
||||
_httpClient = httpClient ?? InterceptedClient.build(
|
||||
interceptors: [LoggingInterceptor()],
|
||||
client: HttpClientFactory.createClient(
|
||||
allowSelfSigned: config.allowSelfSignedCertificate,
|
||||
),
|
||||
);
|
||||
|
||||
Future<AuthResult> login({
|
||||
@@ -39,13 +73,13 @@ class AuthService {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse(_tokenEndpoint),
|
||||
Uri.parse(_config.tokenEndpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
'grant_type': 'password',
|
||||
'client_id': _clientId,
|
||||
'client_id': _config.clientId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'scope': 'openid profile offline_access',
|
||||
@@ -81,13 +115,13 @@ class AuthService {
|
||||
}
|
||||
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse(_tokenEndpoint),
|
||||
Uri.parse(_config.tokenEndpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': _clientId,
|
||||
'client_id': _config.clientId,
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
|
||||
class HttpClientFactory {
|
||||
static http.Client createClient({bool allowSelfSigned = false}) {
|
||||
if (allowSelfSigned) {
|
||||
final ioClient = HttpClient()
|
||||
..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
return IOClient(ioClient);
|
||||
}
|
||||
return http.Client();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user