feat: Version 3.6.3 - Carte IGN, mode boussole, corrections Flutter analyze

Nouvelles fonctionnalités:
- #215 Mode boussole + carte IGN/satellite (Mode terrain)
- #53 Définition zoom maximal pour éviter sur-zoom
- #14 Correction bug F5 déconnexion
- #204 Design couleurs flashy
- #205 Écrans utilisateurs simplifiés

Corrections Flutter analyze:
- Suppression warnings room.g.dart, chat_service.dart, api_service.dart
- 0 error, 0 warning, 30 infos (suggestions de style)

Autres:
- Intégration tuiles IGN Plan et IGN Ortho (geopf.fr)
- flutter_compass pour Android/iOS
- Réorganisation assets store

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 17:46:03 +01:00
parent 232940b1eb
commit 5b6808db25
62 changed files with 1428 additions and 3130 deletions

View File

@@ -313,13 +313,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Appeler le nouvel endpoint API pour restaurer la session
// IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente
// Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués
debugPrint('🔄 Appel API: user/session avec sessionId: ${sessionId.substring(0, 10)}...');
final response = await ApiService.instance.getWithoutQueue(
'/api/user/session',
'user/session',
queryParameters: {'mode': displayMode},
);
// Gestion des codes de retour HTTP
final statusCode = response.statusCode ?? 0;
// Vérifier que la réponse est bien du JSON et pas du HTML
if (response.data is String) {
final dataStr = response.data as String;
if (dataStr.contains('<!DOCTYPE') || dataStr.contains('<html')) {
debugPrint('❌ ERREUR: L\'API a retourné du HTML au lieu de JSON !');
debugPrint('❌ StatusCode: $statusCode');
debugPrint('❌ URL de base: ${ApiService.instance.baseUrl}');
debugPrint('❌ Début de la réponse: ${dataStr.substring(0, 100)}...');
await CurrentUserService.instance.clearUser();
return false;
}
}
final data = response.data as Map<String, dynamic>?;
switch (statusCode) {
@@ -589,9 +605,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
_progress = 0.12;
});
}
await Future.delayed(const Duration(milliseconds: 200)); // Petit délai pour voir le début
if (mounted) {
setState(() {
_statusMessage = "Chargement des composants...";
@@ -599,21 +615,68 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
});
}
// Étape 2: Initialisation Hive - 15 à 60% (étape la plus longue)
await HiveService.instance.initializeAndResetHive();
// === GESTION F5 WEB : Vérifier session AVANT de détruire les données ===
// Sur Web, on essaie d'abord de récupérer une session existante
if (kIsWeb) {
debugPrint('🌐 Web détecté - tentative de récupération de session existante...');
if (mounted) {
setState(() {
_statusMessage = "Vérification de session...";
_progress = 0.20;
});
}
// Initialisation légère qui préserve les données
final hasExistingSession = await HiveService.instance.initializeWithoutReset();
if (hasExistingSession) {
debugPrint('✅ Session existante détectée, tentative de restauration...');
if (mounted) {
setState(() {
_statusMessage = "Restauration de la session...";
_progress = 0.40;
});
}
// Tenter la restauration via l'API
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Si la restauration API échoue, on continue vers le login
debugPrint('⚠️ Restauration API échouée, passage au login normal');
} else {
debugPrint(' Pas de session existante, initialisation normale');
}
}
// === INITIALISATION NORMALE (si pas de session F5 ou pas Web) ===
// Étape 2: Initialisation Hive complète - 15 à 60%
if (mounted) {
setState(() {
_statusMessage = "Configuration du stockage...";
_progress = 0.45;
_progress = 0.30;
});
}
await Future.delayed(const Duration(milliseconds: 300)); // Simulation du temps de traitement
await HiveService.instance.initializeAndResetHive();
if (mounted) {
setState(() {
_statusMessage = "Préparation des données...";
_progress = 0.45;
});
}
await Future.delayed(const Duration(milliseconds: 300)); // Simulation du temps de traitement
if (mounted) {
setState(() {
_statusMessage = "Ouverture des bases...";
_progress = 0.60;
});
}
@@ -621,19 +684,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Étape 3: Ouverture des Box - 60 à 80%
await HiveService.instance.ensureBoxesAreOpen();
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement)
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
// Vérifier et nettoyer si nouvelle version (Web uniquement)
await _checkVersionAndCleanIfNeeded();
// NOUVEAU : Détecter et gérer le F5 (refresh de page web avec session existante)
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
// Session restaurée avec succès, on arrête ici
// L'utilisateur a été redirigé vers son interface
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Gérer la box pending_requests séparément pour préserver les données
try {
debugPrint('📦 Gestion de la box pending_requests...');

File diff suppressed because it is too large Load Diff

View File

@@ -40,17 +40,19 @@ class _HomeContentState extends State<HomeContent> {
final isDesktop = screenWidth > 800;
// Retourner seulement le contenu (sans scaffold)
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Widget BtnPassages
const BtnPassages(),
const SizedBox(height: AppTheme.spacingL),
return Column(
children: [
// Widget BtnPassages collé en haut/gauche/droite
const BtnPassages(),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
isDesktop
@@ -172,9 +174,12 @@ class _HomeContentState extends State<HomeContent> {
),
),
],
],
],
),
),
),
);
],
);
}
// Construit la carte de répartition par type de passage

