ionic-planb-logistic-app-fl.../lib/pages/routes_page.dart
Mathias Beaulieu-Duncan 0fefe80d13 Add mobile UX optimization with toggleable deliveries overlay
- Add MobileDeliveriesListOpenNotifier provider for overlay state
- Create MobileMapWithOverlay component with slide-up animation
- Update routes_page.dart for responsive mobile/tablet/desktop layouts
- Mobile: full-screen map with FAB toggle for deliveries list
- Tablet/Desktop: maintain existing split-view layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:58:52 -05:00

618 lines
20 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_localizations.dart';
import 'package:image_picker/image_picker.dart';
import 'package:http/http.dart' as http;
import '../models/delivery.dart';
import '../models/delivery_route.dart';
import '../models/delivery_commands.dart';
import '../providers/providers.dart';
import '../api/client.dart';
import '../utils/toast_helper.dart';
import '../api/openapi_config.dart';
import '../utils/http_client_factory.dart';
import '../components/collapsible_routes_sidebar.dart';
import '../components/dark_mode_map.dart';
import '../components/loading_dialog.dart';
import '../components/notes_dialog.dart';
import '../components/photo_capture_dialog.dart';
import '../components/mobile_map_with_overlay.dart';
import '../services/location_permission_service.dart';
import '../utils/breakpoints.dart';
import 'deliveries_page.dart';
import 'settings_page.dart';
class RoutesPage extends ConsumerStatefulWidget {
const RoutesPage({super.key});
@override
ConsumerState<RoutesPage> createState() => _RoutesPageState();
}
class _RoutesPageState extends ConsumerState<RoutesPage> {
late LocationPermissionService _permissionService;
DeliveryRoute? _selectedRoute;
Delivery? _selectedDelivery;
@override
void initState() {
super.initState();
_permissionService = LocationPermissionService();
_requestLocationPermissionOnce();
}
Future<void> _requestLocationPermissionOnce() async {
try {
final hasPermission = await _permissionService.hasLocationPermission();
if (!hasPermission && mounted) {
final result = await _permissionService.requestLocationPermission();
result.when(
granted: () {
debugPrint('Location permission granted');
},
denied: () {
debugPrint('Location permission denied');
},
permanentlyDenied: () {
debugPrint('Location permission permanently denied');
},
error: (message) {
debugPrint('Location permission error: $message');
},
);
}
} catch (e) {
debugPrint('Error requesting location permission: $e');
}
}
void _selectRoute(DeliveryRoute route) {
setState(() {
_selectedRoute = route;
});
}
void _backToRoutes() {
setState(() {
_selectedRoute = null;
_selectedDelivery = null;
});
}
Future<void> _handleDeliveryAction(
String action,
Delivery delivery,
int routeFragmentId,
) async {
// Capture l10n before async operations to avoid BuildContext across async gaps
final l10n = AppLocalizations.of(context);
final authService = ref.read(authServiceProvider);
// Ensure we have a valid token (automatically refreshes if needed)
final token = await authService.ensureValidToken();
if (token == null) {
if (mounted) {
ToastHelper.showError(context, l10n.authenticationRequired);
}
return;
}
// Create API client with auth service for automatic token refresh
final authClient = CqrsApiClient(
config: ApiClientConfig.development,
authService: authService,
);
switch (action) {
case 'complete':
if (mounted) {
LoadingDialog.show(context, message: l10n.completingDelivery);
}
final result = await authClient.executeCommand(
endpoint: 'completeDelivery',
command: CompleteDeliveryCommand(
deliveryId: delivery.id,
),
);
result.when(
success: (_) async {
if (mounted) {
LoadingDialog.hide(context);
}
if (mounted) {
// Invalidate both providers to force refresh
ref.invalidate(deliveriesProvider(routeFragmentId));
ref.invalidate(allDeliveriesProvider);
ref.invalidate(deliveryRoutesProvider);
// Wait for providers to refresh
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
// Get refreshed deliveries
final allDeliveries = await ref.read(allDeliveriesProvider.future);
final routeDeliveries = allDeliveries
.where((d) => d.routeFragmentId == routeFragmentId)
.toList();
// Find the next incomplete delivery in the route
final nextDelivery = routeDeliveries.firstWhere(
(d) => !d.delivered && !d.isSkipped,
orElse: () => routeDeliveries.firstWhere(
(d) => d.id == delivery.id,
orElse: () => delivery,
),
);
setState(() {
_selectedDelivery = nextDelivery;
});
// Auto-show notes for the next delivery if needed
_autoShowNotesIfNeeded(nextDelivery);
// Small delay to let the UI update before map auto-navigates
if (nextDelivery.id != delivery.id && mounted) {
await Future.delayed(const Duration(milliseconds: 200));
}
}
if (mounted) {
ToastHelper.showSuccess(context, l10n.deliverySuccessful);
}
}
},
onError: (error) {
if (mounted) {
LoadingDialog.hide(context);
}
debugPrint('Complete delivery failed - Type: ${error.type}, Message: ${error.message}');
debugPrint('Error details: ${error.details}');
if (mounted) {
String errorMessage = l10n.error(error.message);
if (error.statusCode == 500) {
errorMessage = l10n.serverError;
}
ToastHelper.showError(context, errorMessage);
}
},
);
break;
case 'uncomplete':
if (mounted) {
LoadingDialog.show(context, message: l10n.markingAsUncompleted);
}
final uncompleteResult = await authClient.executeCommand(
endpoint: 'markDeliveryAsUncompleted',
command: MarkDeliveryAsUncompletedCommand(deliveryId: delivery.id),
);
uncompleteResult.when(
success: (_) async {
if (mounted) {
LoadingDialog.hide(context);
}
if (mounted) {
// Invalidate both providers to force refresh
ref.invalidate(deliveriesProvider(routeFragmentId));
ref.invalidate(allDeliveriesProvider);
ref.invalidate(deliveryRoutesProvider);
// Wait for providers to refresh
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
// Get refreshed deliveries
final allDeliveries = await ref.read(allDeliveriesProvider.future);
final updatedDelivery = allDeliveries.firstWhere(
(d) => d.id == delivery.id,
orElse: () => delivery,
);
setState(() {
_selectedDelivery = updatedDelivery;
});
}
if (mounted) {
ToastHelper.showSuccess(context, l10n.deliveryMarkedUncompleted);
}
}
},
onError: (error) {
if (mounted) {
LoadingDialog.hide(context);
}
if (mounted) {
ToastHelper.showError(context, l10n.error(error.message));
}
},
);
break;
case 'photo':
await _handlePhotoCapture(delivery);
break;
case 'note':
await _showNotesDialog(delivery);
break;
}
}
Future<void> _handlePhotoCapture(
Delivery delivery,
) async {
final authService = ref.read(authServiceProvider);
final l10n = AppLocalizations.of(context);
// Ensure we have a valid token (automatically refreshes if needed)
final token = await authService.ensureValidToken();
if (token == null) {
if (mounted) {
ToastHelper.showError(context, l10n.authenticationRequired);
}
return;
}
final ImagePicker picker = ImagePicker();
XFile? pickedFile;
try {
pickedFile = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
} catch (e) {
if (mounted) {
ToastHelper.showError(context, l10n.cameraError(e.toString()));
}
return;
}
if (pickedFile == null) {
return;
}
if (!mounted) return;
// Show photo confirmation dialog
final bool? confirmed = await PhotoCaptureDialog.show(
context,
imageFile: File(pickedFile.path),
deliveryName: delivery.name,
);
if (confirmed != true) {
return;
}
if (!mounted) return;
// Show uploading dialog
LoadingDialog.show(context, message: l10n.uploadingPhoto);
try {
final Uri uploadUrl = Uri.parse(
'${ApiClientConfig.development.baseUrl}/api/delivery/uploadDeliveryPicture?deliveryId=${delivery.id}',
);
// Create HTTP client that accepts self-signed certificates
final client = HttpClientFactory.createClient(
allowSelfSigned: ApiClientConfig.development.allowSelfSignedCertificate,
);
final http.MultipartRequest request = http.MultipartRequest('POST', uploadUrl);
request.headers['Authorization'] = 'Bearer $token';
request.files.add(await http.MultipartFile.fromPath('file', pickedFile.path));
final http.StreamedResponse streamedResponse = await client.send(request);
final http.Response response = await http.Response.fromStream(streamedResponse);
client.close();
if (mounted) {
LoadingDialog.hide(context);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
if (mounted) {
ToastHelper.showSuccess(context, l10n.photoUploadSuccess);
}
ref.invalidate(allDeliveriesProvider);
} else {
debugPrint('Photo upload failed - Status: ${response.statusCode}');
debugPrint('Response body: ${response.body}');
if (mounted) {
String errorMessage = l10n.photoUploadFailed(response.statusCode);
if (response.statusCode == 500) {
errorMessage = l10n.serverError;
} else if (response.statusCode == 401) {
errorMessage = l10n.authenticationRequired;
}
ToastHelper.showError(context, errorMessage);
}
}
} catch (e) {
if (mounted) {
LoadingDialog.hide(context);
ToastHelper.showError(context, l10n.uploadError(e.toString()));
}
}
}
bool _shouldAutoShowNotes(Delivery? delivery) {
// Only auto-show notes if delivery is not yet delivered and has notes
if (delivery == null || delivery.delivered) return false;
final hasNotes = delivery.orders.any(
(order) => order.note != null && order.note!.isNotEmpty,
);
return hasNotes;
}
Future<void> _autoShowNotesIfNeeded(Delivery? delivery) async {
if (delivery != null && _shouldAutoShowNotes(delivery)) {
// Use post-frame callback to ensure UI is ready
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showNotesDialog(delivery);
}
});
}
}
Future<void> _showNotesDialog(Delivery delivery) async {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
final hasNotes = await NotesDialog.show(context, delivery);
if (!hasNotes && mounted) {
ToastHelper.showInfo(context, l10n.noNotesMessage);
}
}
@override
Widget build(BuildContext context) {
final routesData = ref.watch(deliveryRoutesProvider);
final allDeliveriesData = ref.watch(allDeliveriesProvider);
final userProfile = ref.watch(userProfileProvider);
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l10n.deliveryRoutes),
elevation: 0,
scrolledUnderElevation: 0,
actions: [
IconButton(
icon: (routesData.isLoading || allDeliveriesData.isLoading)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: (routesData.isLoading || allDeliveriesData.isLoading)
? null
: () {
ref.invalidate(deliveryRoutesProvider);
ref.invalidate(allDeliveriesProvider);
},
tooltip: 'Refresh',
),
userProfile.when(
data: (profile) {
String getInitials(String? fullName) {
if (fullName == null || fullName.isEmpty) return 'U';
final names = fullName.trim().split(' ');
if (names.length == 1) {
return names[0][0].toUpperCase();
}
return '${names.first[0]}${names.last[0]}'.toUpperCase();
}
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SettingsPage(),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
getInitials(profile?.fullName),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
loading: () => const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (error, stackTrace) => const SizedBox(),
),
],
),
body: routesData.when(
data: (routes) {
if (routes.isEmpty) {
return Center(
child: Text(l10n.noRoutes),
);
}
return allDeliveriesData.when(
data: (allDeliveries) {
final isMobile = context.isMobile;
// Mobile layout: Show routes list full-screen when no route selected
if (isMobile && _selectedRoute == null) {
return RefreshIndicator(
onRefresh: () async {
// ignore: unused_result
ref.refresh(deliveryRoutesProvider);
// ignore: unused_result
ref.refresh(allDeliveriesProvider);
},
child: CollapsibleRoutesSidebar(
routes: routes,
selectedRoute: null,
onRouteSelected: _selectRoute,
),
);
}
// Mobile layout: full-screen map with overlay when route is selected
if (isMobile && _selectedRoute != null) {
final routeDeliveries = allDeliveries
.where((d) => d.routeFragmentId == _selectedRoute!.id)
.toList();
return RefreshIndicator(
onRefresh: () async {
// ignore: unused_result
ref.refresh(deliveryRoutesProvider);
// ignore: unused_result
ref.refresh(allDeliveriesProvider);
},
child: Stack(
children: [
MobileMapWithOverlay(
deliveries: routeDeliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
onDeliveryAction: (delivery, action) {
_handleDeliveryAction(action, delivery, _selectedRoute!.id);
},
),
// Back button to return to routes list
Positioned(
top: 16,
left: 16,
child: FloatingActionButton.small(
onPressed: _backToRoutes,
backgroundColor: Theme.of(context).colorScheme.surface,
child: const Icon(Icons.arrow_back),
),
),
],
),
);
}
// Tablet/Desktop layout: split view with map + sidebar
return RefreshIndicator(
onRefresh: () async {
// ignore: unused_result
ref.refresh(deliveryRoutesProvider);
// ignore: unused_result
ref.refresh(allDeliveriesProvider);
},
child: Row(
children: [
Expanded(
child: DarkModeMapComponent(
deliveries: allDeliveries,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
onAction: (action) {
if (_selectedDelivery != null && _selectedRoute != null) {
_handleDeliveryAction(action, _selectedDelivery!, _selectedRoute!.id);
}
},
),
),
_selectedRoute == null
? CollapsibleRoutesSidebar(
routes: routes,
selectedRoute: null,
onRouteSelected: _selectRoute,
)
: DeliveriesPage(
routeFragmentId: _selectedRoute!.id,
routeName: _selectedRoute!.name,
onBack: _backToRoutes,
showAsEmbedded: true,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
),
],
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(l10n.error(error.toString())),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(allDeliveriesProvider),
child: Text(l10n.retry),
),
],
),
),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(l10n.error(error.toString())),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(deliveryRoutesProvider),
child: Text(l10n.retry),
),
],
),
),
),
);
}
}