Restructures navigation session initialization to occur after the view is created, eliminating race conditions. Session initialization now happens in onViewCreated callback with proper delay before setting destination. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
479 lines
16 KiB
Dart
479 lines
16 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 '../models/delivery_route.dart';
|
|
import '../providers/providers.dart';
|
|
import '../api/client.dart';
|
|
import '../api/openapi_config.dart';
|
|
import '../models/delivery_commands.dart';
|
|
import '../utils/breakpoints.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 token = ref.watch(authTokenProvider).valueOrNull;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.routeName),
|
|
elevation: 0,
|
|
),
|
|
body: 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);
|
|
});
|
|
}
|
|
|
|
final todoDeliveries = deliveries
|
|
.where((d) => !d.delivered && !d.isSkipped)
|
|
.toList();
|
|
final completedDeliveries = deliveries
|
|
.where((d) => d.delivered)
|
|
.toList();
|
|
|
|
return routesData.when(
|
|
data: (routes) {
|
|
DeliveryRoute? currentRoute;
|
|
try {
|
|
currentRoute = routes.firstWhere(
|
|
(r) => r.id == widget.routeFragmentId,
|
|
);
|
|
} catch (_) {
|
|
currentRoute = routes.isNotEmpty ? routes.first : null;
|
|
}
|
|
|
|
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.symmetric(vertical: 8),
|
|
itemCount: deliveries.length,
|
|
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.withOpacity(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
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|