ionic-planb-logistic-app-fl.../lib/pages/deliveries_page.dart
Jean-Philippe Brule 98ce195bbb Add delivery order badges and optimize list performance
Enhance delivery list UI with sequential order badges and improve scrolling
performance with faster animations for better user experience.

Delivery Order Badge:
- Add 60x60px status-colored square badge showing delivery sequence number
- Position badge to the left of vertical status bar for clear visual hierarchy
- White text (26px, bold) centered in badge
- Automatically displays deliveryIndex + 1 (first delivery = 1, second = 2, etc.)
- Status color matches delivery state (green for completed, gray for pending, etc.)
- 10px border radius for modern appearance

Layout Enhancements:
- Sidebar expanded from 360px to 420px to accommodate order badges
- Vertical centering of row elements for better visual balance
- Maintains optimized spacing: badge (60px) + gap (12px) + bar (6px) + gap (16px) + content
- Full list scrolling restored (removed 4-item limit)

Animation Performance:
- Stagger animation delay reduced by 90% (50ms to 5ms)
- Fast stagger: 30ms to 3ms for ultra-responsive scrolling
- Delivery items appear almost instantly when scrolling
- Total animation time for 20 items: 500ms to 100ms
- Maintains subtle stagger effect while feeling immediate

User Experience Improvements:
- Clear visual indication of delivery order in route
- Faster perceived loading when scrolling through deliveries
- Better readability with larger, prominent order numbers
- Consistent status color coding across badge and accent bar

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 10:29:17 -05:00

