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: