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
@@ -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),
);
}
}
+330
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,
),
],
),
),
),
],
),
),
],
),
),
],
),
);
},
),
),
);
}
}