465 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/delivery.dart';
import '../providers/providers.dart';
import '../api/client.dart';
import '../api/openapi_config.dart';
import '../models/delivery_commands.dart';
import '../components/map_sidebar_layout.dart';
import '../components/dark_mode_map.dart';
import '../components/delivery_list_item.dart';
class DeliveriesPage extends ConsumerStatefulWidget {
final int routeFragmentId;
final String routeName;
const DeliveriesPage({
super.key,
required this.routeFragmentId,
required this.routeName,
});
@override
ConsumerState<DeliveriesPage> createState() => _DeliveriesPageState();
}
class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
late ScrollController _listScrollController;
Delivery? _selectedDelivery;
int? _lastRouteFragmentId;
@override
void initState() {
super.initState();
_listScrollController = ScrollController();
}
@override
void dispose() {
_listScrollController.dispose();
super.dispose();
}
Future<void> _autoScrollToFirstPending(List<Delivery> deliveries) async {
final firstPendingIndex = deliveries.indexWhere((d) => !d.delivered && !d.isSkipped);
if (_listScrollController.hasClients && firstPendingIndex != -1) {
await Future.delayed(const Duration(milliseconds: 200));
// Scroll to position first pending delivery at top of list
// Each item is approximately 70 pixels tall
final scrollOffset = firstPendingIndex * 70.0;
_listScrollController.animateTo(
scrollOffset.clamp(0, _listScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
final deliveriesData = ref.watch(deliveriesProvider(widget.routeFragmentId));
final routesData = ref.watch(deliveryRoutesProvider);
final tokenAsync = ref.watch(authTokenProvider);
final token = tokenAsync.hasValue ? tokenAsync.value : null;
return Scaffold(
appBar: AppBar(
title: Text(widget.routeName),
elevation: 0,
),
body: SafeArea(
child: deliveriesData.when(
data: (deliveries) {
// Auto-scroll to first pending delivery when page loads or route changes
if (_lastRouteFragmentId != widget.routeFragmentId) {
_lastRouteFragmentId = widget.routeFragmentId;
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoScrollToFirstPending(deliveries);
});
}
return routesData.when(
data: (routes) {
return MapSidebarLayout(
mapWidget: DarkModeMapComponent(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
},
onAction: (action) => _selectedDelivery != null
? _handleDeliveryAction(context, _selectedDelivery!, action, token)
: null,
),
sidebarWidget: UnifiedDeliveryListView(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
scrollController: _listScrollController,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
},
onItemAction: (delivery, action) {
_handleDeliveryAction(context, delivery, action, token);
_autoScrollToFirstPending(deliveries);
},
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => MapSidebarLayout(
mapWidget: DarkModeMapComponent(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
},
onAction: (action) => _selectedDelivery != null
? _handleDeliveryAction(context, _selectedDelivery!, action, token)
: null,
),
sidebarWidget: UnifiedDeliveryListView(
deliveries: deliveries,
selectedDelivery: _selectedDelivery,
scrollController: _listScrollController,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
},
onItemAction: (delivery, action) {
_handleDeliveryAction(context, delivery, action, token);
_autoScrollToFirstPending(deliveries);
},
),
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Text('Error: $error'),
),
),
),
);
}
Future<void> _handleDeliveryAction(
BuildContext context,
Delivery delivery,
String action,
String? token,
) async {
if (token == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Authentication required')),
);
return;
}
final authClient = CqrsApiClient(
config: ApiClientConfig(
baseUrl: ApiClientConfig.production.baseUrl,
defaultHeaders: {'Authorization': 'Bearer $token'},
),
);
switch (action) {
case 'complete':
final result = await authClient.executeCommand(
endpoint: 'completeDelivery',
command: CompleteDeliveryCommand(
deliveryId: delivery.id,
),
);
result.when(
success: (_) {
// ignore: unused_result
ref.refresh(deliveriesProvider(widget.routeFragmentId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Delivery marked as completed')),
);
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
},
);
break;
case 'uncomplete':
final result = await authClient.executeCommand(
endpoint: 'markDeliveryAsUncompleted',
command: MarkDeliveryAsUncompletedCommand(deliveryId: delivery.id),
);
result.when(
success: (_) {
// ignore: unused_result
ref.refresh(deliveriesProvider(widget.routeFragmentId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Delivery marked as uncompleted')),
);
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
},
);
break;
case 'call':
final contact = delivery.orders.isNotEmpty && delivery.orders.first.contact != null
? delivery.orders.first.contact
: null;
if (contact?.phoneNumber != null) {
final Uri phoneUri = Uri(scheme: 'tel', path: contact!.phoneNumber);
if (await canLaunchUrl(phoneUri)) {
await launchUrl(phoneUri);
}
}
break;
case 'map':
// Navigation is now handled in-app by the DeliveryMap component
// Just ensure the delivery is selected
break;
}
}
}
class UnifiedDeliveryListView extends StatelessWidget {
final List<Delivery> deliveries;
final Delivery? selectedDelivery;
final ScrollController scrollController;
final ValueChanged<Delivery> onDeliverySelected;
final Function(Delivery, String) onItemAction;
const UnifiedDeliveryListView({
super.key,
required this.deliveries,
this.selectedDelivery,
required this.scrollController,
required this.onDeliverySelected,
required this.onItemAction,
});
@override
Widget build(BuildContext context) {
if (deliveries.isEmpty) {
return const Center(
child: Text('No deliveries'),
);
}
return RefreshIndicator(
onRefresh: () async {
// Trigger refresh via provider
},
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 8),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: deliveries.length, // Show all deliveries with scrolling
itemBuilder: (context, index) {
final delivery = deliveries[index];
return DeliveryListItem(
delivery: delivery,
isSelected: selectedDelivery?.id == delivery.id,
onTap: () => onDeliverySelected(delivery),
onCall: () => onItemAction(delivery, 'call'),
onAction: (action) => onItemAction(delivery, action),
animationIndex: index,
);
},
),
);
}
}
class DeliveryCard extends StatelessWidget {
final Delivery delivery;
final bool isSelected;
final VoidCallback onTap;
final Function(Delivery, String) onAction;
const DeliveryCard({
super.key,
required this.delivery,
this.isSelected = false,
required this.onTap,
required this.onAction,
});
@override
Widget build(BuildContext context) {
final contact = delivery.orders.isNotEmpty && delivery.orders.first.contact != null
? delivery.orders.first.contact
: null;
final order = delivery.orders.isNotEmpty ? delivery.orders.first : null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: isSelected
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (contact != null)
Text(
contact.fullName,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (delivery.delivered)
Chip(
label: const Text('Delivered'),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
)
else if (order?.isNewCustomer ?? false)
Chip(
label: const Text('New Customer'),
backgroundColor: const Color(0xFFFFFBEB),
),
],
),
const SizedBox(height: 12),
if (delivery.deliveryAddress != null)
Text(
delivery.deliveryAddress!.formattedAddress,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (order != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (order.totalItems != null)
Text(
'${order.totalItems} items',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'${order.totalAmount} MAD',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
if (contact?.phoneNumber != null)
OutlinedButton.icon(
onPressed: () => onAction(delivery, 'call'),
icon: const Icon(Icons.phone),
label: const Text('Call'),
),
if (delivery.deliveryAddress != null)
OutlinedButton.icon(
onPressed: () {
onTap(); // Select the delivery
onAction(delivery, 'map');
},
icon: const Icon(Icons.map),
label: const Text('Navigate'),
),
OutlinedButton.icon(
onPressed: () => _showDeliveryActions(context),
icon: const Icon(Icons.more_vert),
label: const Text('More'),
),
],
),
],
),
),
),
);
}
void _showDeliveryActions(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!delivery.delivered)
ListTile(
leading: const Icon(Icons.check_circle),
title: const Text('Mark as Completed'),
onTap: () {
Navigator.pop(context);
onAction(delivery, 'complete');
},
)
else
ListTile(
leading: const Icon(Icons.undo),
title: const Text('Mark as Uncompleted'),
onTap: () {
Navigator.pop(context);
onAction(delivery, 'uncomplete');
},
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Upload Photo'),
onTap: () {
Navigator.pop(context);
// TODO: Implement photo upload
},
),
ListTile(
leading: const Icon(Icons.description),
title: const Text('View Details'),
onTap: () {
Navigator.pop(context);
// TODO: Navigate to delivery details
},
),
],
),
),
);
}
}