15 Commits

Author SHA1 Message Date
35e9ddbed5 fix: Remplacer NavigationHelper par NavigationConfig dans map_page.dart
NavigationHelper a été supprimé lors du refactoring #74.
Utilisation de NavigationConfig à la place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:06:51 +01:00
3daf5a204a docs: Marquer tâche #74 comme complétée (26/01)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:28:10 +01:00
1cdb4ec58c refactor: Simplifier DashboardLayout/AppScaffold (tâche #74)
Centralisation et simplification de l'architecture de navigation :

CRÉATIONS :
- navigation_config.dart : Configuration centralisée de la navigation
  * Toutes les destinations (admin/user)
  * Logique de navigation (index → route)
  * Résolution inverse (route → index)
  * Titres et utilitaires

- backgrounds/dots_painter.dart : Painter de points décoratifs
  * Extrait depuis AppScaffold et AdminScaffold
  * Paramétrable (opacité, densité, seed)
  * Réutilisable

- backgrounds/gradient_background.dart : Fond dégradé
  * Gère les couleurs admin (rouge) / user (vert)
  * Option pour afficher/masquer les points
  * Widget indépendant

SIMPLIFICATIONS :
- app_scaffold.dart : 426 → 192 lignes (-55%)
  * Utilise NavigationConfig au lieu de NavigationHelper
  * Utilise GradientBackground au lieu de code dupliqué
  * Suppression de DotsPainter local

- dashboard_layout.dart : 140 → 77 lignes (-45%)
  * Suppression validations excessives (try/catch, vérifications)
  * Code épuré et plus lisible

SUPPRESSIONS :
- admin_scaffold.dart : Supprimé (207 lignes)
  * Obsolète depuis unification avec AppScaffold
  * Code dupliqué avec AppScaffold
  * AdminNavigationHelper fusionné dans NavigationConfig

RÉSULTATS :
- Avant : 773 lignes (AppScaffold + AdminScaffold + DashboardLayout)
- Après : 623 lignes (tout inclus)
- Réduction nette : -150 lignes (-19%)
- Architecture plus claire et maintenable
- Aucune duplication de code
- Navigation centralisée en un seul endroit

Résout tâche #74 du PLANNING-2026-Q1.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:27:54 +01:00
7345cf805e fix: Correction format version YY.MM.DDNN (3 parties au lieu de 4)
- VERSION file stocke maintenant: 26.01.2604 (3 parties)
- Au lieu de: 26.01.26.04 (4 parties - invalide pour semver)
- Regex ajustée pour parser le nouveau format: ^YY.MM.DDNN$
- Détection changement de date compare YY, MM, DD séparément
- Build number reste YYMMDDNN (26012604)
- Commentaires mis à jour pour refléter format YY.MM.DDNN

Résout: "Could not parse 26.01.26.04+26012604"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:35:09 +01:00
eef1fc8d32 fix: Format semver YY.MM.DDNN pour compatibilité Dart/Flutter
- Correction format version pour pubspec.yaml: YY.MM.DDNN
- VERSION file garde format lisible: 26.01.26.03
- pubspec.yaml reçoit format semver: 26.01.2603+26012603
- Concat DD+NN pour 3ème partie: 26+03 = 2603
- Build number complet: 26012603

Résout erreur: Could not parse '26.01.26.03+26012603'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:30:40 +01:00
097335193e fix: Ajout fonction echo_success manquante
- Ajout fonction echo_success() avec symbole ✓ en vert
- Utilisée dans le système de versioning automatique
- Correction erreur : "echo_success : commande introuvable"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:22:36 +01:00
cf1e54d8d0 docs: Mise à jour commentaires AppInfoService pour format YY.MM.DD.NN
- Clarification format version : YY.MM.DD.NN
- Commentaire : auto-incrémenté à chaque déploiement DEV
- Build number format : YYMMDDNN (sans points)
- Full version format : vYY.MM.DD.NN+YYMMDDNN

La version complète sera automatiquement affichée dans :
- splash_page.dart (écran chargement)
- login_page.dart (connexion)
- register_page.dart (inscription)
- dashboard_app_bar.dart (tableau de bord)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:15:37 +01:00
d6c4c6d228 feat: Versioning automatique YY.MM.DD.NN pour DEV
- Système 100% automatique de numérotation de version
- Format : YY.MM.DD.NN (ex: 26.01.27.01)
- Détection automatique de la date du jour
- Incrémentation auto du build number (.01 → .02 → .03...)
- Reset auto à .01 lors d'un changement de date
- Compatible avec ancien format (conversion auto)

Logique :
1. Récupération date système : date +%y.%m.%d
2. Si date différente de VERSION → YY.MM.DD.01
3. Si même date → incrémenter dernier nombre
4. Écriture dans VERSION et pubspec.yaml

Exemple :
- 26/01 build 1 → 26.01.26.01
- 26/01 build 2 → 26.01.26.02
- 27/01 build 1 → 26.01.27.01 (reset auto)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:14:54 +01:00
b3e03ef6e6 feat: Filtres membres/secteurs dynamiques et liés (#42)
- Filtrage croisé via box user_sector (UserSectorModel)
- Si secteur sélectionné → membres filtrés (uniquement ce secteur)
- Si membre sélectionné → secteurs filtrés (uniquement ses secteurs)
- Relation : UserSectorModel.opeUserId ↔ UserSectorModel.fkSector
- Import UserSectorModel ajouté
- Simplification dropdown secteurs (liste directe, plus de map)

Comportement :
1. Aucun filtre → tous les membres et tous les secteurs
2. Secteur choisi → liste membres réduite
3. Membre choisi → liste secteurs réduite
4. Les deux choisis → affichage le plus restreint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:00:28 +01:00
9c837f8adb feat: Bouton réinitialiser filtres historique admin (#42)
- Ajout bouton IconButton avec icône clear (X)
- Visible uniquement si au moins un filtre est actif
- Réinitialise : recherche textuelle + membre + secteur
- Remet les 2 selects à "Tous"
- Style : fond gris clair, padding 12px
- Tooltip : "Réinitialiser les filtres"

Affichage conditionnel :
isAdmin && (recherche OU membre OU secteur non vides)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:51:18 +01:00
16b30b0e9e feat: Poids police inputs w600 + suppression thème dark
- Augmentation poids police inputs : w500 → w600 (semi-bold)
- bodyLarge w600 dans _getTextTheme (statique)
- bodyLarge w600 dans getResponsiveTextTheme (responsive)
- Suppression complète du thème dark inutilisé (~95 lignes)
- Suppression constantes backgroundDarkColor et textDarkColor
- Application forcée sur tous les TextFormField/TextField

Amélioration de la lisibilité des champs de saisie.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:41:22 +01:00
c1c6c55cbe feat: Format affichage membres avec secteur (#42)
- Format dropdown membre : "FirstName name (sectName)"
- Gestion des cas où firstName ou name sont vides
- Affichage sectName entre parenthèses si disponible
- Fallback : "Membre #opeUserId" si aucun nom

Exemples :
- "Pierre Dupont (Secteur A)"
- "Pierre (Secteur B)"
- "Dupont (Secteur C)"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:29:17 +01:00
3c3a9b90aa fix: Utiliser membre.opeUserId pour filtre membre (#42)
- Correction : utilise membre.opeUserId (et non membre.id)
- Liste tous les membres ayant un opeUserId != null
- Filtre passages par passage.fkUser == membre.opeUserId
- Retire les debugPrint inutiles
- Affichage : membre.name ou membre.firstName

Relation: MembreModel.opeUserId == PassageModel.fkUser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:25:42 +01:00
6952417147 fix: Utiliser box Hive membres pour filtre membre (#42)
- Correction du filtre membre : utilise membreRepository.getMembresBox()
- Récupère les membres depuis la box Hive (ope_users)
- Filtre uniquement les membres ayant des passages (memberIdsInPassages)
- Affichage : member.name ou member.firstName
- Tri alphabétique par nom

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 11:12:57 +01:00
a392305820 feat: Ajout filtres membre/secteur dans historique admin (#42)
- Ajout de 2 dropdowns de filtres dans history_page.dart (admin uniquement)
- Filtre par membre (fkUser) : liste dynamique depuis passages
- Filtre par secteur (fkSector) : liste dynamique depuis passages
- Valeurs par défaut : "Tous" pour chaque filtre
- Tri alphabétique des dropdowns
- Mise à jour du planning : #42 validée (26/01)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 10:53:07 +01:00
11 changed files with 765 additions and 731 deletions

View File

@@ -15,7 +15,8 @@ set -euo pipefail
START_TIME=$(($(date +%s%N)/1000000))
echo "[$(date '+%H:%M:%S.%3N')] Début du script deploy-app.sh"
cd /home/pierre/dev/geosector/app
# Note: Le code source est sur IN1:/home/pierre/dev/geosector/app
# Ce script peut être lancé depuis n'importe où (desktop/laptop)
# =====================================
# Configuration générale
@@ -62,6 +63,10 @@ echo_info() {
echo -e "${BLUE}Info:${NC} $1"
}
echo_success() {
echo -e "${GREEN}${NC} $1"
}
echo_warning() {
echo -e "${YELLOW}Warning:${NC} $1"
}
@@ -144,51 +149,121 @@ ARCHIVE_NAME="app-deploy-${TIMESTAMP}.tar.gz"
TEMP_ARCHIVE="/tmp/${ARCHIVE_NAME}"
if [ "$SOURCE_TYPE" = "local_build" ]; then
# DEV: Build Flutter et créer une archive
echo_step "Building Flutter app for DEV..."
# DEV: Build Flutter sur IN1 et créer une archive
echo_step "Building Flutter app on IN1 for DEV..."
# Configuration du cache local (partagé web + iOS/Android)
echo_info "📦 Configuration du cache local pour builds web et mobile..."
export PUB_CACHE="$PWD/.pub-cache-local"
export GRADLE_USER_HOME="$PWD/.gradle-local"
mkdir -p "$PUB_CACHE" "$GRADLE_USER_HOME"
# Variables pour IN1
IN1_HOST="IN1"
IN1_PROJECT_PATH="/home/pierre/dev/geosector/app"
echo_info " Cache Pub: $PUB_CACHE"
echo_info " Cache Gradle: $GRADLE_USER_HOME"
echo_info " → Packages patchés seront prêts pour transfert iOS/Android"
echo_info "🖥️ Compilation distante sur IN1:${IN1_PROJECT_PATH}"
# Charger les variables d'environnement
# Charger les variables d'environnement localement pour la version
if [ ! -f .env-deploy-dev ]; then
echo_error "Missing .env-deploy-dev file"
fi
source .env-deploy-dev
# Mise à jour de la version
echo_info "Managing version..."
# Système automatique de versioning YY.MM.DDNN
echo_info "Managing version (automatic YY.MM.DDNN)..."
# Date du jour
TODAY=$(date +%y.%m.%d)
TODAY_YY=$(date +%y)
TODAY_MM=$(date +%m)
TODAY_DD=$(date +%d)
if [ -f ../VERSION ]; then
VERSION=$(cat ../VERSION | tr -d '\n\r' | tr -d ' ')
echo_info "Version found: $VERSION"
else
echo_warning "VERSION file not found"
read -p "Enter version number (x.x.x format): " VERSION
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "$VERSION" > ../VERSION
echo_info "VERSION file created with $VERSION"
CURRENT_VERSION=$(cat ../VERSION | tr -d '\n\r' | tr -d ' ')
echo_info "Current version: $CURRENT_VERSION"
# Extraire la partie date et le build number (format: YY.MM.DDNN)
if [[ $CURRENT_VERSION =~ ^([0-9]{2})\.([0-9]{2})\.([0-9]{2})([0-9]{2})$ ]]; then
PREV_YY="${BASH_REMATCH[1]}"
PREV_MM="${BASH_REMATCH[2]}"
PREV_DD="${BASH_REMATCH[3]}"
BUILD_NUM="${BASH_REMATCH[4]}"
# Si la date a changé, reset à 01
if [ "$PREV_YY" != "$TODAY_YY" ] || [ "$PREV_MM" != "$TODAY_MM" ] || [ "$PREV_DD" != "$TODAY_DD" ]; then
NEW_BUILD="01"
echo_info "Date changed: $PREV_YY.$PREV_MM.$PREV_DD$TODAY_YY.$TODAY_MM.$TODAY_DD, resetting build to 01"
else
# Incrémenter le build number
NEW_BUILD=$(printf "%02d" $((10#$BUILD_NUM + 1)))
echo_info "Same date, incrementing build: $BUILD_NUM$NEW_BUILD"
fi
else
echo_error "Invalid version format"
# Format ancien ou invalide, créer nouveau format
NEW_BUILD="01"
echo_warning "Old version format detected, creating new format"
fi
else
# Pas de fichier VERSION, créer avec .01
NEW_BUILD="01"
echo_warning "VERSION file not found, creating new one"
fi
# Génération du build number et mise à jour du pubspec.yaml
BUILD_NUMBER=$(echo $VERSION | tr -d '.')
# Construire la version semver: YY.MM.DDNN (3 parties)
VERSION="${TODAY_YY}.${TODAY_MM}.${TODAY_DD}${NEW_BUILD}"
echo "$VERSION" > ../VERSION
echo_success "✅ New version: $VERSION"
# Build number: YYMMDDNN (tous les chiffres)
BUILD_NUMBER="${TODAY_YY}${TODAY_MM}${TODAY_DD}${NEW_BUILD}"
# Version complète pour pubspec.yaml
FULL_VERSION="${VERSION}+${BUILD_NUMBER}"
echo_info "Full version: $FULL_VERSION"
echo_info "Semver version for pubspec.yaml: $FULL_VERSION"
sed -i "s/^version: .*/version: $FULL_VERSION/" pubspec.yaml || echo_error "Failed to update pubspec.yaml"
# Génération automatique du fichier AppInfoService (remplace package_info_plus)
echo_info "Auto-generating app_info_service.dart with version $VERSION+$BUILD_NUMBER..."
cat > lib/core/services/app_info_service.dart <<EOF
# Lancer la compilation sur IN1 via SSH
echo_info "🚀 Lancement de la compilation sur IN1..."
BUILD_START=$(($(date +%s%N)/1000000))
ssh ${IN1_HOST} "bash -l -s" <<'REMOTE_SCRIPT' || echo_error "Remote build failed on IN1"
set -euo pipefail
# Charger le profil utilisateur pour avoir Flutter dans le PATH
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
if [ -f ~/.profile ]; then
source ~/.profile
fi
cd /home/pierre/dev/geosector/app
echo "[IN1] Starting Flutter build process..."
# Charger les variables d'environnement
if [ ! -f .env-deploy-dev ]; then
echo "ERROR: Missing .env-deploy-dev file"
exit 1
fi
source .env-deploy-dev
# Lire la version
if [ -f ../VERSION ]; then
VERSION=$(cat ../VERSION | tr -d '\n\r' | tr -d ' ')
echo "[IN1] Version: $VERSION"
else
echo "ERROR: VERSION file not found"
exit 1
fi
# Génération du build number
BUILD_NUMBER=$(echo $VERSION | tr -d '.')
FULL_VERSION="${VERSION}+${BUILD_NUMBER}"
echo "[IN1] Full version: $FULL_VERSION"
# Mise à jour du pubspec.yaml
sed -i "s/^version: .*/version: $FULL_VERSION/" pubspec.yaml
# Génération automatique du fichier AppInfoService
echo "[IN1] Generating app_info_service.dart..."
cat > lib/core/services/app_info_service.dart <<EOF
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
// This file is automatically generated by deploy-app.sh script
// Last update: $(date '+%Y-%m-%d %H:%M:%S')
@@ -198,13 +273,13 @@ if [ "$SOURCE_TYPE" = "local_build" ]; then
// Provides application version and build information without external dependencies
class AppInfoService {
// Version number (format: x.x.x)
// Version number (format: YY.MM.DDNN - auto-incremented on each DEV deploy)
static const String version = '$VERSION';
// Build number (version without dots: xxx)
// Build number (version without dots: YYMMDDNN)
static const String buildNumber = '$BUILD_NUMBER';
// Full version string (format: vx.x.x+xxx)
// Full version string (format: vYY.MM.DDNN+YYMMDDNN)
static String get fullVersion => 'v\$version+\$buildNumber';
// Application name
@@ -214,53 +289,60 @@ class AppInfoService {
static const String packageName = 'fr.geosector.app3';
}
EOF
echo_info "✓ app_info_service.dart updated successfully"
# Mode de compilation en RELEASE (production)
echo_info "🏁 Mode RELEASE - Compilation optimisée pour production"
BUILD_FLAGS="--release"
# Configuration du cache local
echo "[IN1] Configuring local cache..."
export PUB_CACHE="$PWD/.pub-cache-local"
export GRADLE_USER_HOME="$PWD/.gradle-local"
mkdir -p "$PUB_CACHE" "$GRADLE_USER_HOME"
# Nettoyage
echo_info "Cleaning previous builds..."
rm -rf .dart_tool build .packages pubspec.lock 2>/dev/null || true
flutter clean || echo_warning "Flutter clean partially failed"
# Nettoyage
echo "[IN1] Cleaning previous builds..."
rm -rf .dart_tool build .packages pubspec.lock 2>/dev/null || true
flutter clean
# Build
echo_info "Getting dependencies..."
flutter pub get || echo_error "Flutter pub get failed"
# Build
echo "[IN1] Getting dependencies..."
flutter pub get
# Patch nfc_manager 3.3.0 (AndroidManifest namespace)
echo_info "Applying nfc_manager 3.3.0 patch..."
./fastlane/scripts/commun/fix-nfc-manager.sh || echo_error "nfc_manager patch failed"
echo_info "Cleaning generated files..."
dart run build_runner clean || echo_error "Build runner clean failed"
echo_info "Generating code files..."
dart run build_runner build --delete-conflicting-outputs || echo_error "Code generation failed"
echo_info "Building Flutter web application..."
# Mesure du temps de compilation Flutter
BUILD_START=$(($(date +%s%N)/1000000))
echo_info "[$(date '+%H:%M:%S.%3N')] Début de la compilation Flutter (Mode: RELEASE)"
# Patch nfc_manager 3.3.0
echo "[IN1] Applying nfc_manager patch..."
./fastlane/scripts/commun/fix-nfc-manager.sh
flutter build web $BUILD_FLAGS || echo_error "Flutter build failed"
echo "[IN1] Cleaning generated files..."
dart run build_runner clean
echo "[IN1] Generating code files..."
dart run build_runner build --delete-conflicting-outputs
# Mode de compilation en RELEASE
echo "[IN1] 🏁 Building Flutter web (RELEASE mode)..."
flutter build web --release
echo "[IN1] Fixing web assets structure..."
./copy-web-images.sh
# Créer l'archive sur IN1
echo "[IN1] Creating deployment archive..."
tar -czf /tmp/app-deploy-build.tar.gz -C build/web \
--exclude='*.symbols' \
--exclude='*.kra' \
--exclude='.DS_Store' \
.
echo "[IN1] Build completed successfully!"
REMOTE_SCRIPT
BUILD_END=$(($(date +%s%N)/1000000))
BUILD_TIME=$((BUILD_END - BUILD_START))
echo_info "[$(date '+%H:%M:%S.%3N')] Fin de la compilation Flutter"
echo_info "⏱️ Temps de compilation Flutter: ${BUILD_TIME} ms ($((BUILD_TIME/1000)) secondes)"
echo_info "⏱️ Temps de compilation sur IN1: ${BUILD_TIME} ms ($((BUILD_TIME/1000)) secondes)"
echo_info "Fixing web assets structure..."
./copy-web-images.sh || echo_error "Failed to fix web assets"
# Récupérer l'archive depuis IN1
echo_info "📥 Downloading archive from IN1..."
scp ${IN1_HOST}:/tmp/app-deploy-build.tar.gz ${TEMP_ARCHIVE} || echo_error "Failed to download archive from IN1"
# Créer l'archive depuis le build (avec exclusions pour réduire la taille)
echo_info "Creating archive from build..."
tar -czf "${TEMP_ARCHIVE}" -C ${FLUTTER_BUILD_DIR} \
--exclude='*.symbols' \
--exclude='*.kra' \
--exclude='.DS_Store' \
. || echo_error "Failed to create archive"
# Nettoyer sur IN1
ssh ${IN1_HOST} "rm -f /tmp/app-deploy-build.tar.gz"
create_local_backup "${TEMP_ARCHIVE}" "dev"

View File

@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
/// Configuration centralisée de la navigation pour toute l'application
/// Gère les destinations, routes et logique de navigation pour admin et user
class NavigationConfig {
// ========================================
// DESTINATIONS DE NAVIGATION
// ========================================
/// Destinations communes à tous les rôles
static const List<NavigationDestination> _commonDestinations = [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
/// Destination Messages avec badge (commune à tous)
static NavigationDestination get _messagesDestination {
return createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
);
}
/// Destination Terrain (user uniquement)
static const NavigationDestination _fieldModeDestination = NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
);
/// Destinations admin desktop uniquement
static const List<NavigationDestination> _adminDesktopDestinations = [
NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
NavigationDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Connexions',
),
];
// ========================================
// GETTERS DE DESTINATIONS
// ========================================
/// Obtenir la liste des destinations selon le rôle et le device
static List<NavigationDestination> getDestinations({
required bool isAdmin,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[];
// Ajouter les destinations communes
destinations.addAll(_commonDestinations);
// Ajouter Messages (avec badge)
destinations.add(_messagesDestination);
if (isAdmin) {
// Admin : ajouter les pages supplémentaires sur desktop uniquement
if (!isMobile) {
destinations.addAll(_adminDesktopDestinations);
}
} else {
// User : ajouter la page Terrain
destinations.add(_fieldModeDestination);
}
return destinations;
}
// ========================================
// NAVIGATION (INDEX → ROUTE)
// ========================================
/// Naviguer vers une page selon l'index et le rôle
static void navigateToIndex(BuildContext context, int index, bool isAdmin) {
if (isAdmin) {
_navigateAdminIndex(context, index);
} else {
_navigateUserIndex(context, index);
}
}
/// Navigation pour les admins
static void _navigateAdminIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/history');
break;
case 2:
context.go('/admin/map');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/amicale');
break;
case 5:
context.go('/admin/operations');
break;
case 6:
context.go('/admin/connexions');
break;
default:
context.go('/admin');
}
}
/// Navigation pour les utilisateurs standards
static void _navigateUserIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/user/dashboard');
break;
case 1:
context.go('/user/history');
break;
case 2:
context.go('/user/map');
break;
case 3:
context.go('/user/messages');
break;
case 4:
context.go('/user/field-mode');
break;
default:
context.go('/user/dashboard');
}
}
// ========================================
// RÉSOLUTION (ROUTE → INDEX)
// ========================================
/// Obtenir l'index selon la route actuelle et le rôle
static int getIndexFromRoute(String route, bool isAdmin) {
// Enlever les paramètres de query si présents
final cleanRoute = route.split('?').first;
if (isAdmin) {
return _getAdminIndexFromRoute(cleanRoute);
} else {
return _getUserIndexFromRoute(cleanRoute);
}
}
/// Obtenir l'index admin depuis la route
static int _getAdminIndexFromRoute(String route) {
if (route.contains('/admin/history')) return 1;
if (route.contains('/admin/map')) return 2;
if (route.contains('/admin/messages')) return 3;
if (route.contains('/admin/amicale')) return 4;
if (route.contains('/admin/operations')) return 5;
if (route.contains('/admin/connexions')) return 6;
return 0; // Dashboard par défaut
}
/// Obtenir l'index user depuis la route
static int _getUserIndexFromRoute(String route) {
if (route.contains('/user/history')) return 1;
if (route.contains('/user/map')) return 2;
if (route.contains('/user/messages')) return 3;
if (route.contains('/user/field-mode')) return 4;
return 0; // Dashboard par défaut
}
// ========================================
// UTILITAIRES
// ========================================
/// Obtenir le nom de la page selon l'index et le rôle
static String getPageNameFromIndex(int index, bool isAdmin) {
if (isAdmin) {
return _getAdminPageName(index);
} else {
return _getUserPageName(index);
}
}
/// Obtenir le nom de page admin
static String _getAdminPageName(int index) {
switch (index) {
case 0:
return 'dashboard';
case 1:
return 'history';
case 2:
return 'map';
case 3:
return 'messages';
case 4:
return 'amicale';
case 5:
return 'operations';
case 6:
return 'connexions';
default:
return 'dashboard';
}
}
/// Obtenir le nom de page user
static String _getUserPageName(int index) {
switch (index) {
case 0:
return 'dashboard';
case 1:
return 'history';
case 2:
return 'map';
case 3:
return 'messages';
case 4:
return 'field-mode';
default:
return 'dashboard';
}
}
// ========================================
// TITRES ET LABELS
// ========================================
/// Obtenir le titre du dashboard selon le rôle
static String getDashboardTitle(bool isAdmin) {
return isAdmin ? 'Tableau de bord Administration' : 'GEOSECTOR';
}
}

View File

@@ -37,9 +37,7 @@ class AppTheme {
static const Color warningColor = Color(0xFFF7A278); // Orange
static const Color backgroundLightColor =
Color(0xFFF4F5F6); // Gris très clair
static const Color backgroundDarkColor = Color(0xFF111827);
static const Color textLightColor = Color(0xFF000000); // Noir
static const Color textDarkColor = Color(0xFFF9FAFB);
// Couleurs de texte supplémentaires
static const Color textSecondaryColor = Color(0xFF7F8C8D);
@@ -191,103 +189,6 @@ class AppTheme {
);
}
// Thème sombre
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: 'Inter',
colorScheme: const ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
tertiary: accentColor,
surface: Color(0xFF1F2937),
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: textDarkColor,
error: errorColor,
),
scaffoldBackgroundColor: backgroundDarkColor,
textTheme: _getTextTheme(textDarkColor),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1F2937),
foregroundColor: Colors.white,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: spacingL, vertical: spacingM),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusRounded),
),
textStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
padding: const EdgeInsets.symmetric(
horizontal: spacingL, vertical: spacingM),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
padding: const EdgeInsets.symmetric(
horizontal: spacingM, vertical: spacingS),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF374151),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: spacingM, vertical: spacingM),
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusXL),
),
color: const Color(0xFF1F2937),
),
dividerTheme: DividerThemeData(
color: textDarkColor.withOpacity(0.1),
thickness: 1,
space: spacingM,
),
);
}
// Méthode helper pour générer le TextTheme responsive
static TextTheme getResponsiveTextTheme(BuildContext context, Color textColor) {
final scaleFactor = getFontScaleFactor(MediaQuery.of(context).size.width);
@@ -348,10 +249,10 @@ class AppTheme {
// Body styles (texte principal)
bodyLarge: TextStyle(
fontFamily: 'Inter',
fontFamily: 'Inter',
color: textColor,
fontSize: 16 * scaleFactor,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
bodyMedium: TextStyle(
fontFamily: 'Inter',
@@ -360,9 +261,10 @@ class AppTheme {
fontWeight: FontWeight.w500,
),
bodySmall: TextStyle(
fontFamily: 'Inter',
fontFamily: 'Inter',
color: textColor.withOpacity(0.7),
fontSize: 12 * scaleFactor,
fontWeight: FontWeight.w500,
),
// Label styles (petits textes, boutons)
@@ -373,14 +275,16 @@ class AppTheme {
fontWeight: FontWeight.w600,
),
labelMedium: TextStyle(
fontFamily: 'Inter',
fontFamily: 'Inter',
color: textColor.withOpacity(0.7),
fontSize: 12 * scaleFactor,
fontWeight: FontWeight.w500,
),
labelSmall: TextStyle(
fontFamily: 'Inter',
fontFamily: 'Inter',
color: textColor.withOpacity(0.7),
fontSize: 11 * scaleFactor,
fontWeight: FontWeight.w500,
),
);
}
@@ -397,12 +301,12 @@ class AppTheme {
titleLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 22),
titleMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w600),
titleSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
bodyLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
bodyLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w600),
bodyMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12),
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12, fontWeight: FontWeight.w500),
labelLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12),
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 11),
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12, fontWeight: FontWeight.w500),
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 11, fontWeight: FontWeight.w500),
);
}

