checkpoint
This commit is contained in:
parent
d46ac9dc14
commit
ef5c0c1a95
295
docs/IMPELLER_GOOGLE_MAPS_FR.md
Normal file
295
docs/IMPELLER_GOOGLE_MAPS_FR.md
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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: () {},
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
80
lib/utils/toast_helper.dart
Normal file
80
lib/utils/toast_helper.dart
Normal 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
3704
swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user