View File

@@ -123,6 +123,9 @@ class _MapPageContentState extends State<MapPageContent> {
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
bool _isCenteringOnSector = false;
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
TileSource _tileSource = TileSource.ignPlan;
// Comptages des secteurs (calculés uniquement lors de création/modification de secteurs)
Map<int, int> _sectorPassageCount = {};
Map<int, int> _sectorMemberCount = {};
@@ -215,6 +218,16 @@ class _MapPageContentState extends State<MapPageContent> {
_settingsBox.put('mapZoom', 15.0);
debugPrint('🔍 MapPage: Aucun zoom sauvegardé, utilisation du défaut = 15.0');
}
// Charger la source des tuiles (IGN Plan par défaut)
final savedTileSource = _settingsBox.get('mapTileSource');
if (savedTileSource != null) {
_tileSource = TileSource.values.firstWhere(
(t) => t.name == savedTileSource,
orElse: () => TileSource.ignPlan,
);
debugPrint('🗺️ MapPage: Source tuiles chargée = $_tileSource');
}
}
// Méthode pour gérer les changements de sélection de secteur
@@ -4151,8 +4164,8 @@ class _MapPageContentState extends State<MapPageContent> {
initialZoom: _currentZoom,
mapController: _mapController,
disableDrag: _isDraggingPoint,
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
tileSource: _tileSource,
labelMarkers: _buildSectorLabels(),
markers: [
..._buildMarkers(),
@@ -4199,14 +4212,38 @@ class _MapPageContentState extends State<MapPageContent> {
),
)),
// Boutons d'action en haut à droite (Web uniquement et admin seulement)
if (kIsWeb && canEditSectors)
Positioned(
right: 16,
top: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Bouton switch IGN Plan / Ortho en haut à droite (visible pour tous)
Positioned(
right: 16,
top: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Bouton switch IGN Plan / Ortho
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
_buildActionButton(
icon: _tileSource == TileSource.ignPlan
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
tooltip: _tileSource == TileSource.ignPlan
? 'Passer en vue satellite'
: 'Passer en vue plan',
color: Colors.white,
iconColor: Colors.blueGrey[700],
onPressed: () {
setState(() {
_tileSource = _tileSource == TileSource.ignPlan
? TileSource.ignOrtho
: TileSource.ignPlan;
_settingsBox.put('mapTileSource', _tileSource.name);
debugPrint('🗺️ MapPage: Source tuiles changée = $_tileSource');
});
},
),
// Espacement avant les boutons admin
if (kIsWeb && canEditSectors) const SizedBox(height: 16),
// Boutons admin (création, modification, suppression de secteurs)
if (kIsWeb && canEditSectors) ...[
// Bouton Créer
_buildActionButton(
icon: Icons.pentagon_outlined,
@@ -4246,8 +4283,9 @@ class _MapPageContentState extends State<MapPageContent> {
: null,
),
],
),
],
),
),
// Menu contextuel (apparaît selon le mode) - Web uniquement et admin seulement
if (kIsWeb && canEditSectors && _mapMode != MapMode.view)

View File