186
app/lib/presentation/pages/history_page.dart Normal file → Executable file
View File

@@ -5,6 +5,7 @@ import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
@@ -55,6 +56,8 @@ class _HistoryContentState extends State<HistoryContent> {
String _selectedTypeFilter = 'Tous les types';
String _searchQuery = '';
int? selectedTypeId;
int? _selectedMemberId; // null = "Tous" (admin uniquement)
int? _selectedSectorId; // null = "Tous" (admin uniquement)
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
@@ -221,6 +224,20 @@ class _HistoryContentState extends State<HistoryContent> {
}
}
// Filtre par membre (admin uniquement)
if (isAdmin && _selectedMemberId != null) {
if (passage.fkUser != _selectedMemberId) {
return false;
}
}
// Filtre par secteur (admin uniquement)
if (isAdmin && _selectedSectorId != null) {
if (passage.fkSector != _selectedSectorId) {
return false;
}
}
// Filtre par recherche textuelle
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
@@ -302,6 +319,7 @@ class _HistoryContentState extends State<HistoryContent> {
children: [
// Barre de recherche
Expanded(
flex: 3,
child: TextFormField(
controller: _searchController,
decoration: const InputDecoration(
@@ -319,6 +337,42 @@ class _HistoryContentState extends State<HistoryContent> {
},
),
),
// Filtres admin uniquement
if (isAdmin) ...[
const SizedBox(width: 8),
// Filtre par membre
Expanded(
flex: 2,
child: _buildMemberDropdown(),
),
const SizedBox(width: 8),
// Filtre par secteur
Expanded(
flex: 2,
child: _buildSectorDropdown(),
),
],
// Bouton réinitialiser les filtres
if (isAdmin && (_searchQuery.isNotEmpty || _selectedMemberId != null || _selectedSectorId != null)) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.clear, size: 20),
tooltip: 'Réinitialiser les filtres',
onPressed: () {
setState(() {
_searchQuery = '';
_searchController.clear();
_selectedMemberId = null;
_selectedSectorId = null;
});
_applyFilters();
},
style: IconButton.styleFrom(
backgroundColor: Colors.grey[200],
padding: const EdgeInsets.all(12),
),
),
],
],
),
),
@@ -563,4 +617,136 @@ class _HistoryContentState extends State<HistoryContent> {
}
}
}
/// Construit le dropdown de sélection de membre (admin uniquement)
Widget _buildMemberDropdown() {
// Récupérer tous les membres depuis la box Hive
final membresBox = membreRepository.getMembresBox();
var membres = membresBox.values.where((membre) {
// Ne garder que les membres ayant un opeUserId (membres actifs dans une opération)
return membre.opeUserId != null;
}).toList();
// Filtrage dynamique : si un secteur est sélectionné, ne montrer que les membres de ce secteur
if (_selectedSectorId != null) {
final userSectorsBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
final memberIdsInSector = userSectorsBox.values
.where((us) => us.fkSector == _selectedSectorId)
.map((us) => us.opeUserId)
.toSet();
membres = membres.where((membre) => memberIdsInSector.contains(membre.opeUserId)).toList();
}
// Trier par nom
membres.sort((a, b) {
final nameA = a.name ?? a.firstName ?? '';
final nameB = b.name ?? b.firstName ?? '';
return nameA.compareTo(nameB);
});
return DropdownButtonFormField<int?>(
value: _selectedMemberId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
labelText: 'Membre',
prefixIcon: Icon(Icons.person, size: 20),
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous'),
),
...membres.map((membre) {
// Format: FirstName name (sectName)
final firstName = membre.firstName ?? '';
final name = membre.name ?? '';
final sectName = membre.sectName ?? '';
String displayName = '';
if (firstName.isNotEmpty && name.isNotEmpty) {
displayName = '$firstName $name';
} else if (firstName.isNotEmpty) {
displayName = firstName;
} else if (name.isNotEmpty) {
displayName = name;
} else {
displayName = 'Membre #${membre.opeUserId}';
}
if (sectName.isNotEmpty) {
displayName = '$displayName ($sectName)';
}
return DropdownMenuItem<int?>(
value: membre.opeUserId,
child: Text(
displayName,
overflow: TextOverflow.ellipsis,
),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedMemberId = newValue;
});
_applyFilters();
},
);
}
/// Construit le dropdown de sélection de secteur (admin uniquement)
Widget _buildSectorDropdown() {
// Récupérer tous les secteurs depuis le repository
var allSectors = sectorRepository.getAllSectors();
// Filtrage dynamique : si un membre est sélectionné, ne montrer que ses secteurs
if (_selectedMemberId != null) {
final userSectorsBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
final sectorIdsForMember = userSectorsBox.values
.where((us) => us.opeUserId == _selectedMemberId)
.map((us) => us.fkSector)
.toSet();
allSectors = allSectors.where((sector) => sectorIdsForMember.contains(sector.id)).toList();
}
// Trier par nom
allSectors.sort((a, b) => a.libelle.compareTo(b.libelle));
return DropdownButtonFormField<int?>(
value: _selectedSectorId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
labelText: 'Secteur',
prefixIcon: Icon(Icons.location_on, size: 20),
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous'),
),
...allSectors.map((sector) {
return DropdownMenuItem<int?>(
value: sector.id,
child: Text(
sector.libelle,
overflow: TextOverflow.ellipsis,
),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedSectorId = newValue;
});
_applyFilters();
},
);
}
}

