checkpoint
This commit is contained in:
+121
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user