checkpoint

This commit is contained in:
Mathias Beaulieu-Duncan 2025-11-26 15:59:20 -05:00
parent d46ac9dc14
commit ef5c0c1a95
14 changed files with 5281 additions and 260 deletions

View File

@ -0,0 +1,295 @@
# Problème de Compatibilité: Impeller et Google Navigation intégrée sur Flutter
## Vue d'ensemble du problème
Les applications Flutter utilisant le SDK Google Navigation intégrée (Google Maps Navigation SDK) peuvent rencontrer des problèmes de rendu graphique lorsque le moteur de rendu Impeller est activé sur Android. Ce document explique le problème, ses symptômes et les solutions de contournement disponibles.
### Appareil testé
Ce problème a été observé et documenté sur l'appareil Android **KM10**. D'autres appareils Android peuvent également être affectés.
## Contexte technique
### Qu'est-ce qu'Impeller?
Impeller est le nouveau moteur de rendu de Flutter, conçu pour remplacer le backend Skia. Il offre plusieurs avantages:
- Performance prévisible grâce à la précompilation des shaders
- Réduction du jank et des pertes d'images
- Meilleure utilisation des API graphiques (Metal sur iOS, Vulkan sur Android)
Cependant, Impeller est encore en cours de stabilisation et certains plugins tiers (particulièrement ceux utilisant des vues natives de plateforme) peuvent rencontrer des problèmes de compatibilité.
### Pourquoi Google Navigation intégrée a des problèmes avec Impeller
Le SDK Google Navigation intégrée utilise des vues natives de plateforme (SurfaceView sur Android) pour afficher le contenu de la carte et de la navigation. L'interaction entre:
1. Le pipeline de rendu de Flutter (Impeller)
2. Les vues natives Android (Platform Views)
3. Le rendu complexe de la navigation (SDK Google Navigation)
Peut causer des glitches de rendu, des problèmes de z-index ou des artefacts visuels.
## Symptômes observés
Lorsque Impeller est activé (comportement par défaut), les problèmes suivants peuvent survenir:
### 1. Glitches de rendu de la carte
- Artefacts visuels sur la surface de la carte
- Problèmes de superposition (z-index) entre les widgets Flutter et la vue native de la carte
- Rendu incohérent des tuiles de carte ou des éléments de navigation
### 2. Problèmes de performance
- Sauts d'images: "Skipped X frames! The application may be doing too much work on its main thread"
- Conflits possibles de rendu GPU entre Impeller et le rendu natif de Google Navigation
### 3. Problèmes d'intégration des vues de plateforme
- La carte utilise une SurfaceView (vue native Android) intégrée dans l'arbre de widgets Flutter
- La composition d'Impeller peut entrer en conflit avec la façon dont Flutter gère les vues de plateforme
## Solutions de contournement
### Solution 1: Désactiver Impeller avec le flag de ligne de commande
**Commande de développement:**
```bash
flutter run -d KM10 --no-enable-impeller
```
**Effet:** Force Flutter à utiliser le backend Skia legacy au lieu d'Impeller.
**Avertissement de dépréciation:**
Lorsque vous exécutez avec `--no-enable-impeller`, Flutter affiche l'avertissement suivant:
```
[IMPORTANT:flutter/shell/common/shell.cc(527)] [Action Required]: Impeller opt-out deprecated.
The application opted out of Impeller by either using the
`--no-enable-impeller` flag or the
`io.flutter.embedding.android.EnableImpeller` `AndroidManifest.xml` entry.
These options are going to go away in an upcoming Flutter release. Remove
the explicit opt-out. If you need to opt-out, please report a bug describing
the issue.
```
**Important:** Ce flag sera retiré dans une future version de Flutter. Cette solution est donc temporaire.
### Solution 2: Configuration permanente dans AndroidManifest
Pour désactiver Impeller dans les builds de production, ajoutez dans `android/app/src/main/AndroidManifest.xml`:
```xml
<application
...>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application>
```
**Avertissement:** Cette configuration sera également dépréciée et retirée dans les futures versions de Flutter.
### Solution 3: Configuration via build.gradle (recommandé)
Dans `android/app/build.gradle`:
```gradle
android {
defaultConfig {
// Désactiver Impeller pour les builds Android
manifestPlaceholders = [
enableImpeller: 'false'
]
}
}
```
Puis référencez dans `AndroidManifest.xml`:
```xml
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="${enableImpeller}" />
```
Cette approche permet de gérer la configuration de manière centralisée.
## Solutions potentielles à long terme
### 1. Attendre les correctifs upstream (Recommandé)
**Action:** Surveiller les dépôts suivants pour les mises à jour:
- [Flutter Engine Issues](https://github.com/flutter/flutter/issues)
- [Plugin Flutter Google Navigation](https://github.com/googlemaps/flutter-navigation-sdk/issues)
**Termes de recherche:**
- "Impeller platform view glitch"
- "Google Navigation Impeller rendering"
- "AndroidView Impeller artifacts"
### 2. Signaler le problème à l'équipe Flutter
Si le problème n'est pas déjà signalé, créez un rapport de bug avec:
- Modèle de l'appareil Android (ex: KM10)
- Version de Flutter (obtenir avec `flutter --version`)
- Version du SDK Google Navigation
- Étapes de reproduction détaillées
- Captures d'écran/vidéo du glitch
**Signaler à:** https://github.com/flutter/flutter/issues/new?template=02_bug.yml
### 3. Essayer Hybrid Composition
Tentez de passer en mode Hybrid Composition pour les vues de plateforme:
Dans `android/app/src/main/AndroidManifest.xml`:
```xml
<meta-data
android:name="io.flutter.embedded_views_preview"
android:value="true" />
```
Cela modifie la façon dont Flutter compose les vues natives et peut résoudre les conflits de rendu avec Impeller.
### 4. Surveiller les mises à jour Flutter
À mesure que l'implémentation d'Impeller par Flutter mature, les futures versions stables incluront des correctifs pour les problèmes de rendu des vues de plateforme. Mettez régulièrement à jour Flutter et testez avec Impeller activé:
```bash
flutter upgrade
flutter run -d KM10 # Tester sans --no-enable-impeller
```
## Implémentation du code
### Exemple d'utilisation de AndroidView pour Google Navigation intégrée
```dart
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class MapWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Platform.isAndroid
? AndroidView(
viewType: 'google_navigation_flutter',
onPlatformViewCreated: _onViewCreated,
creationParams: _viewCreationParams,
creationParamsCodec: const StandardMessageCodec(),
)
: UiKitView(
viewType: 'google_navigation_flutter',
onPlatformViewCreated: _onViewCreated,
creationParams: _viewCreationParams,
creationParamsCodec: const StandardMessageCodec(),
);
}
void _onViewCreated(int id) {
// Configuration de la navigation et de la carte
}
Map<String, dynamic> get _viewCreationParams {
return {
// Paramètres de création
};
}
}
```
## Liste de vérification pour tester la compatibilité Impeller
Lors des tests de compatibilité Impeller dans les futures versions de Flutter:
- [ ] La carte s'affiche correctement sans artefacts visuels
- [ ] Les widgets Flutter superposés sur la carte ne causent pas de problèmes de rendu
- [ ] Les marqueurs de carte et éléments UI s'affichent correctement
- [ ] Pas de problèmes de z-index entre les widgets Flutter et la vue de carte
- [ ] Défilement et panoramique fluides sans perte d'images
- [ ] Le rendu des itinéraires de navigation fonctionne correctement
- [ ] Les transitions de caméra sont fluides
- [ ] Les performances sont acceptables (vérifier le timing des images dans DevTools)
## Workflow de développement
### Recommandations actuelles
**Pour le développement Android (testé sur KM10):**
```bash
# Utiliser ceci jusqu'à confirmation de la compatibilité Impeller
flutter run -d KM10 --no-enable-impeller
```
**Pour le développement iOS:**
```bash
# Impeller iOS est plus stable, peut utiliser le comportement par défaut
flutter run -d ios
```
### Hot Reload
Le hot reload fonctionne normalement avec Impeller désactivé:
```bash
# Appuyez sur 'r' dans le terminal
r
```
### Builds de production
**Pour les builds Android de production, désactivez également Impeller** en utilisant l'une des méthodes décrites ci-dessus.
## Avertissements liés aux vues de plateforme
Les avertissements suivants peuvent apparaître dans les logs avec Skia ou Impeller et sont des particularités des vues de plateforme Android, pas des erreurs critiques:
```
E/FrameEvents: updateAcquireFence: Did not find frame.
W/Parcel: Expecting binder but got null!
```
## Références
- [Documentation Flutter Impeller](https://docs.flutter.dev/perf/impeller)
- [Vues de plateforme Flutter](https://docs.flutter.dev/platform-integration/android/platform-views)
- [SDK Google Navigation Flutter](https://developers.google.com/maps/documentation/navigation/flutter/reference)
- [Issues Flutter: Support des vues de plateforme Impeller](https://github.com/flutter/flutter/issues?q=is%3Aissue+impeller+platform+view)
## Notes importantes
### Statut de la compatibilité
- **Court terme:** Utilisez `--no-enable-impeller` pour le développement Android
- **Moyen terme:** Surveillez les versions stables de Flutter pour les améliorations d'Impeller
- **Long terme:** L'option de désactivation d'Impeller sera retirée de Flutter
### Alternatives à considérer
Si les problèmes de compatibilité persistent après le retrait de l'option de désactivation:
1. Rechercher des problèmes GitHub existants et voter pour eux
2. Envisager des solutions de cartographie alternatives (Apple Maps sur iOS, autres SDKs)
3. Explorer différents modes de composition des vues de plateforme
## Pour les développeurs futurs
Si vous lisez cette documentation:
1. **D'abord, essayez sans le flag** - Impeller peut avoir été corrigé dans votre version de Flutter:
```bash
flutter run -d KM10
```
2. **Si vous voyez des glitches de carte**, ajoutez le flag:
```bash
flutter run -d KM10 --no-enable-impeller
```
3. **Si le flag a été retiré et que les cartes ont toujours des glitches**, recherchez:
- "Flutter Impeller Google Navigation" sur les Issues GitHub
- Solutions de navigation et cartographie alternatives
- Modes de composition des vues de plateforme
4. **Considérez ceci comme une solution temporaire**, pas une solution permanente.
## Dernière mise à jour
**Date:** Novembre 2025
**Appareil testé:** KM10 (Android)
**Statut:** Solution de contournement active, surveillance des correctifs upstream en cours

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;
}

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(

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),

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,
),
),
),
),
],
),
),

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

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 {

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: () {},
);

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);
},
),
],
),

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(

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;
}

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);

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,
),
),
);
}
}

3704
swagger.json Normal file

File diff suppressed because it is too large Load Diff