ios build, connected data (not finished)
This commit is contained in:
+5
-1
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_interceptor/http_interceptor.dart';
|
||||
import 'types.dart';
|
||||
import 'openapi_config.dart';
|
||||
import '../utils/logging_interceptor.dart';
|
||||
|
||||
class CqrsApiClient {
|
||||
final ApiClientConfig config;
|
||||
@@ -12,7 +14,9 @@ class CqrsApiClient {
|
||||
required this.config,
|
||||
http.Client? httpClient,
|
||||
}) {
|
||||
_httpClient = httpClient ?? http.Client();
|
||||
_httpClient = httpClient ?? InterceptedClient.build(
|
||||
interceptors: [LoggingInterceptor()],
|
||||
);
|
||||
}
|
||||
|
||||
String get baseUrl => config.baseUrl;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
|
||||
import '../models/delivery.dart';
|
||||
|
||||
class DeliveryMap extends StatefulWidget {
|
||||
final List<Delivery> deliveries;
|
||||
final Delivery? selectedDelivery;
|
||||
final ValueChanged<Delivery?>? onDeliverySelected;
|
||||
|
||||
const DeliveryMap({
|
||||
super.key,
|
||||
required this.deliveries,
|
||||
this.selectedDelivery,
|
||||
this.onDeliverySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DeliveryMap> createState() => _DeliveryMapState();
|
||||
}
|
||||
|
||||
class _DeliveryMapState extends State<DeliveryMap> {
|
||||
GoogleNavigationViewController? _navigationController;
|
||||
bool _isNavigating = false;
|
||||
LatLng? _destinationLocation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeNavigation();
|
||||
}
|
||||
|
||||
Future<void> _initializeNavigation() async {
|
||||
try {
|
||||
debugPrint('🗺️ Starting navigation initialization');
|
||||
// Check if terms and conditions need to be shown
|
||||
final termsAccepted = await GoogleMapsNavigator.areTermsAccepted();
|
||||
debugPrint('🗺️ Terms accepted: $termsAccepted');
|
||||
|
||||
if (!termsAccepted) {
|
||||
debugPrint('🗺️ Showing terms and conditions dialog');
|
||||
// Show terms and conditions
|
||||
await GoogleMapsNavigator.showTermsAndConditionsDialog(
|
||||
'Plan B Logistics',
|
||||
'com.goutezplanb.planbLogistic',
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize navigation session
|
||||
debugPrint('🗺️ Initializing navigation session');
|
||||
await GoogleMapsNavigator.initializeNavigationSession();
|
||||
debugPrint('🗺️ Navigation session initialized successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error initializing navigation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DeliveryMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
|
||||
_updateDestination();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateDestination() {
|
||||
if (widget.selectedDelivery != null) {
|
||||
final address = widget.selectedDelivery!.deliveryAddress;
|
||||
if (address?.latitude != null && address?.longitude != null) {
|
||||
setState(() {
|
||||
_destinationLocation = LatLng(
|
||||
latitude: address!.latitude!,
|
||||
longitude: address.longitude!,
|
||||
);
|
||||
});
|
||||
_navigateToLocation(_destinationLocation!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToLocation(LatLng location) async {
|
||||
if (_navigationController == null) return;
|
||||
|
||||
try {
|
||||
await _navigationController!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(location, 15),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error moving camera: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startNavigation() async {
|
||||
if (_destinationLocation == null) return;
|
||||
|
||||
try {
|
||||
final waypoint = NavigationWaypoint.withLatLngTarget(
|
||||
title: widget.selectedDelivery?.name ?? 'Destination',
|
||||
target: _destinationLocation!,
|
||||
);
|
||||
|
||||
final destinations = Destinations(
|
||||
waypoints: [waypoint],
|
||||
displayOptions: NavigationDisplayOptions(showDestinationMarkers: true),
|
||||
);
|
||||
|
||||
await GoogleMapsNavigator.setDestinations(destinations);
|
||||
await GoogleMapsNavigator.startGuidance();
|
||||
|
||||
setState(() {
|
||||
_isNavigating = true;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error starting navigation: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error starting navigation: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopNavigation() async {
|
||||
try {
|
||||
await GoogleMapsNavigator.stopGuidance();
|
||||
await GoogleMapsNavigator.clearDestinations();
|
||||
|
||||
setState(() {
|
||||
_isNavigating = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error stopping navigation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initialPosition = widget.selectedDelivery?.deliveryAddress != null &&
|
||||
widget.selectedDelivery!.deliveryAddress!.latitude != null &&
|
||||
widget.selectedDelivery!.deliveryAddress!.longitude != null
|
||||
? LatLng(
|
||||
latitude: widget.selectedDelivery!.deliveryAddress!.latitude!,
|
||||
longitude: widget.selectedDelivery!.deliveryAddress!.longitude!,
|
||||
)
|
||||
: const LatLng(latitude: 45.5017, longitude: -73.5673); // Default to Montreal
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
GoogleMapsNavigationView(
|
||||
onViewCreated: (controller) {
|
||||
debugPrint('🗺️ Map view created successfully');
|
||||
_navigationController = controller;
|
||||
controller.setMyLocationEnabled(true);
|
||||
|
||||
// Set initial camera position
|
||||
controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(initialPosition, 12),
|
||||
);
|
||||
debugPrint('🗺️ Initial camera position set to: $initialPosition');
|
||||
},
|
||||
initialNavigationUIEnabledPreference: NavigationUIEnabledPreference.disabled,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: initialPosition,
|
||||
zoom: 12,
|
||||
),
|
||||
),
|
||||
if (_destinationLocation != null && !_isNavigating)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _startNavigation,
|
||||
icon: const Icon(Icons.navigation),
|
||||
label: const Text('Start Navigation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.all(16),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isNavigating)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _stopNavigation,
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('Stop Navigation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.all(16),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'zoom_in',
|
||||
onPressed: () {
|
||||
_navigationController?.animateCamera(CameraUpdate.zoomIn());
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'zoom_out',
|
||||
onPressed: () {
|
||||
_navigationController?.animateCamera(CameraUpdate.zoomOut());
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
GoogleMapsNavigator.cleanup();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/breakpoints.dart';
|
||||
|
||||
class MapSidebarLayout extends StatelessWidget {
|
||||
final Widget mapWidget;
|
||||
final Widget sidebarWidget;
|
||||
final double mapRatio;
|
||||
|
||||
const MapSidebarLayout({
|
||||
super.key,
|
||||
required this.mapWidget,
|
||||
required this.sidebarWidget,
|
||||
this.mapRatio = 2 / 3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < Breakpoints.tablet;
|
||||
|
||||
if (isMobile) {
|
||||
return sidebarWidget;
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: (mapRatio * 100).toInt(),
|
||||
child: mapWidget,
|
||||
),
|
||||
Expanded(
|
||||
flex: ((1 - mapRatio) * 100).toInt(),
|
||||
child: sidebarWidget,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'theme.dart';
|
||||
@@ -6,7 +7,14 @@ import 'providers/providers.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'pages/routes_page.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: PlanBLogisticApp(),
|
||||
@@ -46,8 +54,22 @@ class AppHome extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: Re-enable authentication when Keycloak is configured
|
||||
// For now, bypass auth and go directly to RoutesPage
|
||||
return const RoutesPage();
|
||||
final isAuthenticatedAsync = ref.watch(isAuthenticatedProvider);
|
||||
|
||||
return isAuthenticatedAsync.when(
|
||||
data: (isAuthenticated) {
|
||||
if (isAuthenticated) {
|
||||
return const RoutesPage();
|
||||
} else {
|
||||
return const LoginPage();
|
||||
}
|
||||
},
|
||||
loading: () => const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stackTrace) => const LoginPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,58 +2,54 @@ import '../api/types.dart';
|
||||
|
||||
class DeliveryRoute implements Serializable {
|
||||
final int id;
|
||||
final int routeId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final int routeFragmentId;
|
||||
final int totalDeliveries;
|
||||
final int completedDeliveries;
|
||||
final int skippedDeliveries;
|
||||
final String routeName;
|
||||
final int deliveriesCount;
|
||||
final int deliveredCount;
|
||||
final bool completed;
|
||||
final String createdAt;
|
||||
final String? updatedAt;
|
||||
|
||||
const DeliveryRoute({
|
||||
required this.id,
|
||||
required this.routeId,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.routeFragmentId,
|
||||
required this.totalDeliveries,
|
||||
required this.completedDeliveries,
|
||||
required this.skippedDeliveries,
|
||||
required this.routeName,
|
||||
required this.deliveriesCount,
|
||||
required this.deliveredCount,
|
||||
required this.completed,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory DeliveryRoute.fromJson(Map<String, dynamic> json) {
|
||||
return DeliveryRoute(
|
||||
id: json['id'] as int,
|
||||
routeId: json['routeId'] as int,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
routeFragmentId: json['routeFragmentId'] as int,
|
||||
totalDeliveries: json['totalDeliveries'] as int,
|
||||
completedDeliveries: json['completedDeliveries'] as int,
|
||||
skippedDeliveries: json['skippedDeliveries'] as int,
|
||||
routeName: json['routeName'] as String,
|
||||
deliveriesCount: json['deliveriesCount'] as int,
|
||||
deliveredCount: json['deliveredCount'] as int,
|
||||
completed: json['completed'] as bool,
|
||||
createdAt: json['createdAt'] as String,
|
||||
updatedAt: json['updatedAt'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
double get progress {
|
||||
if (totalDeliveries == 0) return 0.0;
|
||||
return completedDeliveries / totalDeliveries;
|
||||
if (deliveriesCount == 0) return 0.0;
|
||||
return deliveredCount / deliveriesCount;
|
||||
}
|
||||
|
||||
int get pendingDeliveries => totalDeliveries - completedDeliveries - skippedDeliveries;
|
||||
int get pendingDeliveries => deliveriesCount - deliveredCount;
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'routeId': routeId,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'routeFragmentId': routeFragmentId,
|
||||
'totalDeliveries': totalDeliveries,
|
||||
'completedDeliveries': completedDeliveries,
|
||||
'skippedDeliveries': skippedDeliveries,
|
||||
'routeName': routeName,
|
||||
'deliveriesCount': deliveriesCount,
|
||||
'deliveredCount': deliveredCount,
|
||||
'completed': completed,
|
||||
'createdAt': createdAt,
|
||||
'updatedAt': updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
+101
-67
@@ -8,6 +8,8 @@ import '../api/openapi_config.dart';
|
||||
import '../models/delivery_commands.dart';
|
||||
import '../utils/breakpoints.dart';
|
||||
import '../utils/responsive.dart';
|
||||
import '../components/map_sidebar_layout.dart';
|
||||
import '../components/delivery_map.dart';
|
||||
|
||||
class DeliveriesPage extends ConsumerStatefulWidget {
|
||||
final int routeFragmentId;
|
||||
@@ -26,6 +28,7 @@ class DeliveriesPage extends ConsumerStatefulWidget {
|
||||
class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
|
||||
late PageController _pageController;
|
||||
int _currentSegment = 0;
|
||||
Delivery? _selectedDelivery;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -58,57 +61,80 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
|
||||
.where((d) => d.delivered)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
label: Text('To Do'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
label: Text('Delivered'),
|
||||
),
|
||||
],
|
||||
selected: <int>{_currentSegment},
|
||||
onSelectionChanged: (Set<int> newSelection) {
|
||||
setState(() {
|
||||
_currentSegment = newSelection.first;
|
||||
_pageController.animateToPage(
|
||||
_currentSegment,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
return MapSidebarLayout(
|
||||
mapWidget: DeliveryMap(
|
||||
deliveries: deliveries,
|
||||
selectedDelivery: _selectedDelivery,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
},
|
||||
),
|
||||
sidebarWidget: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
label: Text('To Do'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
label: Text('Delivered'),
|
||||
),
|
||||
],
|
||||
selected: <int>{_currentSegment},
|
||||
onSelectionChanged: (Set<int> newSelection) {
|
||||
setState(() {
|
||||
_currentSegment = newSelection.first;
|
||||
_pageController.animateToPage(
|
||||
_currentSegment,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentSegment = index;
|
||||
});
|
||||
},
|
||||
children: [
|
||||
DeliveryListView(
|
||||
deliveries: todoDeliveries,
|
||||
onAction: (delivery, action) =>
|
||||
_handleDeliveryAction(context, delivery, action, token),
|
||||
),
|
||||
DeliveryListView(
|
||||
deliveries: completedDeliveries,
|
||||
onAction: (delivery, action) =>
|
||||
_handleDeliveryAction(context, delivery, action, token),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentSegment = index;
|
||||
});
|
||||
},
|
||||
children: [
|
||||
DeliveryListView(
|
||||
deliveries: todoDeliveries,
|
||||
selectedDelivery: _selectedDelivery,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
},
|
||||
onAction: (delivery, action) =>
|
||||
_handleDeliveryAction(context, delivery, action, token),
|
||||
),
|
||||
DeliveryListView(
|
||||
deliveries: completedDeliveries,
|
||||
selectedDelivery: _selectedDelivery,
|
||||
onDeliverySelected: (delivery) {
|
||||
setState(() {
|
||||
_selectedDelivery = delivery;
|
||||
});
|
||||
},
|
||||
onAction: (delivery, action) =>
|
||||
_handleDeliveryAction(context, delivery, action, token),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
@@ -200,19 +226,8 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
|
||||
break;
|
||||
|
||||
case 'map':
|
||||
if (delivery.deliveryAddress != null) {
|
||||
final address = delivery.deliveryAddress!;
|
||||
final Uri mapUri = Uri(
|
||||
scheme: 'https',
|
||||
host: 'maps.google.com',
|
||||
queryParameters: {
|
||||
'q': '${address.latitude},${address.longitude}',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(mapUri)) {
|
||||
await launchUrl(mapUri);
|
||||
}
|
||||
}
|
||||
// Navigation is now handled in-app by the DeliveryMap component
|
||||
// Just ensure the delivery is selected
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -220,11 +235,15 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
|
||||
|
||||
class DeliveryListView extends StatelessWidget {
|
||||
final List<Delivery> deliveries;
|
||||
final Delivery? selectedDelivery;
|
||||
final ValueChanged<Delivery> onDeliverySelected;
|
||||
final Function(Delivery, String) onAction;
|
||||
|
||||
const DeliveryListView({
|
||||
super.key,
|
||||
required this.deliveries,
|
||||
this.selectedDelivery,
|
||||
required this.onDeliverySelected,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
@@ -246,6 +265,8 @@ class DeliveryListView extends StatelessWidget {
|
||||
final delivery = deliveries[index];
|
||||
return DeliveryCard(
|
||||
delivery: delivery,
|
||||
isSelected: selectedDelivery?.id == delivery.id,
|
||||
onTap: () => onDeliverySelected(delivery),
|
||||
onAction: onAction,
|
||||
);
|
||||
},
|
||||
@@ -256,11 +277,15 @@ class DeliveryListView extends StatelessWidget {
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -273,9 +298,14 @@ class DeliveryCard extends StatelessWidget {
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
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(
|
||||
@@ -350,9 +380,12 @@ class DeliveryCard extends StatelessWidget {
|
||||
),
|
||||
if (delivery.deliveryAddress != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => onAction(delivery, 'map'),
|
||||
onPressed: () {
|
||||
onTap(); // Select the delivery
|
||||
onAction(delivery, 'map');
|
||||
},
|
||||
icon: const Icon(Icons.map),
|
||||
label: const Text('Map'),
|
||||
label: const Text('Navigate'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showDeliveryActions(context),
|
||||
@@ -362,6 +395,7 @@ class DeliveryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+157
-42
@@ -2,55 +2,170 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
class LoginPage extends ConsumerWidget {
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final authService = ref.read(authServiceProvider);
|
||||
final result = await authService.login(
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
result.when(
|
||||
success: (token) {
|
||||
// ignore: unused_result
|
||||
ref.refresh(isAuthenticatedProvider);
|
||||
},
|
||||
onError: (error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
},
|
||||
cancelled: () {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Plan B Logistics',
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_shipping,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Plan B Logistics',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Delivery Management System',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
hintText: 'Enter your username',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
enabled: !_isLoading,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your username';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
enabled: !_isLoading,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Login'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Delivery Management System',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final authService = ref.read(authServiceProvider);
|
||||
final result = await authService.login();
|
||||
result.when(
|
||||
success: (token) {
|
||||
if (context.mounted) {
|
||||
// ignore: unused_result
|
||||
ref.refresh(isAuthenticatedProvider);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Login failed: $error')),
|
||||
);
|
||||
}
|
||||
},
|
||||
cancelled: () {},
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
),
|
||||
child: const Text('Login with Keycloak'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ class RoutesPage extends ConsumerWidget {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DeliveriesPage(
|
||||
routeFragmentId: route.routeFragmentId,
|
||||
routeFragmentId: route.id,
|
||||
routeName: route.name,
|
||||
),
|
||||
),
|
||||
@@ -163,7 +163,7 @@ class RoutesPage extends ConsumerWidget {
|
||||
),
|
||||
SizedBox(height: ResponsiveSpacing.sm(context)),
|
||||
Text(
|
||||
'${route.completedDeliveries}/${route.totalDeliveries} completed',
|
||||
'${route.deliveredCount}/${route.deliveriesCount} completed',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
SizedBox(height: ResponsiveSpacing.md(context)),
|
||||
|
||||
@@ -36,41 +36,10 @@ final authTokenProvider = FutureProvider<String?>((ref) async {
|
||||
});
|
||||
|
||||
final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
||||
// ignore: unused_local_variable
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final token = ref.watch(authTokenProvider).valueOrNull;
|
||||
|
||||
// TODO: Remove mock data when Keycloak is configured
|
||||
if (token == null) {
|
||||
return [
|
||||
DeliveryRoute(
|
||||
id: 1,
|
||||
name: 'Route A - Downtown',
|
||||
routeFragmentId: 1,
|
||||
totalDeliveries: 12,
|
||||
completedDeliveries: 5,
|
||||
skippedDeliveries: 0,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 1)).toIso8601String(),
|
||||
),
|
||||
DeliveryRoute(
|
||||
id: 2,
|
||||
name: 'Route B - Suburbs',
|
||||
routeFragmentId: 2,
|
||||
totalDeliveries: 8,
|
||||
completedDeliveries: 8,
|
||||
skippedDeliveries: 0,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(),
|
||||
),
|
||||
DeliveryRoute(
|
||||
id: 3,
|
||||
name: 'Route C - Industrial Zone',
|
||||
routeFragmentId: 3,
|
||||
totalDeliveries: 15,
|
||||
completedDeliveries: 3,
|
||||
skippedDeliveries: 2,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 3)).toIso8601String(),
|
||||
),
|
||||
];
|
||||
throw Exception('User not authenticated');
|
||||
}
|
||||
|
||||
// Create a new client with auth token
|
||||
@@ -85,8 +54,14 @@ final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
||||
endpoint: 'simpleDeliveryRouteQueryItems',
|
||||
query: _EmptyQuery(),
|
||||
fromJson: (json) {
|
||||
final routes = json['items'] as List?;
|
||||
return routes?.map((r) => DeliveryRoute.fromJson(r as Map<String, dynamic>)).toList() ?? [];
|
||||
// API returns data wrapped in object with "data" field
|
||||
if (json is Map<String, dynamic>) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
return (data as List<dynamic>).map((r) => DeliveryRoute.fromJson(r as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
@@ -94,13 +69,10 @@ final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
|
||||
});
|
||||
|
||||
final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, routeFragmentId) async {
|
||||
// ignore: unused_local_variable
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final token = ref.watch(authTokenProvider).valueOrNull;
|
||||
|
||||
// TODO: Remove mock data when Keycloak is configured
|
||||
if (token == null) {
|
||||
return _getMockDeliveries(routeFragmentId);
|
||||
throw Exception('User not authenticated');
|
||||
}
|
||||
|
||||
final authClient = CqrsApiClient(
|
||||
@@ -114,8 +86,14 @@ final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, rout
|
||||
endpoint: 'simpleDeliveriesQueryItems',
|
||||
query: _DeliveriesQuery(routeFragmentId: routeFragmentId),
|
||||
fromJson: (json) {
|
||||
final items = json['items'] as List?;
|
||||
return items?.map((d) => Delivery.fromJson(d as Map<String, dynamic>)).toList() ?? [];
|
||||
// API returns data wrapped in object with "data" field
|
||||
if (json is Map<String, dynamic>) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
return (data as List<dynamic>).map((d) => Delivery.fromJson(d as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
@@ -126,65 +104,6 @@ final languageProvider = StateProvider<String>((ref) {
|
||||
return 'fr';
|
||||
});
|
||||
|
||||
// Mock data generator for testing without authentication
|
||||
List<Delivery> _getMockDeliveries(int routeFragmentId) {
|
||||
final mockDeliveries = <Delivery>[];
|
||||
|
||||
for (int i = 1; i <= 6; i++) {
|
||||
final isDelivered = i <= 2;
|
||||
mockDeliveries.add(
|
||||
Delivery(
|
||||
id: i,
|
||||
routeFragmentId: routeFragmentId,
|
||||
deliveryIndex: i,
|
||||
orders: [
|
||||
DeliveryOrder(
|
||||
id: i * 100,
|
||||
isNewCustomer: i == 3,
|
||||
totalAmount: 150.0 + (i * 10),
|
||||
totalItems: 3 + i,
|
||||
contacts: [
|
||||
DeliveryContact(
|
||||
firstName: 'Client',
|
||||
lastName: 'Name$i',
|
||||
fullName: 'Client Name $i',
|
||||
phoneNumber: '+212${i}23456789',
|
||||
),
|
||||
],
|
||||
contact: DeliveryContact(
|
||||
firstName: 'Client',
|
||||
lastName: 'Name$i',
|
||||
fullName: 'Client Name $i',
|
||||
phoneNumber: '+212${i}23456789',
|
||||
),
|
||||
),
|
||||
],
|
||||
deliveryAddress: DeliveryAddress(
|
||||
id: i,
|
||||
line1: 'Street $i',
|
||||
line2: 'Building ${i * 10}',
|
||||
postalCode: '3000${i.toString().padLeft(2, '0')}',
|
||||
city: 'Casablanca',
|
||||
subdivision: 'Casablanca-Settat',
|
||||
countryCode: 'MA',
|
||||
latitude: 33.5731 + (i * 0.01),
|
||||
longitude: -7.5898 + (i * 0.01),
|
||||
formattedAddress: 'Street $i, Building ${i * 10}, Casablanca, Morocco',
|
||||
),
|
||||
delivered: isDelivered,
|
||||
isSkipped: false,
|
||||
hasBeenSkipped: false,
|
||||
deliveredAt: isDelivered ? DateTime.now().subtract(Duration(hours: i)).toIso8601String() : null,
|
||||
name: 'Delivery #${routeFragmentId}-$i',
|
||||
createdAt: DateTime.now().subtract(Duration(days: 1)).toIso8601String(),
|
||||
updatedAt: DateTime.now().toIso8601String(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return mockDeliveries;
|
||||
}
|
||||
|
||||
class _EmptyQuery implements Serializable {
|
||||
@override
|
||||
Map<String, Object?> toJson() => {};
|
||||
|
||||
@@ -1,46 +1,114 @@
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_interceptor/http_interceptor.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import '../utils/logging_interceptor.dart';
|
||||
|
||||
class AuthService {
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _tokenEndpoint = 'https://auth.goutezplanb.com/realms/planb-internal/protocol/openid-connect/token';
|
||||
static const String _clientId = 'delivery-mobile-app';
|
||||
|
||||
final FlutterAppAuth _appAuth;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final http.Client _httpClient;
|
||||
|
||||
AuthService({
|
||||
FlutterAppAuth? appAuth,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
}) : _appAuth = appAuth ?? const FlutterAppAuth(),
|
||||
_secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
Future<AuthResult> login() async {
|
||||
try {
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
'delivery-mobile-app',
|
||||
'com.goutezplanb.delivery://callback',
|
||||
discoveryUrl: 'https://auth.goutezplanb.com/realms/planb-internal/.well-known/openid-configuration',
|
||||
scopes: const ['openid', 'profile', 'offline_access'],
|
||||
promptValues: const ['login'],
|
||||
http.Client? httpClient,
|
||||
}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
mOptions: MacOsOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
),
|
||||
_httpClient = httpClient ?? InterceptedClient.build(
|
||||
interceptors: [LoggingInterceptor()],
|
||||
);
|
||||
|
||||
Future<AuthResult> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse(_tokenEndpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
'grant_type': 'password',
|
||||
'client_id': _clientId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'scope': 'openid profile offline_access',
|
||||
},
|
||||
);
|
||||
|
||||
// ignore: unnecessary_null_comparison
|
||||
if (result == null) {
|
||||
return const AuthResult.cancelled();
|
||||
}
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final accessToken = data['access_token'] as String;
|
||||
final refreshToken = data['refresh_token'] as String?;
|
||||
|
||||
await _secureStorage.write(key: _tokenKey, value: result.accessToken ?? '');
|
||||
if (result.refreshToken != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: result.refreshToken!);
|
||||
}
|
||||
await _secureStorage.write(key: _tokenKey, value: accessToken);
|
||||
if (refreshToken != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
|
||||
}
|
||||
|
||||
return AuthResult.success(token: result.accessToken ?? '');
|
||||
return AuthResult.success(token: accessToken);
|
||||
} else if (response.statusCode == 401) {
|
||||
return AuthResult.error(error: 'Invalid username or password');
|
||||
} else {
|
||||
return AuthResult.error(error: 'Authentication failed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
return AuthResult.error(error: e.toString());
|
||||
return AuthResult.error(error: 'Network error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthResult> refreshAccessToken() async {
|
||||
try {
|
||||
final refreshToken = await getRefreshToken();
|
||||
if (refreshToken == null) {
|
||||
return AuthResult.error(error: 'No refresh token available');
|
||||
}
|
||||
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse(_tokenEndpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': _clientId,
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final accessToken = data['access_token'] as String;
|
||||
final newRefreshToken = data['refresh_token'] as String?;
|
||||
|
||||
await _secureStorage.write(key: _tokenKey, value: accessToken);
|
||||
if (newRefreshToken != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: newRefreshToken);
|
||||
}
|
||||
|
||||
return AuthResult.success(token: accessToken);
|
||||
} else {
|
||||
await logout();
|
||||
return AuthResult.error(error: 'Token refresh failed');
|
||||
}
|
||||
} catch (e) {
|
||||
return AuthResult.error(error: 'Token refresh error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:http_interceptor/http_interceptor.dart';
|
||||
|
||||
class LoggingInterceptor implements InterceptorContract {
|
||||
@override
|
||||
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
|
||||
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
print('📤 REQUEST: ${request.method} ${request.url}');
|
||||
print('Headers: ${request.headers}');
|
||||
if (request is Request) {
|
||||
print('Body: ${request.body}');
|
||||
}
|
||||
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
return request;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaseResponse> interceptResponse({required BaseResponse response}) async {
|
||||
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
print('📥 RESPONSE: ${response.statusCode} ${response.request?.url}');
|
||||
if (response is Response) {
|
||||
print('Body: ${response.body}');
|
||||
}
|
||||
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldInterceptRequest() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> shouldInterceptResponse() async => true;
|
||||
}
|
||||
Reference in New Issue
Block a user