@@ -8,13 +8,14 @@ import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart' show TileSource;
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
@@ -59,10 +60,20 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Listener pour les changements de la box passages
Box<PassageModel>? _passagesBox;
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
TileSource _tileSource = TileSource.ignPlan;
Box? _settingsBox;
// Mode boussole (Android/iOS uniquement)
bool _compassModeEnabled = false;
StreamSubscription<CompassEvent>? _compassSubscription;
double _currentHeading = 0.0;
@override
void initState() {
super.initState();
_initializeAnimations();
_loadTileSourceSetting();
// Écouter les changements de la Hive box passages pour rafraîchir la carte
_passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
@@ -85,6 +96,26 @@ class _UserFieldModePageState extends State<UserFieldModePage>
}
}
// Charger le paramètre de source des tuiles depuis Hive
Future<void> _loadTileSourceSetting() async {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
final savedTileSource = _settingsBox?.get('mapTileSource');
if (savedTileSource != null && mounted) {
setState(() {
_tileSource = TileSource.values.firstWhere(
(t) => t.name == savedTileSource,
orElse: () => TileSource.ignPlan,
);
});
debugPrint('FieldMode: Source tuiles chargée = $_tileSource');
}
}
void _initializeWebMode() async {
// Essayer d'obtenir la position réelle depuis le navigateur
try {
@@ -539,6 +570,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
void dispose() {
_positionStreamSubscription?.cancel();
_qualityUpdateTimer?.cancel();
_compassSubscription?.cancel();
_gpsBlinkController.dispose();
_networkBlinkController.dispose();
_searchController.dispose();
@@ -546,6 +578,35 @@ class _UserFieldModePageState extends State<UserFieldModePage>
super.dispose();
}
// Activer/désactiver le mode boussole (Android/iOS uniquement)
void _toggleCompassMode() {
if (kIsWeb) return; // Pas de boussole sur web
setState(() {
_compassModeEnabled = !_compassModeEnabled;
});
if (_compassModeEnabled) {
// Activer l'écoute de la boussole
_compassSubscription = FlutterCompass.events?.listen((CompassEvent event) {
if (event.heading != null && mounted) {
setState(() {
_currentHeading = event.heading!;
});
// Faire pivoter la carte selon la direction
_mapController.rotate(-_currentHeading);
}
});
debugPrint('FieldMode: Mode boussole activé');
} else {
// Désactiver l'écoute et remettre la carte vers le nord
_compassSubscription?.cancel();
_compassSubscription = null;
_mapController.rotate(0);
debugPrint('FieldMode: Mode boussole désactivé');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -823,10 +884,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
}
final apiService = ApiService.instance;
final mapboxApiKey =
AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment());
return Stack(
children: [
FlutterMap(
@@ -837,21 +894,36 @@ class _UserFieldModePageState extends State<UserFieldModePage>
initialZoom: 17,
maxZoom: 19,
minZoom: 10,
interactionOptions: const InteractionOptions(
interactionOptions: InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
// Permettre la rotation uniquement si le mode boussole est activé
flags: _compassModeEnabled
? InteractiveFlag.all
: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
TileLayer(
// Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback
urlTemplate: kIsWeb
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey'
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
urlTemplate: _tileSource == TileSource.ignOrtho
? 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
'&STYLE=normal'
'&FORMAT=image/jpeg'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}'
: 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
'&STYLE=normal'
'&FORMAT=image/png'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}',
userAgentPackageName: 'app3.geosector.fr',
additionalOptions: const {
'attribution': '© OpenStreetMap contributors',
},
maxNativeZoom: 19,
maxZoom: 20,
minZoom: 7,
),
// Markers des passages
MarkerLayer(
@@ -900,6 +972,56 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: const Icon(Icons.my_location),
),
),
// Boutons haut droite (IGN + Boussole)
Positioned(
top: 16,
right: 16,
child: Column(
children: [
// Bouton switch IGN Plan / Ortho
FloatingActionButton.small(
heroTag: 'tileSource',
backgroundColor: Colors.white,
foregroundColor: Colors.green[700],
tooltip: _tileSource == TileSource.ignPlan
? 'Passer en vue satellite'
: 'Passer en vue plan',
onPressed: () {
setState(() {
_tileSource = _tileSource == TileSource.ignPlan
? TileSource.ignOrtho
: TileSource.ignPlan;
});
// Sauvegarder le choix
_settingsBox?.put('mapTileSource', _tileSource.name);
debugPrint('FieldMode: Source tuiles = $_tileSource');
},
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
child: Icon(
_tileSource == TileSource.ignPlan
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
),
),
// Bouton mode boussole (uniquement sur mobile)
if (!kIsWeb) ...[
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'compass',
backgroundColor: _compassModeEnabled ? Colors.green[700] : Colors.white,
foregroundColor: _compassModeEnabled ? Colors.white : Colors.green[700],
tooltip: _compassModeEnabled
? 'Désactiver le mode boussole'
: 'Activer le mode boussole',
onPressed: _toggleCompassMode,
child: Icon(
_compassModeEnabled ? Icons.explore : Icons.explore_outlined,
),
),
],
],
),
),
],
);
}

