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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user