3
app/lib/presentation/pages/map_page.dart Normal file → Executable file
View File

@@ -8,6 +8,7 @@ import 'dart:math' as math;
import 'dart:async';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/core/config/navigation_config.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
@@ -36,7 +37,7 @@ class MapPage extends StatelessWidget {
// Obtenir l'index de navigation selon la route actuelle
final currentRoute = GoRouterState.of(context).uri.toString();
final selectedIndex = NavigationHelper.getIndexFromRoute(currentRoute, isAdmin);
final selectedIndex = NavigationConfig.getIndexFromRoute(currentRoute, isAdmin);
return AppScaffold(
selectedIndex: selectedIndex,

View File

@@ -1,207 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Scaffold partagé pour toutes les pages d'administration
/// Fournit le fond dégradé et la navigation commune
class AdminScaffold extends StatelessWidget {
/// Le contenu de la page
final Widget body;
/// L'index de navigation sélectionné
final int selectedIndex;
/// Le titre de la page
final String pageTitle;
/// Callback optionnel pour gérer la navigation personnalisée
final Function(int)? onDestinationSelected;
const AdminScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.pageTitle,
this.onDestinationSelected,
});
@override
Widget build(BuildContext context) {
final currentUser = userRepository.getCurrentUser();
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 900;
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_$selectedIndex'),
title: 'Tableau de bord Administration',
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
// Navigation par défaut si pas de callback personnalisé
AdminNavigationHelper.navigateToIndex(context, index);
},
destinations: AdminNavigationHelper.getDestinations(
currentUser: currentUser,
isMobile: isMobile,
),
isAdmin: true,
body: body,
),
],
);
}
}
/// Helper pour centraliser la logique de navigation admin
class AdminNavigationHelper {
/// Obtenir la liste des destinations de navigation selon le rôle et le device
static List<NavigationDestination> getDestinations({
required dynamic currentUser,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[
// Pages de base toujours visibles
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Statistiques',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter les pages admin (role 2) seulement sur desktop
if (currentUser?.role == 2 && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index
static void navigateToIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/statistics');
break;
case 2:
context.go('/admin/history');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/map');
break;
case 5:
context.go('/admin/amicale');
break;
case 6:
context.go('/admin/operations');
break;
default:
context.go('/admin');
}
}
/// Obtenir l'index selon la route actuelle
static int getIndexFromRoute(String route) {
if (route.contains('/statistics')) return 1;
if (route.contains('/history')) return 2;
if (route.contains('/messages')) return 3;
if (route.contains('/map')) return 4;
if (route.contains('/amicale')) return 5;
if (route.contains('/operations')) return 6;
return 0; // Dashboard par défaut
}
/// Obtenir le nom de la page selon l'index
static String getPageNameFromIndex(int index) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'statistics';
case 2: return 'history';
case 3: return 'messages';
case 4: return 'map';
case 5: return 'amicale';
case 6: return 'operations';
default: return 'dashboard';
}
}
}