View File

@@ -40,7 +40,7 @@ class BtnPassages extends StatelessWidget {
final shouldShowLotType = _shouldShowLotType();
return SizedBox(
height: 80,
height: 92, // 80 + 12 pour le triangle indicateur
width: double.infinity,
child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
@@ -121,6 +121,7 @@ class BtnPassages extends StatelessWidget {
/// Colonne TOTAL (cliquable, affiche tous les passages)
Widget _buildTotalColumn(BuildContext context, int total) {
final bool isSelected = selectedTypeId == null;
final Color bgColor = Colors.grey[200]!;
return InkWell(
onTap: () async {
@@ -147,55 +148,71 @@ class BtnPassages extends StatelessWidget {
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[200],
border: Border.all(
color: Colors.grey[400]!,
width: isSelected ? 5 : 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.route,
size: 20,
color: Colors.black54,
),
const SizedBox(height: 2),
Text(
total.toString(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: Border.all(
color: Colors.grey[400]!,
width: 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.route,
size: 20,
color: Colors.black54,
),
const SizedBox(height: 2),
Text(
total.toString(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
total > 1 ? 'passages' : 'passage',
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 2),
Text(
total > 1 ? 'passages' : 'passage',
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
),
// Triangle indicateur de sélection
if (isSelected)
Center(
child: CustomPaint(
size: const Size(20, 12),
painter: _TrianglePainter(color: bgColor),
),
textAlign: TextAlign.center,
),
],
),
)
else
const SizedBox(height: 12),
],
),
);
}
@@ -236,62 +253,78 @@ class BtnPassages extends StatelessWidget {
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
border: Border.all(
color: couleur,
width: isSelected ? 5 : 1,
),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
size: 20,
color: couleur,
),
const SizedBox(height: 2),
Text(
count.toString(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: couleur,
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
titre,
style: TextStyle(
fontSize: 10,
border: Border.all(
color: couleur,
width: 1,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
size: 20,
color: Colors.white,
),
const SizedBox(height: 2),
Text(
count.toString(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
titre,
style: const TextStyle(
fontSize: 10,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
// Triangle indicateur de sélection
if (isSelected)
Center(
child: CustomPaint(
size: const Size(20, 12),
painter: _TrianglePainter(color: couleur),
),
)
else
const SizedBox(height: 12),
],
),
);
}
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert)
/// Colonne NOUVEAU PASSAGE (bouton +, fond blanc)
Widget _buildAddColumn(BuildContext context) {
return InkWell(
onTap: () {
@@ -302,47 +335,55 @@ class BtnPassages extends StatelessWidget {
_showPassageFormDialog(context);
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
border: Border.all(
color: AppTheme.buttonSuccessColor,
width: 1,
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppTheme.borderRadiusMedium),
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
size: 24,
color: AppTheme.buttonSuccessColor,
),
const SizedBox(height: 2),
Text(
'Nouveau',
style: TextStyle(
fontSize: 10,
color: AppTheme.buttonSuccessColor,
fontWeight: FontWeight.w600,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Colors.grey[400]!,
width: 1,
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppTheme.borderRadiusMedium),
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.add_circle_outline,
size: 24,
color: Colors.black87,
),
const SizedBox(height: 2),
Text(
'Nouveau',
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
textAlign: TextAlign.center,
),
],
),
),
// Espace pour aligner avec les autres colonnes (pas de triangle sur ce bouton)
const SizedBox(height: 12),
],
),
);
}
@@ -377,3 +418,30 @@ class BtnPassages extends StatelessWidget {
);
}
}
/// CustomPainter pour dessiner un triangle pointant vers le bas
class _TrianglePainter extends CustomPainter {
final Color color;
_TrianglePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, 0) // Coin supérieur gauche
..lineTo(size.width, 0) // Coin supérieur droit
..lineTo(size.width / 2, size.height) // Pointe en bas au centre
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _TrianglePainter oldDelegate) {
return oldDelegate.color != color;
}
}

