677 lines
22 KiB
Dart
677 lines
22 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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/breakpoints.dart';
|
|
import '../utils/http_client_factory.dart';
|
|
import '../components/collapsible_routes_sidebar.dart';
|
|
import '../components/dark_mode_map.dart';
|
|
import '../services/location_permission_service.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 {
|
|
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, 'Authentication required');
|
|
}
|
|
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) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext dialogContext) {
|
|
return const Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Completing delivery...'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
final result = await authClient.executeCommand(
|
|
endpoint: 'completeDelivery',
|
|
command: CompleteDeliveryCommand(
|
|
deliveryId: delivery.id,
|
|
),
|
|
);
|
|
result.when(
|
|
success: (_) async {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
if (mounted) {
|
|
// Invalidate both providers to force refresh
|
|
ref.invalidate(deliveriesProvider(routeFragmentId));
|
|
ref.invalidate(allDeliveriesProvider);
|
|
|
|
// 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, 'Delivery marked as completed');
|
|
}
|
|
}
|
|
},
|
|
onError: (error) {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
debugPrint('Complete delivery failed - Type: ${error.type}, Message: ${error.message}');
|
|
debugPrint('Error details: ${error.details}');
|
|
if (mounted) {
|
|
String errorMessage = 'Error: ${error.message}';
|
|
if (error.statusCode == 500) {
|
|
errorMessage = 'Server error - Please contact support';
|
|
}
|
|
ToastHelper.showError(context, errorMessage);
|
|
}
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'uncomplete':
|
|
if (mounted) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext dialogContext) {
|
|
return const Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Marking as uncompleted...'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
final result = await authClient.executeCommand(
|
|
endpoint: 'markDeliveryAsUncompleted',
|
|
command: MarkDeliveryAsUncompletedCommand(deliveryId: delivery.id),
|
|
);
|
|
result.when(
|
|
success: (_) async {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
if (mounted) {
|
|
// Invalidate both providers to force refresh
|
|
ref.invalidate(deliveriesProvider(routeFragmentId));
|
|
ref.invalidate(allDeliveriesProvider);
|
|
|
|
// 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, 'Delivery marked as uncompleted');
|
|
}
|
|
}
|
|
},
|
|
onError: (error) {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
if (mounted) {
|
|
ToastHelper.showError(context, '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);
|
|
|
|
// Ensure we have a valid token (automatically refreshes if needed)
|
|
final token = await authService.ensureValidToken();
|
|
if (token == null) {
|
|
if (mounted) {
|
|
ToastHelper.showError(context, 'Authentication required');
|
|
}
|
|
return;
|
|
}
|
|
final ImagePicker picker = ImagePicker();
|
|
XFile? pickedFile;
|
|
|
|
try {
|
|
pickedFile = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 85,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ToastHelper.showError(context, 'Camera error: $e');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pickedFile == null) {
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final bool? confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext dialogContext) {
|
|
return AlertDialog(
|
|
title: const Text('Confirm Photo'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(dialogContext).size.height * 0.5,
|
|
maxWidth: MediaQuery.of(dialogContext).size.width * 0.8,
|
|
),
|
|
child: Image.file(
|
|
File(pickedFile!.path),
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Upload this photo for ${delivery.name}?',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('Upload'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (confirmed != true) {
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext dialogContext) {
|
|
return const Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Uploading photo...'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
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) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
if (mounted) {
|
|
ToastHelper.showSuccess(context, 'Photo uploaded successfully');
|
|
}
|
|
ref.refresh(allDeliveriesProvider);
|
|
} else {
|
|
debugPrint('Photo upload failed - Status: ${response.statusCode}');
|
|
debugPrint('Response body: ${response.body}');
|
|
if (mounted) {
|
|
String errorMessage = 'Upload failed';
|
|
if (response.statusCode == 500) {
|
|
errorMessage = 'Server error - Please contact support';
|
|
} else if (response.statusCode == 401) {
|
|
errorMessage = 'Authentication required - Please log in again';
|
|
} else {
|
|
errorMessage = 'Upload failed: ${response.statusCode}';
|
|
}
|
|
ToastHelper.showError(context, errorMessage);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
ToastHelper.showError(context, 'Upload error: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
final notes = delivery.orders
|
|
.where((order) => order.note != null && order.note!.isNotEmpty)
|
|
.map((order) => order.note!)
|
|
.toList();
|
|
|
|
if (!mounted) return;
|
|
|
|
if (notes.isEmpty) {
|
|
ToastHelper.showInfo(context, 'No notes attached to this delivery');
|
|
return;
|
|
}
|
|
|
|
await showDialog(
|
|
context: context,
|
|
builder: (BuildContext dialogContext) {
|
|
return AlertDialog(
|
|
title: Text('Notes for ${delivery.name}'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: notes.map((note) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 16.0),
|
|
child: Text(
|
|
note,
|
|
style: Theme.of(dialogContext).textTheme.bodyLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
)).toList(),
|
|
),
|
|
),
|
|
actionsAlignment: MainAxisAlignment.center,
|
|
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
actions: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final routesData = ref.watch(deliveryRoutesProvider);
|
|
final allDeliveriesData = ref.watch(allDeliveriesProvider);
|
|
final userProfile = ref.watch(userProfileProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Delivery Routes'),
|
|
elevation: 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.refresh(deliveryRoutesProvider);
|
|
ref.refresh(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 const Center(
|
|
child: Text('No routes available'),
|
|
);
|
|
}
|
|
return allDeliveriesData.when(
|
|
data: (allDeliveries) {
|
|
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('Error loading deliveries: $error'),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () => ref.refresh(allDeliveriesProvider),
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
error: (error, stackTrace) => Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text('Error: $error'),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () => ref.refresh(deliveryRoutesProvider),
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
}
|