265
app/lib/presentation/widgets/app_scaffold.dart Normal file → Executable file
View File

@@ -1,33 +1,9 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/presentation/widgets/backgrounds/gradient_background.dart';
import 'package:geosector_app/core/config/navigation_config.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Classe pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Scaffold unifié pour toutes les pages (admin et user)
/// Adapte automatiquement son apparence selon le rôle de l'utilisateur
@@ -102,43 +78,26 @@ class AppScaffold extends StatelessWidget {
}
}
// Couleurs de fond selon le rôle
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300] // Admin: dégradé rouge
: [Colors.white, Colors.green.shade300]; // User: dégradé vert
// Titre avec suffixe selon le rôle
final dashboardTitle = isAdmin
? 'Tableau de bord Administration'
: 'GEOSECTOR';
return Stack(
children: [
// Fond dégradé avec petits points blancs (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
GradientBackground(
isAdmin: isAdmin,
showDots: true,
),
// Contenu de la page avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_${isAdmin ? 'admin' : 'user'}_$selectedIndex'),
title: dashboardTitle,
key: ValueKey(
'dashboard_layout_${isAdmin ? 'admin' : 'user'}_$selectedIndex'),
title: NavigationConfig.getDashboardTitle(isAdmin),
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
NavigationHelper.navigateToIndex(context, index, isAdmin);
},
destinations: NavigationHelper.getDestinations(
onDestinationSelected: onDestinationSelected ??
(index) {
NavigationConfig.navigateToIndex(context, index, isAdmin);
},
destinations: NavigationConfig.getDestinations(
isAdmin: isAdmin,
isMobile: isMobile,
),
@@ -159,27 +118,13 @@ class AppScaffold extends StatelessWidget {
}) {
final theme = Theme.of(context);
// Utiliser le même fond que pour un utilisateur normal (vert)
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300]
: [Colors.white, Colors.green.shade300];
return Stack(
children: [
// Fond dégradé (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
GradientBackground(
isAdmin: isAdmin,
showDots: true,
),
// Message d'accès restreint
@@ -245,182 +190,4 @@ class AppScaffold extends StatelessWidget {
],
);
}
}
/// Helper centralisé pour la navigation
class NavigationHelper {
/// Obtenir la liste des destinations selon le mode d'affichage et le device
static List<NavigationDestination> getDestinations({
required bool isAdmin,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[];
// Pages communes à tous les rôles
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
]);
// Pages spécifiques aux utilisateurs standards
if (!isAdmin) {
destinations.add(
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
);
}
// Pages spécifiques aux admins (seulement sur desktop)
if (isAdmin && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
const NavigationDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Connexions',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index et le rôle
static void navigateToIndex(BuildContext context, int index, bool isAdmin) {
if (isAdmin) {
_navigateAdminIndex(context, index);
} else {
_navigateUserIndex(context, index);
}
}
/// Navigation pour les admins
static void _navigateAdminIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/history');
break;
case 2:
context.go('/admin/map');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/amicale');
break;
case 5:
context.go('/admin/operations');
break;
case 6:
context.go('/admin/connexions');
break;
default:
context.go('/admin');
}
}
/// Navigation pour les utilisateurs standards
static void _navigateUserIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/user/dashboard');
break;
case 1:
context.go('/user/history');
break;
case 2:
context.go('/user/map');
break;
case 3:
context.go('/user/messages');
break;
case 4:
context.go('/user/field-mode');
break;
default:
context.go('/user/dashboard');
}
}
/// Obtenir l'index selon la route actuelle et le rôle
static int getIndexFromRoute(String route, bool isAdmin) {
// Enlever les paramètres de query si présents
final cleanRoute = route.split('?').first;
if (isAdmin) {
if (cleanRoute.contains('/admin/history')) return 1;
if (cleanRoute.contains('/admin/map')) return 2;
if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5;
if (cleanRoute.contains('/admin/connexions')) return 6;
return 0; // Dashboard par défaut
} else {
if (cleanRoute.contains('/user/history')) return 1;
if (cleanRoute.contains('/user/map')) return 2;
if (cleanRoute.contains('/user/messages')) return 3;
if (cleanRoute.contains('/user/field-mode')) return 4;
return 0; // Dashboard par défaut
}
}
/// Obtenir le nom de la page selon l'index et le rôle
static String getPageNameFromIndex(int index, bool isAdmin) {
if (isAdmin) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'amicale';
case 5: return 'operations';
case 6: return 'connexions';
default: return 'dashboard';
}
} else {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'field-mode';
default: return 'dashboard';
}
}
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// Painter pour dessiner des petits points blancs semi-transparents sur un fond
/// Utilisé pour créer un effet visuel subtil sur les fonds dégradés
class DotsPainter extends CustomPainter {
/// Opacité des points (0.0 à 1.0)
final double opacity;
/// Seed pour le générateur aléatoire (pour consistance du pattern)
final int seed;
/// Densité des points (nombre de pixels par point)
/// Plus la valeur est élevée, moins il y a de points
final int density;
const DotsPainter({
this.opacity = 0.5,
this.seed = 42,
this.density = 1500,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(opacity)
..style = PaintingStyle.fill;
final random = math.Random(seed);
final numberOfDots = (size.width * size.height) ~/ density;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant DotsPainter oldDelegate) {
return oldDelegate.opacity != opacity ||
oldDelegate.seed != seed ||
oldDelegate.density != density;
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/backgrounds/dots_painter.dart';
/// Widget de fond dégradé avec points décoratifs
/// Utilisé comme fond pour les pages admin et user avec des couleurs différentes
class GradientBackground extends StatelessWidget {
/// Indique si le fond est pour un admin (rouge) ou user (vert)
final bool isAdmin;
/// Afficher ou non les points décoratifs
final bool showDots;
/// Opacité des points (0.0 à 1.0)
final double dotsOpacity;
const GradientBackground({
super.key,
required this.isAdmin,
this.showDots = true,
this.dotsOpacity = 0.5,
});
@override
Widget build(BuildContext context) {
// Couleurs de fond selon le rôle
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300] // Admin: dégradé rouge
: [Colors.white, Colors.green.shade300]; // User: dégradé vert
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: showDots
? CustomPaint(
painter: DotsPainter(opacity: dotsOpacity),
child: const SizedBox(
width: double.infinity,
height: double.infinity,
),
)
: null,
);
}
}

View File

@@ -48,92 +48,30 @@ class DashboardLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
try {
debugPrint('Building DashboardLayout');
// Vérifier que les destinations ne sont pas vides
if (destinations.isEmpty) {
debugPrint('ERREUR: destinations est vide dans DashboardLayout');
return const Scaffold(
body: Center(
child: Text('Erreur: Aucune destination de navigation disponible'),
),
);
}
// Vérifier que selectedIndex est valide
if (selectedIndex < 0 || selectedIndex >= destinations.length) {
debugPrint('ERREUR: selectedIndex invalide dans DashboardLayout');
return Scaffold(
body: Center(
child:
Text('Erreur: Index de navigation invalide ($selectedIndex)'),
),
);
}
// Scaffold avec fond transparent (le fond est géré par AppScaffold)
return Scaffold(
key: ValueKey('dashboard_scaffold_$selectedIndex'),
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
key: ValueKey('dashboard_appbar_$selectedIndex'),
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
key: ValueKey('responsive_nav_$selectedIndex'),
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
);
} catch (e) {
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
// Afficher une interface de secours en cas d'erreur
return Scaffold(
appBar: AppBar(
title: Text('Erreur - $title'),
backgroundColor: Colors.red,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
Text(
'Une erreur est survenue',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('Détails: $e'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
Navigator.of(context)
.pushNamedAndRemoveUntil('/', (route) => false);
},
child: const Text('Retour à l\'accueil'),
),
],
),
),
);
}
// Scaffold avec fond transparent (le fond est géré par AppScaffold)
return Scaffold(
key: ValueKey('dashboard_scaffold_$selectedIndex'),
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
key: ValueKey('dashboard_appbar_$selectedIndex'),
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
key: ValueKey('responsive_nav_$selectedIndex'),
title: title,
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
showAppBar: false,
),
);
}
}

