From 5714fd84433845fd8730d18d6b200399afc52135 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Brule Date: Sat, 15 Nov 2025 17:55:18 -0500 Subject: [PATCH] Implement UI/UX enhancements with collapsible routes sidebar and glassmorphic route cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new components (CollapsibleRoutesSidebar, GlassmorphicRouteCard) and internationalization support. Updates deliveries and routes pages with improved navigation and visual presentation using Material Design 3 principles. 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../collapsible_routes_sidebar.dart | 200 ++++++++++ lib/components/glassmorphic_route_card.dart | 330 ++++++++++++++++ lib/l10n/app_localizations.dart | 368 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 137 +++++++ lib/l10n/app_localizations_fr.dart | 137 +++++++ lib/pages/deliveries_page.dart | 259 ++++++++---- lib/pages/routes_page.dart | 135 ++++--- lib/providers/providers.dart | 26 ++ 8 files changed, 1463 insertions(+), 129 deletions(-) create mode 100644 lib/components/collapsible_routes_sidebar.dart create mode 100644 lib/components/glassmorphic_route_card.dart create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_fr.dart diff --git a/lib/components/collapsible_routes_sidebar.dart b/lib/components/collapsible_routes_sidebar.dart new file mode 100644 index 0000000..9b7b950 --- /dev/null +++ b/lib/components/collapsible_routes_sidebar.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.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 'glassmorphic_route_card.dart'; + + +class CollapsibleRoutesSidebar extends StatefulWidget { + final List routes; + final DeliveryRoute? selectedRoute; + final ValueChanged onRouteSelected; + + const CollapsibleRoutesSidebar({ + super.key, + required this.routes, + this.selectedRoute, + required this.onRouteSelected, + }); + + @override + State createState() => + _CollapsibleRoutesSidebarState(); +} + +class _CollapsibleRoutesSidebarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + bool _isExpanded = true; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + if (_isExpanded) { + _animationController.forward(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _toggleSidebar() { + setState(() { + _isExpanded = !_isExpanded; + }); + if (_isExpanded) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final isMobile = context.isMobile; + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + // On mobile, always show as collapsible + if (isMobile) { + return Container( + color: isDarkMode ? SvrntyColors.almostBlack : Colors.white, + child: Column( + children: [ + // Header with toggle button + Container( + padding: EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDarkMode ? SvrntyColors.darkSlate : SvrntyColors.slateGray.withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (_isExpanded) + Text( + 'Routes', + style: Theme.of(context).textTheme.titleMedium, + ), + IconButton( + icon: AnimatedRotation( + turns: _isExpanded ? 0 : -0.25, + duration: Duration( + milliseconds: AppAnimations.durationFast.inMilliseconds, + ), + child: const Icon(Icons.chevron_right), + ), + onPressed: _toggleSidebar, + iconSize: AppSizes.iconMd, + ), + ], + ), + ), + // Collapsible content + if (_isExpanded) + Expanded( + child: _buildRoutesList(context), + ), + ], + ), + ); + } + + // On tablet/desktop, show full sidebar with toggle + return Container( + width: _isExpanded ? 280 : 64, + color: isDarkMode ? SvrntyColors.almostBlack : Colors.white, + child: Column( + children: [ + // Header with toggle button + Container( + height: kToolbarHeight, + padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: isDarkMode ? SvrntyColors.darkSlate : SvrntyColors.slateGray.withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: _isExpanded ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center, + children: [ + if (_isExpanded) + Expanded( + child: Text( + 'Routes', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + 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), + ), + onPressed: _toggleSidebar, + iconSize: AppSizes.iconMd, + ), + ), + ], + ), + ), + // Routes list + Expanded( + child: _buildRoutesList(context), + ), + ], + ), + ); + } + + Widget _buildRoutesList(BuildContext context) { + return ListView.builder( + padding: EdgeInsets.all(AppSpacing.sm), + itemCount: widget.routes.length, + itemBuilder: (context, index) { + final route = widget.routes[index]; + final isSelected = widget.selectedRoute?.id == route.id; + + return Padding( + padding: EdgeInsets.only(bottom: AppSpacing.sm), + child: _buildRouteButton(context, route, isSelected), + ); + }, + ); + } + + Widget _buildRouteButton( + BuildContext context, + DeliveryRoute route, + bool isSelected, + ) { + return GlassmorphicRouteCard( + route: route, + isSelected: isSelected, + isCollapsed: !_isExpanded, + onTap: () => widget.onRouteSelected(route), + ); + } +} diff --git a/lib/components/glassmorphic_route_card.dart b/lib/components/glassmorphic_route_card.dart new file mode 100644 index 0000000..ab7b848 --- /dev/null +++ b/lib/components/glassmorphic_route_card.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'dart:ui' as ui; +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'; + +/// Modern glassmorphic route card with status-based gradient and animated progress +class GlassmorphicRouteCard extends StatefulWidget { + final DeliveryRoute route; + final bool isSelected; + final VoidCallback onTap; + final bool isCollapsed; + + const GlassmorphicRouteCard({ + super.key, + required this.route, + required this.isSelected, + required this.onTap, + this.isCollapsed = false, + }); + + @override + State createState() => _GlassmorphicRouteCardState(); +} + +class _GlassmorphicRouteCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _hoverController; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _hoverController = AnimationController( + duration: Duration(milliseconds: AppAnimations.durationFast.inMilliseconds), + vsync: this, + ); + } + + @override + void dispose() { + _hoverController.dispose(); + super.dispose(); + } + + /// Calculate color based on completion percentage + Color _getProgressColor(double progress) { + if (progress < 0.3) { + // Red to orange (0-30%) + return Color.lerp( + SvrntyColors.crimsonRed, + const Color(0xFFFF9800), + (progress / 0.3), + )!; + } else if (progress < 0.7) { + // Orange to yellow (30-70%) + return Color.lerp( + const Color(0xFFFF9800), + const Color(0xFFFFC107), + ((progress - 0.3) / 0.4), + )!; + } else { + // Yellow to green (70-100%) + return Color.lerp( + const Color(0xFFFFC107), + const Color(0xFF4CAF50), + ((progress - 0.7) / 0.3), + )!; + } + } + + void _setHovered(bool hovered) { + setState(() { + _isHovered = hovered; + }); + if (hovered) { + _hoverController.forward(); + } else { + _hoverController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final progress = widget.route.deliveredCount / widget.route.deliveriesCount; + final progressColor = _getProgressColor(progress); + + if (widget.isCollapsed) { + return _buildCollapsedCard(context, isDarkMode, progress, progressColor); + } + + return _buildExpandedCard(context, isDarkMode, progress, progressColor); + } + + Widget _buildCollapsedCard(BuildContext context, bool isDarkMode, + double progress, Color progressColor) { + return MouseRegion( + onEnter: (_) => _setHovered(true), + onExit: (_) => _setHovered(false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: Duration( + milliseconds: AppAnimations.durationFast.inMilliseconds, + ), + height: AppSizes.buttonHeightMd, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppSpacing.md), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Glassmorphic background + ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.md), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + decoration: BoxDecoration( + color: (isDarkMode + ? SvrntyColors.darkSlate + : Colors.white) + .withValues(alpha: 0.7), + border: Border.all( + color: (isDarkMode ? Colors.white : Colors.white) + .withValues(alpha: 0.2), + width: 1.5, + ), + ), + ), + ), + ), + // Progress indicator at bottom + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(AppSpacing.md - AppSpacing.xs), + bottomRight: Radius.circular(AppSpacing.md - AppSpacing.xs), + ), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: progressColor.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(progressColor), + ), + ), + ), + // Content + Center( + child: Text( + widget.route.name.substring(0, 1).toUpperCase(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 16, + color: widget.isSelected ? progressColor : null, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExpandedCard(BuildContext context, bool isDarkMode, + double progress, Color progressColor) { + return MouseRegion( + onEnter: (_) => _setHovered(true), + onExit: (_) => _setHovered(false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _hoverController, + builder: (context, child) { + final hoverValue = _hoverController.value; + final blurSigma = 8 + (hoverValue * 3); + final bgOpacity = 0.7 + (hoverValue * 0.1); + + return AnimatedContainer( + duration: Duration( + milliseconds: AppAnimations.durationFast.inMilliseconds, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppSpacing.lg), + boxShadow: [ + BoxShadow( + color: progressColor.withValues(alpha: 0.1 + (hoverValue * 0.2)), + blurRadius: 12 + (hoverValue * 8), + offset: Offset(0, 2 + (hoverValue * 2)), + ), + ], + ), + child: Stack( + children: [ + // Glassmorphic background + ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.lg), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), + child: Container( + decoration: BoxDecoration( + color: (isDarkMode + ? SvrntyColors.darkSlate + : Colors.white) + .withValues(alpha: bgOpacity), + border: Border.all( + color: (isDarkMode ? Colors.white : Colors.white) + .withValues(alpha: 0.2 + (hoverValue * 0.15)), + width: 1.5, + ), + borderRadius: BorderRadius.circular(AppSpacing.lg), + ), + ), + ), + ), + // Content + Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Route name + Text( + widget.route.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: widget.isSelected ? progressColor : null, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: AppSpacing.xs), + // Delivery count + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${widget.route.deliveredCount}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: progressColor, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + TextSpan( + text: '/${widget.route.deliveriesCount}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + fontSize: 11, + color: (Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black) + .withValues(alpha: 0.6), + ), + ), + ], + ), + ), + SizedBox(height: AppSpacing.sm), + // Animated gradient progress bar + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Stack( + children: [ + // Background + Container( + height: 6, + decoration: BoxDecoration( + color: progressColor + .withValues(alpha: 0.15), + ), + ), + // Progress fill with gradient + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Container( + height: 6, + width: 100 * progress, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + SvrntyColors.crimsonRed, + progressColor, + ], + ), + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: progressColor + .withValues(alpha: 0.4), + blurRadius: 4, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..5493696 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,368 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_fr.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('fr'), + ]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'Plan B Logistics'** + String get appTitle; + + /// No description provided for @appDescription. + /// + /// In en, this message translates to: + /// **'Delivery Management System'** + String get appDescription; + + /// No description provided for @loginWithKeycloak. + /// + /// In en, this message translates to: + /// **'Login with Keycloak'** + String get loginWithKeycloak; + + /// No description provided for @deliveryRoutes. + /// + /// In en, this message translates to: + /// **'Delivery Routes'** + String get deliveryRoutes; + + /// No description provided for @routes. + /// + /// In en, this message translates to: + /// **'Routes'** + String get routes; + + /// No description provided for @deliveries. + /// + /// In en, this message translates to: + /// **'Deliveries'** + String get deliveries; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @profile. + /// + /// In en, this message translates to: + /// **'Profile'** + String get profile; + + /// No description provided for @logout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// No description provided for @completed. + /// + /// In en, this message translates to: + /// **'Completed'** + String get completed; + + /// No description provided for @pending. + /// + /// In en, this message translates to: + /// **'Pending'** + String get pending; + + /// No description provided for @todo. + /// + /// In en, this message translates to: + /// **'To Do'** + String get todo; + + /// No description provided for @delivered. + /// + /// In en, this message translates to: + /// **'Delivered'** + String get delivered; + + /// No description provided for @newCustomer. + /// + /// In en, this message translates to: + /// **'New Customer'** + String get newCustomer; + + /// No description provided for @items. + /// + /// In en, this message translates to: + /// **'{count} items'** + String items(int count); + + /// No description provided for @moneyCurrency. + /// + /// In en, this message translates to: + /// **'{amount} MAD'** + String moneyCurrency(double amount); + + /// No description provided for @call. + /// + /// In en, this message translates to: + /// **'Call'** + String get call; + + /// No description provided for @map. + /// + /// In en, this message translates to: + /// **'Map'** + String get map; + + /// No description provided for @more. + /// + /// In en, this message translates to: + /// **'More'** + String get more; + + /// No description provided for @markAsCompleted. + /// + /// In en, this message translates to: + /// **'Mark as Completed'** + String get markAsCompleted; + + /// No description provided for @markAsUncompleted. + /// + /// In en, this message translates to: + /// **'Mark as Uncompleted'** + String get markAsUncompleted; + + /// No description provided for @uploadPhoto. + /// + /// In en, this message translates to: + /// **'Upload Photo'** + String get uploadPhoto; + + /// No description provided for @viewDetails. + /// + /// In en, this message translates to: + /// **'View Details'** + String get viewDetails; + + /// No description provided for @deliverySuccessful. + /// + /// In en, this message translates to: + /// **'Delivery marked as completed'** + String get deliverySuccessful; + + /// No description provided for @deliveryFailed. + /// + /// In en, this message translates to: + /// **'Failed to mark delivery'** + String get deliveryFailed; + + /// No description provided for @noDeliveries. + /// + /// In en, this message translates to: + /// **'No deliveries'** + String get noDeliveries; + + /// No description provided for @noRoutes. + /// + /// In en, this message translates to: + /// **'No routes available'** + String get noRoutes; + + /// No description provided for @error. + /// + /// In en, this message translates to: + /// **'Error: {message}'** + String error(String message); + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @authenticationRequired. + /// + /// In en, this message translates to: + /// **'Authentication required'** + String get authenticationRequired; + + /// No description provided for @phoneCall. + /// + /// In en, this message translates to: + /// **'Call customer'** + String get phoneCall; + + /// No description provided for @navigateToAddress. + /// + /// In en, this message translates to: + /// **'Show on map'** + String get navigateToAddress; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @english. + /// + /// In en, this message translates to: + /// **'English'** + String get english; + + /// No description provided for @french. + /// + /// In en, this message translates to: + /// **'French'** + String get french; + + /// No description provided for @appVersion. + /// + /// In en, this message translates to: + /// **'App Version'** + String get appVersion; + + /// No description provided for @about. + /// + /// In en, this message translates to: + /// **'About'** + String get about; + + /// No description provided for @fullName. + /// + /// In en, this message translates to: + /// **'{firstName} {lastName}'** + String fullName(String firstName, String lastName); + + /// No description provided for @completedDeliveries. + /// + /// In en, this message translates to: + /// **'{completed}/{total} completed'** + String completedDeliveries(int completed, int total); +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'fr'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'fr': + return AppLocalizationsFr(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..5ec892f --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,137 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'Plan B Logistics'; + + @override + String get appDescription => 'Delivery Management System'; + + @override + String get loginWithKeycloak => 'Login with Keycloak'; + + @override + String get deliveryRoutes => 'Delivery Routes'; + + @override + String get routes => 'Routes'; + + @override + String get deliveries => 'Deliveries'; + + @override + String get settings => 'Settings'; + + @override + String get profile => 'Profile'; + + @override + String get logout => 'Logout'; + + @override + String get completed => 'Completed'; + + @override + String get pending => 'Pending'; + + @override + String get todo => 'To Do'; + + @override + String get delivered => 'Delivered'; + + @override + String get newCustomer => 'New Customer'; + + @override + String items(int count) { + return '$count items'; + } + + @override + String moneyCurrency(double amount) { + return '$amount MAD'; + } + + @override + String get call => 'Call'; + + @override + String get map => 'Map'; + + @override + String get more => 'More'; + + @override + String get markAsCompleted => 'Mark as Completed'; + + @override + String get markAsUncompleted => 'Mark as Uncompleted'; + + @override + String get uploadPhoto => 'Upload Photo'; + + @override + String get viewDetails => 'View Details'; + + @override + String get deliverySuccessful => 'Delivery marked as completed'; + + @override + String get deliveryFailed => 'Failed to mark delivery'; + + @override + String get noDeliveries => 'No deliveries'; + + @override + String get noRoutes => 'No routes available'; + + @override + String error(String message) { + return 'Error: $message'; + } + + @override + String get retry => 'Retry'; + + @override + String get authenticationRequired => 'Authentication required'; + + @override + String get phoneCall => 'Call customer'; + + @override + String get navigateToAddress => 'Show on map'; + + @override + String get language => 'Language'; + + @override + String get english => 'English'; + + @override + String get french => 'French'; + + @override + String get appVersion => 'App Version'; + + @override + String get about => 'About'; + + @override + String fullName(String firstName, String lastName) { + return '$firstName $lastName'; + } + + @override + String completedDeliveries(int completed, int total) { + return '$completed/$total completed'; + } +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..5d5b701 --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,137 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class AppLocalizationsFr extends AppLocalizations { + AppLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get appTitle => 'Plan B Logistique'; + + @override + String get appDescription => 'Systme de Gestion des Livraisons'; + + @override + String get loginWithKeycloak => 'Connexion avec Keycloak'; + + @override + String get deliveryRoutes => 'Itinraires de Livraison'; + + @override + String get routes => 'Itinraires'; + + @override + String get deliveries => 'Livraisons'; + + @override + String get settings => 'Paramtres'; + + @override + String get profile => 'Profil'; + + @override + String get logout => 'Dconnexion'; + + @override + String get completed => 'Livr'; + + @override + String get pending => 'En attente'; + + @override + String get todo => 'livrer'; + + @override + String get delivered => 'Livr'; + + @override + String get newCustomer => 'Nouveau Client'; + + @override + String items(int count) { + return '$count articles'; + } + + @override + String moneyCurrency(double amount) { + return '$amount MAD'; + } + + @override + String get call => 'Appeler'; + + @override + String get map => 'Carte'; + + @override + String get more => 'Plus'; + + @override + String get markAsCompleted => 'Marquer comme livr'; + + @override + String get markAsUncompleted => 'Marquer comme livrer'; + + @override + String get uploadPhoto => 'Tlcharger une photo'; + + @override + String get viewDetails => 'Voir les dtails'; + + @override + String get deliverySuccessful => 'Livraison marque comme complte'; + + @override + String get deliveryFailed => 'chec du marquage de la livraison'; + + @override + String get noDeliveries => 'Aucune livraison'; + + @override + String get noRoutes => 'Aucun itinraire disponible'; + + @override + String error(String message) { + return 'Erreur: $message'; + } + + @override + String get retry => 'Ressayer'; + + @override + String get authenticationRequired => 'Authentification requise'; + + @override + String get phoneCall => 'Appeler le client'; + + @override + String get navigateToAddress => 'Afficher sur la carte'; + + @override + String get language => 'Langue'; + + @override + String get english => 'English'; + + @override + String get french => 'Franais'; + + @override + String get appVersion => 'Version de l\'application'; + + @override + String get about => ' propos'; + + @override + String fullName(String firstName, String lastName) { + return '$firstName $lastName'; + } + + @override + String completedDeliveries(int completed, int total) { + return '$completed/$total livrs'; + } +} diff --git a/lib/pages/deliveries_page.dart b/lib/pages/deliveries_page.dart index 4aed501..77cb380 100644 --- a/lib/pages/deliveries_page.dart +++ b/lib/pages/deliveries_page.dart @@ -2,15 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/delivery.dart'; +import '../models/delivery_route.dart'; import '../providers/providers.dart'; import '../api/client.dart'; import '../api/openapi_config.dart'; import '../models/delivery_commands.dart'; import '../utils/breakpoints.dart'; -import '../utils/responsive.dart'; import '../components/map_sidebar_layout.dart'; import '../components/dark_mode_map.dart'; import '../components/delivery_list_item.dart'; +import '../components/collapsible_routes_sidebar.dart' + show CollapsibleRoutesSidebar; class DeliveriesPage extends ConsumerStatefulWidget { final int routeFragmentId; @@ -46,6 +48,7 @@ class _DeliveriesPageState extends ConsumerState { @override Widget build(BuildContext context) { final deliveriesData = ref.watch(deliveriesProvider(widget.routeFragmentId)); + final routesData = ref.watch(deliveryRoutesProvider); final token = ref.watch(authTokenProvider).valueOrNull; return Scaffold( @@ -62,79 +65,193 @@ class _DeliveriesPageState extends ConsumerState { .where((d) => d.delivered) .toList(); - return MapSidebarLayout( - mapWidget: DarkModeMapComponent( - deliveries: deliveries, - selectedDelivery: _selectedDelivery, - onDeliverySelected: (delivery) { - setState(() { - _selectedDelivery = delivery; - }); - }, + return routesData.when( + data: (routes) { + DeliveryRoute? currentRoute; + try { + currentRoute = routes.firstWhere( + (r) => r.id == widget.routeFragmentId, + ); + } catch (_) { + currentRoute = routes.isNotEmpty ? routes.first : null; + } + + return Row( + children: [ + if (context.isDesktop && routes.isNotEmpty) + CollapsibleRoutesSidebar( + routes: routes, + selectedRoute: currentRoute, + onRouteSelected: (route) { + if (route.id != widget.routeFragmentId) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => DeliveriesPage( + routeFragmentId: route.id, + routeName: route.name, + ), + ), + ); + } + }, + ), + Expanded( + child: MapSidebarLayout( + mapWidget: DarkModeMapComponent( + deliveries: deliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + ), + sidebarWidget: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 0, + label: Text('To Do'), + ), + ButtonSegment( + value: 1, + label: Text('Delivered'), + ), + ], + selected: {_currentSegment}, + onSelectionChanged: (Set newSelection) { + setState(() { + _currentSegment = newSelection.first; + _pageController.animateToPage( + _currentSegment, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }); + }, + ), + ), + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentSegment = index; + }); + }, + children: [ + DeliveryListView( + deliveries: todoDeliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + onAction: (delivery, action) => + _handleDeliveryAction(context, delivery, action, token), + ), + DeliveryListView( + deliveries: completedDeliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + onAction: (delivery, action) => + _handleDeliveryAction(context, delivery, action, token), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), ), - sidebarWidget: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: 0, - label: Text('To Do'), - ), - ButtonSegment( - value: 1, - label: Text('Delivered'), - ), - ], - selected: {_currentSegment}, - onSelectionChanged: (Set newSelection) { - setState(() { - _currentSegment = newSelection.first; - _pageController.animateToPage( - _currentSegment, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }); - }, + error: (error, stackTrace) => MapSidebarLayout( + mapWidget: DarkModeMapComponent( + deliveries: deliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + ), + sidebarWidget: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 0, + label: Text('To Do'), + ), + ButtonSegment( + value: 1, + label: Text('Delivered'), + ), + ], + selected: {_currentSegment}, + onSelectionChanged: (Set newSelection) { + setState(() { + _currentSegment = newSelection.first; + _pageController.animateToPage( + _currentSegment, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }); + }, + ), ), - ), - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: (index) { - setState(() { - _currentSegment = index; - }); - }, - children: [ - DeliveryListView( - deliveries: todoDeliveries, - selectedDelivery: _selectedDelivery, - onDeliverySelected: (delivery) { - setState(() { - _selectedDelivery = delivery; - }); - }, - onAction: (delivery, action) => - _handleDeliveryAction(context, delivery, action, token), - ), - DeliveryListView( - deliveries: completedDeliveries, - selectedDelivery: _selectedDelivery, - onDeliverySelected: (delivery) { - setState(() { - _selectedDelivery = delivery; - }); - }, - onAction: (delivery, action) => - _handleDeliveryAction(context, delivery, action, token), - ), - ], + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentSegment = index; + }); + }, + children: [ + DeliveryListView( + deliveries: todoDeliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + onAction: (delivery, action) => + _handleDeliveryAction(context, delivery, action, token), + ), + DeliveryListView( + deliveries: completedDeliveries, + selectedDelivery: _selectedDelivery, + onDeliverySelected: (delivery) { + setState(() { + _selectedDelivery = delivery; + }); + }, + onAction: (delivery, action) => + _handleDeliveryAction(context, delivery, action, token), + ), + ], + ), ), - ), - ], + ], + ), ), ); }, diff --git a/lib/pages/routes_page.dart b/lib/pages/routes_page.dart index 7a1aa25..56b4362 100644 --- a/lib/pages/routes_page.dart +++ b/lib/pages/routes_page.dart @@ -3,17 +3,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/delivery_route.dart'; import '../providers/providers.dart'; import '../utils/breakpoints.dart'; -import '../utils/responsive.dart'; -import '../components/premium_route_card.dart'; +import '../components/collapsible_routes_sidebar.dart'; +import '../components/dark_mode_map.dart'; import 'deliveries_page.dart'; import 'settings_page.dart'; class RoutesPage extends ConsumerWidget { const RoutesPage({super.key}); + void _navigateToDeliveries(BuildContext context, DeliveryRoute route) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DeliveriesPage( + routeFragmentId: route.id, + routeName: route.name, + ), + ), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final routesData = ref.watch(deliveryRoutesProvider); + final allDeliveriesData = ref.watch(allDeliveriesProvider); final userProfile = ref.watch(userProfileProvider); return Scaffold( @@ -73,14 +85,70 @@ class RoutesPage extends ConsumerWidget { child: Text('No routes available'), ); } - return RefreshIndicator( - onRefresh: () async { - // ignore: unused_result - ref.refresh(deliveryRoutesProvider); + return allDeliveriesData.when( + data: (allDeliveries) { + return RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + ref.refresh(deliveryRoutesProvider); + // 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: context.isDesktop - ? _buildDesktopGrid(context, routes) - : _buildMobileList(context, routes), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Error loading deliveries: $error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.refresh(allDeliveriesProvider), + child: const Text('Retry'), + ), + ], + ), + ), ); }, loading: () => const Center( @@ -103,53 +171,4 @@ class RoutesPage extends ConsumerWidget { ); } - Widget _buildMobileList(BuildContext context, List routes) { - final spacing = ResponsiveSpacing.md(context); - return ListView.builder( - padding: EdgeInsets.all(ResponsiveSpacing.md(context)), - itemCount: routes.length, - itemBuilder: (context, index) { - final route = routes[index]; - return Padding( - padding: EdgeInsets.only(bottom: spacing), - child: _buildRouteCard(context, route), - ); - }, - ); - } - - Widget _buildDesktopGrid(BuildContext context, List routes) { - final spacing = ResponsiveSpacing.lg(context); - final columns = context.isTablet ? 2 : 3; - return GridView.builder( - padding: EdgeInsets.all(spacing), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - crossAxisSpacing: spacing, - mainAxisSpacing: spacing, - childAspectRatio: 1.2, - ), - itemCount: routes.length, - itemBuilder: (context, index) { - final route = routes[index]; - return _buildRouteCard(context, route); - }, - ); - } - - Widget _buildRouteCard(BuildContext context, DeliveryRoute route) { - return PremiumRouteCard( - route: route, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => DeliveriesPage( - routeFragmentId: route.id, - routeName: route.name, - ), - ), - ); - }, - ); - } } diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 528ed6b..59d7ef6 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -100,6 +100,32 @@ final deliveriesProvider = FutureProvider.family, int>((ref, rout return result.whenSuccess((deliveries) => deliveries) ?? []; }); +/// Provider to get all deliveries from all routes +final allDeliveriesProvider = FutureProvider>((ref) async { + final routes = ref.watch(deliveryRoutesProvider).valueOrNull ?? []; + + if (routes.isEmpty) { + return []; + } + + // Fetch deliveries for all routes + final deliveriesFutures = routes.map((route) { + return ref.watch(deliveriesProvider(route.id)).when( + data: (deliveries) => deliveries, + loading: () => [], + error: (_, __) => [], + ); + }); + + // Combine all deliveries + final allDeliveries = []; + for (final deliveries in deliveriesFutures) { + allDeliveries.addAll(deliveries); + } + + return allDeliveries; +}); + final languageProvider = StateProvider((ref) { return 'fr'; });