diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ef72359..0a30a8c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -33,6 +33,11 @@
+
+
+
-
diff --git a/docs/IMPELLER_MAP_GLITCH.md b/docs/IMPELLER_MAP_GLITCH.md
new file mode 100644
index 0000000..1be8e8c
--- /dev/null
+++ b/docs/IMPELLER_MAP_GLITCH.md
@@ -0,0 +1,295 @@
+# Impeller Map Rendering Issue Documentation
+
+## Issue Overview
+
+The Plan B Logistics Flutter app experiences map rendering glitches when using Flutter's Impeller rendering engine on Android. This requires the app to run with the `--no-enable-impeller` flag to function correctly.
+
+## Affected Components
+
+- **Component**: Google Maps Navigation SDK for Flutter
+- **Platform**: Android (device: KM10 - Android physical device)
+- **Rendering Engine**: Impeller (Flutter's new rendering backend)
+- **Symptoms**: Visual glitches and rendering artifacts on the map view
+
+## Technical Background
+
+### What is Impeller?
+
+Impeller is Flutter's next-generation rendering engine designed to replace the Skia backend. It provides:
+- Predictable performance by precompiling shaders
+- Reduced jank and frame drops
+- Better graphics API utilization (Metal on iOS, Vulkan on Android)
+
+However, Impeller is still being stabilized and some third-party plugins (particularly those with native platform views) may experience compatibility issues.
+
+### Why Google Maps Has Issues with Impeller
+
+Google Maps Navigation SDK uses native platform views (SurfaceView on Android) that render the map content. The interaction between:
+1. Flutter's rendering pipeline (Impeller)
+2. Native Android views (Platform Views)
+3. Complex map rendering (Google Maps SDK)
+
+Can cause rendering glitches, z-index issues, or visual artifacts.
+
+## Current Workaround
+
+### Running with Impeller Disabled
+
+**Command:**
+```bash
+flutter run -d KM10 --no-enable-impeller
+```
+
+**Effect:** Forces Flutter to use the legacy Skia rendering backend instead of Impeller.
+
+### Deprecation Warning
+
+When running with `--no-enable-impeller`, Flutter displays the following warning:
+
+```
+[IMPORTANT:flutter/shell/common/shell.cc(527)] [Action Required]: Impeller opt-out deprecated.
+ The application opted out of Impeller by either using the
+ `--no-enable-impeller` flag or the
+ `io.flutter.embedding.android.EnableImpeller` `AndroidManifest.xml` entry.
+ These options are going to go away in an upcoming Flutter release. Remove
+ the explicit opt-out. If you need to opt-out, please report a bug describing
+ the issue.
+```
+
+**Important:** This flag will be removed in a future Flutter release, meaning this workaround is temporary.
+
+## Observed Symptoms (When Impeller is Enabled)
+
+When running with Impeller enabled (default behavior), the following issues occur:
+
+1. **Map Rendering Glitches**
+ - Visual artifacts on the map surface
+ - Possible z-index layering issues between Flutter widgets and native map view
+ - Inconsistent rendering of map tiles or navigation elements
+
+2. **Performance Issues**
+ - Frame skipping: "Skipped 128 frames! The application may be doing too much work on its main thread"
+ - Possible GPU rendering conflicts between Impeller and Google Maps' native rendering
+
+3. **Platform View Integration Issues**
+ - The map uses a SurfaceView (native Android view) embedded in Flutter's widget tree
+ - Impeller's composition may conflict with how Flutter manages platform views
+
+## File References
+
+### Map Component Implementation
+
+**File:** `lib/components/dark_mode_map.dart`
+- Implements Google Maps Navigation SDK integration
+- Uses AndroidView platform view for native map rendering
+- Lines 403-408, 421-426: Auto-recenter functionality
+- Configures map settings for performance optimization
+
+**Key Code Sections:**
+```dart
+// Platform view creation (Android)
+body: Platform.isAndroid
+ ? AndroidView(
+ viewType: 'google_navigation_flutter',
+ onPlatformViewCreated: _onViewCreated,
+ creationParams: _viewCreationParams,
+ creationParamsCodec: const StandardMessageCodec(),
+ )
+```
+
+## Potential Solutions
+
+### 1. Wait for Upstream Fixes (Recommended)
+
+**Action:** Monitor the following repositories for updates:
+- [Flutter Engine Issues](https://github.com/flutter/flutter/issues)
+- [Google Maps Flutter Navigation Plugin](https://github.com/googlemaps/flutter-navigation-sdk/issues)
+
+**Search Terms:**
+- "Impeller platform view glitch"
+- "Google Maps Impeller rendering"
+- "AndroidView Impeller artifacts"
+
+### 2. Report Issue to Flutter Team
+
+If not already reported, file a bug report with:
+- Device: KM10 Android device
+- Flutter version: (check with `flutter --version`)
+- Google Maps Navigation SDK version
+- Detailed reproduction steps
+- Screenshots/video of the glitch
+
+**Report at:** https://github.com/flutter/flutter/issues/new?template=02_bug.yml
+
+### 3. Permanent AndroidManifest Configuration (Temporary)
+
+If needed for production builds before Flutter removes the flag, add to `android/app/src/main/AndroidManifest.xml`:
+
+```xml
+
+
+
+```
+
+**Warning:** This will also be deprecated and removed in future Flutter releases.
+
+### 4. Investigate Hybrid Composition (Alternative)
+
+Try switching to Hybrid Composition mode for platform views:
+
+```dart
+// In android/app/src/main/AndroidManifest.xml
+
+```
+
+This changes how Flutter composites native views and may resolve rendering conflicts with Impeller.
+
+### 5. Monitor Flutter Stable Channel Updates
+
+As Flutter's Impeller implementation matures, future stable releases will include fixes for platform view rendering issues. Regularly update Flutter and test with Impeller enabled:
+
+```bash
+flutter upgrade
+flutter run -d KM10 # Test without --no-enable-impeller flag
+```
+
+## Testing Checklist
+
+When testing Impeller compatibility in future Flutter versions:
+
+- [ ] Map renders correctly without visual artifacts
+- [ ] Delivery list sidebar doesn't interfere with map rendering
+- [ ] Map markers and navigation UI elements display properly
+- [ ] No z-index issues between Flutter widgets and map view
+- [ ] Smooth scrolling and panning without frame drops
+- [ ] Navigation route rendering works correctly
+- [ ] Camera transitions are smooth
+- [ ] Auto-recenter functionality works
+- [ ] Performance is acceptable (check frame timing in DevTools)
+
+## Development Workflow
+
+### Current Recommendation
+
+**For Android development:**
+```bash
+# Use this until Impeller compatibility is confirmed
+flutter run -d KM10 --no-enable-impeller
+```
+
+**For iOS development:**
+```bash
+# iOS Impeller is more stable, can use default
+flutter run -d ios
+```
+
+### Hot Reload
+
+Hot reload works normally with Impeller disabled:
+```bash
+# Press 'r' in terminal or
+echo "r" | nc localhost 7182
+```
+
+### Release Builds
+
+**Current Android release builds should also disable Impeller:**
+
+In `android/app/build.gradle`:
+```gradle
+android {
+ defaultConfig {
+ // Add Impeller opt-out for release builds
+ manifestPlaceholders = [
+ enableImpeller: 'false'
+ ]
+ }
+}
+```
+
+Then reference in AndroidManifest.xml:
+```xml
+
+```
+
+## Timeline and Impact
+
+### Short-term (Current)
+- **Status:** Using `--no-enable-impeller` flag for all Android development
+- **Impact:** No user-facing issues, development proceeds normally
+- **Risk:** None, Skia backend is stable and well-tested
+
+### Medium-term (Next 3-6 months)
+- **Status:** Monitor Flutter stable releases for Impeller improvements
+- **Action:** Test each stable release with Impeller enabled
+- **Risk:** Low, workaround is stable
+
+### Long-term (6-12 months)
+- **Status:** Impeller opt-out will be removed from Flutter
+- **Action:** Must resolve compatibility or report blocking issues
+- **Risk:** Medium - may need to migrate away from Google Maps if incompatibility persists
+
+## Related Issues
+
+### Performance Warnings
+
+The following warnings appear in logs but are expected during development:
+
+```
+I/Choreographer(22365): Skipped 128 frames! The application may be doing too much work on its main thread.
+```
+
+This is related to initial app load and map initialization, not specifically to the Impeller workaround.
+
+### Platform View Warnings
+
+```
+E/FrameEvents(22365): updateAcquireFence: Did not find frame.
+W/Parcel(22365): Expecting binder but got null!
+```
+
+These warnings appear with both Skia and Impeller backends and are Android platform view quirks, not critical errors.
+
+## References
+
+- [Flutter Impeller Documentation](https://docs.flutter.dev/perf/impeller)
+- [Flutter Platform Views](https://docs.flutter.dev/platform-integration/android/platform-views)
+- [Google Maps Flutter Navigation SDK](https://developers.google.com/maps/documentation/navigation/flutter/reference)
+- [Flutter Issue: Impeller platform view support](https://github.com/flutter/flutter/issues?q=is%3Aissue+impeller+platform+view)
+
+## Last Updated
+
+**Date:** 2025-11-25
+**Flutter Version:** (Run `flutter --version` to check)
+**Device:** KM10 Android
+**Status:** Workaround active, monitoring for upstream fixes
+
+---
+
+## Notes for Future Developers
+
+If you're reading this documentation:
+
+1. **First, try without the flag** - Impeller may have been fixed in your Flutter version:
+ ```bash
+ flutter run -d android
+ ```
+
+2. **If you see map glitches**, add the flag:
+ ```bash
+ flutter run -d android --no-enable-impeller
+ ```
+
+3. **If the flag is removed and maps still glitch**, search for:
+ - "Flutter Impeller Google Maps" on GitHub Issues
+ - Alternative map solutions (Apple Maps on iOS, alternative SDKs)
+ - Platform view composition modes
+
+4. **Consider this a temporary workaround**, not a permanent solution.
diff --git a/lib/api/client.dart b/lib/api/client.dart
index b0a5a63..df51d31 100644
--- a/lib/api/client.dart
+++ b/lib/api/client.dart
@@ -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,
+ ),
);
}
diff --git a/lib/api/openapi_config.dart b/lib/api/openapi_config.dart
index 7bbd0f3..fa290ad 100644
--- a/lib/api/openapi_config.dart
+++ b/lib/api/openapi_config.dart
@@ -2,16 +2,19 @@ class ApiClientConfig {
final String baseUrl;
final Duration timeout;
final Map 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(
diff --git a/lib/components/collapsible_routes_sidebar.dart b/lib/components/collapsible_routes_sidebar.dart
index 8f8225b..971122a 100644
--- a/lib/components/collapsible_routes_sidebar.dart
+++ b/lib/components/collapsible_routes_sidebar.dart
@@ -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 routes;
final DeliveryRoute? selectedRoute;
final ValueChanged onRouteSelected;
@@ -21,14 +23,13 @@ class CollapsibleRoutesSidebar extends StatefulWidget {
});
@override
- State createState() =>
+ ConsumerState createState() =>
_CollapsibleRoutesSidebarState();
}
-class _CollapsibleRoutesSidebarState extends State
+class _CollapsibleRoutesSidebarState extends ConsumerState
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
- bool _isExpanded = true;
@override
void initState() {
@@ -37,9 +38,15 @@ class _CollapsibleRoutesSidebarState extends State
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
}
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
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
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
),
),
// 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
),
),
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
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
),
),
// 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
isSelected: isSelected,
onTap: () => widget.onRouteSelected(route),
animationIndex: index,
- isCollapsed: !_isExpanded,
+ isCollapsed: !isExpanded,
);
},
);
diff --git a/lib/components/dark_mode_map.dart b/lib/components/dark_mode_map.dart
index a4384a9..39dcbf9 100644
--- a/lib/components/dark_mode_map.dart
+++ b/lib/components/dark_mode_map.dart
@@ -34,6 +34,7 @@ class _DarkModeMapComponentState extends State {
String _loadingMessage = 'Initializing...';
Brightness? _lastBrightness;
bool _isMapViewReady = false;
+ bool _isDisposed = false;
@override
void initState() {
@@ -41,6 +42,13 @@ class _DarkModeMapComponentState extends State {
_initializeNavigation();
}
+ @override
+ void dispose() {
+ _isDisposed = true;
+ _navigationController = null;
+ super.dispose();
+ }
+
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -49,7 +57,8 @@ class _DarkModeMapComponentState extends State {
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 {
Future _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 {
// 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 {
// 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');
}
diff --git a/lib/components/delivery_list_item.dart b/lib/components/delivery_list_item.dart
index ba18024..02ce0b5 100644
--- a/lib/components/delivery_list_item.dart
+++ b/lib/components/delivery_list_item.dart
@@ -54,7 +54,7 @@ class _DeliveryListItemState extends State
}
});
- _slideAnimation = Tween(begin: 20, end: 0).animate(
+ _slideAnimation = Tween(begin: 0, end: 0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
@@ -154,16 +154,14 @@ class _DeliveryListItemState extends State
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
]
: [],
),
- 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
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,
diff --git a/lib/components/route_list_item.dart b/lib/components/route_list_item.dart
index d84a549..aa7ca9c 100644
--- a/lib/components/route_list_item.dart
+++ b/lib/components/route_list_item.dart
@@ -149,11 +149,11 @@ class _RouteListItemState extends State
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
]
: [],
),
- 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
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,
diff --git a/lib/main.dart b/lib/main.dart
index 207705f..84f64dc 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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(),
);
diff --git a/lib/pages/deliveries_page.dart b/lib/pages/deliveries_page.dart
index 8a984ac..6f7e41a 100644
--- a/lib/pages/deliveries_page.dart
+++ b/lib/pages/deliveries_page.dart
@@ -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? 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 {
@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),
diff --git a/lib/pages/routes_page.dart b/lib/pages/routes_page.dart
index 35bb742..baef7b4 100644
--- a/lib/pages/routes_page.dart
+++ b/lib/pages/routes_page.dart
@@ -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 {
late LocationPermissionService _permissionService;
+ DeliveryRoute? _selectedRoute;
+ Delivery? _selectedDelivery;
@override
void initState() {
@@ -51,15 +54,17 @@ class _RoutesPageState extends ConsumerState {
}
}
- 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 {
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(
onSelected: (value) {
@@ -134,43 +147,41 @@ class _RoutesPageState extends ConsumerState {
// 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(
diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart
index a18feb7..06963fe 100644
--- a/lib/providers/providers.dart
+++ b/lib/providers/providers.dart
@@ -9,11 +9,11 @@ import '../models/delivery_route.dart';
import '../models/delivery.dart';
final authServiceProvider = Provider((ref) {
- return AuthService();
+ return AuthService(config: AuthConfig.development);
});
final apiClientProvider = Provider((ref) {
- return CqrsApiClient(config: ApiClientConfig.production);
+ return CqrsApiClient(config: ApiClientConfig.development);
});
final isAuthenticatedProvider = FutureProvider((ref) async {
@@ -43,8 +43,9 @@ final deliveryRoutesProvider = FutureProvider>((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, 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>((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: () => [],
- error: (_, __) => [],
- );
- });
+ 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 = [];
- for (final deliveries in deliveriesFutures) {
+ for (final deliveries in deliveriesLists) {
allDeliveries.addAll(deliveries);
}
@@ -148,6 +149,19 @@ final themeModeProvider = NotifierProvider(() {
return ThemeModeNotifier();
});
+// Collapse state notifier for sidebar persistence
+class CollapseStateNotifier extends Notifier {
+ @override
+ bool build() => true; // Default: expanded
+
+ void toggle() => state = !state;
+ void setExpanded(bool expanded) => state = expanded;
+}
+
+final collapseStateProvider = NotifierProvider(() {
+ return CollapseStateNotifier();
+});
+
class _EmptyQuery implements Serializable {
@override
Map toJson() => {};
diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart
index 36b9700..d5eebf6 100644
--- a/lib/services/auth_service.dart
+++ b/lib/services/auth_service.dart
@@ -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 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,
},
);
diff --git a/lib/utils/http_client_factory.dart b/lib/utils/http_client_factory.dart
new file mode 100644
index 0000000..9c542c8
--- /dev/null
+++ b/lib/utils/http_client_factory.dart
@@ -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();
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index d4b541e..ee1feae 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -465,10 +465,10 @@ packages:
dependency: "direct main"
description:
name: google_navigation_flutter
- sha256: f1a892e58c94601716ed5879e07c2a31031fcd609088705f5c97dc9c28748717
+ sha256: fdf79ddeda8bbba9d8b9218c41551b918447032004cbe72ea7365287c9d9bf80
url: "https://pub.dev"
source: hosted
- version: "0.6.5"
+ version: "0.7.0"
graphs:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index a7be13f..5833e7d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -40,7 +40,7 @@ dependencies:
go_router: ^17.0.0
shared_preferences: ^2.5.3
http_interceptor: ^2.0.0
- google_navigation_flutter: ^0.6.5
+ google_navigation_flutter: ^0.7.0
dev_dependencies:
flutter_test: