checkpoint

This commit is contained in:
2025-11-26 15:59:20 -05:00
parent d46ac9dc14
commit ef5c0c1a95
14 changed files with 5281 additions and 260 deletions
+121 -6
View File
@@ -6,13 +6,16 @@ import 'types.dart';
import 'openapi_config.dart';
import '../utils/logging_interceptor.dart';
import '../utils/http_client_factory.dart';
import '../services/auth_service.dart';
class CqrsApiClient {
final ApiClientConfig config;
final AuthService? authService;
late final http.Client _httpClient;
CqrsApiClient({
required this.config,
this.authService,
http.Client? httpClient,
}) {
_httpClient = httpClient ?? InterceptedClient.build(
@@ -29,10 +32,11 @@ class CqrsApiClient {
required String endpoint,
required Serializable query,
required T Function(Map<String, dynamic>) fromJson,
bool isRetry = false,
}) async {
try {
final url = Uri.parse('$baseUrl/api/query/$endpoint');
final headers = _buildHeaders();
final headers = await _buildHeaders();
final response = await _httpClient
.post(
@@ -42,6 +46,20 @@ class CqrsApiClient {
)
.timeout(config.timeout);
if (response.statusCode == 401 && !isRetry && authService != null) {
final refreshResult = await authService!.refreshAccessToken();
return refreshResult.when(
success: (token) => executeQuery(
endpoint: endpoint,
query: query,
fromJson: fromJson,
isRetry: true,
),
onError: (error) => _handleResponse<T>(response, fromJson),
cancelled: () => _handleResponse<T>(response, fromJson),
);
}
return _handleResponse<T>(response, fromJson);
} on TimeoutException {
return Result.error(ApiError.timeout());
@@ -62,12 +80,13 @@ class CqrsApiClient {
required int page,
required int pageSize,
List<FilterCriteria>? filters,
bool isRetry = false,
}) async {
try {
final url = Uri.parse(
'$baseUrl/api/query/$endpoint?page=$page&pageSize=$pageSize',
);
final headers = _buildHeaders();
final headers = await _buildHeaders();
final queryData = {
...query.toJson(),
@@ -83,6 +102,23 @@ class CqrsApiClient {
)
.timeout(config.timeout);
if (response.statusCode == 401 && !isRetry && authService != null) {
final refreshResult = await authService!.refreshAccessToken();
return refreshResult.when(
success: (token) => executePaginatedQuery(
endpoint: endpoint,
query: query,
itemFromJson: itemFromJson,
page: page,
pageSize: pageSize,
filters: filters,
isRetry: true,
),
onError: (error) => _handlePaginatedResponse<T>(response, itemFromJson, page, pageSize),
cancelled: () => _handlePaginatedResponse<T>(response, itemFromJson, page, pageSize),
);
}
return _handlePaginatedResponse<T>(response, itemFromJson, page, pageSize);
} on TimeoutException {
return Result.error(ApiError.timeout());
@@ -99,10 +135,11 @@ class CqrsApiClient {
Future<Result<void>> executeCommand({
required String endpoint,
required Serializable command,
bool isRetry = false,
}) async {
try {
final url = Uri.parse('$baseUrl/api/command/$endpoint');
final headers = _buildHeaders();
final headers = await _buildHeaders();
final response = await _httpClient
.post(
@@ -112,6 +149,31 @@ class CqrsApiClient {
)
.timeout(config.timeout);
if (response.statusCode == 401 && !isRetry && authService != null) {
final refreshResult = await authService!.refreshAccessToken();
return refreshResult.when(
success: (token) => executeCommand(
endpoint: endpoint,
command: command,
isRetry: true,
),
onError: (error) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(null);
} else {
return _handleErrorResponse(response);
}
},
cancelled: () {
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(null);
} else {
return _handleErrorResponse(response);
}
},
);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(null);
} else {
@@ -133,10 +195,11 @@ class CqrsApiClient {
required String endpoint,
required Serializable command,
required T Function(Map<String, dynamic>) fromJson,
bool isRetry = false,
}) async {
try {
final url = Uri.parse('$baseUrl/api/command/$endpoint');
final headers = _buildHeaders();
final headers = await _buildHeaders();
final response = await _httpClient
.post(
@@ -146,6 +209,20 @@ class CqrsApiClient {
)
.timeout(config.timeout);
if (response.statusCode == 401 && !isRetry && authService != null) {
final refreshResult = await authService!.refreshAccessToken();
return refreshResult.when(
success: (token) => executeCommandWithResult(
endpoint: endpoint,
command: command,
fromJson: fromJson,
isRetry: true,
),
onError: (error) => _handleResponse<T>(response, fromJson),
cancelled: () => _handleResponse<T>(response, fromJson),
);
}
return _handleResponse<T>(response, fromJson);
} on TimeoutException {
return Result.error(ApiError.timeout());
@@ -164,11 +241,13 @@ class CqrsApiClient {
required String filePath,
required String fieldName,
Map<String, String>? additionalFields,
bool isRetry = false,
}) async {
try {
final url = Uri.parse('$baseUrl/api/command/$endpoint');
final headers = await _buildHeaders();
final request = http.MultipartRequest('POST', url)
..headers.addAll(_buildHeaders())
..headers.addAll(headers)
..files.add(await http.MultipartFile.fromPath(fieldName, filePath));
if (additionalFields != null) {
@@ -178,6 +257,33 @@ class CqrsApiClient {
final response = await request.send().timeout(config.timeout);
final responseBody = await response.stream.bytesToString();
if (response.statusCode == 401 && !isRetry && authService != null) {
final refreshResult = await authService!.refreshAccessToken();
return refreshResult.when(
success: (token) => uploadFile(
endpoint: endpoint,
filePath: filePath,
fieldName: fieldName,
additionalFields: additionalFields,
isRetry: true,
),
onError: (error) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(responseBody);
} else {
return _parseErrorFromString(responseBody, response.statusCode);
}
},
cancelled: () {
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(responseBody);
} else {
return _parseErrorFromString(responseBody, response.statusCode);
}
},
);
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return Result.success(responseBody);
} else {
@@ -195,12 +301,21 @@ class CqrsApiClient {
}
}
Map<String, String> _buildHeaders() {
Future<Map<String, String>> _buildHeaders() async {
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
...config.defaultHeaders,
};
if (authService != null) {
// Proactively ensure token is valid and refresh if needed
final token = await authService!.ensureValidToken();
if (token != null) {
headers['Authorization'] = 'Bearer $token';
}
}
return headers;
}
+11 -6
View File
@@ -119,7 +119,9 @@ class _CollapsibleRoutesSidebarState extends ConsumerState<CollapsibleRoutesSide
}
// On tablet/desktop, show full sidebar with toggle (expanded: 300px, collapsed: 80px for badge)
return Container(
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: isExpanded ? 300 : 80,
color: isDarkMode ? SvrntyColors.almostBlack : Colors.white,
child: Column(
@@ -141,12 +143,15 @@ class _CollapsibleRoutesSidebarState extends ConsumerState<CollapsibleRoutesSide
children: [
if (isExpanded)
Expanded(
child: Text(
'Routes',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
child: Padding(
padding: EdgeInsets.only(left: AppSpacing.md),
child: Text(
'Routes',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
+140 -30
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:google_navigation_flutter/google_navigation_flutter.dart';
import '../models/delivery.dart';
import '../theme/color_system.dart';
import '../utils/toast_helper.dart';
/// Enhanced dark-mode aware map component with custom styling
class DarkModeMapComponent extends StatefulWidget {
@@ -49,6 +50,21 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
super.dispose();
}
@override
void didUpdateWidget(DarkModeMapComponent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
_updateDestination();
// If navigation was active, restart navigation to new delivery
if (_isNavigating &&
widget.selectedDelivery != null &&
widget.selectedDelivery!.deliveryAddress != null) {
_restartNavigationToNewDelivery();
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -64,6 +80,21 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
_lastBrightness = currentBrightness;
}
Future<void> _restartNavigationToNewDelivery() async {
try {
// Stop current navigation
await _stopNavigation();
// Wait a bit for stop to complete
await Future.delayed(const Duration(milliseconds: 300));
// Start navigation to new delivery
if (mounted && !_isDisposed) {
await _startNavigation();
}
} catch (e) {
debugPrint('Restart navigation error: $e');
}
}
Future<void> _initializeNavigation() async {
if (_isInitializing || _isSessionInitialized) return;
@@ -96,12 +127,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
_isInitializing = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation initialization failed: $errorMessage'),
duration: const Duration(seconds: 5),
),
);
ToastHelper.showError(context, 'Navigation initialization failed: $errorMessage');
}
}
}
@@ -118,14 +144,6 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
return errorString;
}
@override
void didUpdateWidget(DarkModeMapComponent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedDelivery != widget.selectedDelivery) {
_updateDestination();
}
}
void _updateDestination() {
if (widget.selectedDelivery != null) {
final address = widget.selectedDelivery!.deliveryAddress;
@@ -149,8 +167,101 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
try {
if (!mounted || _isDisposed) return;
// Always use default (light) map style
await _navigationController!.setMapStyle(null);
// Force dark mode map style using Google's standard dark theme
const String darkMapStyle = '''
[
{
"elementType": "geometry",
"stylers": [{"color": "#242f3e"}]
},
{
"elementType": "labels.text.stroke",
"stylers": [{"color": "#242f3e"}]
},
{
"elementType": "labels.text.fill",
"stylers": [{"color": "#746855"}]
},
{
"featureType": "administrative.locality",
"elementType": "labels.text.fill",
"stylers": [{"color": "#d59563"}]
},
{
"featureType": "poi",
"elementType": "labels.text.fill",
"stylers": [{"color": "#d59563"}]
},
{
"featureType": "poi.park",
"elementType": "geometry",
"stylers": [{"color": "#263c3f"}]
},
{
"featureType": "poi.park",
"elementType": "labels.text.fill",
"stylers": [{"color": "#6b9a76"}]
},
{
"featureType": "road",
"elementType": "geometry",
"stylers": [{"color": "#38414e"}]
},
{
"featureType": "road",
"elementType": "geometry.stroke",
"stylers": [{"color": "#212a37"}]
},
{
"featureType": "road",
"elementType": "labels.text.fill",
"stylers": [{"color": "#9ca5b3"}]
},
{
"featureType": "road.highway",
"elementType": "geometry",
"stylers": [{"color": "#746855"}]
},
{
"featureType": "road.highway",
"elementType": "geometry.stroke",
"stylers": [{"color": "#1f2835"}]
},
{
"featureType": "road.highway",
"elementType": "labels.text.fill",
"stylers": [{"color": "#f3d19c"}]
},
{
"featureType": "transit",
"elementType": "geometry",
"stylers": [{"color": "#2f3948"}]
},
{
"featureType": "transit.station",
"elementType": "labels.text.fill",
"stylers": [{"color": "#d59563"}]
},
{
"featureType": "water",
"elementType": "geometry",
"stylers": [{"color": "#17263c"}]
},
{
"featureType": "water",
"elementType": "labels.text.fill",
"stylers": [{"color": "#515c6d"}]
},
{
"featureType": "water",
"elementType": "labels.text.stroke",
"stylers": [{"color": "#17263c"}]
}
]
''';
await _navigationController!.setMapStyle(darkMapStyle);
debugPrint('Dark mode map style applied');
} catch (e) {
if (mounted) {
debugPrint('Error applying map style: $e');
@@ -188,12 +299,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
setState(() {
_isStartingNavigation = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Navigation initialization timeout'),
duration: Duration(seconds: 3),
),
);
ToastHelper.showError(context, 'Navigation initialization timeout');
}
return;
}
@@ -270,12 +376,7 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
_isStartingNavigation = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation error: $errorMessage'),
duration: const Duration(seconds: 4),
),
);
ToastHelper.showError(context, 'Navigation error: $errorMessage', duration: const Duration(seconds: 4));
}
}
}
@@ -319,6 +420,13 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
}
}
bool _hasNotes() {
if (widget.selectedDelivery == null) return false;
return widget.selectedDelivery!.orders.any((order) =>
order.note != null && order.note!.isNotEmpty
);
}
@override
Widget build(BuildContext context) {
// Driver's current location (defaults to Montreal if not available)
@@ -479,12 +587,14 @@ class _DarkModeMapComponentState extends State<DarkModeMapComponent> {
),
),
const SizedBox(width: 8),
// Note button
// Note button (only enabled if delivery has notes)
Expanded(
child: _buildBottomActionButton(
label: 'Note',
icon: Icons.note_add,
onPressed: () => widget.onAction?.call('note'),
onPressed: _hasNotes()
? () => widget.onAction?.call('note')
: null,
),
),
const SizedBox(width: 8),
+189 -93
View File
@@ -74,12 +74,22 @@ class _DeliveryListItemState extends State<DeliveryListItem>
}
Color _getStatusColor(Delivery delivery) {
// If delivered, always show green (even if selected)
if (delivery.delivered == true) return SvrntyColors.success;
// If selected and not delivered, show yellow/warning color
if (widget.isSelected) return SvrntyColors.warning;
// If skipped, show grey
if (delivery.isSkipped == true) return SvrntyColors.statusCancelled;
if (delivery.delivered == true) return SvrntyColors.statusCompleted;
// Default: in-transit or pending deliveries
return SvrntyColors.statusInTransit;
}
bool _hasNote() {
return widget.delivery.orders.any((order) =>
order.note != null && order.note!.isNotEmpty
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -102,34 +112,73 @@ class _DeliveryListItemState extends State<DeliveryListItem>
vertical: 10,
),
child: Center(
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(10),
boxShadow: (_isHovered || widget.isSelected)
? [
BoxShadow(
color: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.15,
),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: [],
),
child: Center(
child: Text(
'${widget.delivery.deliveryIndex + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(10),
border: widget.isSelected
? Border.all(
color: Colors.white,
width: 3,
)
: null,
boxShadow: (_isHovered || widget.isSelected)
? [
BoxShadow(
color: widget.isSelected
? statusColor.withValues(alpha: 0.5)
: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.15,
),
blurRadius: widget.isSelected ? 12 : 8,
offset: const Offset(0, 4),
spreadRadius: widget.isSelected ? 2 : 0,
),
]
: [],
),
child: Center(
child: Text(
'${widget.delivery.deliveryIndex + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
),
),
),
),
),
if (_hasNote())
Positioned(
top: -4,
right: -4,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 2,
),
),
child: Transform.rotate(
angle: 4.71239, // 270 degrees in radians (3*pi/2)
child: const Icon(
Icons.note,
size: 12,
color: Colors.white,
),
),
),
),
],
),
),
),
@@ -161,7 +210,17 @@ class _DeliveryListItemState extends State<DeliveryListItem>
borderRadius: BorderRadius.circular(8),
color: widget.delivery.delivered
? Colors.green.withValues(alpha: 0.15)
: Theme.of(context).colorScheme.surfaceContainer,
: widget.isSelected
? statusColor.withValues(alpha: 0.15)
: Theme.of(context).colorScheme.surfaceContainer,
border: widget.isSelected
? Border.all(
color: widget.delivery.delivered
? SvrntyColors.success
: statusColor,
width: 2,
)
: null,
boxShadow: (_isHovered || widget.isSelected) && !widget.delivery.delivered
? [
BoxShadow(
@@ -175,80 +234,117 @@ class _DeliveryListItemState extends State<DeliveryListItem>
: [],
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Column(
child: Stack(
clipBehavior: Clip.none,
children: [
// Main delivery info row
Row(
crossAxisAlignment: CrossAxisAlignment.center,
Column(
children: [
// Order number badge (left of status bar)
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${widget.delivery.deliveryIndex + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
// Main delivery info row
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Order number badge (left of status bar)
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${widget.delivery.deliveryIndex + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
const SizedBox(width: 8),
// Left accent bar (vertical status bar)
Container(
width: 4,
height: 50,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
// Delivery info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Customer Name
Text(
widget.delivery.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
const SizedBox(width: 8),
// Left accent bar (vertical status bar)
Container(
width: 4,
height: 50,
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(2),
),
const SizedBox(height: 4),
// Address
Text(
widget.delivery.deliveryAddress
?.formattedAddress ??
'No address',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
fontSize: 13,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 10),
// Delivery info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Customer Name
Text(
widget.delivery.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Address
Text(
widget.delivery.deliveryAddress
?.formattedAddress ??
'No address',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
fontSize: 13,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
],
),
],
),
if (_hasNote())
Positioned(
top: -8,
right: -4,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Transform.rotate(
angle: 4.71239, // 270 degrees in radians (3*pi/2)
child: const Icon(
Icons.note,
size: 14,
color: Colors.white,
),
),
),
),
],
),
),
+3 -3
View File
@@ -70,9 +70,9 @@ class _RouteListItemState extends State<RouteListItem>
}
Color _getStatusColor(DeliveryRoute route) {
if (route.completed) return SvrntyColors.statusCompleted;
if (route.deliveredCount > 0) return SvrntyColors.statusInTransit;
return SvrntyColors.statusPending;
if (route.completed) return SvrntyColors.statusCompleted; // Green
if (route.deliveredCount > 0) return SvrntyColors.warning; // Yellow - started but not complete
return SvrntyColors.statusCancelled; // Grey - not started
}
@override
+157 -18
View File
@@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:image_picker/image_picker.dart';
import 'package:http/http.dart' as http;
import '../models/delivery.dart';
import '../providers/providers.dart';
import '../api/client.dart';
@@ -9,12 +12,14 @@ import '../models/delivery_commands.dart';
import '../components/map_sidebar_layout.dart';
import '../components/dark_mode_map.dart';
import '../components/delivery_list_item.dart';
import '../utils/toast_helper.dart';
class DeliveriesPage extends ConsumerStatefulWidget {
final int routeFragmentId;
final String routeName;
final VoidCallback? onBack;
final bool showAsEmbedded;
final Delivery? selectedDelivery;
final ValueChanged<Delivery?>? onDeliverySelected;
const DeliveriesPage({
@@ -23,6 +28,7 @@ class DeliveriesPage extends ConsumerStatefulWidget {
required this.routeName,
this.onBack,
this.showAsEmbedded = false,
this.selectedDelivery,
this.onDeliverySelected,
});
@@ -39,6 +45,17 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
void initState() {
super.initState();
_listScrollController = ScrollController();
_selectedDelivery = widget.selectedDelivery;
}
@override
void didUpdateWidget(DeliveriesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedDelivery != oldWidget.selectedDelivery) {
setState(() {
_selectedDelivery = widget.selectedDelivery;
});
}
}
@override
@@ -84,16 +101,16 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
});
}
// Responsive sidebar that changes width when collapsed (420px expanded, 80px collapsed)
// Responsive sidebar that changes width when collapsed (300px expanded, 80px collapsed)
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isExpanded ? 420 : 80,
width: isExpanded ? 300 : 80,
child: Column(
children: [
// Header with back button
Container(
padding: EdgeInsets.symmetric(
horizontal: isExpanded ? 12 : 8,
horizontal: isExpanded ? 12 : 0,
vertical: 8,
),
decoration: BoxDecoration(
@@ -274,9 +291,7 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
String? token,
) async {
if (token == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Authentication required')),
);
ToastHelper.showError(context, 'Authentication required');
return;
}
@@ -299,14 +314,10 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
success: (_) {
// ignore: unused_result
ref.refresh(deliveriesProvider(widget.routeFragmentId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Delivery marked as completed')),
);
ToastHelper.showSuccess(context, 'Delivery marked as completed');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
ToastHelper.showError(context, 'Error: ${error.message}');
},
);
break;
@@ -320,18 +331,18 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
success: (_) {
// ignore: unused_result
ref.refresh(deliveriesProvider(widget.routeFragmentId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Delivery marked as uncompleted')),
);
ToastHelper.showSuccess(context, 'Delivery marked as uncompleted');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
ToastHelper.showError(context, 'Error: ${error.message}');
},
);
break;
case 'photo':
await _handlePhotoCapture(context, delivery, token);
break;
case 'call':
final contact = delivery.orders.isNotEmpty && delivery.orders.first.contact != null
? delivery.orders.first.contact
@@ -350,6 +361,134 @@ class _DeliveriesPageState extends ConsumerState<DeliveriesPage> {
break;
}
}
Future<void> _handlePhotoCapture(
BuildContext context,
Delivery delivery,
String? token,
) async {
if (token == null) {
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 (context.mounted) {
ToastHelper.showError(context, 'Camera error: $e');
}
return;
}
if (pickedFile == null) {
return;
}
if (!context.mounted) return;
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Confirm Photo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.file(
File(pickedFile!.path),
height: 300,
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 (!context.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.production.baseUrl}/api/delivery/uploadDeliveryPicture?deliveryId=${delivery.id}',
);
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 request.send();
final http.Response response = await http.Response.fromStream(streamedResponse);
if (context.mounted) {
Navigator.of(context).pop();
}
if (response.statusCode >= 200 && response.statusCode < 300) {
if (context.mounted) {
ToastHelper.showSuccess(context, 'Photo uploaded successfully');
}
// ignore: unused_result
ref.refresh(deliveriesProvider(widget.routeFragmentId));
} else {
if (context.mounted) {
ToastHelper.showError(context, 'Upload failed: ${response.statusCode}');
}
}
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop();
ToastHelper.showError(context, 'Upload error: $e');
}
}
}
}
class UnifiedDeliveryListView extends StatelessWidget {
+2 -6
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
import '../utils/toast_helper.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@@ -50,12 +51,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
ref.refresh(isAuthenticatedProvider);
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
ToastHelper.showError(context, error);
},
cancelled: () {},
);
+494 -43
View File
@@ -1,9 +1,17 @@
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';
@@ -67,6 +75,433 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
});
}
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);
@@ -79,46 +514,57 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
ref.refresh(deliveryRoutesProvider);
ref.refresh(allDeliveriesProvider);
},
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) => PopupMenuButton<String>(
onSelected: (value) {
if (value == 'settings') {
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(),
),
);
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem(
value: 'profile',
child: Text(profile?.fullName ?? 'User'),
enabled: false,
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'settings',
child: Text('Settings'),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: Text(
profile?.fullName ?? 'User',
style: Theme.of(context).textTheme.titleSmall,
},
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(
@@ -157,6 +603,12 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
onAction: (action) {
if (_selectedDelivery != null && _selectedRoute != null) {
_handleDeliveryAction(action, _selectedDelivery!, _selectedRoute!.id);
}
},
),
),
@@ -166,19 +618,18 @@ class _RoutesPageState extends ConsumerState<RoutesPage> {
selectedRoute: null,
onRouteSelected: _selectRoute,
)
: SizedBox(
width: 300,
child: DeliveriesPage(
routeFragmentId: _selectedRoute!.id,
routeName: _selectedRoute!.name,
onBack: _backToRoutes,
showAsEmbedded: true,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
},
),
: DeliveriesPage(
routeFragmentId: _selectedRoute!.id,
routeName: _selectedRoute!.name,
onBack: _backToRoutes,
showAsEmbedded: true,
selectedDelivery: _selectedDelivery,
onDeliverySelected: (delivery) {
setState(() {
_selectedDelivery = delivery;
});
_autoShowNotesIfNeeded(delivery);
},
),
],
),
+28 -38
View File
@@ -64,6 +64,22 @@ class SettingsPage extends ConsumerWidget {
],
),
),
IconButton.filled(
icon: const Icon(Icons.logout),
onPressed: () async {
final authService = ref.read(authServiceProvider);
await authService.logout();
if (context.mounted) {
// ignore: unused_result
ref.refresh(isAuthenticatedProvider);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/');
}
}
},
color: Theme.of(context).colorScheme.error,
tooltip: 'Logout',
),
],
),
],
@@ -90,7 +106,13 @@ class SettingsPage extends ConsumerWidget {
const SizedBox(height: 16),
ListTile(
title: const Text('Language'),
subtitle: Text(language == 'fr' ? 'Franais' : 'English'),
subtitle: Text(
language == 'system'
? 'System'
: language == 'fr'
? 'Français'
: 'English'
),
trailing: DropdownButton<String>(
value: language,
onChanged: (String? newValue) {
@@ -99,13 +121,17 @@ class SettingsPage extends ConsumerWidget {
}
},
items: const [
DropdownMenuItem(
value: 'system',
child: Text('System'),
),
DropdownMenuItem(
value: 'en',
child: Text('English'),
),
DropdownMenuItem(
value: 'fr',
child: Text('Franais'),
child: Text('Français'),
),
],
),
@@ -148,42 +174,6 @@ class SettingsPage extends ConsumerWidget {
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Account',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.logout),
label: const Text('Logout'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () async {
final authService = ref.read(authServiceProvider);
await authService.logout();
if (context.mounted) {
// ignore: unused_result
ref.refresh(isAuthenticatedProvider);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/');
}
}
},
),
),
],
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
+17 -17
View File
@@ -13,7 +13,11 @@ final authServiceProvider = Provider<AuthService>((ref) {
});
final apiClientProvider = Provider<CqrsApiClient>((ref) {
return CqrsApiClient(config: ApiClientConfig.development);
final authService = ref.watch(authServiceProvider);
return CqrsApiClient(
config: ApiClientConfig.development,
authService: authService,
);
});
final isAuthenticatedProvider = FutureProvider<bool>((ref) async {
@@ -34,19 +38,17 @@ final authTokenProvider = FutureProvider<String?>((ref) async {
});
final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
final token = await ref.read(authTokenProvider.future);
final authService = ref.watch(authServiceProvider);
final isAuthenticated = await authService.isAuthenticated();
if (token == null) {
if (!isAuthenticated) {
throw Exception('User not authenticated');
}
// Create a new client with auth token
// Create a new client with auth service for automatic token refresh
final authClient = CqrsApiClient(
config: ApiClientConfig(
baseUrl: ApiClientConfig.development.baseUrl,
defaultHeaders: {'Authorization': 'Bearer $token'},
allowSelfSignedCertificate: ApiClientConfig.development.allowSelfSignedCertificate,
),
config: ApiClientConfig.development,
authService: authService,
);
final result = await authClient.executeQuery<List<DeliveryRoute>>(
@@ -68,18 +70,16 @@ final deliveryRoutesProvider = FutureProvider<List<DeliveryRoute>>((ref) async {
});
final deliveriesProvider = FutureProvider.family<List<Delivery>, int>((ref, routeFragmentId) async {
final token = await ref.read(authTokenProvider.future);
final authService = ref.watch(authServiceProvider);
final isAuthenticated = await authService.isAuthenticated();
if (token == null) {
if (!isAuthenticated) {
throw Exception('User not authenticated');
}
final authClient = CqrsApiClient(
config: ApiClientConfig(
baseUrl: ApiClientConfig.development.baseUrl,
defaultHeaders: {'Authorization': 'Bearer $token'},
allowSelfSignedCertificate: ApiClientConfig.development.allowSelfSignedCertificate,
),
config: ApiClientConfig.development,
authService: authService,
);
final result = await authClient.executeQuery<List<Delivery>>(
@@ -128,7 +128,7 @@ final allDeliveriesProvider = FutureProvider<List<Delivery>>((ref) async {
// Language notifier for state management
class LanguageNotifier extends Notifier<String> {
@override
String build() => 'fr';
String build() => 'system';
void setLanguage(String lang) => state = lang;
}
+40
View File
@@ -170,6 +170,46 @@ class AuthService {
}
}
/// Check if token expires within the specified duration (default: 5 minutes)
bool isTokenExpiringSoon(String? token, {Duration threshold = const Duration(minutes: 5)}) {
if (token == null || token.isEmpty) return true;
try {
final decodedToken = JwtDecoder.decode(token);
final exp = decodedToken['exp'] as int?;
if (exp == null) return true;
final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
final now = DateTime.now();
final timeUntilExpiration = expirationTime.difference(now);
return timeUntilExpiration <= threshold;
} catch (e) {
return true;
}
}
/// Proactively refresh token if it's expiring soon
/// Returns the current valid token or a newly refreshed token
Future<String?> ensureValidToken() async {
final currentToken = await getToken();
// If no token, return null
if (currentToken == null) return null;
// If token is still valid and not expiring soon, return it
if (!isTokenExpiringSoon(currentToken)) {
return currentToken;
}
// Token is expiring soon, refresh it
final refreshResult = await refreshAccessToken();
return refreshResult.when(
success: (newToken) => newToken,
onError: (error) => null,
cancelled: () => null,
);
}
UserProfile? decodeToken(String token) {
try {
final decodedToken = JwtDecoder.decode(token);
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
class ToastHelper {
static void showSuccess(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
}) {
_showToast(
context,
message,
duration: duration,
backgroundColor: Colors.green,
);
}
static void showError(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 5),
}) {
_showToast(
context,
message,
duration: duration,
backgroundColor: Colors.red,
);
}
static void showInfo(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
}) {
_showToast(
context,
message,
duration: duration,
);
}
static void _showToast(
BuildContext context,
String message, {
required Duration duration,
Color? backgroundColor,
}) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final topPadding = MediaQuery.of(context).padding.top;
final toastWidth = screenWidth * 0.5; // 50% of screen width
final horizontalMargin = (screenWidth - toastWidth) / 2;
// Position toast very close to top (10px into safe area)
final topMargin = topPadding - 10;
const toastHeight = 60.0;
final bottomMargin = screenHeight - topMargin - toastHeight;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
message,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
duration: duration,
behavior: SnackBarBehavior.floating,
backgroundColor: backgroundColor,
margin: EdgeInsets.only(
top: topMargin,
left: horizontalMargin,
right: horizontalMargin,
bottom: bottomMargin,
),
),
);
}
}