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