Implement premium UI/UX refinements for Apple-like polish

Add three major UI components with animations and dark mode support:

- PremiumRouteCard: Enhanced route cards with left accent bar, delivery count badge, animated hover effects (1.02x scale), and dynamic shadow elevation
- DarkModeMapComponent: Google Maps integration with dark theme styling, custom info panels, navigation buttons, and delivery status indicators
- DeliveryListItem: Animated list items with staggered entrance animations, status badges with icons, contact indicators, and hover states

Updates:
- RoutesPage: Now uses PremiumRouteCard with improved visual hierarchy
- DeliveriesPage: Integrated DarkModeMapComponent and DeliveryListItem with proper theme awareness
- Animation system: Leverages existing AppAnimations constants for 200ms fast interactions and easeOut curves

Design philosophy:
- Element separation through left accent bars (status-coded)
- Elevation and shadow for depth (0.1-0.3 opacity)
- Staggered animations for list items (50ms delays)
- Dark mode optimized for evening use (reduced brightness)
- Responsive hover states with tactile feedback

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jean-Philippe Brule
2025-11-15 14:41:32 -05:00
parent ccb817e3c6
commit 3f31a509e0
23 changed files with 3519 additions and 791 deletions
+456
View File
@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
import '../models/delivery.dart';
import '../theme/color_system.dart';
/// Enhanced dark-mode aware map component with custom styling
class DarkModeMapComponent extends StatefulWidget {
final List<Delivery> deliveries;
final Delivery? selectedDelivery;
final ValueChanged<Delivery?>? onDeliverySelected;
const DarkModeMapComponent({
super.key,
required this.deliveries,
this.selectedDelivery,
this.onDeliverySelected,
});
@override
State<DarkModeMapComponent> createState() => _DarkModeMapComponentState();
}
class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
GoogleNavigationViewController? _navigationController;
bool _isNavigating = false;
LatLng? _destinationLocation;
@override
void initState() {
super.initState();
_initializeNavigation();
}
Future<void> _initializeNavigation() async {
try {
final termsAccepted = await GoogleMapsNavigator.areTermsAccepted();
if (!termsAccepted) {
await GoogleMapsNavigator.showTermsAndConditionsDialog(
'Plan B Logistics',
'com.goutezplanb.planbLogistic',
);
}
await GoogleMapsNavigator.initializeNavigationSession();
} catch (e) {
debugPrint('Map initialization error: $e');
}
}
@override
void didUpdateWidget(DarkModeMapComponent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
_updateDestination();
}
}
void _updateDestination() {
if (widget.selectedDelivery != null) {
final address = widget.selectedDelivery!.deliveryAddress;
if (address?.latitude != null && address?.longitude != null) {
setState(() {
_destinationLocation = LatLng(
latitude: address!.latitude!,
longitude: address.longitude!,
);
});
_navigateToLocation(_destinationLocation!);
}
}
}
Future<void> _navigateToLocation(LatLng location) async {
if (_navigationController == null) return;
try {
await _navigationController!.animateCamera(
CameraUpdate.newLatLngZoom(location, 15),
);
} catch (e) {
debugPrint('Camera navigation error: $e');
}
}
Future<void> _applyDarkModeStyle() async {
if (_navigationController == null) return;
try {
// Apply dark mode style configuration for Google Maps
// This reduces eye strain in low-light environments
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
if (isDarkMode) {
// Dark map style with warm accent colors
await _navigationController!.setMapStyle(_getDarkMapStyle());
}
} catch (e) {
debugPrint('Error applying map style: $e');
}
}
String _getDarkMapStyle() {
// Google Maps style JSON for dark mode with warm accents
return '''[
{
"elementType": "geometry",
"stylers": [{"color": "#212121"}]
},
{
"elementType": "labels.icon",
"stylers": [{"visibility": "off"}]
},
{
"elementType": "labels.text.fill",
"stylers": [{"color": "#757575"}]
},
{
"elementType": "labels.text.stroke",
"stylers": [{"color": "#212121"}]
},
{
"featureType": "administrative",
"elementType": "geometry",
"stylers": [{"color": "#757575"}]
},
{
"featureType": "administrative.country",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "administrative.land_parcel",
"stylers": [{"visibility": "off"}]
},
{
"featureType": "administrative.locality",
"elementType": "labels.text.fill",
"stylers": [{"color": "#bdbdbd"}]
},
{
"featureType": "administrative.neighborhood",
"stylers": [{"visibility": "off"}]
},
{
"featureType": "administrative.province",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "landscape",
"elementType": "geometry",
"stylers": [{"color": "#000000"}]
},
{
"featureType": "poi",
"elementType": "geometry",
"stylers": [{"color": "#383838"}]
},
{
"featureType": "poi",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9e9e9e"}]
},
{
"featureType": "poi.park",
"elementType": "geometry",
"stylers": [{"color": "#181818"}]
},
{
"featureType": "poi.park",
"elementType": "labels.text.fill",
"stylers": [{"color": "#616161"}]
},
{
"featureType": "road",
"elementType": "geometry.fill",
"stylers": [{"color": "#2c2c2c"}]
},
{
"featureType": "road",
"elementType": "labels.text.fill",
"stylers": [{"color": "#8a8a8a"}]
},
{
"featureType": "road.arterial",
"elementType": "geometry",
"stylers": [{"color": "#373737"}]
},
{
"featureType": "road.highway",
"elementType": "geometry",
"stylers": [{"color": "#3c3c3c"}]
},
{
"featureType": "road.highway.controlled_access",
"elementType": "geometry",
"stylers": [{"color": "#4e4e4e"}]
},
{
"featureType": "road.local",
"elementType": "labels.text.fill",
"stylers": [{"color": "#616161"}]
},
{
"featureType": "transit",
"elementType": "labels.text.fill",
"stylers": [{"color": "#757575"}]
},
{
"featureType": "water",
"elementType": "geometry",
"stylers": [{"color": "#0c1221"}]
},
{
"featureType": "water",
"elementType": "labels.text.fill",
"stylers": [{"color": "#3d3d3d"}]
}
]''';
}
Future<void> _startNavigation() async {
if (_destinationLocation == null) return;
try {
final waypoint = NavigationWaypoint.withLatLngTarget(
title: widget.selectedDelivery?.name ?? 'Destination',
target: _destinationLocation!,
);
final destinations = Destinations(
waypoints: [waypoint],
displayOptions: NavigationDisplayOptions(showDestinationMarkers: true),
);
await GoogleMapsNavigator.setDestinations(destinations);
await GoogleMapsNavigator.startGuidance();
setState(() {
_isNavigating = true;
});
} catch (e) {
debugPrint('Navigation start error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigation error: $e')),
);
}
}
}
Future<void> _stopNavigation() async {
try {
await GoogleMapsNavigator.stopGuidance();
await GoogleMapsNavigator.clearDestinations();
setState(() {
_isNavigating = false;
});
} catch (e) {
debugPrint('Navigation stop error: $e');
}
}
@override
Widget build(BuildContext context) {
final initialPosition = widget.selectedDelivery?.deliveryAddress != null &&
widget.selectedDelivery!.deliveryAddress!.latitude != null &&
widget.selectedDelivery!.deliveryAddress!.longitude != null
? LatLng(
latitude: widget.selectedDelivery!.deliveryAddress!.latitude!,
longitude: widget.selectedDelivery!.deliveryAddress!.longitude!,
)
: const LatLng(latitude: 45.5017, longitude: -73.5673);
return Stack(
children: [
GoogleMapsNavigationView(
onViewCreated: (controller) {
_navigationController = controller;
_applyDarkModeStyle();
controller.animateCamera(
CameraUpdate.newLatLngZoom(initialPosition, 12),
);
},
initialCameraPosition: CameraPosition(
target: initialPosition,
zoom: 12,
),
),
// Custom dark-themed controls overlay
Positioned(
bottom: 16,
right: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Start navigation button
if (widget.selectedDelivery != null && !_isNavigating)
_buildActionButton(
label: 'Navigate',
icon: Icons.directions,
onPressed: _startNavigation,
color: SvrntyColors.crimsonRed,
),
if (_isNavigating)
_buildActionButton(
label: 'Stop',
icon: Icons.stop,
onPressed: _stopNavigation,
color: Colors.grey[600]!,
),
],
),
),
// Selected delivery info panel (dark themed)
if (widget.selectedDelivery != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? const Color(0xFF14161A)
: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.selectedDelivery!.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
widget.selectedDelivery!.deliveryAddress
?.formattedAddress ??
'No address',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context).brightness ==
Brightness.dark
? Colors.grey[400]
: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: widget.selectedDelivery!.delivered
? SvrntyColors.statusCompleted
: SvrntyColors.statusPending,
borderRadius: BorderRadius.circular(4),
),
child: Text(
widget.selectedDelivery!.delivered
? 'Delivered'
: 'Pending',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
],
);
}
Widget _buildActionButton({
required String label,
required IconData icon,
required VoidCallback onPressed,
required Color color,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: color,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
),
),
),
),
);
}
}
+293
View File
@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import '../models/delivery.dart';
import '../theme/animation_system.dart';
import '../theme/color_system.dart';
class DeliveryListItem extends StatefulWidget {
final Delivery delivery;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback? onCall;
final int? animationIndex;
const DeliveryListItem({
super.key,
required this.delivery,
required this.isSelected,
required this.onTap,
this.onCall,
this.animationIndex,
});
@override
State<DeliveryListItem> createState() => _DeliveryListItemState();
}
class _DeliveryListItemState extends State<DeliveryListItem>
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(Delivery delivery) {
if (delivery.isSkipped == true) return SvrntyColors.statusSkipped;
if (delivery.delivered == true) return SvrntyColors.statusCompleted;
return SvrntyColors.statusPending;
}
IconData _getStatusIcon(Delivery delivery) {
if (delivery.isSkipped) return Icons.skip_next;
if (delivery.delivered) return Icons.check_circle;
return Icons.schedule;
}
String _getStatusLabel(Delivery delivery) {
if (delivery.isSkipped) return 'Skipped';
if (delivery.delivered) return 'Delivered';
return 'Pending';
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final statusColor = _getStatusColor(widget.delivery);
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: 12,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: _isHovered || widget.isSelected
? (isDark
? Colors.grey[800]
: Colors.grey[100])
: Colors.transparent,
boxShadow: _isHovered || widget.isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(
isDark ? 0.3 : 0.08,
),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: [],
),
padding: const EdgeInsets.all(12),
child: Column(
children: [
// Main delivery info row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left accent bar
Container(
width: 4,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
// Delivery info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
Text(
widget.delivery.name,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Address
Text(
widget.delivery.deliveryAddress
?.formattedAddress ??
'No address',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: isDark
? Colors.grey[400]
: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Order count
Text(
'${widget.delivery.orders.length} order${widget.delivery.orders.length != 1 ? 's' : ''}',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
fontSize: 10,
color: isDark
? Colors.grey[500]
: Colors.grey[500],
),
),
],
),
),
const SizedBox(width: 8),
// Status badge + Call button
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Status badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getStatusIcon(widget.delivery),
color: Colors.white,
size: 12,
),
const SizedBox(width: 4),
Text(
_getStatusLabel(widget.delivery),
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
),
const SizedBox(height: 6),
// Call button (if contact exists)
if (widget.delivery.orders.isNotEmpty &&
widget.delivery.orders.first.contact != null)
GestureDetector(
onTap: widget.onCall,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.phone,
color: Colors.grey[700],
size: 14,
),
),
),
],
),
],
),
// Total amount (if present)
if (widget.delivery.orders.isNotEmpty &&
widget.delivery.orders.first.totalAmount != null)
Padding(
padding: const EdgeInsets.only(top: 8, left: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Total: \$${widget.delivery.orders.first.totalAmount!.toStringAsFixed(2)}',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: isDark
? Colors.amber[300]
: Colors.amber[700],
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
),
),
),
);
}
}
+252
View File
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import '../models/delivery_route.dart';
import '../theme/animation_system.dart';
import '../theme/color_system.dart';
class PremiumRouteCard extends StatefulWidget {
final DeliveryRoute route;
final VoidCallback onTap;
final EdgeInsets padding;
const PremiumRouteCard({
super.key,
required this.route,
required this.onTap,
this.padding = const EdgeInsets.all(16.0),
});
@override
State<PremiumRouteCard> createState() => _PremiumRouteCardState();
}
class _PremiumRouteCardState extends State<PremiumRouteCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _shadowAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: AppAnimations.durationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.02).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_shadowAnimation = Tween<double>(begin: 2.0, end: 8.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onHoverEnter() {
setState(() => _isHovered = true);
_controller.forward();
}
void _onHoverExit() {
setState(() => _isHovered = false);
_controller.reverse();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final progressPercentage = (widget.route.progress * 100).toStringAsFixed(0);
final isCompleted = widget.route.progress >= 1.0;
return MouseRegion(
onEnter: (_) => _onHoverEnter(),
onExit: (_) => _onHoverExit(),
child: GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: _scaleAnimation,
child: AnimatedBuilder(
animation: _shadowAnimation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
blurRadius: _shadowAnimation.value,
offset: Offset(0, _shadowAnimation.value * 0.5),
),
],
),
child: child,
);
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: isDark
? const Color(0xFF14161A)
: const Color(0xFFFAFAFC),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left accent bar + Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Accent bar
Container(
width: 4,
height: double.infinity,
decoration: BoxDecoration(
color: isCompleted
? SvrntyColors.statusCompleted
: SvrntyColors.crimsonRed,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Route name
Text(
widget.route.name,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Completion text
RichText(
text: TextSpan(
children: [
TextSpan(
text:
'${widget.route.deliveredCount}/${widget.route.deliveriesCount}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
fontWeight: FontWeight.w600,
color: isCompleted
? SvrntyColors.statusCompleted
: SvrntyColors.crimsonRed,
),
),
TextSpan(
text: ' completed',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: isDark
? Colors.grey[400]
: Colors.grey[600],
),
),
],
),
),
],
),
),
),
// Delivery count badge
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 16, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: SvrntyColors.crimsonRed,
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.route.deliveriesCount.toString(),
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
// Progress section
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Progress percentage text
Text(
'$progressPercentage% progress',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isDark
? Colors.grey[400]
: Colors.grey[600],
fontSize: 11,
letterSpacing: 0.3,
),
),
const SizedBox(height: 8),
// Progress bar with gradient
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
height: 6,
decoration: BoxDecoration(
color: isDark
? Colors.grey[800]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: LinearProgressIndicator(
value: widget.route.progress,
backgroundColor: Colors.transparent,
valueColor: AlwaysStoppedAnimation<Color>(
isCompleted
? SvrntyColors.statusCompleted
: SvrntyColors.crimsonRed,
),
),
),
),
],
),
),
],
),
),
),
),
),
);
}
}