ios build, connected data (not finished)

This commit is contained in:
2025-11-14 12:27:40 -05:00
parent 4b03e9aba5
commit ccb817e3c6
81 changed files with 3127 additions and 284 deletions
+5 -1
View File
@@ -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;
+230
View File
@@ -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();
}
}
+37
View File
@@ -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
View File
@@ -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(),
);
}
}
+23 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
),
],
),
),
),
);
+2 -2
View File
@@ -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)),
+18 -99
View File
@@ -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() => {};
+93 -25
View File
@@ -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()}');
}
}
+32
View File
@@ -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;
}