Implement collapsible sidebar with badge-only view

Add collapsible sidebar functionality for both deliveries and routes pages:

- DeliveryListItem: Add isCollapsed parameter to show badge-only view when sidebar is collapsed
- RouteListItem: Add isCollapsed parameter with same badge-only behavior
- MapSidebarLayout: Add sidebarBuilder function to pass collapsed state to child widgets
- CollapsibleRoutesSidebar: Pass collapsed state to RouteListItem components
- UnifiedDeliveryListView: Add isCollapsed parameter and pass to DeliveryListItem

Collapsed sidebar:
- Width: 80px (accommodates 60px badge with 10px margins)
- Shows only status-colored order number badges
- Badges remain centered and aligned during animations
- Removed horizontal slide animation in collapsed view to prevent misalignment
- Maintains scale and fade animations for smooth entrance

Expanded sidebar:
- Width: 420px (original full layout)
- Shows badge, vertical accent bar, and delivery/route details
- Full animations including horizontal slide

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jean-Philippe Brule
2025-11-17 11:00:48 -05:00
parent 98ce195bbb
commit 65f0f4451b
33 changed files with 373 additions and 29 deletions
+11 -20
View File
@@ -5,7 +5,7 @@ import '../theme/size_system.dart';
import '../theme/animation_system.dart';
import '../theme/color_system.dart';
import '../utils/breakpoints.dart';
import 'glassmorphic_route_card.dart';
import 'route_list_item.dart';
class CollapsibleRoutesSidebar extends StatefulWidget {
@@ -113,9 +113,9 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
);
}
// On tablet/desktop, show full sidebar with toggle
// On tablet/desktop, show full sidebar with toggle (expanded: 420px, collapsed: 80px for badge)
return Container(
width: _isExpanded ? 280 : 64,
width: _isExpanded ? 420 : 80,
color: isDarkMode ? SvrntyColors.almostBlack : Colors.white,
child: Column(
children: [
@@ -171,30 +171,21 @@ class _CollapsibleRoutesSidebarState extends State<CollapsibleRoutesSidebar>
Widget _buildRoutesList(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.all(AppSpacing.sm),
padding: const EdgeInsets.only(top: 4, bottom: 8),
physics: const AlwaysScrollableScrollPhysics(),
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),
return RouteListItem(
route: route,
isSelected: isSelected,
onTap: () => widget.onRouteSelected(route),
animationIndex: index,
isCollapsed: !_isExpanded,
);
},
);
}
Widget _buildRouteButton(
BuildContext context,
DeliveryRoute route,
bool isSelected,
) {
return GlassmorphicRouteCard(
route: route,
isSelected: isSelected,
isCollapsed: !_isExpanded,
onTap: () => widget.onRouteSelected(route),
);
}
}
+57
View File
@@ -10,6 +10,7 @@ class DeliveryListItem extends StatefulWidget {
final VoidCallback? onCall;
final Function(String)? onAction;
final int? animationIndex;
final bool isCollapsed;
const DeliveryListItem({
super.key,
@@ -19,6 +20,7 @@ class DeliveryListItem extends StatefulWidget {
this.onCall,
this.onAction,
this.animationIndex,
this.isCollapsed = false,
});
@override
@@ -83,6 +85,61 @@ class _DeliveryListItemState extends State<DeliveryListItem>
final isDark = Theme.of(context).brightness == Brightness.dark;
final statusColor = _getStatusColor(widget.delivery);
// Collapsed view: Show only the badge
if (widget.isCollapsed) {
return ScaleTransition(
scale: _scaleAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
child: Center(
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(10),
boxShadow: (_isHovered || widget.isSelected)
? [
BoxShadow(
color: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.15,
),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: [],
),
child: Center(
child: Text(
'${widget.delivery.deliveryIndex + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
),
),
),
);
}
// Expanded view: Show full layout
return ScaleTransition(
scale: _scaleAnimation,
child: FadeTransition(
+14 -7
View File
@@ -7,15 +7,18 @@ import '../theme/color_system.dart';
class MapSidebarLayout extends StatefulWidget {
final Widget mapWidget;
final Widget sidebarWidget;
final Widget Function(bool isCollapsed)? sidebarBuilder;
final Widget? sidebarWidget;
final double mapRatio;
const MapSidebarLayout({
super.key,
required this.mapWidget,
required this.sidebarWidget,
this.sidebarBuilder,
this.sidebarWidget,
this.mapRatio = 0.60, // Reduced from 2/3 to give 15% more space to sidebar
});
}) : assert(sidebarBuilder != null || sidebarWidget != null,
'Either sidebarBuilder or sidebarWidget must be provided');
@override
State<MapSidebarLayout> createState() => _MapSidebarLayoutState();
@@ -61,7 +64,9 @@ class _MapSidebarLayoutState extends State<MapSidebarLayout>
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
if (isMobile) {
return widget.sidebarWidget;
return widget.sidebarBuilder != null
? widget.sidebarBuilder!(false)
: widget.sidebarWidget!;
}
// Desktop: Show map with collapsible sidebar
@@ -71,10 +76,10 @@ class _MapSidebarLayoutState extends State<MapSidebarLayout>
flex: (widget.mapRatio * 100).toInt(),
child: widget.mapWidget,
),
// Collapsible sidebar with toggle button (expanded to 420px for badge + content)
// Collapsible sidebar with toggle button (expanded: 420px, collapsed: 80px for badge)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _isExpanded ? 420 : 64,
width: _isExpanded ? 420 : 80,
color: isDarkMode ? SvrntyColors.almostBlack : Colors.white,
child: Column(
children: [
@@ -126,7 +131,9 @@ class _MapSidebarLayoutState extends State<MapSidebarLayout>
),
// Sidebar content
Expanded(
child: _isExpanded ? widget.sidebarWidget : const SizedBox.shrink(),
child: widget.sidebarBuilder != null
? widget.sidebarBuilder!(!_isExpanded)
: (_isExpanded ? widget.sidebarWidget! : const SizedBox.shrink()),
),
],
),
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import '../models/delivery_route.dart';
import '../theme/animation_system.dart';
import '../theme/color_system.dart';
class RouteListItem extends StatefulWidget {
final DeliveryRoute route;
final bool isSelected;
final VoidCallback onTap;
final int? animationIndex;
final bool isCollapsed;
const RouteListItem({
super.key,
required this.route,
required this.isSelected,
required this.onTap,
this.animationIndex,
this.isCollapsed = false,
});
@override
State<RouteListItem> createState() => _RouteListItemState();
}
class _RouteListItemState extends State<RouteListItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _slideAnimation;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
final staggerDelay = Duration(
milliseconds:
(widget.animationIndex ?? 0) * AppAnimations.staggerDelayMs,
);
Future.delayed(staggerDelay, () {
if (mounted) {
_controller.forward();
}
});
_slideAnimation = Tween<double>(begin: 20, end: 0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Color _getStatusColor(DeliveryRoute route) {
if (route.completed) return SvrntyColors.statusCompleted;
if (route.deliveredCount > 0) return SvrntyColors.statusInTransit;
return SvrntyColors.statusPending;
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final statusColor = _getStatusColor(widget.route);
// Collapsed view: Show only the badge
if (widget.isCollapsed) {
return ScaleTransition(
scale: _scaleAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
child: Center(
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(10),
boxShadow: (_isHovered || widget.isSelected)
? [
BoxShadow(
color: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.15,
),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: [],
),
child: Center(
child: Text(
'${(widget.animationIndex ?? 0) + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
),
),
),
);
}
// Expanded view: Show full layout
return ScaleTransition(
scale: _scaleAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Transform.translate(
offset: Offset(_slideAnimation.value, 0),
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppAnimations.durationFast,
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: widget.route.completed
? Colors.green.withValues(alpha: 0.15)
: (_isHovered || widget.isSelected
? Theme.of(context).colorScheme.surfaceContainer
: Colors.transparent),
boxShadow: (_isHovered || widget.isSelected) && !widget.route.completed
? [
BoxShadow(
color: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.08,
),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: [],
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24),
child: Column(
children: [
// Main route info row
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Route number badge (left of status bar)
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
'${(widget.animationIndex ?? 0) + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 12),
// Left accent bar (vertical status bar)
Container(
width: 6,
height: 80,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 16),
// Route info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Route Name (24px)
Text(
widget.route.name,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 24,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
// Route details (18px)
Text(
'${widget.route.deliveredCount}/${widget.route.deliveriesCount} deliveries',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
fontSize: 18,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
],
),
),
),
),
),
),
);
}
}
+7 -2
View File
@@ -95,7 +95,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
? _handleDeliveryAction(context, _selectedDelivery!, action, token)
: null,
),
sidebarWidget: UnifiedDeliveryListView(
sidebarBuilder: (isCollapsed) => UnifiedDeliveryListView(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
scrollController: _listScrollController,
@@ -108,6 +108,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
_handleDeliveryAction(context, delivery, action, token);
_autoScrollToFirstPending(deliveries);
},
isCollapsed: isCollapsed,
),
);
},
@@ -127,7 +128,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
? _handleDeliveryAction(context, _selectedDelivery!, action, token)
: null,
),
sidebarWidget: UnifiedDeliveryListView(
sidebarBuilder: (isCollapsed) => UnifiedDeliveryListView(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
scrollController: _listScrollController,
@@ -140,6 +141,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
_handleDeliveryAction(context, delivery, action, token);
_autoScrollToFirstPending(deliveries);
},
isCollapsed: isCollapsed,
),
),
);
@@ -246,6 +248,7 @@ class UnifiedDeliveryListView extends StatelessWidget {
final ScrollController scrollController;
final ValueChanged<Delivery> onDeliverySelected;
final Function(Delivery, String) onItemAction;
final bool isCollapsed;
const UnifiedDeliveryListView({
super.key,
@@ -254,6 +257,7 @@ class UnifiedDeliveryListView extends StatelessWidget {
required this.scrollController,
required this.onDeliverySelected,
required this.onItemAction,
this.isCollapsed = false,
});
@override
@@ -282,6 +286,7 @@ class UnifiedDeliveryListView extends StatelessWidget {
onCall: () => onItemAction(delivery, 'call'),
onAction: (action) => onItemAction(delivery, action),
animationIndex: index,
isCollapsed: isCollapsed,
);
},
),