27
docs/PLANNING-2026-Q1.md Normal file → Executable file
View File

@@ -31,12 +31,10 @@
|-------|------------|--------------------------------------------|--------|
| 19/01 | `#13` Jour 1 | ✅ `#204` Design couleurs flashy | à livrer v3.6.3 |
| 19/01 | | ✅ `#205` Écrans utilisateurs simplifiés | à livrer v3.6.3 |
| 20/01 | `#13` Jour 2 | `#113` Couleur repasses orange | |
| 20/01 | | `#72`Épaisseur police lisibilité | |
| 21/01 | `#13` Jour 3 | `#71`Visibilité bouton "Envoyer message" | |
| 21/01 | | `#59`Listing rues invisible (clavier) | |
| 22/01 | `#13` Jour 4 | `#42`Historique adresses cliquables | |
| 23/01 | `#13` Jour 5 | `#74`Simplifier DashboardLayout/AppScaffold | |
| 20/01 | `#13` Jour 2 | `#113` Couleur repasses orange | ✅ Validé 26/01 |
| 20/01 | | `#72` Épaisseur police lisibilité (theme + BtnPassages) | ✅ Livré 26/01 |
| 21/01 | `#13` Jour 3 | `#42` Filtres membre/secteur history (admin) | ✅ Livré 26/01 |
| 23/01 | `#13` Jour 5 | `#74`Simplifier DashboardLayout/AppScaffold | ✅ Livré 26/01 |
| 24/01 | | `#28`Gestion reçus Flutter nouveaux champs | |
| 25/01 | | `#50`Modifier secteur au clic | |
@@ -143,17 +141,28 @@
---
## PHASE 7 : DIVERS (sans date)
### Tâches diverses - À planifier ultérieurement
| ID | Tâche | Cat | Statut |
|------|----------------------------------------|------|--------|
| `#71` | Visibilité bouton "Envoyer message" | UX | |
| `#59` | Listing rues invisible (clavier) | UX | |
---
## RÉCAPITULATIF
| Phase | Période | Jours | Tâches | Focus |
|-----------|--------------|-------|--------|---------------------|
| 1 | 16-18/01 | 3 | 5 | Bugs critiques |
| 2 | 19-25/01 | 7 | 10 | Stripe iOS + UX |
| 1 | 16-18/01 | 3 | 7 | Bugs critiques |
| 2 | 19-25/01 | 7 | 8 | Stripe iOS + UX |
| 3 | 26/01-07/02 | 10 | 25 | MAP / Carte |
| 4 | 08-14/02 | 6 | 11 | Stripe + Passages |
| 5 | 15-22/02 | 7 | 22 | Admin + Membres |
| 6 | 23-28/02 | 5 | 15 | Export + Divers |
| **TOTAL** | **44 jours** | | **88** | |
| 7 | Sans date | - | 2 | Divers |
| **TOTAL** | **44 jours** | | **90** | |
---