Implement UI/UX enhancements with collapsible routes sidebar and glassmorphic route cards

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 <noreply@anthropic.com>
This commit is contained in:
Jean-Philippe Brule 2025-11-15 17:55:18 -05:00
parent 6e6d279d77
commit 5714fd8443
8 changed files with 1463 additions and 129 deletions

View File

@ -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<DeliveryRoute> routes;
final DeliveryRoute? selectedRoute;
final ValueChanged<DeliveryRoute> onRouteSelected;
const CollapsibleRoutesSidebar({
super.key,
required this.routes,
this.selectedRoute,
required this.onRouteSelected,
});
@override
State<CollapsibleRoutesSidebar> createState() =>
_CollapsibleRoutesSidebarState();
}
class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
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),
);
}
}

View File

@ -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<GlassmorphicRouteCard> createState() => _GlassmorphicRouteCardState();
}
class _GlassmorphicRouteCardState extends State<GlassmorphicRouteCard>
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<Color>(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,
),
],
),
),
),
],
),
),
],
),
),
],
),
);
},
),
),
);
}
}

View File

@ -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, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects 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<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> 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<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
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<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['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.',
);
}

View File

@ -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';
}
}

View File

@ -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';
}
}

View File

@ -2,15 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../models/delivery.dart'; import '../models/delivery.dart';
import '../models/delivery_route.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../api/client.dart'; import '../api/client.dart';
import '../api/openapi_config.dart'; import '../api/openapi_config.dart';
import '../models/delivery_commands.dart'; import '../models/delivery_commands.dart';
import '../utils/breakpoints.dart'; import '../utils/breakpoints.dart';
import '../utils/responsive.dart';
import '../components/map_sidebar_layout.dart'; import '../components/map_sidebar_layout.dart';
import '../components/dark_mode_map.dart'; import '../components/dark_mode_map.dart';
import '../components/delivery_list_item.dart'; import '../components/delivery_list_item.dart';
import '../components/collapsible_routes_sidebar.dart'
show CollapsibleRoutesSidebar;
class DeliveriesPage extends ConsumerStatefulWidget { class DeliveriesPage extends ConsumerStatefulWidget {
final int routeFragmentId; final int routeFragmentId;
@ -46,6 +48,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final deliveriesData = ref.watch(deliveriesProvider(widget.routeFragmentId)); final deliveriesData = ref.watch(deliveriesProvider(widget.routeFragmentId));
final routesData = ref.watch(deliveryRoutesProvider);
final token = ref.watch(authTokenProvider).valueOrNull; final token = ref.watch(authTokenProvider).valueOrNull;
return Scaffold( return Scaffold(
@ -62,7 +65,38 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
.where((d) => d.delivered) .where((d) => d.delivered)
.toList(); .toList();
return MapSidebarLayout( 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( mapWidget: DarkModeMapComponent(
deliveries: deliveries, deliveries: deliveries,
selectedDelivery: _selectedDelivery, selectedDelivery: _selectedDelivery,
@ -136,6 +170,89 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
), ),
], ],
), ),
),
),
],
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
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<int>(
segments: const [
ButtonSegment(
value: 0,
label: Text('To Do'),
),
ButtonSegment(
value: 1,
label: Text('Delivered'),
),
],
selected: <int>{_currentSegment},
onSelectionChanged: (Set<int> 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( loading: () => const Center(

View File

@ -3,17 +3,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/delivery_route.dart'; import '../models/delivery_route.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../utils/breakpoints.dart'; import '../utils/breakpoints.dart';
import '../utils/responsive.dart'; import '../components/collapsible_routes_sidebar.dart';
import '../components/premium_route_card.dart'; import '../components/dark_mode_map.dart';
import 'deliveries_page.dart'; import 'deliveries_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
class RoutesPage extends ConsumerWidget { class RoutesPage extends ConsumerWidget {
const RoutesPage({super.key}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final routesData = ref.watch(deliveryRoutesProvider); final routesData = ref.watch(deliveryRoutesProvider);
final allDeliveriesData = ref.watch(allDeliveriesProvider);
final userProfile = ref.watch(userProfileProvider); final userProfile = ref.watch(userProfileProvider);
return Scaffold( return Scaffold(
@ -73,14 +85,70 @@ class RoutesPage extends ConsumerWidget {
child: Text('No routes available'), child: Text('No routes available'),
); );
} }
return allDeliveriesData.when(
data: (allDeliveries) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
// ignore: unused_result // ignore: unused_result
ref.refresh(deliveryRoutesProvider); ref.refresh(deliveryRoutesProvider);
// ignore: unused_result
ref.refresh(allDeliveriesProvider);
}, },
child: context.isDesktop child: context.isDesktop
? _buildDesktopGrid(context, routes) ? Row(
: _buildMobileList(context, routes), 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);
},
),
],
),
);
},
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( loading: () => const Center(
@ -103,53 +171,4 @@ class RoutesPage extends ConsumerWidget {
); );
} }
Widget _buildMobileList(BuildContext context, List<DeliveryRoute> 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<DeliveryRoute> 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,
),
),
);
},
);
}
} }

View File

@ -100,6 +100,32 @@ final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, rout
return result.whenSuccess((deliveries) => deliveries) ?? []; return result.whenSuccess((deliveries) => deliveries) ?? [];
}); });
/// Provider to get all deliveries from all routes
final allDeliveriesProvider = FutureProvider<List<Delivery>>((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: () => <Delivery>[],
error: (_, __) => <Delivery>[],
);
});
// Combine all deliveries
final allDeliveries = <Delivery>[];
for (final deliveries in deliveriesFutures) {
allDeliveries.addAll(deliveries);
}
return allDeliveries;
});
final languageProvider = StateProvider<String>((ref) { final languageProvider = StateProvider<String>((ref) {
return 'fr'; return 'fr';
}); });