ionic-planb-logistic-app-fl.../lib/pages/deliveries_page.dart
Jean-Philippe Brule d8bdaed63e Upgrade Flutter packages and fix breaking changes for Riverpod 3.0
Major package upgrades:
- Riverpod 2.x → 3.0.3 (breaking changes)
- flutter_appauth 7.0.0 → 11.0.0
- go_router 14.0.0 → 17.0.0
- permission_handler 11.3.0 → 12.0.1
- flutter_lints 5.0.0 → 6.0.0
- animate_do 3.1.2 → 4.2.0
- Plus 41 transitive dependency updates

Breaking changes fixed:

Riverpod 3.0 migration:
- Replace StateProvider with NotifierProvider pattern
- Update .valueOrNull to proper async value handling with .future
- Create LanguageNotifier and ThemeModeNotifier classes
- Fix all provider async value access patterns

Google Maps Navigation API updates:
- Rename GoogleMapsNavigationViewController to GoogleNavigationViewController
- Update Waypoint to NavigationWaypoint.withLatLngTarget
- Migrate controller methods to static GoogleMapsNavigator methods
- Update event listener types and callbacks

Localization system fixes:
- Update l10n.yaml synthetic-package configuration
- Fix import paths from flutter_gen to package imports
- Add errorTitle translation key for both en/fr
- Remove unnecessary null-aware operators (AppLocalizations is non-nullable)
- Regenerate localization files

iOS/macOS configuration:
- Update CocoaPods dependencies (AppAuth 1.7.5 → 2.0.0)
- Create missing Profile.xcconfig files for both platforms
- Sync Podfile.lock files with updated dependencies

Code quality:
- Fix all analyzer errors (0 errors remaining)
- Resolve deprecated API usage warnings
- Update async/await patterns for better error handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 01:25:16 -05:00

483 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 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);
});
}
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),
physics: const AlwaysScrollableScrollPhysics(),
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
},
),
],
),
),
);
}
}