View File

@@ -8,6 +8,18 @@ import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton
/// Enum représentant les différentes sources de tuiles disponibles
enum TileSource {
/// Tuiles Mapbox (par défaut)
mapbox,
/// Tuiles OpenStreetMap
openStreetMap,
/// Tuiles IGN Plan (carte routière française)
ignPlan,
/// Tuiles IGN Ortho Photos (photos aériennes)
ignOrtho,
}
/// Widget de carte réutilisable utilisant Mapbox
///
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
@@ -46,10 +58,14 @@ class MapboxMap extends StatefulWidget {
/// Désactive le drag de la carte
final bool disableDrag;
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
@Deprecated('Utiliser tileSource à la place')
final bool useOpenStreetMap;
/// Source des tuiles de la carte (Mapbox, OpenStreetMap, IGN Plan, IGN Ortho)
final TileSource tileSource;
const MapboxMap({
super.key,
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
@@ -64,6 +80,7 @@ class MapboxMap extends StatefulWidget {
this.mapStyle,
this.disableDrag = false,
this.useOpenStreetMap = false,
this.tileSource = TileSource.mapbox,
});
@override
@@ -125,7 +142,7 @@ class _MapboxMapState extends State<MapboxMap> {
_cacheInitialized = true;
});
}
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}');
debugPrint('MapboxMap: Cache initialisé avec succès');
} catch (e) {
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
// En cas d'erreur, on continue sans cache
@@ -175,30 +192,76 @@ class _MapboxMapState extends State<MapboxMap> {
);
}
/// Retourne l'URL template pour la source de tuiles sélectionnée
String _getTileUrlTemplate() {
// Rétrocompatibilité avec useOpenStreetMap
// ignore: deprecated_member_use_from_same_package
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
}
switch (widget.tileSource) {
case TileSource.openStreetMap:
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
case TileSource.ignPlan:
// IGN Plan IGN v2 - Carte routière française
// Source: https://data.geopf.fr/wmts
return 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
'&STYLE=normal'
'&FORMAT=image/png'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
case TileSource.ignOrtho:
// IGN Ortho Photos - Photos aériennes
// Source: https://data.geopf.fr/wmts
return 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
'&STYLE=normal'
'&FORMAT=image/jpeg'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
case TileSource.mapbox:
default:
// Déterminer l'URL du template de tuiles Mapbox
final String environment = ApiService.instance.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
if (kIsWeb) {
return 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
} else {
return 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
}
}
}
/// Retourne le nom de la source de tuiles pour le debug
String _getTileSourceName() {
// ignore: deprecated_member_use_from_same_package
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
return 'OpenStreetMap (legacy)';
}
switch (widget.tileSource) {
case TileSource.mapbox:
return 'Mapbox';
case TileSource.openStreetMap:
return 'OpenStreetMap';
case TileSource.ignPlan:
return 'IGN Plan';
case TileSource.ignOrtho:
return 'IGN Ortho Photos';
}
}
@override
Widget build(BuildContext context) {
String urlTemplate;
if (widget.useOpenStreetMap) {
// Utiliser OpenStreetMap comme alternative
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
} else {
// Déterminer l'URL du template de tuiles Mapbox
// Utiliser l'environnement actuel pour obtenir la bonne clé API
final String environment = ApiService.instance.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
// Essayer différentes API Mapbox selon la plateforme
if (kIsWeb) {
// Sur web, on peut utiliser l'API styles
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
} else {
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
}
}
final urlTemplate = _getTileUrlTemplate();
debugPrint('MapboxMap: Utilisation de ${_getTileSourceName()}');
// Afficher un indicateur pendant l'initialisation du cache
if (!_cacheInitialized) {