Initialisation du projet geosector complet (web + flutter)
This commit is contained in:
214
flutt/lib/app.dart
Normal file
214
flutt/lib/app.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/core/services/sync_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/auth/splash_page.dart';
|
||||
import 'package:geosector_app/presentation/public/landing_page.dart';
|
||||
import 'package:geosector_app/presentation/auth/login_page.dart';
|
||||
import 'package:geosector_app/presentation/auth/register_page.dart';
|
||||
import 'package:geosector_app/presentation/admin/admin_dashboard_page.dart';
|
||||
import 'package:geosector_app/presentation/user/user_dashboard_page.dart';
|
||||
|
||||
// Instances globales des services et repositories
|
||||
final apiService = ApiService();
|
||||
final operationRepository = OperationRepository(apiService);
|
||||
final passageRepository = PassageRepository(apiService);
|
||||
final userRepository = UserRepository(apiService);
|
||||
final sectorRepository = SectorRepository(apiService);
|
||||
final membreRepository = MembreRepository(apiService);
|
||||
final syncService = SyncService(userRepository: userRepository);
|
||||
final connectivityService = ConnectivityService();
|
||||
|
||||
class GeoSectorApp extends StatelessWidget {
|
||||
const GeoSectorApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Utiliser directement le router sans provider
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
debugLogDiagnostics: true,
|
||||
refreshListenable:
|
||||
userRepository, // Écouter les changements d'état d'authentification
|
||||
redirect: (context, state) {
|
||||
// Sauvegarder le chemin actuel pour l'utilisateur connecté, sauf pour la page de splash
|
||||
if (state.uri.toString() != '/' && userRepository.isLoggedIn) {
|
||||
// Ne pas sauvegarder les chemins de login/register
|
||||
if (!state.uri.toString().startsWith('/login') &&
|
||||
!state.uri.toString().startsWith('/register') &&
|
||||
!state.uri.toString().startsWith('/public')) {
|
||||
userRepository.updateLastPath(state.uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est sur la page de splash
|
||||
if (state.uri.toString() == '/') {
|
||||
// Vérifier si l'utilisateur a une session valide
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser == null || currentUser.sessionId == null) {
|
||||
// Si pas de session valide, rediriger vers la landing page
|
||||
return '/public';
|
||||
}
|
||||
|
||||
// Si l'utilisateur a une session valide et un chemin précédent, y retourner
|
||||
final lastPath = userRepository.getLastPath();
|
||||
if (lastPath != null && lastPath.isNotEmpty) {
|
||||
return lastPath;
|
||||
}
|
||||
|
||||
// Sinon, rediriger vers le tableau de bord approprié
|
||||
if (userRepository.isAdmin()) {
|
||||
return '/admin';
|
||||
} else {
|
||||
return '/user';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est sur une page d'authentification
|
||||
final isLoggedIn = userRepository.isLoggedIn;
|
||||
final isOnLoginPage = state.uri.toString() == '/login';
|
||||
final isOnRegisterPage = state.uri.toString() == '/register';
|
||||
final isOnAdminRegisterPage = state.uri.toString() == '/admin-register';
|
||||
final isOnPublicPage = state.uri.toString() == '/public';
|
||||
|
||||
// Vérifier si l'utilisateur vient de la landing page et va vers la page de connexion
|
||||
// Cette information est stockée dans les paramètres de la route
|
||||
final isFromLandingPage =
|
||||
state.uri.queryParameters['from'] == 'landing';
|
||||
|
||||
// Permettre l'accès aux pages publiques sans authentification
|
||||
if (isOnPublicPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si l'utilisateur vient de la landing page et va vers la page de connexion ou d'inscription,
|
||||
// ne pas rediriger, même s'il est déjà connecté
|
||||
if ((isOnLoginPage || isOnRegisterPage) && isFromLandingPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si l'utilisateur n'est pas connecté et n'est pas sur une page d'authentification, rediriger vers la page de connexion
|
||||
if (!isLoggedIn &&
|
||||
!isOnLoginPage &&
|
||||
!isOnRegisterPage &&
|
||||
!isOnAdminRegisterPage) {
|
||||
return '/login';
|
||||
}
|
||||
|
||||
// Si l'utilisateur est connecté et se trouve sur une page d'authentification, rediriger vers le tableau de bord approprié
|
||||
if (isLoggedIn &&
|
||||
(isOnLoginPage || isOnRegisterPage || isOnAdminRegisterPage)) {
|
||||
if (userRepository.isAdmin()) {
|
||||
return '/admin';
|
||||
} else {
|
||||
return '/user';
|
||||
}
|
||||
}
|
||||
|
||||
// Si l'utilisateur est connecté en tant qu'administrateur mais essaie d'accéder à une page utilisateur, rediriger vers le tableau de bord admin
|
||||
if (isLoggedIn &&
|
||||
userRepository.isAdmin() &&
|
||||
state.uri.toString().startsWith('/user')) {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
// Si l'utilisateur est connecté en tant qu'utilisateur mais essaie d'accéder à une page admin, rediriger vers le tableau de bord utilisateur
|
||||
if (isLoggedIn &&
|
||||
!userRepository.isAdmin() &&
|
||||
state.uri.toString().startsWith('/admin')) {
|
||||
return '/user';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Splash screen et page de démarrage
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
|
||||
// Pages publiques
|
||||
GoRoute(
|
||||
path: '/public',
|
||||
builder: (context, state) => const LandingPage(),
|
||||
),
|
||||
|
||||
// Pages d'authentification
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) {
|
||||
// Extraire le type de connexion depuis les extras
|
||||
Map<String, dynamic>? extras;
|
||||
if (state.extra != null && state.extra is Map<String, dynamic>) {
|
||||
extras = state.extra as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
String? loginType = extras?['type'];
|
||||
print('DEBUG ROUTER: Type dans les extras: $loginType');
|
||||
|
||||
// Nettoyer le paramètre type si présent
|
||||
if (loginType != null) {
|
||||
loginType = loginType.trim().toLowerCase();
|
||||
print('DEBUG ROUTER: Type nettoyé: $loginType');
|
||||
} else {
|
||||
// Fallback: essayer de récupérer depuis les paramètres d'URL
|
||||
final queryParams = state.uri.queryParameters;
|
||||
loginType = queryParams['type'];
|
||||
if (loginType != null) {
|
||||
loginType = loginType.trim().toLowerCase();
|
||||
print('DEBUG ROUTER: Type récupéré des params URL: $loginType');
|
||||
} else {
|
||||
loginType = 'admin'; // Valeur par défaut
|
||||
print('DEBUG ROUTER: Type par défaut: admin');
|
||||
}
|
||||
}
|
||||
|
||||
return LoginPage(
|
||||
key: Key('login_page_${loginType}'),
|
||||
loginType: loginType,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/register',
|
||||
builder: (context, state) => const RegisterPage(),
|
||||
),
|
||||
|
||||
// Pages administrateur
|
||||
GoRoute(
|
||||
path: '/admin',
|
||||
builder: (context, state) => const AdminDashboardPage(),
|
||||
routes: [
|
||||
// Ajouter d'autres routes admin ici
|
||||
],
|
||||
),
|
||||
|
||||
// Pages utilisateur
|
||||
GoRoute(
|
||||
path: '/user',
|
||||
builder: (context, state) => const UserDashboardPage(),
|
||||
routes: [
|
||||
// Ajouter d'autres routes utilisateur ici
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'GEOSECTOR',
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
82
flutt/lib/chat/README.md
Normal file
82
flutt/lib/chat/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Module Chat GEOSECTOR
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module chat est organisé selon une architecture modulaire respectant la séparation des préoccupations :
|
||||
|
||||
```
|
||||
lib/chat/
|
||||
├── models/ # Modèles de données
|
||||
│ ├── conversation_model.dart
|
||||
│ ├── message_model.dart
|
||||
│ ├── participant_model.dart
|
||||
│ └── audience_target_model.dart
|
||||
├── repositories/ # Gestion des données
|
||||
│ └── chat_repository.dart
|
||||
├── services/ # Services techniques
|
||||
│ ├── chat_api_service.dart
|
||||
│ └── offline_queue_service.dart
|
||||
├── widgets/ # Composants UI
|
||||
│ ├── chat_screen.dart
|
||||
│ ├── conversations_list.dart
|
||||
│ ├── message_bubble.dart
|
||||
│ └── chat_input.dart
|
||||
├── pages/ # Pages de l'application
|
||||
│ └── chat_page.dart
|
||||
├── chat.dart # Point d'entrée avec exports
|
||||
└── README.md # Documentation du module
|
||||
```
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
1. **Conversations** : Support des conversations one-to-one, groupes et annonces
|
||||
2. **Messages** : Envoi/réception de messages texte et pièces jointes
|
||||
3. **Participants** : Gestion des participants aux conversations
|
||||
4. **Annonces** : Diffusion de messages à des groupes spécifiques
|
||||
5. **Mode hors ligne** : File d'attente pour la synchronisation des données
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Importation
|
||||
|
||||
```dart
|
||||
import 'package:geosector/chat/chat.dart';
|
||||
```
|
||||
|
||||
### Affichage de la page chat
|
||||
|
||||
```dart
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatPage()),
|
||||
);
|
||||
```
|
||||
|
||||
### Création d'une conversation
|
||||
|
||||
```dart
|
||||
final chatRepository = ChatRepository();
|
||||
final conversation = await chatRepository.createConversation({
|
||||
'type': 'one_to_one',
|
||||
'participants': [userId1, userId2],
|
||||
});
|
||||
```
|
||||
|
||||
## États d'implémentation
|
||||
|
||||
- [x] Structure de base
|
||||
- [ ] Modèles de données complets
|
||||
- [ ] Intégration avec Hive
|
||||
- [ ] Services API
|
||||
- [ ] Gestion hors ligne
|
||||
- [ ] Widgets visuels
|
||||
- [ ] Tests unitaires
|
||||
|
||||
## À faire
|
||||
|
||||
1. Compléter l'implémentation des modèles avec les adaptateurs Hive
|
||||
2. Implémenter les méthodes dans les services et repositories
|
||||
3. Créer les widgets visuels avec le design approprié
|
||||
4. Ajouter les adaptateurs Hive pour le stockage local
|
||||
5. Implémenter la gestion des pièces jointes
|
||||
6. Ajouter les tests unitaires
|
||||
35
flutt/lib/chat/chat.dart
Normal file
35
flutt/lib/chat/chat.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
/// Exportation principale du module chat
|
||||
///
|
||||
/// Ce fichier centralise les exportations du module chat
|
||||
/// pour faciliter l'importation dans d'autres parties de l'application
|
||||
|
||||
// Models
|
||||
export 'models/conversation_model.dart';
|
||||
export 'models/message_model.dart';
|
||||
export 'models/participant_model.dart';
|
||||
export 'models/audience_target_model.dart';
|
||||
export 'models/anonymous_user_model.dart';
|
||||
export 'models/chat_config.dart';
|
||||
export 'models/notification_settings.dart';
|
||||
|
||||
// Repositories
|
||||
export 'repositories/chat_repository.dart';
|
||||
|
||||
// Services
|
||||
export 'services/chat_api_service.dart';
|
||||
export 'services/offline_queue_service.dart';
|
||||
export 'services/notifications/mqtt_notification_service.dart';
|
||||
export 'services/notifications/mqtt_config.dart';
|
||||
|
||||
// Widgets
|
||||
export 'widgets/chat_screen.dart';
|
||||
export 'widgets/conversations_list.dart';
|
||||
export 'widgets/message_bubble.dart';
|
||||
export 'widgets/chat_input.dart';
|
||||
export 'widgets/notification_settings_widget.dart';
|
||||
|
||||
// Pages
|
||||
export 'pages/chat_page.dart';
|
||||
|
||||
// Constants
|
||||
export 'constants/chat_constants.dart';
|
||||
510
flutt/lib/chat/chat_updated.md
Normal file
510
flutt/lib/chat/chat_updated.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Solution de Chat pour Applications Flutter
|
||||
|
||||
## Présentation générale
|
||||
|
||||
Cette solution propose un système de chat personnalisé et autonome pour des applications Flutter, avec possibilité d'intégration web. Elle est conçue pour fonctionner dans deux contextes différents :
|
||||
|
||||
1. **Chat entre utilisateurs authentifiés** (cas Geosector) : communications one-to-one ou en groupe entre utilisateurs déjà enregistrés dans la base de données.
|
||||
2. **Chat entre professionnels et visiteurs anonymes** (cas Resalice) : communications initiées par des visiteurs anonymes qui peuvent ensuite être convertis en clients référencés.
|
||||
|
||||
## Architecture technique
|
||||
|
||||
### 1. Structure générale
|
||||
|
||||
La solution s'articule autour de quatre composants principaux :
|
||||
|
||||
- **Module Flutter** : Widgets et logique pour l'interface utilisateur mobile
|
||||
- **Module Web** : Composants pour l'intégration web (compatible avec Flutter Web ou sites traditionnels)
|
||||
- **API Backend** : Endpoints REST uniquement pour la récupération de l'historique des conversations
|
||||
- **Module Go Chat Service** : Service de gestion des messages MQTT, modération et synchronisation avec la base de données
|
||||
|
||||
### 2. Infrastructure de notifications
|
||||
|
||||
#### Broker MQTT
|
||||
Le système utilise MQTT pour les notifications en temps réel :
|
||||
- Broker Mosquitto hébergé dans un container Incus
|
||||
- Connexion sécurisée via SSL/TLS (port 8883)
|
||||
- Authentification par username/password
|
||||
- QoS 1 (at least once) pour garantir la livraison
|
||||
|
||||
#### Module Go Chat Service
|
||||
Un service externe en Go qui :
|
||||
- Écoute les événements MQTT
|
||||
- Enregistre les messages dans la base de données
|
||||
- Applique des règles de modération configurables
|
||||
- Synchronise les notifications avec le stockage
|
||||
|
||||
```go
|
||||
type ChatService struct {
|
||||
mqttClient mqtt.Client
|
||||
db *sql.DB
|
||||
moderator *Moderator
|
||||
config *ChatConfig
|
||||
}
|
||||
|
||||
type ChatConfig struct {
|
||||
ApplicationID string
|
||||
ModeratorEnabled bool
|
||||
BadWords []string
|
||||
FloodLimits int
|
||||
SpamRules map[string]interface{}
|
||||
Webhooks []string
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Modèle de données
|
||||
|
||||
#### Entités principales
|
||||
|
||||
```
|
||||
Conversation
|
||||
├── id : Identifiant unique
|
||||
├── type : Type de conversation (one_to_one, group, anonymous, broadcast, announcement)
|
||||
├── title : Titre facultatif pour les groupes et obligatoire pour les annonces
|
||||
├── reply_permission : Niveau de permission pour répondre (all, admins_only, sender_only, none)
|
||||
├── created_at : Date de création
|
||||
├── updated_at : Dernière mise à jour
|
||||
├── is_pinned : Indique si la conversation est épinglée (pour annonces importantes)
|
||||
├── expiry_date : Date d'expiration optionnelle (pour annonces temporaires)
|
||||
└── participants : Liste des participants
|
||||
|
||||
Message
|
||||
├── id : Identifiant unique
|
||||
├── conversation_id : ID de la conversation
|
||||
├── sender_id : ID de l'expéditeur (null pour anonyme)
|
||||
├── sender_type : Type d'expéditeur (user, anonymous, system)
|
||||
├── content : Contenu du message
|
||||
├── content_type : Type de contenu (text, image, file)
|
||||
├── created_at : Date d'envoi
|
||||
├── delivered_at : Date de réception
|
||||
├── read_at : Date de lecture
|
||||
├── status : Statut du message (sent, delivered, read, error)
|
||||
├── is_announcement : Indique s'il s'agit d'une annonce officielle
|
||||
├── is_moderated : Indique si le message a été modéré
|
||||
└── moderation_status : Statut de la modération (pending, approved, rejected)
|
||||
|
||||
Participant
|
||||
├── id : Identifiant unique
|
||||
├── conversation_id : ID de la conversation
|
||||
├── user_id : ID de l'utilisateur (si authentifié)
|
||||
├── anonymous_id : ID anonyme (pour Resalice)
|
||||
├── role : Rôle (admin, member, read_only)
|
||||
├── joined_at : Date d'ajout à la conversation
|
||||
├── via_target : Indique si l'utilisateur est inclus via un AudienceTarget
|
||||
├── can_reply : Possibilité explicite de répondre (override de reply_permission)
|
||||
└── last_read_message_id : ID du dernier message lu
|
||||
|
||||
AudienceTarget
|
||||
├── id : Identifiant unique
|
||||
├── conversation_id : ID de la conversation
|
||||
├── target_type : Type de cible (role, entity, all, combined)
|
||||
├── target_id : ID du rôle ou de l'entité ciblée (pour compatibility)
|
||||
├── role_filter : Filtre de rôle pour le ciblage combiné ('all', '1', '2', etc.)
|
||||
├── entity_filter : Filtre d'entité pour le ciblage combiné ('all', 'id_entité')
|
||||
└── created_at : Date de création
|
||||
|
||||
AnonymousUser (pour Resalice)
|
||||
├── id : Identifiant unique
|
||||
├── device_id : Identifiant du dispositif
|
||||
├── name : Nom temporaire (si fourni)
|
||||
├── email : Email (si fourni)
|
||||
├── created_at : Date de création
|
||||
├── converted_to_user_id : ID utilisateur après conversion
|
||||
└── metadata : Informations supplémentaires
|
||||
|
||||
ChatNotification
|
||||
├── id : Identifiant unique
|
||||
├── user_id : ID de l'utilisateur destinataire
|
||||
├── message_id : ID du message
|
||||
├── conversation_id : ID de la conversation
|
||||
├── type : Type de notification
|
||||
├── status : Statut (sent, delivered, read)
|
||||
├── sent_at : Date d'envoi
|
||||
└── read_at : Date de lecture
|
||||
```
|
||||
|
||||
### 4. Backend et API
|
||||
|
||||
#### Structure de l'API
|
||||
|
||||
L'API sera développée en PHP 8.3 pour s'intégrer avec vos systèmes existants :
|
||||
|
||||
```
|
||||
/api/chat/conversations
|
||||
GET - Liste des conversations de l'utilisateur
|
||||
POST - Créer une nouvelle conversation
|
||||
|
||||
/api/chat/conversations/{id}
|
||||
GET - Détails d'une conversation
|
||||
PUT - Mettre à jour une conversation
|
||||
DELETE - Supprimer une conversation
|
||||
|
||||
/api/chat/conversations/{id}/messages
|
||||
GET - Messages d'une conversation (pagination) - uniquement pour l'historique
|
||||
|
||||
/api/chat/conversations/{id}/participants
|
||||
GET - Liste des participants
|
||||
POST - Ajouter un participant
|
||||
DELETE - Retirer un participant
|
||||
|
||||
/api/chat/messages/{id}
|
||||
PUT - Mettre à jour un message (ex: marquer comme lu)
|
||||
DELETE - Supprimer un message
|
||||
|
||||
/api/chat/anonymous
|
||||
POST - Démarrer une conversation anonyme
|
||||
|
||||
# Nouveaux endpoints pour les annonces
|
||||
/api/chat/announcements
|
||||
GET - Liste des annonces pour l'utilisateur
|
||||
POST - Créer une nouvelle annonce
|
||||
|
||||
/api/chat/announcements/{id}/stats
|
||||
GET - Obtenir les statistiques de lecture (qui a lu/non lu)
|
||||
|
||||
/api/chat/audience-targets
|
||||
GET - Obtenir les cibles disponibles pour l'utilisateur actuel
|
||||
|
||||
/api/chat/conversations/{id}/pin
|
||||
PUT - Épingler/désépingler une conversation
|
||||
|
||||
/api/chat/conversations/{id}/reply-permission
|
||||
PUT - Modifier les permissions de réponse
|
||||
|
||||
/api/chat/moderation/rules
|
||||
GET - Obtenir les règles de modération
|
||||
PUT - Mettre à jour les règles de modération
|
||||
```
|
||||
|
||||
#### Synchronisation
|
||||
|
||||
Le système supporte deux flux de données distincts :
|
||||
|
||||
1. **Temps réel via MQTT** :
|
||||
- Envoi de messages en temps réel
|
||||
- Notifications instantanées
|
||||
- Gestion via le module Go
|
||||
|
||||
2. **Récupération historique via REST** :
|
||||
- Chargement de l'historique des conversations
|
||||
- Synchronisation des anciens messages
|
||||
- Accès direct à la base de données
|
||||
|
||||
- Enregistrement local des messages avec Hive pour le fonctionnement hors ligne
|
||||
|
||||
### 5. Widgets Flutter
|
||||
|
||||
#### Widgets principaux
|
||||
|
||||
1. **ChatScreen** : Écran principal d'une conversation
|
||||
|
||||
```dart
|
||||
ChatScreen({
|
||||
required String conversationId,
|
||||
String? title,
|
||||
Widget? header,
|
||||
Widget? footer,
|
||||
bool enableAttachments = true,
|
||||
bool showTypingIndicator = true,
|
||||
bool enableReadReceipts = true,
|
||||
bool isAnnouncement = false,
|
||||
bool canReply = true,
|
||||
})
|
||||
```
|
||||
|
||||
2. **ConversationsList** : Liste des conversations
|
||||
|
||||
```dart
|
||||
ConversationsList({
|
||||
List<ConversationModel>? conversations,
|
||||
bool loadFromHive = true,
|
||||
Function(ConversationModel)? onConversationSelected,
|
||||
bool showLastMessage = true,
|
||||
bool showUnreadCount = true,
|
||||
bool showAnnouncementBadge = true,
|
||||
bool showPinnedFirst = true,
|
||||
Widget? emptyStateWidget,
|
||||
})
|
||||
```
|
||||
|
||||
3. **MessageBubble** : Bulle de message
|
||||
|
||||
```dart
|
||||
MessageBubble({
|
||||
required MessageModel message,
|
||||
bool showSenderInfo = true,
|
||||
bool showTimestamp = true,
|
||||
bool showStatus = true,
|
||||
bool isAnnouncement = false,
|
||||
double maxWidth = 300,
|
||||
})
|
||||
```
|
||||
|
||||
4. **ChatInput** : Zone de saisie de message
|
||||
|
||||
```dart
|
||||
ChatInput({
|
||||
required Function(String) onSendText,
|
||||
Function(File)? onSendFile,
|
||||
Function(File)? onSendImage,
|
||||
bool enableAttachments = true,
|
||||
bool enabled = true,
|
||||
String hintText = 'Saisissez votre message...',
|
||||
String? disabledMessage = 'Vous ne pouvez pas répondre à cette annonce',
|
||||
int? maxLength,
|
||||
})
|
||||
```
|
||||
|
||||
5. **AnonymousChatStarter** : Widget pour démarrer un chat anonyme (Resalice)
|
||||
|
||||
```dart
|
||||
AnonymousChatStarter({
|
||||
required Function(String?) onChatStarted,
|
||||
bool requireName = false,
|
||||
bool requireEmail = false,
|
||||
String buttonLabel = 'Démarrer une conversation',
|
||||
Widget? customForm,
|
||||
})
|
||||
```
|
||||
|
||||
6. **AnnouncementComposer** : Widget pour créer des annonces (Geosector uniquement)
|
||||
|
||||
```dart
|
||||
AnnouncementComposer({
|
||||
required Function(Map<String, dynamic>) onSend,
|
||||
List<Map<String, dynamic>>? availableTargets,
|
||||
String? initialTitle,
|
||||
String? initialMessage,
|
||||
bool allowAttachments = true,
|
||||
bool allowPinning = true,
|
||||
List<String> replyPermissionOptions = const ['all', 'admins_only', 'sender_only', 'none'],
|
||||
String defaultReplyPermission = 'none',
|
||||
DateTime? expiryDate,
|
||||
bool isGeosector = true, // Active la sélection des destinataires
|
||||
})
|
||||
```
|
||||
|
||||
### 6. Gestion des notifications MQTT
|
||||
|
||||
#### Service MQTT Flutter
|
||||
|
||||
```dart
|
||||
class MqttNotificationService {
|
||||
final String mqttHost;
|
||||
final int mqttPort;
|
||||
final String mqttUsername;
|
||||
final String mqttPassword;
|
||||
|
||||
Future<void> initialize({required String userId}) async {
|
||||
// Initialisation du client MQTT
|
||||
await _initializeMqttClient();
|
||||
// Abonnement aux topics de l'utilisateur
|
||||
_subscribeToUserTopics(userId);
|
||||
}
|
||||
|
||||
void _subscribeToUserTopics(String userId) {
|
||||
// Topics pour les messages personnels
|
||||
client.subscribe('chat/user/$userId/messages', MqttQos.atLeastOnce);
|
||||
// Topics pour les annonces
|
||||
client.subscribe('chat/announcement', MqttQos.atLeastOnce);
|
||||
}
|
||||
|
||||
Future<void> _handleMessage(String topic, Map<String, dynamic> data) async {
|
||||
// Traitement et affichage de la notification locale
|
||||
await _showLocalNotification(data);
|
||||
// Stockage local pour la synchronisation
|
||||
await _syncWithHive(data);
|
||||
}
|
||||
|
||||
// Pour envoyer un message en temps réel
|
||||
Future<void> sendMessage(String conversationId, String content) async {
|
||||
final message = {
|
||||
'conversationId': conversationId,
|
||||
'content': content,
|
||||
'senderId': currentUserId,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
await client.publishMessage(
|
||||
'chat/message/send',
|
||||
MqttQos.atLeastOnce,
|
||||
MqttClientPayloadBuilder().addString(jsonEncode(message)).payload!,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Service REST Flutter
|
||||
|
||||
```dart
|
||||
class ChatApiService {
|
||||
Future<List<Message>> getHistoricalMessages(
|
||||
String conversationId, {
|
||||
int page = 1,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
final response = await get('/api/chat/conversations/$conversationId/messages');
|
||||
return (response.data as List)
|
||||
.map((json) => Message.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Note: Pas de POST pour les messages - uniquement pour l'historique
|
||||
}
|
||||
```
|
||||
|
||||
#### Structure des topics MQTT
|
||||
|
||||
```
|
||||
chat/user/{userId}/messages - Messages personnels
|
||||
chat/conversation/{convId} - Messages de groupe
|
||||
chat/announcement - Annonces générales
|
||||
chat/moderation/{msgId} - Résultats de modération
|
||||
chat/typing/{convId} - Indicateurs de frappe
|
||||
```
|
||||
|
||||
### 7. Module Go Chat Service
|
||||
|
||||
Le module Go gère :
|
||||
|
||||
1. **Réception MQTT**
|
||||
- Écoute les topics de chat
|
||||
- Parse les messages JSON
|
||||
- Valide le format
|
||||
|
||||
2. **Modération**
|
||||
- Analyse du contenu
|
||||
- Application des règles configurables
|
||||
- Filtrage des mots interdits
|
||||
- Détection de spam
|
||||
- Notification des résultats
|
||||
|
||||
3. **Synchronisation base de données**
|
||||
- Enregistrement des messages en base
|
||||
- Création des notifications
|
||||
- Mise à jour des statuts de livraison
|
||||
- Gestion des acquittements
|
||||
|
||||
**Note importante** : Le module Go n'a aucune interaction avec l'API REST. Il est uniquement connecté au broker MQTT pour recevoir les messages et à la base de données pour les stocker.
|
||||
|
||||
4. **Configuration par application**
|
||||
```yaml
|
||||
applications:
|
||||
geosector:
|
||||
moderator_enabled: true
|
||||
bad_words: ["liste", "des", "mots"]
|
||||
flood_limit: 5
|
||||
spam_rules:
|
||||
url_limit: 2
|
||||
repetition_threshold: 0.8
|
||||
resalice:
|
||||
moderator_enabled: false
|
||||
# Configuration différente
|
||||
```
|
||||
|
||||
### 8. Stockage des fichiers
|
||||
|
||||
Le système supportera le téléchargement et le partage de fichiers :
|
||||
|
||||
1. **Côté serveur** : Stockage dans un répertoire sécurisé avec restriction d'accès
|
||||
2. **Côté client** : Mise en cache des fichiers pour éviter des téléchargements redondants
|
||||
3. **Types supportés** : Images, documents, autres fichiers selon configuration
|
||||
|
||||
## Cas d'utilisation spécifiques
|
||||
|
||||
### 1. Geosector
|
||||
|
||||
- **Utilisateurs authentifiés uniquement**
|
||||
- **Groupes par équipe** avec administrateurs pour les communications internes
|
||||
- **Modération active** avec filtrage de contenu
|
||||
- **Historique complet** des conversations
|
||||
- **Intégration avec la structure existante** des amicales et équipes
|
||||
- **Annonces et broadcasts**:
|
||||
- Super admin → tous les admins d'entités
|
||||
- Admin d'entité → tous les utilisateurs de son entité
|
||||
- Communications descendantes sans possibilité de réponse
|
||||
- Statistiques de lecture des annonces importantes
|
||||
- **Ciblage flexible des destinataires** :
|
||||
- Par entité (toutes ou une spécifique)
|
||||
- Par rôle (tous, membres, administrateurs)
|
||||
- Combinaison entité + rôle (ex: admins de l'entité 5)
|
||||
- Sélection via le widget `AnnouncementTargetSelector`
|
||||
|
||||
### 2. Resalice
|
||||
|
||||
- **Chats initiés par des anonymes**
|
||||
- **Conversation one-to-one uniquement** entre professionnel et client/prospect
|
||||
- **Pas de modération active** par défaut
|
||||
- **Conversion client** : Processus pour transformer un utilisateur anonyme en client référencé
|
||||
- **Conservation des historiques** après conversion
|
||||
- **Interface professionnelle** adaptée aux échanges client/professionnel
|
||||
- **Pas de fonctionnalité d'annonce** - uniquement des conversations directes
|
||||
|
||||
## Adaptabilité et extensibilité
|
||||
|
||||
### 1. Options de personnalisation
|
||||
|
||||
- **Thèmes** : Adaptation aux couleurs et styles de l'application
|
||||
- **Fonctionnalités** : Activation/désactivation de certaines fonctionnalités
|
||||
- **Comportements** : Configuration des notifications, comportement hors ligne, etc.
|
||||
- **Modération** : Configuration par application
|
||||
|
||||
### 2. Extensions possibles
|
||||
|
||||
- **Chatbot** : Possibilité d'intégrer des réponses automatiques
|
||||
- **Transfert** : Transfert de conversations entre professionnels
|
||||
- **Intégration CRM** : Liaison avec des systèmes CRM pour le suivi client
|
||||
- **Analyse** : Statistiques sur les conversations, temps de réponse, etc.
|
||||
- **Audio/Vidéo** : Support des messages vocaux et vidéo
|
||||
|
||||
## Étapes d'implémentation suggérées
|
||||
|
||||
1. **Phase 1 : Infrastructure de base** (4-5 semaines)
|
||||
- Installation et configuration du broker MQTT
|
||||
- Développement du module Go Chat Service
|
||||
- Modèles de données et adaptateurs Hive
|
||||
- Configuration de l'API backend
|
||||
|
||||
2. **Phase 2 : Fonctionnalités principales** (4-5 semaines)
|
||||
- Widgets de base pour affichage/envoi de messages
|
||||
- Gestion des notifications MQTT
|
||||
- Système de modération
|
||||
- Structure de base pour les annonces et broadcasts
|
||||
|
||||
3. **Phase 3 : Fonctionnalités avancées** (3-4 semaines)
|
||||
- Gestion hors ligne et synchronisation
|
||||
- Support des fichiers et images
|
||||
- Indicateurs de lecture et d'écriture
|
||||
- Système de ciblage d'audience pour les annonces
|
||||
|
||||
4. **Phase 4 : Cas spécifiques** (3-4 semaines)
|
||||
- Support des conversations anonymes (Resalice)
|
||||
- Groupes et permissions avancées (Geosector)
|
||||
- Statistiques de lecture des annonces
|
||||
- Interface administrateur pour les annonces globales
|
||||
- Intégration web complète
|
||||
|
||||
Le temps total d'implémentation pour Geosector est estimé à 12-15 semaines pour un développeur expérimenté en Flutter, PHP et Go. L'adaptation ultérieure à Resalice devrait prendre environ 3-4 semaines supplémentaires grâce à la conception modulaire du système.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Cette solution de chat personnalisée offre un équilibre entre robustesse et simplicité d'intégration. Elle répond aux besoins spécifiques de vos applications tout en restant suffisamment flexible pour s'adapter à d'autres contextes.
|
||||
|
||||
Le système prend en charge non seulement les conversations classiques (one-to-one, groupes) mais aussi les communications de type annonce/broadcast où un administrateur peut communiquer des informations importantes à des groupes d'utilisateurs définis par rôle ou entité, avec ou sans possibilité de réponse.
|
||||
|
||||
### Points clés de l'architecture
|
||||
|
||||
1. **Séparation des flux** :
|
||||
- **Temps réel** : Via MQTT pour l'envoi de messages et les notifications
|
||||
- **Historique** : Via REST pour la récupération des anciennes conversations
|
||||
|
||||
2. **Modération centrée** : Le module Go gère la modération sans interaction avec l'API REST
|
||||
|
||||
3. **Auto-hébergement** :
|
||||
- Broker MQTT dans votre container Incus
|
||||
- Module Go dédié pour la gestion des messages
|
||||
- Contrôle total de l'infrastructure
|
||||
|
||||
4. **Configuration flexible** : Modération et comportement adaptables par application
|
||||
|
||||
En développant cette solution en interne, vous gardez un contrôle total sur les fonctionnalités et l'expérience utilisateur, tout en assurant une cohérence avec le reste de vos applications. La conception modulaire et réutilisable permettra également un déploiement efficace sur vos différentes plateformes et applications.
|
||||
50
flutt/lib/chat/constants/chat_constants.dart
Normal file
50
flutt/lib/chat/constants/chat_constants.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
/// Constantes spécifiques au module chat
|
||||
|
||||
class ChatConstants {
|
||||
// Types de conversations
|
||||
static const String conversationTypeOneToOne = 'one_to_one';
|
||||
static const String conversationTypeGroup = 'group';
|
||||
static const String conversationTypeAnonymous = 'anonymous';
|
||||
static const String conversationTypeBroadcast = 'broadcast';
|
||||
static const String conversationTypeAnnouncement = 'announcement';
|
||||
|
||||
// Types de messages
|
||||
static const String messageTypeText = 'text';
|
||||
static const String messageTypeImage = 'image';
|
||||
static const String messageTypeFile = 'file';
|
||||
static const String messageTypeSystem = 'system';
|
||||
|
||||
// Types d'expéditeurs
|
||||
static const String senderTypeUser = 'user';
|
||||
static const String senderTypeAnonymous = 'anonymous';
|
||||
static const String senderTypeSystem = 'system';
|
||||
|
||||
// Rôles des participants
|
||||
static const String participantRoleAdmin = 'admin';
|
||||
static const String participantRoleMember = 'member';
|
||||
static const String participantRoleReadOnly = 'read_only';
|
||||
|
||||
// Permissions de réponse
|
||||
static const String replyPermissionAll = 'all';
|
||||
static const String replyPermissionAdminsOnly = 'admins_only';
|
||||
static const String replyPermissionSenderOnly = 'sender_only';
|
||||
static const String replyPermissionNone = 'none';
|
||||
|
||||
// Types de cibles d'audience
|
||||
static const String targetTypeRole = 'role';
|
||||
static const String targetTypeEntity = 'entity';
|
||||
static const String targetTypeAll = 'all';
|
||||
|
||||
// Noms des boîtes Hive
|
||||
static const String conversationsBoxName = 'chat_conversations';
|
||||
static const String messagesBoxName = 'chat_messages';
|
||||
static const String participantsBoxName = 'chat_participants';
|
||||
static const String anonymousUsersBoxName = 'chat_anonymous_users';
|
||||
static const String offlineQueueBoxName = 'chat_offline_queue';
|
||||
|
||||
// Configurations
|
||||
static const int defaultMessagePageSize = 50;
|
||||
static const int maxAttachmentSizeMB = 10;
|
||||
static const int maxMessageLength = 5000;
|
||||
static const Duration typingIndicatorTimeout = Duration(seconds: 3);
|
||||
}
|
||||
166
flutt/lib/chat/example_integration/mqtt_integration_example.dart
Normal file
166
flutt/lib/chat/example_integration/mqtt_integration_example.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../chat.dart';
|
||||
|
||||
/// Exemple d'intégration du service MQTT dans l'application
|
||||
///
|
||||
/// Montre comment initialiser et utiliser le service de notifications MQTT
|
||||
|
||||
class MqttIntegrationExample extends StatefulWidget {
|
||||
const MqttIntegrationExample({super.key});
|
||||
|
||||
@override
|
||||
State<MqttIntegrationExample> createState() => _MqttIntegrationExampleState();
|
||||
}
|
||||
|
||||
class _MqttIntegrationExampleState extends State<MqttIntegrationExample> {
|
||||
late final MqttNotificationService _notificationService;
|
||||
bool _isInitialized = false;
|
||||
String _status = 'Non initialisé';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeMqttService();
|
||||
}
|
||||
|
||||
Future<void> _initializeMqttService() async {
|
||||
try {
|
||||
// Initialiser le service avec la configuration
|
||||
_notificationService = MqttNotificationService(
|
||||
mqttHost: MqttConfig.host,
|
||||
mqttPort: MqttConfig.port,
|
||||
mqttUsername: MqttConfig.username,
|
||||
mqttPassword: MqttConfig.password,
|
||||
);
|
||||
|
||||
// Configurer les callbacks
|
||||
_notificationService.onMessageTap = (messageId) {
|
||||
debugPrint('Notification tapée : $messageId');
|
||||
// Naviguer vers la conversation correspondante
|
||||
_navigateToMessage(messageId);
|
||||
};
|
||||
|
||||
_notificationService.onNotificationReceived = (data) {
|
||||
debugPrint('Notification reçue : $data');
|
||||
setState(() {
|
||||
_status = 'Notification reçue : ${data['content']}';
|
||||
});
|
||||
};
|
||||
|
||||
// Initialiser avec l'ID utilisateur (récupéré du UserRepository)
|
||||
final userId = _getCurrentUserId(); // À implémenter selon votre logique
|
||||
await _notificationService.initialize(userId: userId);
|
||||
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
_status = 'Service MQTT initialisé';
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Erreur : $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _getCurrentUserId() {
|
||||
// Dans votre application réelle, vous récupéreriez l'ID utilisateur
|
||||
// depuis le UserRepository ou le contexte de l'application
|
||||
return '123'; // Exemple
|
||||
}
|
||||
|
||||
void _navigateToMessage(String messageId) {
|
||||
// Implémenter la navigation vers le message
|
||||
// Par exemple :
|
||||
// Navigator.push(context, MaterialPageRoute(
|
||||
// builder: (_) => ChatScreen(messageId: messageId),
|
||||
// ));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Test MQTT Notifications'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_status,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isInitialized) ...[
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_notificationService.pauseNotifications();
|
||||
setState(() {
|
||||
_status = 'Notifications en pause';
|
||||
});
|
||||
},
|
||||
child: const Text('Pause Notifications'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_notificationService.resumeNotifications();
|
||||
setState(() {
|
||||
_status = 'Notifications actives';
|
||||
});
|
||||
},
|
||||
child: const Text('Reprendre Notifications'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Exemple de test en publiant un message
|
||||
await _notificationService.publishMessage(
|
||||
'chat/user/${_getCurrentUserId()}/messages',
|
||||
{
|
||||
'type': 'chat_message',
|
||||
'messageId': 'test_${DateTime.now().millisecondsSinceEpoch}',
|
||||
'content': 'Message de test',
|
||||
'senderId': '999',
|
||||
'senderName': 'Système',
|
||||
},
|
||||
);
|
||||
setState(() {
|
||||
_status = 'Message test envoyé';
|
||||
});
|
||||
},
|
||||
child: const Text('Envoyer Message Test'),
|
||||
),
|
||||
] else ...[
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exemple d'intégration dans le main.dart de votre application
|
||||
void mainExample() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: const MqttIntegrationExample(),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
flutt/lib/chat/models/anonymous_user_model.dart
Normal file
104
flutt/lib/chat/models/anonymous_user_model.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'anonymous_user_model.g.dart';
|
||||
|
||||
/// Modèle d'utilisateur anonyme pour le système de chat
|
||||
///
|
||||
/// Ce modèle représente un utilisateur anonyme (pour le cas Resalice)
|
||||
/// et permet de tracker sa conversion éventuelle en utilisateur authentifié
|
||||
|
||||
@HiveType(typeId: 24)
|
||||
class AnonymousUserModel extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String deviceId;
|
||||
|
||||
@HiveField(2)
|
||||
final String? name;
|
||||
|
||||
@HiveField(3)
|
||||
final String? email;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(5)
|
||||
final String? convertedToUserId;
|
||||
|
||||
@HiveField(6)
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
AnonymousUserModel({
|
||||
required this.id,
|
||||
required this.deviceId,
|
||||
this.name,
|
||||
this.email,
|
||||
required this.createdAt,
|
||||
this.convertedToUserId,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory AnonymousUserModel.fromJson(Map<String, dynamic> json) {
|
||||
return AnonymousUserModel(
|
||||
id: json['id'] as String,
|
||||
deviceId: json['device_id'] as String,
|
||||
name: json['name'] as String?,
|
||||
email: json['email'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
convertedToUserId: json['converted_to_user_id'] as String?,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'device_id': deviceId,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'converted_to_user_id': convertedToUserId,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
AnonymousUserModel copyWith({
|
||||
String? id,
|
||||
String? deviceId,
|
||||
String? name,
|
||||
String? email,
|
||||
DateTime? createdAt,
|
||||
String? convertedToUserId,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
return AnonymousUserModel(
|
||||
id: id ?? this.id,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
convertedToUserId: convertedToUserId ?? this.convertedToUserId,
|
||||
metadata: metadata ?? this.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a été converti en utilisateur authentifié
|
||||
bool get isConverted => convertedToUserId != null;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
deviceId,
|
||||
name,
|
||||
email,
|
||||
createdAt,
|
||||
convertedToUserId,
|
||||
metadata,
|
||||
];
|
||||
}
|
||||
59
flutt/lib/chat/models/anonymous_user_model.g.dart
Normal file
59
flutt/lib/chat/models/anonymous_user_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'anonymous_user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AnonymousUserModelAdapter extends TypeAdapter<AnonymousUserModel> {
|
||||
@override
|
||||
final int typeId = 24;
|
||||
|
||||
@override
|
||||
AnonymousUserModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AnonymousUserModel(
|
||||
id: fields[0] as String,
|
||||
deviceId: fields[1] as String,
|
||||
name: fields[2] as String?,
|
||||
email: fields[3] as String?,
|
||||
createdAt: fields[4] as DateTime,
|
||||
convertedToUserId: fields[5] as String?,
|
||||
metadata: (fields[6] as Map?)?.cast<String, dynamic>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AnonymousUserModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.deviceId)
|
||||
..writeByte(2)
|
||||
..write(obj.name)
|
||||
..writeByte(3)
|
||||
..write(obj.email)
|
||||
..writeByte(4)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(5)
|
||||
..write(obj.convertedToUserId)
|
||||
..writeByte(6)
|
||||
..write(obj.metadata);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AnonymousUserModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
138
flutt/lib/chat/models/audience_target_model.dart
Normal file
138
flutt/lib/chat/models/audience_target_model.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'audience_target_model.g.dart';
|
||||
|
||||
/// Modèle de cible d'audience pour le système de chat
|
||||
///
|
||||
/// Ce modèle représente une cible d'audience pour les annonces et broadcasts
|
||||
/// Il supporte maintenant le ciblage combiné avec les filtres de rôle et d'entité
|
||||
|
||||
@HiveType(typeId: 23)
|
||||
class AudienceTargetModel extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String conversationId;
|
||||
|
||||
@HiveField(2)
|
||||
final String targetType;
|
||||
|
||||
@HiveField(3)
|
||||
final String? targetId;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(5)
|
||||
final String? roleFilter;
|
||||
|
||||
@HiveField(6)
|
||||
final String? entityFilter;
|
||||
|
||||
AudienceTargetModel({
|
||||
required this.id,
|
||||
required this.conversationId,
|
||||
required this.targetType,
|
||||
this.targetId,
|
||||
required this.createdAt,
|
||||
this.roleFilter,
|
||||
this.entityFilter,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory AudienceTargetModel.fromJson(Map<String, dynamic> json) {
|
||||
return AudienceTargetModel(
|
||||
id: json['id'] as String,
|
||||
conversationId: json['conversation_id'] as String,
|
||||
targetType: json['target_type'] as String,
|
||||
targetId: json['target_id'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
roleFilter: json['role_filter'] as String?,
|
||||
entityFilter: json['entity_filter'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'conversation_id': conversationId,
|
||||
'target_type': targetType,
|
||||
'target_id': targetId,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'role_filter': roleFilter,
|
||||
'entity_filter': entityFilter,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
AudienceTargetModel copyWith({
|
||||
String? id,
|
||||
String? conversationId,
|
||||
String? targetType,
|
||||
String? targetId,
|
||||
DateTime? createdAt,
|
||||
String? roleFilter,
|
||||
String? entityFilter,
|
||||
}) {
|
||||
return AudienceTargetModel(
|
||||
id: id ?? this.id,
|
||||
conversationId: conversationId ?? this.conversationId,
|
||||
targetType: targetType ?? this.targetType,
|
||||
targetId: targetId ?? this.targetId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
roleFilter: roleFilter ?? this.roleFilter,
|
||||
entityFilter: entityFilter ?? this.entityFilter,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est ciblé par cette règle
|
||||
bool targetsUser({
|
||||
required String userId,
|
||||
required int userRole,
|
||||
required String userEntityId,
|
||||
}) {
|
||||
switch (targetType) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'role':
|
||||
if (roleFilter != null && roleFilter != 'all') {
|
||||
return userRole.toString() == roleFilter;
|
||||
}
|
||||
return true;
|
||||
case 'entity':
|
||||
if (entityFilter != null && entityFilter != 'all') {
|
||||
return userEntityId == entityFilter;
|
||||
}
|
||||
return true;
|
||||
case 'combined':
|
||||
bool matchesRole = true;
|
||||
bool matchesEntity = true;
|
||||
|
||||
if (roleFilter != null && roleFilter != 'all') {
|
||||
matchesRole = userRole.toString() == roleFilter;
|
||||
}
|
||||
|
||||
if (entityFilter != null && entityFilter != 'all') {
|
||||
matchesEntity = userEntityId == entityFilter;
|
||||
}
|
||||
|
||||
return matchesRole && matchesEntity;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
conversationId,
|
||||
targetType,
|
||||
targetId,
|
||||
createdAt,
|
||||
roleFilter,
|
||||
entityFilter,
|
||||
];
|
||||
}
|
||||
59
flutt/lib/chat/models/audience_target_model.g.dart
Normal file
59
flutt/lib/chat/models/audience_target_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'audience_target_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
|
||||
@override
|
||||
final int typeId = 23;
|
||||
|
||||
@override
|
||||
AudienceTargetModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AudienceTargetModel(
|
||||
id: fields[0] as String,
|
||||
conversationId: fields[1] as String,
|
||||
targetType: fields[2] as String,
|
||||
targetId: fields[3] as String?,
|
||||
createdAt: fields[4] as DateTime,
|
||||
roleFilter: fields[5] as String?,
|
||||
entityFilter: fields[6] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AudienceTargetModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.conversationId)
|
||||
..writeByte(2)
|
||||
..write(obj.targetType)
|
||||
..writeByte(3)
|
||||
..write(obj.targetId)
|
||||
..writeByte(4)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(5)
|
||||
..write(obj.roleFilter)
|
||||
..writeByte(6)
|
||||
..write(obj.entityFilter);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AudienceTargetModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
15
flutt/lib/chat/models/chat_adapters.dart
Normal file
15
flutt/lib/chat/models/chat_adapters.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
// Fichier central pour regrouper tous les adaptateurs Hive du module chat
|
||||
|
||||
// Exports des modèles et leurs adaptateurs
|
||||
export 'conversation_model.dart';
|
||||
export 'message_model.dart';
|
||||
export 'participant_model.dart';
|
||||
export 'anonymous_user_model.dart';
|
||||
export 'audience_target_model.dart';
|
||||
export 'notification_settings.dart';
|
||||
|
||||
// Fonction pour enregistrer tous les adaptateurs Hive du chat
|
||||
Future<void> registerChatHiveAdapters() async {
|
||||
// Les adaptateurs sont déjà générés dans les fichiers .g.dart
|
||||
// Ils sont automatiquement enregistrés lors de l'appel de registerAdapter
|
||||
}
|
||||
104
flutt/lib/chat/models/chat_config.dart
Normal file
104
flutt/lib/chat/models/chat_config.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Configuration du module chat
|
||||
///
|
||||
/// Permet d'adapter le comportement du chat selon l'application
|
||||
/// (Geosector ou Resalice)
|
||||
|
||||
class ChatConfig with EquatableMixin {
|
||||
/// Active/désactive les annonces
|
||||
final bool enableAnnouncements;
|
||||
|
||||
/// Active/désactive la sélection de cibles pour les annonces
|
||||
final bool enableTargetSelection;
|
||||
|
||||
/// Active/désactive les statistiques des annonces
|
||||
final bool showAnnouncementStats;
|
||||
|
||||
/// Permission de réponse par défaut
|
||||
final String defaultReplyPermission;
|
||||
|
||||
/// Active/désactive les conversations anonymes
|
||||
final bool enableAnonymousConversations;
|
||||
|
||||
/// Active/désactive les conversations de groupe
|
||||
final bool enableGroupConversations;
|
||||
|
||||
/// Types de conversation autorisés
|
||||
final List<String> allowedConversationTypes;
|
||||
|
||||
/// Taille maximale des fichiers en Mo
|
||||
final int maxAttachmentSizeMB;
|
||||
|
||||
/// Nombre de messages par page
|
||||
final int messagePageSize;
|
||||
|
||||
ChatConfig({
|
||||
this.enableAnnouncements = true,
|
||||
this.enableTargetSelection = true,
|
||||
this.showAnnouncementStats = true,
|
||||
this.defaultReplyPermission = 'none',
|
||||
this.enableAnonymousConversations = false,
|
||||
this.enableGroupConversations = true,
|
||||
this.allowedConversationTypes = const [
|
||||
'one_to_one',
|
||||
'group',
|
||||
'announcement',
|
||||
'broadcast'
|
||||
],
|
||||
this.maxAttachmentSizeMB = 10,
|
||||
this.messagePageSize = 50,
|
||||
});
|
||||
|
||||
/// Configuration par défaut pour Geosector
|
||||
factory ChatConfig.geosector() {
|
||||
return ChatConfig(
|
||||
enableAnnouncements: true,
|
||||
enableTargetSelection: true,
|
||||
showAnnouncementStats: true,
|
||||
defaultReplyPermission: 'none',
|
||||
enableAnonymousConversations: false,
|
||||
enableGroupConversations: true,
|
||||
allowedConversationTypes: const [
|
||||
'one_to_one',
|
||||
'group',
|
||||
'announcement',
|
||||
'broadcast'
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Configuration par défaut pour Resalice
|
||||
factory ChatConfig.resalice() {
|
||||
return ChatConfig(
|
||||
enableAnnouncements: false,
|
||||
enableTargetSelection: false,
|
||||
showAnnouncementStats: false,
|
||||
defaultReplyPermission: 'all',
|
||||
enableAnonymousConversations: true,
|
||||
enableGroupConversations: false,
|
||||
allowedConversationTypes: const [
|
||||
'one_to_one',
|
||||
'anonymous'
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si un type de conversation est autorisé
|
||||
bool isConversationTypeAllowed(String type) {
|
||||
return allowedConversationTypes.contains(type);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
enableAnnouncements,
|
||||
enableTargetSelection,
|
||||
showAnnouncementStats,
|
||||
defaultReplyPermission,
|
||||
enableAnonymousConversations,
|
||||
enableGroupConversations,
|
||||
allowedConversationTypes,
|
||||
maxAttachmentSizeMB,
|
||||
messagePageSize,
|
||||
];
|
||||
}
|
||||
139
flutt/lib/chat/models/conversation_model.dart
Normal file
139
flutt/lib/chat/models/conversation_model.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'participant_model.dart';
|
||||
|
||||
part 'conversation_model.g.dart';
|
||||
|
||||
/// Modèle de conversation pour le système de chat
|
||||
///
|
||||
/// Ce modèle représente une conversation entre utilisateurs
|
||||
/// Il supporte différents types de conversations :
|
||||
/// - one_to_one : conversation privée entre 2 utilisateurs
|
||||
/// - group : groupe de plusieurs utilisateurs
|
||||
/// - anonymous : conversation avec un utilisateur anonyme
|
||||
/// - broadcast : message diffusé à plusieurs utilisateurs
|
||||
/// - announcement : annonce officielle
|
||||
|
||||
@HiveType(typeId: 20)
|
||||
class ConversationModel extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String type;
|
||||
|
||||
@HiveField(2)
|
||||
final String? title;
|
||||
|
||||
@HiveField(3)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime updatedAt;
|
||||
|
||||
@HiveField(5)
|
||||
final List<ParticipantModel> participants;
|
||||
|
||||
@HiveField(6)
|
||||
final bool isSynced;
|
||||
|
||||
@HiveField(7)
|
||||
final String replyPermission;
|
||||
|
||||
@HiveField(8)
|
||||
final bool isPinned;
|
||||
|
||||
@HiveField(9)
|
||||
final DateTime? expiryDate;
|
||||
|
||||
ConversationModel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.title,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.participants,
|
||||
this.isSynced = false,
|
||||
this.replyPermission = 'all',
|
||||
this.isPinned = false,
|
||||
this.expiryDate,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory ConversationModel.fromJson(Map<String, dynamic> json) {
|
||||
return ConversationModel(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
participants: (json['participants'] as List?)
|
||||
?.map((e) => ParticipantModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
isSynced: json['is_synced'] as bool? ?? false,
|
||||
replyPermission: json['reply_permission'] as String? ?? 'all',
|
||||
isPinned: json['is_pinned'] as bool? ?? false,
|
||||
expiryDate: json['expiry_date'] != null
|
||||
? DateTime.parse(json['expiry_date'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'participants': participants.map((e) => e.toJson()).toList(),
|
||||
'is_synced': isSynced,
|
||||
'reply_permission': replyPermission,
|
||||
'is_pinned': isPinned,
|
||||
'expiry_date': expiryDate?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
ConversationModel copyWith({
|
||||
String? id,
|
||||
String? type,
|
||||
String? title,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<ParticipantModel>? participants,
|
||||
bool? isSynced,
|
||||
String? replyPermission,
|
||||
bool? isPinned,
|
||||
DateTime? expiryDate,
|
||||
}) {
|
||||
return ConversationModel(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
participants: participants ?? this.participants,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
replyPermission: replyPermission ?? this.replyPermission,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
expiryDate: expiryDate ?? this.expiryDate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
participants,
|
||||
isSynced,
|
||||
replyPermission,
|
||||
isPinned,
|
||||
expiryDate,
|
||||
];
|
||||
}
|
||||
68
flutt/lib/chat/models/conversation_model.g.dart
Normal file
68
flutt/lib/chat/models/conversation_model.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'conversation_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ConversationModelAdapter extends TypeAdapter<ConversationModel> {
|
||||
@override
|
||||
final int typeId = 20;
|
||||
|
||||
@override
|
||||
ConversationModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ConversationModel(
|
||||
id: fields[0] as String,
|
||||
type: fields[1] as String,
|
||||
title: fields[2] as String?,
|
||||
createdAt: fields[3] as DateTime,
|
||||
updatedAt: fields[4] as DateTime,
|
||||
participants: (fields[5] as List).cast<ParticipantModel>(),
|
||||
isSynced: fields[6] as bool,
|
||||
replyPermission: fields[7] as String,
|
||||
isPinned: fields[8] as bool,
|
||||
expiryDate: fields[9] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ConversationModel obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.type)
|
||||
..writeByte(2)
|
||||
..write(obj.title)
|
||||
..writeByte(3)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(4)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(5)
|
||||
..write(obj.participants)
|
||||
..writeByte(6)
|
||||
..write(obj.isSynced)
|
||||
..writeByte(7)
|
||||
..write(obj.replyPermission)
|
||||
..writeByte(8)
|
||||
..write(obj.isPinned)
|
||||
..writeByte(9)
|
||||
..write(obj.expiryDate);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ConversationModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
140
flutt/lib/chat/models/message_model.dart
Normal file
140
flutt/lib/chat/models/message_model.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'message_model.g.dart';
|
||||
|
||||
/// Modèle de message pour le système de chat
|
||||
///
|
||||
/// Ce modèle représente un message échangé dans une conversation
|
||||
|
||||
@HiveType(typeId: 21)
|
||||
class MessageModel extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String conversationId;
|
||||
|
||||
@HiveField(2)
|
||||
final String? senderId;
|
||||
|
||||
@HiveField(3)
|
||||
final String senderType;
|
||||
|
||||
@HiveField(4)
|
||||
final String content;
|
||||
|
||||
@HiveField(5)
|
||||
final String contentType;
|
||||
|
||||
@HiveField(6)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(7)
|
||||
final DateTime? deliveredAt;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime? readAt;
|
||||
|
||||
@HiveField(9)
|
||||
final String status;
|
||||
|
||||
@HiveField(10)
|
||||
final bool isAnnouncement;
|
||||
|
||||
MessageModel({
|
||||
required this.id,
|
||||
required this.conversationId,
|
||||
this.senderId,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
required this.contentType,
|
||||
required this.createdAt,
|
||||
this.deliveredAt,
|
||||
this.readAt,
|
||||
required this.status,
|
||||
this.isAnnouncement = false,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory MessageModel.fromJson(Map<String, dynamic> json) {
|
||||
return MessageModel(
|
||||
id: json['id'] as String,
|
||||
conversationId: json['conversation_id'] as String,
|
||||
senderId: json['sender_id'] as String?,
|
||||
senderType: json['sender_type'] as String,
|
||||
content: json['content'] as String,
|
||||
contentType: json['content_type'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
deliveredAt: json['delivered_at'] != null
|
||||
? DateTime.parse(json['delivered_at'] as String)
|
||||
: null,
|
||||
readAt: json['read_at'] != null
|
||||
? DateTime.parse(json['read_at'] as String)
|
||||
: null,
|
||||
status: json['status'] as String,
|
||||
isAnnouncement: json['is_announcement'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'conversation_id': conversationId,
|
||||
'sender_id': senderId,
|
||||
'sender_type': senderType,
|
||||
'content': content,
|
||||
'content_type': contentType,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'delivered_at': deliveredAt?.toIso8601String(),
|
||||
'read_at': readAt?.toIso8601String(),
|
||||
'status': status,
|
||||
'is_announcement': isAnnouncement,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
MessageModel copyWith({
|
||||
String? id,
|
||||
String? conversationId,
|
||||
String? senderId,
|
||||
String? senderType,
|
||||
String? content,
|
||||
String? contentType,
|
||||
DateTime? createdAt,
|
||||
DateTime? deliveredAt,
|
||||
DateTime? readAt,
|
||||
String? status,
|
||||
bool? isAnnouncement,
|
||||
}) {
|
||||
return MessageModel(
|
||||
id: id ?? this.id,
|
||||
conversationId: conversationId ?? this.conversationId,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderType: senderType ?? this.senderType,
|
||||
content: content ?? this.content,
|
||||
contentType: contentType ?? this.contentType,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
readAt: readAt ?? this.readAt,
|
||||
status: status ?? this.status,
|
||||
isAnnouncement: isAnnouncement ?? this.isAnnouncement,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderType,
|
||||
content,
|
||||
contentType,
|
||||
createdAt,
|
||||
deliveredAt,
|
||||
readAt,
|
||||
status,
|
||||
isAnnouncement,
|
||||
];
|
||||
}
|
||||
71
flutt/lib/chat/models/message_model.g.dart
Normal file
71
flutt/lib/chat/models/message_model.g.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'message_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class MessageModelAdapter extends TypeAdapter<MessageModel> {
|
||||
@override
|
||||
final int typeId = 21;
|
||||
|
||||
@override
|
||||
MessageModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return MessageModel(
|
||||
id: fields[0] as String,
|
||||
conversationId: fields[1] as String,
|
||||
senderId: fields[2] as String?,
|
||||
senderType: fields[3] as String,
|
||||
content: fields[4] as String,
|
||||
contentType: fields[5] as String,
|
||||
createdAt: fields[6] as DateTime,
|
||||
deliveredAt: fields[7] as DateTime?,
|
||||
readAt: fields[8] as DateTime?,
|
||||
status: fields[9] as String,
|
||||
isAnnouncement: fields[10] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, MessageModel obj) {
|
||||
writer
|
||||
..writeByte(11)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.conversationId)
|
||||
..writeByte(2)
|
||||
..write(obj.senderId)
|
||||
..writeByte(3)
|
||||
..write(obj.senderType)
|
||||
..writeByte(4)
|
||||
..write(obj.content)
|
||||
..writeByte(5)
|
||||
..write(obj.contentType)
|
||||
..writeByte(6)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(7)
|
||||
..write(obj.deliveredAt)
|
||||
..writeByte(8)
|
||||
..write(obj.readAt)
|
||||
..writeByte(9)
|
||||
..write(obj.status)
|
||||
..writeByte(10)
|
||||
..write(obj.isAnnouncement);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MessageModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
160
flutt/lib/chat/models/notification_settings.dart
Normal file
160
flutt/lib/chat/models/notification_settings.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'notification_settings.g.dart';
|
||||
|
||||
/// Paramètres de notification pour le chat
|
||||
///
|
||||
/// Permet à l'utilisateur de configurer ses préférences de notification
|
||||
|
||||
@HiveType(typeId: 25)
|
||||
class NotificationSettings extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final bool enableNotifications;
|
||||
|
||||
@HiveField(1)
|
||||
final bool soundEnabled;
|
||||
|
||||
@HiveField(2)
|
||||
final bool vibrationEnabled;
|
||||
|
||||
@HiveField(3)
|
||||
final List<String> mutedConversations;
|
||||
|
||||
@HiveField(4)
|
||||
final bool showPreview;
|
||||
|
||||
@HiveField(5)
|
||||
final Map<String, bool> conversationNotifications;
|
||||
|
||||
@HiveField(6)
|
||||
final bool doNotDisturb;
|
||||
|
||||
@HiveField(7)
|
||||
final DateTime? doNotDisturbStart;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime? doNotDisturbEnd;
|
||||
|
||||
@HiveField(9)
|
||||
final String? deviceToken;
|
||||
|
||||
NotificationSettings({
|
||||
this.enableNotifications = true,
|
||||
this.soundEnabled = true,
|
||||
this.vibrationEnabled = true,
|
||||
this.mutedConversations = const [],
|
||||
this.showPreview = true,
|
||||
this.conversationNotifications = const {},
|
||||
this.doNotDisturb = false,
|
||||
this.doNotDisturbStart,
|
||||
this.doNotDisturbEnd,
|
||||
this.deviceToken,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory NotificationSettings.fromJson(Map<String, dynamic> json) {
|
||||
return NotificationSettings(
|
||||
enableNotifications: json['enable_notifications'] as bool? ?? true,
|
||||
soundEnabled: json['sound_enabled'] as bool? ?? true,
|
||||
vibrationEnabled: json['vibration_enabled'] as bool? ?? true,
|
||||
mutedConversations: List<String>.from(json['muted_conversations'] ?? []),
|
||||
showPreview: json['show_preview'] as bool? ?? true,
|
||||
conversationNotifications: Map<String, bool>.from(json['conversation_notifications'] ?? {}),
|
||||
doNotDisturb: json['do_not_disturb'] as bool? ?? false,
|
||||
doNotDisturbStart: json['do_not_disturb_start'] != null
|
||||
? DateTime.parse(json['do_not_disturb_start'])
|
||||
: null,
|
||||
doNotDisturbEnd: json['do_not_disturb_end'] != null
|
||||
? DateTime.parse(json['do_not_disturb_end'])
|
||||
: null,
|
||||
deviceToken: json['device_token'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'enable_notifications': enableNotifications,
|
||||
'sound_enabled': soundEnabled,
|
||||
'vibration_enabled': vibrationEnabled,
|
||||
'muted_conversations': mutedConversations,
|
||||
'show_preview': showPreview,
|
||||
'conversation_notifications': conversationNotifications,
|
||||
'do_not_disturb': doNotDisturb,
|
||||
'do_not_disturb_start': doNotDisturbStart?.toIso8601String(),
|
||||
'do_not_disturb_end': doNotDisturbEnd?.toIso8601String(),
|
||||
'device_token': deviceToken,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
NotificationSettings copyWith({
|
||||
bool? enableNotifications,
|
||||
bool? soundEnabled,
|
||||
bool? vibrationEnabled,
|
||||
List<String>? mutedConversations,
|
||||
bool? showPreview,
|
||||
Map<String, bool>? conversationNotifications,
|
||||
bool? doNotDisturb,
|
||||
DateTime? doNotDisturbStart,
|
||||
DateTime? doNotDisturbEnd,
|
||||
String? deviceToken,
|
||||
}) {
|
||||
return NotificationSettings(
|
||||
enableNotifications: enableNotifications ?? this.enableNotifications,
|
||||
soundEnabled: soundEnabled ?? this.soundEnabled,
|
||||
vibrationEnabled: vibrationEnabled ?? this.vibrationEnabled,
|
||||
mutedConversations: mutedConversations ?? this.mutedConversations,
|
||||
showPreview: showPreview ?? this.showPreview,
|
||||
conversationNotifications: conversationNotifications ?? this.conversationNotifications,
|
||||
doNotDisturb: doNotDisturb ?? this.doNotDisturb,
|
||||
doNotDisturbStart: doNotDisturbStart ?? this.doNotDisturbStart,
|
||||
doNotDisturbEnd: doNotDisturbEnd ?? this.doNotDisturbEnd,
|
||||
deviceToken: deviceToken ?? this.deviceToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si une conversation est en mode silencieux
|
||||
bool isConversationMuted(String conversationId) {
|
||||
return mutedConversations.contains(conversationId);
|
||||
}
|
||||
|
||||
/// Vérifie si les notifications sont activées pour une conversation
|
||||
bool areNotificationsEnabled(String conversationId) {
|
||||
if (!enableNotifications) return false;
|
||||
if (isConversationMuted(conversationId)) return false;
|
||||
if (doNotDisturb && _isInDoNotDisturbPeriod()) return false;
|
||||
|
||||
return conversationNotifications[conversationId] ?? true;
|
||||
}
|
||||
|
||||
/// Vérifie si on est dans la période "Ne pas déranger"
|
||||
bool _isInDoNotDisturbPeriod() {
|
||||
if (!doNotDisturb) return false;
|
||||
if (doNotDisturbStart == null || doNotDisturbEnd == null) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (doNotDisturbStart!.isBefore(doNotDisturbEnd!)) {
|
||||
// Période normale (ex: 22h à 8h)
|
||||
return now.isAfter(doNotDisturbStart!) && now.isBefore(doNotDisturbEnd!);
|
||||
} else {
|
||||
// Période qui chevauche minuit (ex: 20h à 6h)
|
||||
return now.isAfter(doNotDisturbStart!) || now.isBefore(doNotDisturbEnd!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
enableNotifications,
|
||||
soundEnabled,
|
||||
vibrationEnabled,
|
||||
mutedConversations,
|
||||
showPreview,
|
||||
conversationNotifications,
|
||||
doNotDisturb,
|
||||
doNotDisturbStart,
|
||||
doNotDisturbEnd,
|
||||
deviceToken,
|
||||
];
|
||||
}
|
||||
68
flutt/lib/chat/models/notification_settings.g.dart
Normal file
68
flutt/lib/chat/models/notification_settings.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class NotificationSettingsAdapter extends TypeAdapter<NotificationSettings> {
|
||||
@override
|
||||
final int typeId = 25;
|
||||
|
||||
@override
|
||||
NotificationSettings read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return NotificationSettings(
|
||||
enableNotifications: fields[0] as bool,
|
||||
soundEnabled: fields[1] as bool,
|
||||
vibrationEnabled: fields[2] as bool,
|
||||
mutedConversations: (fields[3] as List).cast<String>(),
|
||||
showPreview: fields[4] as bool,
|
||||
conversationNotifications: (fields[5] as Map).cast<String, bool>(),
|
||||
doNotDisturb: fields[6] as bool,
|
||||
doNotDisturbStart: fields[7] as DateTime?,
|
||||
doNotDisturbEnd: fields[8] as DateTime?,
|
||||
deviceToken: fields[9] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, NotificationSettings obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.enableNotifications)
|
||||
..writeByte(1)
|
||||
..write(obj.soundEnabled)
|
||||
..writeByte(2)
|
||||
..write(obj.vibrationEnabled)
|
||||
..writeByte(3)
|
||||
..write(obj.mutedConversations)
|
||||
..writeByte(4)
|
||||
..write(obj.showPreview)
|
||||
..writeByte(5)
|
||||
..write(obj.conversationNotifications)
|
||||
..writeByte(6)
|
||||
..write(obj.doNotDisturb)
|
||||
..writeByte(7)
|
||||
..write(obj.doNotDisturbStart)
|
||||
..writeByte(8)
|
||||
..write(obj.doNotDisturbEnd)
|
||||
..writeByte(9)
|
||||
..write(obj.deviceToken);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is NotificationSettingsAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
118
flutt/lib/chat/models/participant_model.dart
Normal file
118
flutt/lib/chat/models/participant_model.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
part 'participant_model.g.dart';
|
||||
|
||||
/// Modèle de participant pour le système de chat
|
||||
///
|
||||
/// Ce modèle représente un participant à une conversation
|
||||
|
||||
@HiveType(typeId: 22)
|
||||
class ParticipantModel extends HiveObject with EquatableMixin {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String conversationId;
|
||||
|
||||
@HiveField(2)
|
||||
final String? userId;
|
||||
|
||||
@HiveField(3)
|
||||
final String? anonymousId;
|
||||
|
||||
@HiveField(4)
|
||||
final String role;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime joinedAt;
|
||||
|
||||
@HiveField(6)
|
||||
final String? lastReadMessageId;
|
||||
|
||||
@HiveField(7)
|
||||
final bool viaTarget;
|
||||
|
||||
@HiveField(8)
|
||||
final bool? canReply;
|
||||
|
||||
ParticipantModel({
|
||||
required this.id,
|
||||
required this.conversationId,
|
||||
this.userId,
|
||||
this.anonymousId,
|
||||
required this.role,
|
||||
required this.joinedAt,
|
||||
this.lastReadMessageId,
|
||||
this.viaTarget = false,
|
||||
this.canReply,
|
||||
});
|
||||
|
||||
/// Crée une instance depuis le JSON
|
||||
factory ParticipantModel.fromJson(Map<String, dynamic> json) {
|
||||
return ParticipantModel(
|
||||
id: json['id'] as String,
|
||||
conversationId: json['conversation_id'] as String,
|
||||
userId: json['user_id'] as String?,
|
||||
anonymousId: json['anonymous_id'] as String?,
|
||||
role: json['role'] as String,
|
||||
joinedAt: DateTime.parse(json['joined_at'] as String),
|
||||
lastReadMessageId: json['last_read_message_id'] as String?,
|
||||
viaTarget: json['via_target'] as bool? ?? false,
|
||||
canReply: json['can_reply'] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit l'instance en JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'conversation_id': conversationId,
|
||||
'user_id': userId,
|
||||
'anonymous_id': anonymousId,
|
||||
'role': role,
|
||||
'joined_at': joinedAt.toIso8601String(),
|
||||
'last_read_message_id': lastReadMessageId,
|
||||
'via_target': viaTarget,
|
||||
'can_reply': canReply,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie modifiée de l'instance
|
||||
ParticipantModel copyWith({
|
||||
String? id,
|
||||
String? conversationId,
|
||||
String? userId,
|
||||
String? anonymousId,
|
||||
String? role,
|
||||
DateTime? joinedAt,
|
||||
String? lastReadMessageId,
|
||||
bool? viaTarget,
|
||||
bool? canReply,
|
||||
}) {
|
||||
return ParticipantModel(
|
||||
id: id ?? this.id,
|
||||
conversationId: conversationId ?? this.conversationId,
|
||||
userId: userId ?? this.userId,
|
||||
anonymousId: anonymousId ?? this.anonymousId,
|
||||
role: role ?? this.role,
|
||||
joinedAt: joinedAt ?? this.joinedAt,
|
||||
lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId,
|
||||
viaTarget: viaTarget ?? this.viaTarget,
|
||||
canReply: canReply ?? this.canReply,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
conversationId,
|
||||
userId,
|
||||
anonymousId,
|
||||
role,
|
||||
joinedAt,
|
||||
lastReadMessageId,
|
||||
viaTarget,
|
||||
canReply,
|
||||
];
|
||||
}
|
||||
65
flutt/lib/chat/models/participant_model.g.dart
Normal file
65
flutt/lib/chat/models/participant_model.g.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'participant_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ParticipantModelAdapter extends TypeAdapter<ParticipantModel> {
|
||||
@override
|
||||
final int typeId = 22;
|
||||
|
||||
@override
|
||||
ParticipantModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ParticipantModel(
|
||||
id: fields[0] as String,
|
||||
conversationId: fields[1] as String,
|
||||
userId: fields[2] as String?,
|
||||
anonymousId: fields[3] as String?,
|
||||
role: fields[4] as String,
|
||||
joinedAt: fields[5] as DateTime,
|
||||
lastReadMessageId: fields[6] as String?,
|
||||
viaTarget: fields[7] as bool,
|
||||
canReply: fields[8] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ParticipantModel obj) {
|
||||
writer
|
||||
..writeByte(9)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.conversationId)
|
||||
..writeByte(2)
|
||||
..write(obj.userId)
|
||||
..writeByte(3)
|
||||
..write(obj.anonymousId)
|
||||
..writeByte(4)
|
||||
..write(obj.role)
|
||||
..writeByte(5)
|
||||
..write(obj.joinedAt)
|
||||
..writeByte(6)
|
||||
..write(obj.lastReadMessageId)
|
||||
..writeByte(7)
|
||||
..write(obj.viaTarget)
|
||||
..writeByte(8)
|
||||
..write(obj.canReply);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ParticipantModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
79
flutt/lib/chat/pages/chat_page.dart
Normal file
79
flutt/lib/chat/pages/chat_page.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../widgets/conversations_list.dart';
|
||||
import '../widgets/chat_screen.dart';
|
||||
|
||||
/// Page principale du module chat
|
||||
///
|
||||
/// Cette page sert de point d'entrée pour le module chat
|
||||
/// et gère la navigation entre les conversations
|
||||
|
||||
class ChatPage extends StatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
String? _selectedConversationId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLargeScreen = MediaQuery.of(context).size.width > 900;
|
||||
|
||||
if (isLargeScreen) {
|
||||
// Vue desktop (séparée en deux panneaux)
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// Liste des conversations à gauche
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: ConversationsList(
|
||||
onConversationSelected: (conversation) {
|
||||
setState(() {
|
||||
_selectedConversationId = 'conversation-id'; // TODO: obtenir l'ID de la conversation
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
// Conversation sélectionnée à droite
|
||||
Expanded(
|
||||
child: _selectedConversationId != null
|
||||
? ChatScreen(conversationId: _selectedConversationId!)
|
||||
: const Center(child: Text('Sélectionnez une conversation')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Vue mobile
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Chat'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
// TODO: Créer une nouvelle conversation
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ConversationsList(
|
||||
onConversationSelected: (conversation) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(
|
||||
conversationId: 'conversation-id', // TODO: obtenir l'ID de la conversation
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
364
flutt/lib/chat/repositories/chat_repository.dart
Normal file
364
flutt/lib/chat/repositories/chat_repository.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../core/constants/app_keys.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
import '../models/message_model.dart';
|
||||
import '../models/participant_model.dart';
|
||||
import '../services/chat_api_service.dart';
|
||||
import '../services/notifications/mqtt_notification_service.dart';
|
||||
|
||||
/// Repository pour la gestion des fonctionnalités de chat
|
||||
///
|
||||
/// Ce repository centralise toutes les opérations liées au chat,
|
||||
/// y compris la gestion des conversations, des messages et des participants
|
||||
|
||||
class ChatRepository {
|
||||
final ChatApiService _apiService;
|
||||
final MqttNotificationService _mqttService;
|
||||
|
||||
ChatRepository(this._apiService, this._mqttService);
|
||||
|
||||
/// Liste des conversations de l'utilisateur
|
||||
Future<List<ConversationModel>> getConversations({bool forceRefresh = false}) async {
|
||||
try {
|
||||
// Récupérer depuis Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var localConversations = box.values.toList();
|
||||
|
||||
// Si on force le rafraîchissement ou qu'on n'a pas de données locales
|
||||
if (forceRefresh || localConversations.isEmpty) {
|
||||
try {
|
||||
// Récupérer depuis l'API
|
||||
var apiConversations = await _apiService.getConversations();
|
||||
|
||||
// Mettre à jour Hive
|
||||
await box.clear();
|
||||
for (var conversation in apiConversations) {
|
||||
await box.put(conversation.id, conversation);
|
||||
}
|
||||
|
||||
return apiConversations;
|
||||
} catch (e) {
|
||||
// Si l'API échoue, utiliser les données locales
|
||||
if (localConversations.isNotEmpty) {
|
||||
return localConversations;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return localConversations;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère une conversation spécifique
|
||||
Future<ConversationModel> getConversation(String id) async {
|
||||
try {
|
||||
// Vérifier d'abord dans Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var localConversation = box.get(id);
|
||||
|
||||
if (localConversation != null) {
|
||||
return localConversation;
|
||||
}
|
||||
|
||||
// Sinon récupérer depuis l'API
|
||||
var apiConversation = await _apiService.getConversation(id);
|
||||
await box.put(id, apiConversation);
|
||||
|
||||
return apiConversation;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération de la conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une nouvelle conversation
|
||||
Future<ConversationModel> createConversation(Map<String, dynamic> data) async {
|
||||
try {
|
||||
// Créer via l'API
|
||||
var conversation = await _apiService.createConversation(data);
|
||||
|
||||
// Sauvegarder dans Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
await box.put(conversation.id, conversation);
|
||||
|
||||
// S'abonner aux notifications de la conversation
|
||||
await _mqttService.subscribeToConversation(conversation.id);
|
||||
|
||||
return conversation;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de la conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une conversation
|
||||
Future<void> deleteConversation(String id) async {
|
||||
try {
|
||||
// Supprimer via l'API
|
||||
await _apiService.deleteConversation(id);
|
||||
|
||||
// Supprimer de Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
await box.delete(id);
|
||||
|
||||
// Se désabonner des notifications
|
||||
await _mqttService.unsubscribeFromConversation(id);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de la conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Épingle/désépingle une conversation
|
||||
Future<void> pinConversation(String id, bool isPinned) async {
|
||||
try {
|
||||
await _apiService.pinConversation(id, isPinned);
|
||||
|
||||
// Mettre à jour dans Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var conversation = box.get(id);
|
||||
if (conversation != null) {
|
||||
await box.put(id, conversation.copyWith(isPinned: isPinned));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de l\'épinglage de la conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour les permissions de réponse
|
||||
Future<void> updateReplyPermission(String id, String replyPermission) async {
|
||||
try {
|
||||
await _apiService.updateReplyPermission(id, replyPermission);
|
||||
|
||||
// Mettre à jour dans Hive
|
||||
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var conversation = box.get(id);
|
||||
if (conversation != null) {
|
||||
await box.put(id, conversation.copyWith(replyPermission: replyPermission));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour des permissions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les messages d'une conversation
|
||||
Future<List<MessageModel>> getMessages(String conversationId, {int page = 1, int limit = 50}) async {
|
||||
try {
|
||||
// Récupérer depuis Hive
|
||||
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
var localMessages = box.values
|
||||
.where((m) => m.conversationId == conversationId)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
// Si on a assez de messages localement
|
||||
if (localMessages.length >= page * limit) {
|
||||
return localMessages.skip((page - 1) * limit).take(limit).toList();
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer depuis l'API
|
||||
var apiMessages = await _apiService.getMessages(conversationId, page: page, limit: limit);
|
||||
|
||||
// Mettre à jour Hive
|
||||
for (var message in apiMessages) {
|
||||
await box.put(message.id, message);
|
||||
}
|
||||
|
||||
return apiMessages;
|
||||
} catch (e) {
|
||||
// Si l'API échoue, utiliser les données locales
|
||||
if (localMessages.isNotEmpty) {
|
||||
return localMessages.skip((page - 1) * limit).take(limit).toList();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un message via MQTT
|
||||
Future<void> sendMessage(String conversationId, Map<String, dynamic> messageData) async {
|
||||
try {
|
||||
// Générer un ID unique pour le message
|
||||
var messageId = const Uuid().v4();
|
||||
var userId = messageData['senderId'] as String?;
|
||||
|
||||
// Créer le message
|
||||
var message = MessageModel(
|
||||
id: messageId,
|
||||
conversationId: conversationId,
|
||||
senderId: userId,
|
||||
senderType: 'user',
|
||||
content: messageData['content'] as String,
|
||||
contentType: messageData['contentType'] as String? ?? 'text',
|
||||
createdAt: DateTime.now(),
|
||||
status: 'sent',
|
||||
isAnnouncement: messageData['isAnnouncement'] as bool? ?? false,
|
||||
);
|
||||
|
||||
// Sauvegarder temporairement dans Hive
|
||||
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
await box.put(messageId, message);
|
||||
|
||||
// Publier via MQTT
|
||||
await _mqttService.publishMessage('chat/message/send', {
|
||||
'messageId': messageId,
|
||||
'conversationId': conversationId,
|
||||
'senderId': userId,
|
||||
'content': message.content,
|
||||
'contentType': message.contentType,
|
||||
'timestamp': message.createdAt.toIso8601String(),
|
||||
'isAnnouncement': message.isAnnouncement,
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de l\'envoi du message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque un message comme lu
|
||||
Future<void> markMessageAsRead(String messageId) async {
|
||||
try {
|
||||
// Mettre à jour via l'API
|
||||
await _apiService.markMessageAsRead(messageId);
|
||||
|
||||
// Mettre à jour dans Hive
|
||||
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
var message = box.get(messageId);
|
||||
if (message != null) {
|
||||
await box.put(messageId, message.copyWith(
|
||||
status: 'read',
|
||||
readAt: DateTime.now(),
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors du marquage comme lu: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un participant à une conversation
|
||||
Future<void> addParticipant(String conversationId, Map<String, dynamic> participantData) async {
|
||||
try {
|
||||
await _apiService.addParticipant(conversationId, participantData);
|
||||
|
||||
// Mettre à jour la conversation dans Hive
|
||||
var conversationBox = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var conversation = conversationBox.get(conversationId);
|
||||
if (conversation != null) {
|
||||
var updatedParticipants = List<ParticipantModel>.from(conversation.participants);
|
||||
updatedParticipants.add(ParticipantModel.fromJson(participantData));
|
||||
await conversationBox.put(conversationId, conversation.copyWith(participants: updatedParticipants));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de l\'ajout du participant: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire un participant d'une conversation
|
||||
Future<void> removeParticipant(String conversationId, String participantId) async {
|
||||
try {
|
||||
await _apiService.removeParticipant(conversationId, participantId);
|
||||
|
||||
// Mettre à jour la conversation dans Hive
|
||||
var conversationBox = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
var conversation = conversationBox.get(conversationId);
|
||||
if (conversation != null) {
|
||||
var updatedParticipants = List<ParticipantModel>.from(conversation.participants);
|
||||
updatedParticipants.removeWhere((p) => p.id == participantId);
|
||||
await conversationBox.put(conversationId, conversation.copyWith(participants: updatedParticipants));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors du retrait du participant: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un utilisateur anonyme (pour Resalice)
|
||||
Future<String> createAnonymousUser({String? name, String? email}) async {
|
||||
try {
|
||||
return await _apiService.createAnonymousUser(name: name, email: email);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'utilisateur anonyme: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un utilisateur anonyme en utilisateur authentifié
|
||||
Future<void> convertAnonymousToUser(String anonymousId, String userId) async {
|
||||
try {
|
||||
// Mettre à jour tous les messages de l'utilisateur anonyme
|
||||
var messageBox = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
var messages = messageBox.values.where((m) => m.senderId == anonymousId).toList();
|
||||
|
||||
for (var message in messages) {
|
||||
await messageBox.put(message.id, message.copyWith(
|
||||
senderId: userId,
|
||||
senderType: 'user',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la conversion de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les annonces
|
||||
Future<List<ConversationModel>> getAnnouncements({bool forceRefresh = false}) async {
|
||||
try {
|
||||
// Filtrer les conversations pour n'avoir que les annonces
|
||||
var conversations = await getConversations(forceRefresh: forceRefresh);
|
||||
return conversations.where((c) => c.type == 'announcement').toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des annonces: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une nouvelle annonce
|
||||
Future<ConversationModel> createAnnouncement(Map<String, dynamic> data) async {
|
||||
try {
|
||||
// Créer la conversation comme une annonce
|
||||
data['type'] = 'announcement';
|
||||
return await createConversation(data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'annonce: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les statistiques d'une annonce
|
||||
Future<Map<String, dynamic>> getAnnouncementStats(String conversationId) async {
|
||||
try {
|
||||
return await _apiService.getAnnouncementStats(conversationId);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des statistiques: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les cibles d'audience disponibles
|
||||
Future<List<Map<String, dynamic>>> getAvailableAudienceTargets() async {
|
||||
try {
|
||||
return await _apiService.getAvailableAudienceTargets();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des cibles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une cible d'audience
|
||||
Future<void> addAudienceTarget(String conversationId, Map<String, dynamic> targetData) async {
|
||||
try {
|
||||
// L'ajout des cibles d'audience est géré lors de la création de l'annonce
|
||||
// Mais on pourrait avoir besoin de modifier les cibles plus tard
|
||||
throw UnimplementedError('Ajout de cible non encore implémenté');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de l\'ajout de cible: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire une cible d'audience
|
||||
Future<void> removeAudienceTarget(String conversationId, String targetId) async {
|
||||
try {
|
||||
// Le retrait des cibles d'audience est géré lors de la création de l'annonce
|
||||
throw UnimplementedError('Retrait de cible non encore implémenté');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors du retrait de cible: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
213
flutt/lib/chat/scripts/chat_tables.sql
Normal file
213
flutt/lib/chat/scripts/chat_tables.sql
Normal file
@@ -0,0 +1,213 @@
|
||||
-- Script de création des tables chat pour MariaDB
|
||||
-- Compatible avec le module chat GEOSECTOR
|
||||
-- Création des tables pour le système de chat
|
||||
|
||||
-- Table des salles de discussion
|
||||
DROP TABLE IF EXISTS `chat_rooms`;
|
||||
CREATE TABLE `chat_rooms` (
|
||||
`id` varchar(50) NOT NULL,
|
||||
`type` enum('privee', 'groupe', 'liste_diffusion', 'broadcast', 'announcement') NOT NULL,
|
||||
`title` varchar(100) DEFAULT NULL,
|
||||
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`fk_user` int unsigned NOT NULL,
|
||||
`fk_entite` int unsigned DEFAULT NULL,
|
||||
`statut` enum('active', 'archive') NOT NULL DEFAULT 'active',
|
||||
`description` text,
|
||||
`reply_permission` enum('all', 'admins_only', 'sender_only', 'none') NOT NULL DEFAULT 'all',
|
||||
`is_pinned` tinyint(1) unsigned NOT NULL DEFAULT 0,
|
||||
`expiry_date` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`fk_user`),
|
||||
KEY `idx_entite` (`fk_entite`),
|
||||
KEY `idx_type` (`type`),
|
||||
KEY `idx_statut` (`statut`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des participants aux salles de discussion
|
||||
DROP TABLE IF EXISTS `chat_participants`;
|
||||
CREATE TABLE `chat_participants` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`id_room` varchar(50) NOT NULL,
|
||||
`id_user` int unsigned DEFAULT NULL,
|
||||
`anonymous_id` varchar(50) DEFAULT NULL,
|
||||
`role` enum('administrateur', 'participant', 'en_lecture_seule') NOT NULL DEFAULT 'participant',
|
||||
`date_ajout` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`notification_activee` tinyint(1) unsigned NOT NULL DEFAULT 1,
|
||||
`last_read_message_id` varchar(50) DEFAULT NULL,
|
||||
`via_target` tinyint(1) unsigned NOT NULL DEFAULT 0,
|
||||
`can_reply` tinyint(1) unsigned DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_room` (`id_room`),
|
||||
KEY `idx_user` (`id_user`),
|
||||
KEY `idx_anonymous_id` (`anonymous_id`),
|
||||
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`id_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `uc_room_user` UNIQUE (`id_room`, `id_user`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des messages
|
||||
DROP TABLE IF EXISTS `chat_messages`;
|
||||
CREATE TABLE `chat_messages` (
|
||||
`id` varchar(50) NOT NULL,
|
||||
`fk_room` varchar(50) NOT NULL,
|
||||
`fk_user` int unsigned DEFAULT NULL,
|
||||
`sender_type` enum('user', 'anonymous', 'system') NOT NULL DEFAULT 'user',
|
||||
`content` text,
|
||||
`content_type` enum('text', 'image', 'file') NOT NULL DEFAULT 'text',
|
||||
`date_sent` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`date_delivered` timestamp NULL DEFAULT NULL,
|
||||
`date_read` timestamp NULL DEFAULT NULL,
|
||||
`statut` enum('envoye', 'livre', 'lu', 'error') NOT NULL DEFAULT 'envoye',
|
||||
`is_announcement` tinyint(1) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_room` (`fk_room`),
|
||||
KEY `idx_user` (`fk_user`),
|
||||
KEY `idx_date` (`date_sent`),
|
||||
KEY `idx_status` (`statut`),
|
||||
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des cibles d'audience
|
||||
DROP TABLE IF EXISTS `chat_audience_targets`;
|
||||
CREATE TABLE `chat_audience_targets` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`fk_room` varchar(50) NOT NULL,
|
||||
`target_type` enum('role', 'entity', 'all', 'combined') NOT NULL DEFAULT 'all',
|
||||
`target_id` varchar(50) DEFAULT NULL,
|
||||
`role_filter` varchar(20) DEFAULT NULL,
|
||||
`entity_filter` varchar(50) DEFAULT NULL,
|
||||
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_room` (`fk_room`),
|
||||
KEY `idx_type` (`target_type`),
|
||||
CONSTRAINT `fk_chat_audience_targets_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des listes de diffusion
|
||||
DROP TABLE IF EXISTS `chat_broadcast_lists`;
|
||||
CREATE TABLE `chat_broadcast_lists` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`fk_room` varchar(50) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`description` text,
|
||||
`fk_user_creator` int unsigned NOT NULL,
|
||||
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_room` (`fk_room`),
|
||||
KEY `idx_user_creator` (`fk_user_creator`),
|
||||
CONSTRAINT `fk_chat_broadcast_lists_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table pour suivre la lecture des messages
|
||||
DROP TABLE IF EXISTS `chat_read_messages`;
|
||||
CREATE TABLE `chat_read_messages` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`fk_message` varchar(50) NOT NULL,
|
||||
`fk_user` int unsigned NOT NULL,
|
||||
`date_read` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_message` (`fk_message`),
|
||||
KEY `idx_user` (`fk_user`),
|
||||
CONSTRAINT `uc_message_user` UNIQUE (`fk_message`, `fk_user`),
|
||||
CONSTRAINT `fk_chat_read_messages_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des notifications
|
||||
DROP TABLE IF EXISTS `chat_notifications`;
|
||||
CREATE TABLE `chat_notifications` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`fk_user` int unsigned NOT NULL,
|
||||
`fk_message` varchar(50) DEFAULT NULL,
|
||||
`fk_room` varchar(50) DEFAULT NULL,
|
||||
`type` varchar(50) NOT NULL,
|
||||
`contenu` text,
|
||||
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`date_lecture` timestamp NULL DEFAULT NULL,
|
||||
`statut` enum('non_lue', 'lue') NOT NULL DEFAULT 'non_lue',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`fk_user`),
|
||||
KEY `idx_message` (`fk_message`),
|
||||
KEY `idx_room` (`fk_room`),
|
||||
KEY `idx_statut` (`statut`),
|
||||
CONSTRAINT `fk_chat_notifications_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_chat_notifications_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table des utilisateurs anonymes (pour Resalice)
|
||||
DROP TABLE IF EXISTS `chat_anonymous_users`;
|
||||
CREATE TABLE `chat_anonymous_users` (
|
||||
`id` varchar(50) NOT NULL,
|
||||
`device_id` varchar(100) NOT NULL,
|
||||
`name` varchar(100) DEFAULT NULL,
|
||||
`email` varchar(100) DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`converted_to_user_id` int unsigned DEFAULT NULL,
|
||||
`metadata` json DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_device_id` (`device_id`),
|
||||
KEY `idx_converted_user` (`converted_to_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table pour la file d'attente hors ligne
|
||||
DROP TABLE IF EXISTS `chat_offline_queue`;
|
||||
CREATE TABLE `chat_offline_queue` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int unsigned NOT NULL,
|
||||
`operation_type` varchar(50) NOT NULL,
|
||||
`operation_data` json NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`processed_at` timestamp NULL DEFAULT NULL,
|
||||
`status` enum('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
|
||||
`error_message` text,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Table pour les pièces jointes
|
||||
DROP TABLE IF EXISTS `chat_attachments`;
|
||||
CREATE TABLE `chat_attachments` (
|
||||
`id` varchar(50) NOT NULL,
|
||||
`fk_message` varchar(50) NOT NULL,
|
||||
`file_name` varchar(255) NOT NULL,
|
||||
`file_path` varchar(500) NOT NULL,
|
||||
`file_type` varchar(100) NOT NULL,
|
||||
`file_size` int unsigned NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_message` (`fk_message`),
|
||||
CONSTRAINT `fk_chat_attachments_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Vues utiles
|
||||
|
||||
-- Vue des messages avec informations utilisateur
|
||||
CREATE OR REPLACE VIEW `chat_messages_with_users` AS
|
||||
SELECT
|
||||
m.*,
|
||||
u.name as sender_name,
|
||||
u.username as sender_username,
|
||||
u.fk_entite as sender_entity_id
|
||||
FROM chat_messages m
|
||||
LEFT JOIN users u ON m.fk_user = u.id;
|
||||
|
||||
-- Vue des conversations avec compte de messages non lus
|
||||
CREATE OR REPLACE VIEW `chat_conversations_unread` AS
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(DISTINCT m.id) as total_messages,
|
||||
COUNT(DISTINCT rm.id) as read_messages,
|
||||
COUNT(DISTINCT m.id) - COUNT(DISTINCT rm.id) as unread_messages,
|
||||
(SELECT date_sent FROM chat_messages
|
||||
WHERE fk_room = r.id
|
||||
ORDER BY date_sent DESC LIMIT 1) as last_message_date
|
||||
FROM chat_rooms r
|
||||
LEFT JOIN chat_messages m ON r.id = m.fk_room
|
||||
LEFT JOIN chat_read_messages rm ON m.id = rm.fk_message
|
||||
GROUP BY r.id;
|
||||
|
||||
-- Index supplémentaires pour les performances
|
||||
CREATE INDEX idx_messages_unread ON chat_messages(fk_room, statut);
|
||||
CREATE INDEX idx_participants_active ON chat_participants(id_room, id_user, notification_activee);
|
||||
CREATE INDEX idx_notifications_unread ON chat_notifications(fk_user, statut);
|
||||
323
flutt/lib/chat/scripts/mqtt_notification_sender.php
Normal file
323
flutt/lib/chat/scripts/mqtt_notification_sender.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
/**
|
||||
* Service d'envoi de notifications MQTT pour le chat
|
||||
*
|
||||
* Ce script gère l'envoi des notifications via MQTT depuis le backend PHP
|
||||
*/
|
||||
|
||||
require_once 'vendor/autoload.php'; // PhpMqtt
|
||||
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
use PhpMqtt\Client\ConnectionSettings;
|
||||
|
||||
class MqttNotificationSender {
|
||||
private $mqtt;
|
||||
private $db;
|
||||
private $config;
|
||||
|
||||
public function __construct($dbConnection, $mqttConfig) {
|
||||
$this->db = $dbConnection;
|
||||
$this->config = $mqttConfig;
|
||||
|
||||
// Initialiser le client MQTT
|
||||
$this->initializeMqttClient();
|
||||
}
|
||||
|
||||
private function initializeMqttClient() {
|
||||
$this->mqtt = new MqttClient(
|
||||
$this->config['host'],
|
||||
$this->config['port'],
|
||||
'geosector_api_' . uniqid(), // Client ID unique
|
||||
MqttClient::MQTT_3_1_1
|
||||
);
|
||||
|
||||
$connectionSettings = (new ConnectionSettings)
|
||||
->setUsername($this->config['username'])
|
||||
->setPassword($this->config['password'])
|
||||
->setKeepAliveInterval(60)
|
||||
->setConnectTimeout(30)
|
||||
->setUseTls($this->config['use_ssl'] ?? false);
|
||||
|
||||
$this->mqtt->connect($connectionSettings, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification pour un nouveau message
|
||||
*/
|
||||
public function sendMessageNotification($receiverId, $senderId, $messageId, $content, $conversationId) {
|
||||
try {
|
||||
// Vérifier les préférences de notification
|
||||
$settings = $this->getUserNotificationSettings($receiverId);
|
||||
|
||||
if (!$this->shouldSendNotification($settings, $conversationId)) {
|
||||
return ['status' => 'skipped', 'reason' => 'notification_settings'];
|
||||
}
|
||||
|
||||
// Obtenir les informations de l'expéditeur
|
||||
$sender = $this->getSenderInfo($senderId);
|
||||
|
||||
// Obtenir le nom de la conversation
|
||||
$conversationName = $this->getConversationName($conversationId, $receiverId);
|
||||
|
||||
// Préparer le payload de la notification
|
||||
$payload = [
|
||||
'type' => 'chat_message',
|
||||
'messageId' => $messageId,
|
||||
'conversationId' => $conversationId,
|
||||
'senderId' => $senderId,
|
||||
'senderName' => $sender['name'] ?? 'Utilisateur',
|
||||
'content' => $settings['show_preview'] ? $content : 'Nouveau message',
|
||||
'conversationName' => $conversationName,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
|
||||
// Définir le topic MQTT
|
||||
$topic = sprintf('chat/user/%s/messages', $receiverId);
|
||||
|
||||
// Publier le message
|
||||
$this->mqtt->publish($topic, json_encode($payload), 1);
|
||||
|
||||
// Enregistrer la notification dans la base de données
|
||||
$this->saveNotificationToDatabase($receiverId, $messageId, $conversationId, $payload);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'topic' => $topic
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'reason' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une annonce à plusieurs utilisateurs
|
||||
*/
|
||||
public function sendBroadcastAnnouncement($audienceTargets, $messageId, $title, $content, $conversationId) {
|
||||
$results = [];
|
||||
$userIds = $this->resolveAudienceTargets($audienceTargets);
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
// Préparer le payload pour l'annonce
|
||||
$payload = [
|
||||
'type' => 'announcement',
|
||||
'messageId' => $messageId,
|
||||
'conversationId' => $conversationId,
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
|
||||
// Envoyer à chaque utilisateur
|
||||
$topic = sprintf('chat/user/%s/messages', $userId);
|
||||
|
||||
try {
|
||||
$this->mqtt->publish($topic, json_encode($payload), 1);
|
||||
$results[$userId] = ['status' => 'success'];
|
||||
|
||||
// Enregistrer la notification
|
||||
$this->saveNotificationToDatabase($userId, $messageId, $conversationId, $payload);
|
||||
} catch (Exception $e) {
|
||||
$results[$userId] = ['status' => 'error', 'reason' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Publier aussi sur le topic général des annonces
|
||||
$this->mqtt->publish('chat/announcement', json_encode($payload), 1);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification à une conversation spécifique
|
||||
*/
|
||||
public function sendConversationNotification($conversationId, $messageId, $senderId, $content) {
|
||||
$participants = $this->getConversationParticipants($conversationId);
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
if ($participant['id'] !== $senderId) {
|
||||
$this->sendMessageNotification(
|
||||
$participant['id'],
|
||||
$senderId,
|
||||
$messageId,
|
||||
$content,
|
||||
$conversationId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une notification doit être envoyée
|
||||
*/
|
||||
private function shouldSendNotification($settings, $conversationId) {
|
||||
if (!$settings['enable_notifications']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($conversationId, $settings['muted_conversations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($settings['do_not_disturb'] && $this->isInDoNotDisturbPeriod($settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les paramètres de notification de l'utilisateur
|
||||
*/
|
||||
private function getUserNotificationSettings($userId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT * FROM notification_settings
|
||||
WHERE user_id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Valeurs par défaut si pas de préférences
|
||||
return $result ?: [
|
||||
'enable_notifications' => true,
|
||||
'show_preview' => true,
|
||||
'muted_conversations' => [],
|
||||
'do_not_disturb' => false,
|
||||
'do_not_disturb_start' => null,
|
||||
'do_not_disturb_end' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si on est dans la période "Ne pas déranger"
|
||||
*/
|
||||
private function isInDoNotDisturbPeriod($settings) {
|
||||
if (!$settings['do_not_disturb']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = new DateTime();
|
||||
$start = new DateTime($settings['do_not_disturb_start']);
|
||||
$end = new DateTime($settings['do_not_disturb_end']);
|
||||
|
||||
if ($start < $end) {
|
||||
return $now >= $start && $now <= $end;
|
||||
} else {
|
||||
// Période qui chevauche minuit
|
||||
return $now >= $start || $now <= $end;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre la notification dans la base de données
|
||||
*/
|
||||
private function saveNotificationToDatabase($userId, $messageId, $conversationId, $payload) {
|
||||
$stmt = $this->db->prepare("
|
||||
INSERT INTO chat_notifications
|
||||
(fk_user, fk_message, fk_room, type, contenu, statut)
|
||||
VALUES (?, ?, ?, ?, ?, 'non_lue')
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$messageId,
|
||||
$conversationId,
|
||||
$payload['type'],
|
||||
json_encode($payload)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations de l'expéditeur
|
||||
*/
|
||||
private function getSenderInfo($senderId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id, name, username
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$senderId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le nom de la conversation
|
||||
*/
|
||||
private function getConversationName($conversationId, $userId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT title
|
||||
FROM chat_rooms
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$conversationId]);
|
||||
return $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les participants d'une conversation
|
||||
*/
|
||||
private function getConversationParticipants($conversationId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id_user as id, role
|
||||
FROM chat_participants
|
||||
WHERE id_room = ? AND notification_activee = 1
|
||||
");
|
||||
|
||||
$stmt->execute([$conversationId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les cibles d'audience en une liste d'IDs utilisateur
|
||||
*/
|
||||
private function resolveAudienceTargets($targets) {
|
||||
$userIds = [];
|
||||
|
||||
foreach ($targets as $target) {
|
||||
switch ($target['target_type']) {
|
||||
case 'all':
|
||||
$stmt = $this->db->query("SELECT id FROM users WHERE chk_active = 1");
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'role':
|
||||
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_role = ?");
|
||||
$stmt->execute([$target['role_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'entity':
|
||||
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_entite = ?");
|
||||
$stmt->execute([$target['entity_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'combined':
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id FROM users
|
||||
WHERE fk_role = ? AND fk_entite = ?
|
||||
");
|
||||
$stmt->execute([$target['role_filter'], $target['entity_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferme la connexion MQTT
|
||||
*/
|
||||
public function disconnect() {
|
||||
if ($this->mqtt) {
|
||||
$this->mqtt->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
263
flutt/lib/chat/scripts/send_notification.php
Normal file
263
flutt/lib/chat/scripts/send_notification.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* Script d'envoi de notifications push pour le chat
|
||||
*
|
||||
* Ce script est appelé par l'API backend pour envoyer des notifications
|
||||
* lorsqu'un nouveau message est reçu
|
||||
*/
|
||||
|
||||
require_once 'vendor/autoload.php';
|
||||
|
||||
use Kreait\Firebase\Factory;
|
||||
use Kreait\Firebase\Messaging\CloudMessage;
|
||||
use Kreait\Firebase\Messaging\Notification;
|
||||
|
||||
class ChatNotificationSender {
|
||||
private $messaging;
|
||||
private $db;
|
||||
|
||||
public function __construct($firebaseServiceAccount, $dbConnection) {
|
||||
$factory = (new Factory)->withServiceAccount($firebaseServiceAccount);
|
||||
$this->messaging = $factory->createMessaging();
|
||||
$this->db = $dbConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification à un utilisateur pour un nouveau message
|
||||
*/
|
||||
public function sendMessageNotification($userId, $messageId, $senderId, $content, $conversationId) {
|
||||
try {
|
||||
// Récupérer les préférences de notification de l'utilisateur
|
||||
$settings = $this->getUserNotificationSettings($userId);
|
||||
|
||||
if (!$settings['enable_notifications']) {
|
||||
return ['status' => 'skipped', 'reason' => 'notifications_disabled'];
|
||||
}
|
||||
|
||||
// Vérifier si la conversation est en silencieux
|
||||
if (in_array($conversationId, $settings['muted_conversations'])) {
|
||||
return ['status' => 'skipped', 'reason' => 'conversation_muted'];
|
||||
}
|
||||
|
||||
// Vérifier le mode Ne pas déranger
|
||||
if ($this->isInDoNotDisturbPeriod($settings)) {
|
||||
return ['status' => 'skipped', 'reason' => 'do_not_disturb'];
|
||||
}
|
||||
|
||||
// Obtenir le token du device
|
||||
$deviceToken = $this->getUserDeviceToken($userId);
|
||||
if (!$deviceToken) {
|
||||
return ['status' => 'error', 'reason' => 'no_device_token'];
|
||||
}
|
||||
|
||||
// Obtenir les informations de l'expéditeur
|
||||
$sender = $this->getSenderInfo($senderId);
|
||||
|
||||
// Obtenir le nom de la conversation
|
||||
$conversationName = $this->getConversationName($conversationId, $userId);
|
||||
|
||||
// Préparation du contenu de la notification
|
||||
$title = $conversationName ?? $sender['name'];
|
||||
$body = $settings['show_preview'] ? $content : 'Nouveau message';
|
||||
|
||||
// Créer le message Firebase
|
||||
$message = CloudMessage::withTarget('token', $deviceToken)
|
||||
->withNotification(Notification::create($title, $body))
|
||||
->withData([
|
||||
'type' => 'chat_message',
|
||||
'messageId' => $messageId,
|
||||
'conversationId' => $conversationId,
|
||||
'senderId' => $senderId,
|
||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||
])
|
||||
->withAndroidConfig([
|
||||
'priority' => 'high',
|
||||
'notification' => [
|
||||
'sound' => $settings['sound_enabled'] ? 'default' : null,
|
||||
'channel_id' => 'chat_messages',
|
||||
'icon' => 'ic_launcher',
|
||||
],
|
||||
])
|
||||
->withApnsConfig([
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'sound' => $settings['sound_enabled'] ? 'default' : null,
|
||||
'badge' => 1, // TODO: Calculer le nombre réel de messages non lus
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Envoyer la notification
|
||||
$result = $this->messaging->send($message);
|
||||
|
||||
// Enregistrer la notification dans la base de données
|
||||
$this->saveNotificationToDatabase($userId, $messageId, $conversationId, $title, $body);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'message_id' => $result,
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification de type broadcast
|
||||
*/
|
||||
public function sendBroadcastNotification($audienceTargets, $messageId, $content, $conversationId) {
|
||||
$results = [];
|
||||
|
||||
// Résoudre les cibles d'audience
|
||||
$userIds = $this->resolveAudienceTargets($audienceTargets);
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result = $this->sendMessageNotification($userId, $messageId, null, $content, $conversationId);
|
||||
$results[$userId] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre la notification dans la base de données
|
||||
*/
|
||||
private function saveNotificationToDatabase($userId, $messageId, $conversationId, $title, $body) {
|
||||
$stmt = $this->db->prepare("
|
||||
INSERT INTO chat_notifications (fk_user, fk_message, fk_room, type, contenu, statut)
|
||||
VALUES (?, ?, ?, 'chat_message', ?, 'non_lue')
|
||||
");
|
||||
|
||||
$stmt->execute([$userId, $messageId, $conversationId, json_encode([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les préférences de notification de l'utilisateur
|
||||
*/
|
||||
private function getUserNotificationSettings($userId) {
|
||||
// Implémenter la logique pour récupérer les paramètres
|
||||
return [
|
||||
'enable_notifications' => true,
|
||||
'sound_enabled' => true,
|
||||
'vibration_enabled' => true,
|
||||
'muted_conversations' => [],
|
||||
'show_preview' => true,
|
||||
'do_not_disturb' => false,
|
||||
'do_not_disturb_start' => null,
|
||||
'do_not_disturb_end' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si on est dans la période Ne pas déranger
|
||||
*/
|
||||
private function isInDoNotDisturbPeriod($settings) {
|
||||
if (!$settings['do_not_disturb']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = new DateTime();
|
||||
$start = new DateTime($settings['do_not_disturb_start']);
|
||||
$end = new DateTime($settings['do_not_disturb_end']);
|
||||
|
||||
if ($start < $end) {
|
||||
return $now >= $start && $now <= $end;
|
||||
} else {
|
||||
// Période qui chevauche minuit
|
||||
return $now >= $start || $now <= $end;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le token du device de l'utilisateur
|
||||
*/
|
||||
private function getUserDeviceToken($userId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT device_token
|
||||
FROM notification_settings
|
||||
WHERE user_id = ? AND device_token IS NOT NULL
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([$userId]);
|
||||
return $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations de l'expéditeur
|
||||
*/
|
||||
private function getSenderInfo($senderId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id, name, username
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$senderId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le nom de la conversation
|
||||
*/
|
||||
private function getConversationName($conversationId, $userId) {
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT title
|
||||
FROM chat_rooms
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$conversationId]);
|
||||
return $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les cibles d'audience en une liste d'IDs utilisateur
|
||||
*/
|
||||
private function resolveAudienceTargets($targets) {
|
||||
$userIds = [];
|
||||
|
||||
foreach ($targets as $target) {
|
||||
switch ($target['target_type']) {
|
||||
case 'all':
|
||||
// Récupérer tous les utilisateurs
|
||||
$stmt = $this->db->query("SELECT id FROM users WHERE chk_active = 1");
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'role':
|
||||
// Récupérer les utilisateurs par rôle
|
||||
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_role = ?");
|
||||
$stmt->execute([$target['role_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'entity':
|
||||
// Récupérer les utilisateurs par entité
|
||||
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_entite = ?");
|
||||
$stmt->execute([$target['entity_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
|
||||
case 'combined':
|
||||
// Récupérer les utilisateurs par combinaison de rôle et entité
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id FROM users
|
||||
WHERE fk_role = ? AND fk_entite = ?
|
||||
");
|
||||
$stmt->execute([$target['role_filter'], $target['entity_filter']]);
|
||||
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($userIds);
|
||||
}
|
||||
}
|
||||
97
flutt/lib/chat/services/chat_api_service.dart
Normal file
97
flutt/lib/chat/services/chat_api_service.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
/// Service API pour la communication avec le backend du chat
|
||||
///
|
||||
/// Ce service gère toutes les requêtes HTTP vers l'API chat
|
||||
|
||||
class ChatApiService {
|
||||
final String baseUrl;
|
||||
final String? authToken;
|
||||
|
||||
ChatApiService({
|
||||
required this.baseUrl,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
/// Récupère les conversations
|
||||
Future<Map<String, dynamic>> fetchConversations() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les messages d'une conversation
|
||||
Future<Map<String, dynamic>> fetchMessages(String conversationId, {int page = 1, int limit = 50}) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée une nouvelle conversation
|
||||
Future<Map<String, dynamic>> createConversation(Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Envoie un message
|
||||
Future<Map<String, dynamic>> sendMessage(String conversationId, Map<String, dynamic> messageData) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Marque un message comme lu
|
||||
Future<Map<String, dynamic>> markMessageAsRead(String messageId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Ajoute un participant
|
||||
Future<Map<String, dynamic>> addParticipant(String conversationId, Map<String, dynamic> participantData) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Retire un participant
|
||||
Future<Map<String, dynamic>> removeParticipant(String conversationId, String participantId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée un utilisateur anonyme
|
||||
Future<Map<String, dynamic>> createAnonymousUser({String? name, String? email}) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les annonces
|
||||
Future<Map<String, dynamic>> fetchAnnouncements() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée une annonce
|
||||
Future<Map<String, dynamic>> createAnnouncement(Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les statistiques d'une annonce
|
||||
Future<Map<String, dynamic>> fetchAnnouncementStats(String conversationId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les cibles d'audience disponibles
|
||||
Future<Map<String, dynamic>> fetchAvailableAudienceTargets() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Met à jour une conversation
|
||||
Future<Map<String, dynamic>> updateConversation(String id, Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Supprime une conversation
|
||||
Future<void> deleteConversation(String id) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
214
flutt/lib/chat/services/notifications/README_MQTT.md
Normal file
214
flutt/lib/chat/services/notifications/README_MQTT.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Notifications MQTT pour le Chat GEOSECTOR
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce système de notifications utilise MQTT pour fournir des notifications push en temps réel pour le module chat. Il offre une alternative légère à Firebase Cloud Messaging (FCM) et peut être auto-hébergé dans votre infrastructure.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Composants principaux
|
||||
|
||||
1. **MqttNotificationService** (Flutter)
|
||||
- Service de notification côté client
|
||||
- Gère la connexion au broker MQTT
|
||||
- Traite les messages entrants
|
||||
- Affiche les notifications locales
|
||||
|
||||
2. **MqttConfig** (Flutter)
|
||||
- Configuration centralisée pour MQTT
|
||||
- Gestion des topics
|
||||
- Paramètres de connexion
|
||||
|
||||
3. **MqttNotificationSender** (PHP)
|
||||
- Service backend pour envoyer les notifications
|
||||
- Interface avec la base de données
|
||||
- Gestion des cibles d'audience
|
||||
|
||||
## Configuration du broker MQTT
|
||||
|
||||
### Container Incus
|
||||
|
||||
Le broker MQTT (Eclipse Mosquitto recommandé) doit être installé dans votre container Incus :
|
||||
|
||||
```bash
|
||||
# Installer Mosquitto
|
||||
apt-get update
|
||||
apt-get install mosquitto mosquitto-clients
|
||||
|
||||
# Configurer Mosquitto
|
||||
vi /etc/mosquitto/mosquitto.conf
|
||||
```
|
||||
|
||||
Configuration recommandée :
|
||||
```
|
||||
listener 1883
|
||||
allow_anonymous false
|
||||
password_file /etc/mosquitto/passwd
|
||||
|
||||
# Pour SSL/TLS
|
||||
listener 8883
|
||||
cafile /etc/mosquitto/ca.crt
|
||||
certfile /etc/mosquitto/server.crt
|
||||
keyfile /etc/mosquitto/server.key
|
||||
```
|
||||
|
||||
### Sécurité
|
||||
|
||||
Pour un environnement de production, il est fortement recommandé :
|
||||
|
||||
1. D'utiliser SSL/TLS (port 8883)
|
||||
2. De configurer l'authentification par mot de passe
|
||||
3. De limiter les IPs pouvant se connecter
|
||||
4. De configurer des ACLs pour restreindre l'accès aux topics
|
||||
|
||||
## Structure des topics MQTT
|
||||
|
||||
### Topics utilisateur
|
||||
- `chat/user/{userId}/messages` - Messages personnels pour l'utilisateur
|
||||
- `chat/user/{userId}/groups/{groupId}` - Messages des groupes de l'utilisateur
|
||||
|
||||
### Topics globaux
|
||||
- `chat/announcement` - Annonces générales
|
||||
- `chat/broadcast` - Diffusions à grande échelle
|
||||
|
||||
### Topics conversation
|
||||
- `chat/conversation/{conversationId}` - Messages spécifiques à une conversation
|
||||
|
||||
## Intégration Flutter
|
||||
|
||||
### Dépendances requises
|
||||
|
||||
Ajoutez ces dépendances à votre `pubspec.yaml` :
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
mqtt5_client: ^4.0.0 # ou mqtt_client selon votre préférence
|
||||
flutter_local_notifications: ^17.0.0
|
||||
```
|
||||
|
||||
### Initialisation
|
||||
|
||||
```dart
|
||||
// Dans main.dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final notificationService = MqttNotificationService();
|
||||
await notificationService.initialize(userId: currentUserId);
|
||||
|
||||
runApp(const GeoSectorApp());
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```dart
|
||||
// Écouter les messages
|
||||
notificationService.onMessageTap = (messageId) {
|
||||
// Naviguer vers le message
|
||||
Navigator.pushNamed(context, '/chat/$messageId');
|
||||
};
|
||||
|
||||
// Publier un message
|
||||
await notificationService.publishMessage(
|
||||
'chat/user/$userId/messages',
|
||||
{'content': 'Test message'},
|
||||
);
|
||||
```
|
||||
|
||||
## Gestion des notifications
|
||||
|
||||
### Paramètres utilisateur
|
||||
|
||||
Les utilisateurs peuvent configurer :
|
||||
- Activation/désactivation des notifications
|
||||
- Conversations en silencieux
|
||||
- Mode "Ne pas déranger"
|
||||
- Aperçu du contenu
|
||||
|
||||
### Persistance des notifications
|
||||
|
||||
Les notifications sont enregistrées dans la table `chat_notifications` pour :
|
||||
- Traçabilité
|
||||
- Statistiques
|
||||
- Synchronisation
|
||||
|
||||
## Tests
|
||||
|
||||
### Test de connexion
|
||||
|
||||
```dart
|
||||
final service = MqttNotificationService();
|
||||
await service.initialize(userId: 'test_user');
|
||||
// Vérifie les logs pour confirmer la connexion
|
||||
```
|
||||
|
||||
### Test d'envoi
|
||||
|
||||
```php
|
||||
$sender = new MqttNotificationSender($db, $mqttConfig);
|
||||
$result = $sender->sendMessageNotification(
|
||||
'receiver_id',
|
||||
'sender_id',
|
||||
'message_id',
|
||||
'Test message',
|
||||
'conversation_id'
|
||||
);
|
||||
```
|
||||
|
||||
## Surveillance et maintenance
|
||||
|
||||
### Logs
|
||||
|
||||
Les logs sont disponibles dans :
|
||||
- Logs Flutter (console debug)
|
||||
- Logs Mosquitto (`/var/log/mosquitto/mosquitto.log`)
|
||||
- Logs PHP (selon configuration)
|
||||
|
||||
### Métriques à surveiller
|
||||
|
||||
- Nombre de connexions actives
|
||||
- Latence des messages
|
||||
- Taux d'échec des notifications
|
||||
- Consommation mémoire/CPU du broker
|
||||
|
||||
## Comparaison avec Firebase
|
||||
|
||||
### Avantages MQTT
|
||||
|
||||
1. **Auto-hébergé** : Contrôle total de l'infrastructure
|
||||
2. **Léger** : Moins de ressources que Firebase
|
||||
3. **Coût** : Gratuit (uniquement coûts d'infrastructure)
|
||||
4. **Personnalisable** : Configuration fine du broker
|
||||
|
||||
### Inconvénients
|
||||
|
||||
1. **Maintenance** : Nécessite une gestion du broker
|
||||
2. **Évolutivité** : Requiert dimensionnement et clustering
|
||||
3. **Fonctionnalités** : Moins de services intégrés que Firebase
|
||||
|
||||
## Évolutions futures
|
||||
|
||||
1. **WebSocket** : Ajout optionnel pour temps réel strict
|
||||
2. **Clustering** : Pour haute disponibilité
|
||||
3. **Analytics** : Dashboard de monitoring
|
||||
4. **Webhooks** : Intégration avec d'autres services
|
||||
|
||||
## Dépannage
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
1. **Connexion échouée**
|
||||
- Vérifier username/password
|
||||
- Vérifier port/hostname
|
||||
- Vérifier firewall
|
||||
|
||||
2. **Messages non reçus**
|
||||
- Vérifier abonnement aux topics
|
||||
- Vérifier QoS
|
||||
- Vérifier paramètres notifications
|
||||
|
||||
3. **Performance dégradée**
|
||||
- Augmenter keepAlive
|
||||
- Ajuster reconnectInterval
|
||||
- Vérifier charge serveur
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service de gestion des notifications chat
|
||||
///
|
||||
/// Gère l'envoi et la réception des notifications pour le module chat
|
||||
|
||||
class ChatNotificationService {
|
||||
static final ChatNotificationService _instance = ChatNotificationService._internal();
|
||||
factory ChatNotificationService() => _instance;
|
||||
ChatNotificationService._internal();
|
||||
|
||||
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Callback pour les actions sur les notifications
|
||||
Function(String messageId)? onMessageTap;
|
||||
Function(Map<String, dynamic>)? onBackgroundMessage;
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize() async {
|
||||
// Demander les permissions
|
||||
await _requestPermissions();
|
||||
|
||||
// Initialiser les notifications locales
|
||||
await _initializeLocalNotifications();
|
||||
|
||||
// Configurer les handlers de messages
|
||||
_configureFirebaseHandlers();
|
||||
|
||||
// Obtenir le token du device
|
||||
await _initializeDeviceToken();
|
||||
}
|
||||
|
||||
/// Demande les permissions pour les notifications
|
||||
Future<bool> _requestPermissions() async {
|
||||
NotificationSettings settings = await _firebaseMessaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
provisional: false,
|
||||
);
|
||||
|
||||
return settings.authorizationStatus == AuthorizationStatus.authorized;
|
||||
}
|
||||
|
||||
/// Initialise les notifications locales
|
||||
Future<void> _initializeLocalNotifications() async {
|
||||
const AndroidInitializationSettings androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
final DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
onDidReceiveLocalNotification: _onDidReceiveLocalNotification,
|
||||
);
|
||||
|
||||
final InitializationSettings initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Configure les handlers Firebase
|
||||
void _configureFirebaseHandlers() {
|
||||
// Message reçu quand l'app est au premier plan
|
||||
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||
|
||||
// Message reçu quand l'app est en arrière-plan
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_onBackgroundMessageOpened);
|
||||
|
||||
// Handler pour les messages en arrière-plan terminé
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
|
||||
}
|
||||
|
||||
/// Handler pour les messages reçus au premier plan
|
||||
Future<void> _onForegroundMessage(RemoteMessage message) async {
|
||||
if (message.notification != null) {
|
||||
// Afficher une notification locale
|
||||
await _showLocalNotification(
|
||||
title: message.notification!.title ?? 'Nouveau message',
|
||||
body: message.notification!.body ?? '',
|
||||
payload: message.data['messageId'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les messages ouverts depuis l'arrière-plan
|
||||
void _onBackgroundMessageOpened(RemoteMessage message) {
|
||||
final messageId = message.data['messageId'];
|
||||
if (messageId != null) {
|
||||
onMessageTap?.call(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche une notification locale
|
||||
Future<void> _showLocalNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required String payload,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
|
||||
'chat_messages',
|
||||
'Messages de chat',
|
||||
channelDescription: 'Notifications pour les nouveaux messages de chat',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler pour le clic sur une notification
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
onMessageTap?.call(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les notifications iOS reçues au premier plan
|
||||
void _onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) {
|
||||
// Traitement spécifique iOS si nécessaire
|
||||
}
|
||||
|
||||
/// Obtient et stocke le token du device
|
||||
Future<String?> _initializeDeviceToken() async {
|
||||
String? token = await _firebaseMessaging.getToken();
|
||||
if (token != null) {
|
||||
// Envoyer le token au serveur pour stocker
|
||||
await _sendTokenToServer(token);
|
||||
}
|
||||
|
||||
// Écouter les changements de token
|
||||
_firebaseMessaging.onTokenRefresh.listen(_sendTokenToServer);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Envoie le token FCM au serveur
|
||||
Future<void> _sendTokenToServer(String token) async {
|
||||
try {
|
||||
// Appel API pour enregistrer le token
|
||||
// await chatApiService.registerDeviceToken(token);
|
||||
debugPrint('Device token enregistré : $token');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'enregistrement du token : $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// S'abonner aux notifications pour une conversation
|
||||
Future<void> subscribeToConversation(String conversationId) async {
|
||||
await _firebaseMessaging.subscribeToTopic('chat_$conversationId');
|
||||
}
|
||||
|
||||
/// Se désabonner des notifications pour une conversation
|
||||
Future<void> unsubscribeFromConversation(String conversationId) async {
|
||||
await _firebaseMessaging.unsubscribeFromTopic('chat_$conversationId');
|
||||
}
|
||||
|
||||
/// Désactive temporairement les notifications
|
||||
Future<void> pauseNotifications() async {
|
||||
await _firebaseMessaging.setAutoInitEnabled(false);
|
||||
}
|
||||
|
||||
/// Réactive les notifications
|
||||
Future<void> resumeNotifications() async {
|
||||
await _firebaseMessaging.setAutoInitEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les messages en arrière-plan
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
|
||||
// Traitement des messages en arrière-plan
|
||||
debugPrint('Message reçu en arrière-plan : ${message.messageId}');
|
||||
}
|
||||
74
flutt/lib/chat/services/notifications/mqtt_config.dart
Normal file
74
flutt/lib/chat/services/notifications/mqtt_config.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
/// Configuration pour le broker MQTT
|
||||
///
|
||||
/// Centralise les paramètres de connexion au broker MQTT
|
||||
|
||||
class MqttConfig {
|
||||
// Configuration du serveur MQTT
|
||||
static const String host = 'mqtt.geosector.fr';
|
||||
static const int port = 1883;
|
||||
static const int securePort = 8883;
|
||||
static const bool useSsl = false;
|
||||
|
||||
// Configuration d'authentification
|
||||
static const String username = 'geosector_chat';
|
||||
static const String password = 'secure_password_here';
|
||||
|
||||
// Préfixes des topics MQTT
|
||||
static const String topicBase = 'chat';
|
||||
static const String topicUserMessages = '$topicBase/user';
|
||||
static const String topicAnnouncements = '$topicBase/announcement';
|
||||
static const String topicGroups = '$topicBase/groups';
|
||||
static const String topicConversations = '$topicBase/conversation';
|
||||
|
||||
// Configuration des sessions
|
||||
static const int keepAliveInterval = 60;
|
||||
static const int reconnectInterval = 5;
|
||||
static const bool cleanSession = true;
|
||||
|
||||
// Configuration des notifications
|
||||
static const int notificationRetryCount = 3;
|
||||
static const Duration notificationTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Génère un client ID unique pour chaque session
|
||||
static String generateClientId(String userId) {
|
||||
return 'chat_${userId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
/// Retourne l'URL complète du broker selon la configuration SSL
|
||||
static String get brokerUrl {
|
||||
if (useSsl) {
|
||||
return '$host:$securePort';
|
||||
} else {
|
||||
return '$host:$port';
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le topic pour les messages d'un utilisateur
|
||||
static String getUserMessageTopic(String userId) {
|
||||
return '$topicUserMessages/$userId/messages';
|
||||
}
|
||||
|
||||
/// Retourne le topic pour les annonces globales
|
||||
static String getAnnouncementTopic() {
|
||||
return topicAnnouncements;
|
||||
}
|
||||
|
||||
/// Retourne le topic pour une conversation spécifique
|
||||
static String getConversationTopic(String conversationId) {
|
||||
return '$topicConversations/$conversationId';
|
||||
}
|
||||
|
||||
/// Retourne le topic pour un groupe spécifique
|
||||
static String getGroupTopic(String groupId) {
|
||||
return '$topicGroups/$groupId';
|
||||
}
|
||||
|
||||
/// Retourne les topics auxquels un utilisateur doit s'abonner
|
||||
static List<String> getUserSubscriptionTopics(String userId) {
|
||||
return [
|
||||
getUserMessageTopic(userId),
|
||||
getAnnouncementTopic(),
|
||||
// Ajoutez d'autres topics selon les besoins
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:mqtt5_client/mqtt5_client.dart';
|
||||
import 'package:mqtt5_client/mqtt5_server_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
/// Service de gestion des notifications chat via MQTT
|
||||
///
|
||||
/// Utilise MQTT pour recevoir des notifications en temps réel
|
||||
/// et afficher des notifications locales
|
||||
|
||||
class MqttNotificationService {
|
||||
static final MqttNotificationService _instance = MqttNotificationService._internal();
|
||||
factory MqttNotificationService() => _instance;
|
||||
MqttNotificationService._internal();
|
||||
|
||||
late MqttServerClient _client;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Configuration
|
||||
final String mqttHost;
|
||||
final int mqttPort;
|
||||
final String mqttUsername;
|
||||
final String mqttPassword;
|
||||
final String clientId;
|
||||
|
||||
// État
|
||||
bool _initialized = false;
|
||||
String? _userId;
|
||||
StreamSubscription? _messageSubscription;
|
||||
|
||||
// Callbacks
|
||||
Function(String messageId)? onMessageTap;
|
||||
Function(Map<String, dynamic>)? onNotificationReceived;
|
||||
|
||||
MqttNotificationService({
|
||||
this.mqttHost = 'mqtt.geosector.fr',
|
||||
this.mqttPort = 1883,
|
||||
this.mqttUsername = '',
|
||||
this.mqttPassword = '',
|
||||
String? clientId,
|
||||
}) : clientId = clientId ?? 'geosector_chat_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize({required String userId}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_userId = userId;
|
||||
|
||||
// Initialiser les notifications locales
|
||||
await _initializeLocalNotifications();
|
||||
|
||||
// Initialiser le client MQTT
|
||||
await _initializeMqttClient();
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Initialise le client MQTT
|
||||
Future<void> _initializeMqttClient() async {
|
||||
try {
|
||||
_client = MqttServerClient.withPort(mqttHost, clientId, mqttPort);
|
||||
|
||||
_client.logging(on: kDebugMode);
|
||||
_client.keepAlivePeriod = 60;
|
||||
_client.onConnected = _onConnected;
|
||||
_client.onDisconnected = _onDisconnected;
|
||||
_client.onSubscribed = _onSubscribed;
|
||||
_client.autoReconnect = true;
|
||||
|
||||
// Configurer les options de connexion
|
||||
final connMessage = MqttConnectMessage()
|
||||
.authenticateAs(mqttUsername, mqttPassword)
|
||||
.withClientIdentifier(clientId)
|
||||
.startClean()
|
||||
.keepAliveFor(60);
|
||||
|
||||
_client.connectionMessage = connMessage;
|
||||
|
||||
// Se connecter
|
||||
await _connect();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation MQTT : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Se connecte au broker MQTT
|
||||
Future<void> _connect() async {
|
||||
try {
|
||||
await _client.connect();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de connexion MQTT : $e');
|
||||
_client.disconnect();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback lors de la connexion
|
||||
void _onConnected() {
|
||||
debugPrint('Connecté au broker MQTT');
|
||||
|
||||
// S'abonner aux topics de l'utilisateur
|
||||
if (_userId != null) {
|
||||
_subscribeToUserTopics(_userId!);
|
||||
}
|
||||
|
||||
// Écouter les messages
|
||||
_messageSubscription = _client.updates?.listen(_onMessageReceived);
|
||||
}
|
||||
|
||||
/// Callback lors de la déconnexion
|
||||
void _onDisconnected() {
|
||||
debugPrint('Déconnecté du broker MQTT');
|
||||
|
||||
// Tenter une reconnexion
|
||||
if (_client.autoReconnect) {
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
_connect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback lors de l'abonnement
|
||||
void _onSubscribed(MqttSubscription subscription) {
|
||||
debugPrint('Abonné au topic : ${subscription.topic.rawTopic}');
|
||||
}
|
||||
|
||||
/// S'abonner aux topics de l'utilisateur
|
||||
void _subscribeToUserTopics(String userId) {
|
||||
// Topic pour les messages personnels
|
||||
_client.subscribe('chat/user/$userId/messages', MqttQos.atLeastOnce);
|
||||
|
||||
// Topic pour les annonces
|
||||
_client.subscribe('chat/announcement', MqttQos.atLeastOnce);
|
||||
|
||||
// Topic pour les groupes de l'utilisateur (si disponibles)
|
||||
_client.subscribe('chat/user/$userId/groups/+', MqttQos.atLeastOnce);
|
||||
}
|
||||
|
||||
/// Gère les messages reçus
|
||||
void _onMessageReceived(List<MqttReceivedMessage<MqttMessage>> messages) {
|
||||
for (var message in messages) {
|
||||
final topic = message.topic;
|
||||
final payload = message.payload as MqttPublishMessage;
|
||||
final messageText = MqttUtilities.bytesToStringAsString(payload.payload.message!);
|
||||
|
||||
try {
|
||||
final data = jsonDecode(messageText) as Map<String, dynamic>;
|
||||
_handleNotification(topic, data);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du décodage du message : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite la notification reçue
|
||||
Future<void> _handleNotification(String topic, Map<String, dynamic> data) async {
|
||||
// Vérifier les paramètres de notification de l'utilisateur
|
||||
if (!await _shouldShowNotification(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = '';
|
||||
String body = '';
|
||||
String messageId = '';
|
||||
String conversationId = '';
|
||||
|
||||
if (topic.startsWith('chat/user/')) {
|
||||
// Message personnel
|
||||
title = data['senderName'] ?? 'Nouveau message';
|
||||
body = data['content'] ?? '';
|
||||
messageId = data['messageId'] ?? '';
|
||||
conversationId = data['conversationId'] ?? '';
|
||||
} else if (topic.startsWith('chat/announcement')) {
|
||||
// Annonce
|
||||
title = data['title'] ?? 'Annonce';
|
||||
body = data['content'] ?? '';
|
||||
messageId = data['messageId'] ?? '';
|
||||
conversationId = data['conversationId'] ?? '';
|
||||
}
|
||||
|
||||
// Afficher la notification locale
|
||||
await _showLocalNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
payload: jsonEncode({
|
||||
'messageId': messageId,
|
||||
'conversationId': conversationId,
|
||||
}),
|
||||
);
|
||||
|
||||
// Appeler le callback si défini
|
||||
onNotificationReceived?.call(data);
|
||||
}
|
||||
|
||||
/// Vérifie si la notification doit être affichée
|
||||
Future<bool> _shouldShowNotification(Map<String, dynamic> data) async {
|
||||
// TODO: Vérifier les paramètres de notification de l'utilisateur
|
||||
// - Notifications désactivées
|
||||
// - Conversation en silencieux
|
||||
// - Mode Ne pas déranger
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Initialise les notifications locales
|
||||
Future<void> _initializeLocalNotifications() async {
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une notification locale
|
||||
Future<void> _showLocalNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required String payload,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'chat_messages',
|
||||
'Messages de chat',
|
||||
channelDescription: 'Notifications pour les nouveaux messages de chat',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler pour le clic sur une notification
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
try {
|
||||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final messageId = data['messageId'] as String?;
|
||||
if (messageId != null) {
|
||||
onMessageTap?.call(messageId);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement du clic sur notification : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publie un message MQTT
|
||||
Future<void> publishMessage(String topic, Map<String, dynamic> message) async {
|
||||
if (_client.connectionStatus?.state != MqttConnectionState.connected) {
|
||||
await _connect();
|
||||
}
|
||||
|
||||
final messagePayload = jsonEncode(message);
|
||||
final builder = MqttPayloadBuilder();
|
||||
builder.addString(messagePayload);
|
||||
|
||||
_client.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!);
|
||||
}
|
||||
|
||||
/// S'abonner à une conversation spécifique
|
||||
Future<void> subscribeToConversation(String conversationId) async {
|
||||
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
|
||||
_client.subscribe('chat/conversation/$conversationId', MqttQos.atLeastOnce);
|
||||
}
|
||||
}
|
||||
|
||||
/// Se désabonner d'une conversation
|
||||
Future<void> unsubscribeFromConversation(String conversationId) async {
|
||||
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
|
||||
_client.unsubscribeStringTopic('chat/conversation/$conversationId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive temporairement les notifications
|
||||
void pauseNotifications() {
|
||||
_client.pause();
|
||||
}
|
||||
|
||||
/// Réactive les notifications
|
||||
void resumeNotifications() {
|
||||
_client.resume();
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
_messageSubscription?.cancel();
|
||||
_client.disconnect();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
46
flutt/lib/chat/services/offline_queue_service.dart
Normal file
46
flutt/lib/chat/services/offline_queue_service.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
/// Service de gestion de la file d'attente hors ligne
|
||||
///
|
||||
/// Ce service gère les opérations chat en mode hors ligne
|
||||
/// et les synchronise lorsque la connexion revient
|
||||
|
||||
class OfflineQueueService {
|
||||
// TODO: Ajouter le service de connectivité
|
||||
|
||||
OfflineQueueService();
|
||||
|
||||
/// Ajoute une opération en attente
|
||||
Future<void> addPendingOperation(String operationType, Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter l'ajout à la file d'attente
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Traite les opérations en attente
|
||||
Future<void> processPendingOperations() async {
|
||||
// TODO: Implémenter le traitement des opérations
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Écoute les changements de connectivité
|
||||
void listenToConnectivityChanges() {
|
||||
// TODO: Implémenter l'écoute des changements
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Vérifie si une opération est en file d'attente
|
||||
bool hasOperationInQueue(String operationType, String id) {
|
||||
// TODO: Implémenter la vérification
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Supprime une opération de la file d'attente
|
||||
Future<void> removeOperationFromQueue(String operationType, String id) async {
|
||||
// TODO: Implémenter la suppression
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Dispose des ressources
|
||||
void dispose() {
|
||||
// TODO: Implémenter le dispose
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
98
flutt/lib/chat/widgets/chat_input.dart
Normal file
98
flutt/lib/chat/widgets/chat_input.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Zone de saisie de message
|
||||
///
|
||||
/// Ce widget permet à l'utilisateur de saisir et envoyer des messages
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
final Function(String) onSendText;
|
||||
final Function(dynamic)? onSendFile;
|
||||
final Function(dynamic)? onSendImage;
|
||||
final bool enableAttachments;
|
||||
final bool enabled;
|
||||
final String hintText;
|
||||
final String? disabledMessage;
|
||||
final int? maxLength;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
required this.onSendText,
|
||||
this.onSendFile,
|
||||
this.onSendImage,
|
||||
this.enableAttachments = true,
|
||||
this.enabled = true,
|
||||
this.hintText = 'Saisissez votre message...',
|
||||
this.disabledMessage = 'Vous ne pouvez pas répondre à cette annonce',
|
||||
this.maxLength,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.enabled) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: Colors.grey.shade200,
|
||||
child: Text(
|
||||
widget.disabledMessage ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.enableAttachments)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
onPressed: () {
|
||||
// TODO: Gérer les pièces jointes
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
maxLength: widget.maxLength,
|
||||
maxLines: null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () {
|
||||
if (_textController.text.trim().isNotEmpty) {
|
||||
widget.onSendText(_textController.text.trim());
|
||||
_textController.clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
80
flutt/lib/chat/widgets/chat_screen.dart
Normal file
80
flutt/lib/chat/widgets/chat_screen.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Écran principal d'une conversation
|
||||
///
|
||||
/// Ce widget affiche une conversation complète avec :
|
||||
/// - Liste des messages
|
||||
/// - Zone de saisie
|
||||
/// - En-tête et pied de page personnalisables
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final String conversationId;
|
||||
final String? title;
|
||||
final Widget? header;
|
||||
final Widget? footer;
|
||||
final bool enableAttachments;
|
||||
final bool showTypingIndicator;
|
||||
final bool enableReadReceipts;
|
||||
final bool isAnnouncement;
|
||||
final bool canReply;
|
||||
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
this.title,
|
||||
this.header,
|
||||
this.footer,
|
||||
this.enableAttachments = true,
|
||||
this.showTypingIndicator = true,
|
||||
this.enableReadReceipts = true,
|
||||
this.isAnnouncement = false,
|
||||
this.canReply = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// TODO: Initialiser les données du chat
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title ?? 'Chat'),
|
||||
// TODO: Ajouter les actions de l'AppBar
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (widget.header != null) widget.header!,
|
||||
Expanded(
|
||||
child: Container(
|
||||
// TODO: Implémenter la liste des messages
|
||||
child: const Center(child: Text('Messages à venir...')),
|
||||
),
|
||||
),
|
||||
if (widget.footer != null) widget.footer!,
|
||||
if (widget.canReply)
|
||||
Container(
|
||||
// TODO: Implémenter la zone de saisie
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Text('Zone de saisie à venir...'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// TODO: Libérer les ressources
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
78
flutt/lib/chat/widgets/conversations_list.dart
Normal file
78
flutt/lib/chat/widgets/conversations_list.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Liste des conversations
|
||||
///
|
||||
/// Ce widget affiche la liste des conversations de l'utilisateur
|
||||
/// avec leurs derniers messages et statuts
|
||||
|
||||
class ConversationsList extends StatefulWidget {
|
||||
final List<dynamic>? conversations;
|
||||
final bool loadFromHive;
|
||||
final Function(dynamic)? onConversationSelected;
|
||||
final bool showLastMessage;
|
||||
final bool showUnreadCount;
|
||||
final bool showAnnouncementBadge;
|
||||
final bool showPinnedFirst;
|
||||
final Widget? emptyStateWidget;
|
||||
|
||||
const ConversationsList({
|
||||
super.key,
|
||||
this.conversations,
|
||||
this.loadFromHive = true,
|
||||
this.onConversationSelected,
|
||||
this.showLastMessage = true,
|
||||
this.showUnreadCount = true,
|
||||
this.showAnnouncementBadge = true,
|
||||
this.showPinnedFirst = true,
|
||||
this.emptyStateWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConversationsList> createState() => _ConversationsListState();
|
||||
}
|
||||
|
||||
class _ConversationsListState extends State<ConversationsList> {
|
||||
late List<dynamic> _conversations;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConversations();
|
||||
}
|
||||
|
||||
Future<void> _loadConversations() async {
|
||||
if (widget.loadFromHive) {
|
||||
// TODO: Charger depuis Hive
|
||||
} else {
|
||||
_conversations = widget.conversations ?? [];
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_conversations.isEmpty) {
|
||||
return widget.emptyStateWidget ?? const Center(child: Text('Aucune conversation'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: _conversations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conversation = _conversations[index];
|
||||
// TODO: Créer le widget de conversation
|
||||
return ListTile(
|
||||
title: Text('Conversation ${index + 1}'),
|
||||
subtitle: const Text('Derniers messages...'),
|
||||
onTap: () => widget.onConversationSelected?.call(conversation),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
69
flutt/lib/chat/widgets/message_bubble.dart
Normal file
69
flutt/lib/chat/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Bulle de message
|
||||
///
|
||||
/// Ce widget affiche un message dans une conversation
|
||||
/// avec les informations associées
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final dynamic message; // TODO: Remplacer par MessageModel
|
||||
final bool showSenderInfo;
|
||||
final bool showTimestamp;
|
||||
final bool showStatus;
|
||||
final bool isAnnouncement;
|
||||
final double maxWidth;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.showSenderInfo = true,
|
||||
this.showTimestamp = true,
|
||||
this.showStatus = true,
|
||||
this.isAnnouncement = false,
|
||||
this.maxWidth = 300,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showSenderInfo) CircleAvatar(child: Text('S')),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isAnnouncement ? Colors.orange.shade100 : Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showSenderInfo)
|
||||
Text(
|
||||
'Expéditeur',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('Contenu du message...'),
|
||||
if (showTimestamp || showStatus)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (showTimestamp) Text('12:34', style: TextStyle(fontSize: 12)),
|
||||
if (showStatus) const SizedBox(width: 4),
|
||||
if (showStatus) Icon(Icons.check, size: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
flutt/lib/chat/widgets/notification_settings_widget.dart
Normal file
159
flutt/lib/chat/widgets/notification_settings_widget.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/notification_settings.dart';
|
||||
|
||||
/// Widget pour les paramètres de notification
|
||||
///
|
||||
/// Permet à l'utilisateur de configurer ses préférences de notification
|
||||
|
||||
class NotificationSettingsWidget extends StatelessWidget {
|
||||
final NotificationSettings settings;
|
||||
final Function(NotificationSettings) onSettingsChanged;
|
||||
|
||||
const NotificationSettingsWidget({
|
||||
super.key,
|
||||
required this.settings,
|
||||
required this.onSettingsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
// Notifications générales
|
||||
SwitchListTile(
|
||||
title: const Text('Activer les notifications'),
|
||||
subtitle: const Text('Recevoir des notifications pour les nouveaux messages'),
|
||||
value: settings.enableNotifications,
|
||||
onChanged: (value) {
|
||||
onSettingsChanged(settings.copyWith(enableNotifications: value));
|
||||
},
|
||||
),
|
||||
|
||||
if (settings.enableNotifications) ...[
|
||||
// Sons et vibrations
|
||||
SwitchListTile(
|
||||
title: const Text('Sons'),
|
||||
subtitle: const Text('Jouer un son à la réception'),
|
||||
value: settings.soundEnabled,
|
||||
onChanged: (value) {
|
||||
onSettingsChanged(settings.copyWith(soundEnabled: value));
|
||||
},
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Vibration'),
|
||||
subtitle: const Text('Vibrer à la réception'),
|
||||
value: settings.vibrationEnabled,
|
||||
onChanged: (value) {
|
||||
onSettingsChanged(settings.copyWith(vibrationEnabled: value));
|
||||
},
|
||||
),
|
||||
|
||||
// Aperçu des messages
|
||||
SwitchListTile(
|
||||
title: const Text('Aperçu du message'),
|
||||
subtitle: const Text('Afficher le contenu dans la notification'),
|
||||
value: settings.showPreview,
|
||||
onChanged: (value) {
|
||||
onSettingsChanged(settings.copyWith(showPreview: value));
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Mode Ne pas déranger
|
||||
SwitchListTile(
|
||||
title: const Text('Ne pas déranger'),
|
||||
subtitle: settings.doNotDisturb && settings.doNotDisturbStart != null
|
||||
? Text('Actif de ${_formatTime(settings.doNotDisturbStart!)} à ${_formatTime(settings.doNotDisturbEnd!)}')
|
||||
: null,
|
||||
value: settings.doNotDisturb,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
_showTimeRangePicker(context);
|
||||
} else {
|
||||
onSettingsChanged(settings.copyWith(doNotDisturb: false));
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
if (settings.doNotDisturb)
|
||||
ListTile(
|
||||
title: const Text('Horaires'),
|
||||
subtitle: Text('${_formatTime(settings.doNotDisturbStart!)} - ${_formatTime(settings.doNotDisturbEnd!)}'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios),
|
||||
onTap: () => _showTimeRangePicker(context),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Conversations en silencieux
|
||||
if (settings.mutedConversations.isNotEmpty) ...[
|
||||
const ListTile(
|
||||
title: Text('Conversations en silencieux'),
|
||||
subtitle: Text('Ces conversations n\'enverront pas de notifications'),
|
||||
),
|
||||
...settings.mutedConversations.map(
|
||||
(conversationId) => ListTile(
|
||||
title: Text('Conversation $conversationId'), // TODO: Récupérer le vrai nom
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.volume_up),
|
||||
onPressed: () {
|
||||
final muted = List<String>.from(settings.mutedConversations);
|
||||
muted.remove(conversationId);
|
||||
onSettingsChanged(settings.copyWith(mutedConversations: muted));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _showTimeRangePicker(BuildContext context) async {
|
||||
TimeOfDay? startTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: settings.doNotDisturbStart != null
|
||||
? TimeOfDay.fromDateTime(settings.doNotDisturbStart!)
|
||||
: const TimeOfDay(hour: 22, minute: 0),
|
||||
helpText: 'Heure de début',
|
||||
);
|
||||
|
||||
if (startTime != null) {
|
||||
final now = DateTime.now();
|
||||
final start = DateTime(now.year, now.month, now.day, startTime.hour, startTime.minute);
|
||||
|
||||
TimeOfDay? endTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: settings.doNotDisturbEnd != null
|
||||
? TimeOfDay.fromDateTime(settings.doNotDisturbEnd!)
|
||||
: const TimeOfDay(hour: 8, minute: 0),
|
||||
helpText: 'Heure de fin',
|
||||
);
|
||||
|
||||
if (endTime != null) {
|
||||
DateTime end = DateTime(now.year, now.month, now.day, endTime.hour, endTime.minute);
|
||||
|
||||
// Si l'heure de fin est avant l'heure de début, on considère qu'elle est le lendemain
|
||||
if (end.isBefore(start)) {
|
||||
end = end.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
onSettingsChanged(
|
||||
settings.copyWith(
|
||||
doNotDisturb: true,
|
||||
doNotDisturbStart: start,
|
||||
doNotDisturbEnd: end,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
flutt/lib/core/constants/app_keys.dart
Normal file
136
flutt/lib/core/constants/app_keys.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
/// Fichier contenant toutes les constantes utilisées dans l'application
|
||||
/// Centralise les clés, noms de boîtes Hive, et autres constantes
|
||||
/// pour faciliter la maintenance et éviter les erreurs de frappe
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppKeys {
|
||||
// Noms des boîtes Hive
|
||||
static const String usersBoxName = 'users';
|
||||
static const String operationsBoxName = 'operations';
|
||||
static const String sectorsBoxName = 'sectors';
|
||||
static const String passagesBoxName = 'passages';
|
||||
static const String settingsBoxName = 'settings';
|
||||
static const String membresBoxName = 'membres';
|
||||
static const String chatConversationsBoxName = 'chat_conversations';
|
||||
static const String chatMessagesBoxName = 'chat_messages';
|
||||
|
||||
// Rôles utilisateurs
|
||||
static const int roleUser = 1;
|
||||
static const int roleAdmin1 = 2;
|
||||
static const int roleAdmin2 = 4;
|
||||
static const int roleAdmin3 = 9;
|
||||
|
||||
// URLs API
|
||||
static const String baseApiUrl = 'https://app.geosector.fr/api/geo';
|
||||
|
||||
// Endpoints API
|
||||
static const String loginEndpoint = '/login';
|
||||
static const String logoutEndpoint = '/logout';
|
||||
static const String registerEndpoint = '/register';
|
||||
static const String syncDataEndpoint = '/data/sync';
|
||||
static const String sectorsEndpoint = '/sectors';
|
||||
|
||||
// Durées
|
||||
static const Duration connectionTimeout = Duration(seconds: 5);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
static const Duration sessionDefaultExpiry = Duration(days: 7);
|
||||
|
||||
// Clés API externes
|
||||
static const String mapboxApiKey =
|
||||
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY204dTNhNmd0MGV1ZzJqc2pnNnB0NjYxdSJ9.TA5Mvliyn91Oi01F_2Yuxw'; // À remplacer par votre clé API Mapbox
|
||||
|
||||
// Headers
|
||||
static const String sessionHeader = 'Authorization';
|
||||
|
||||
// En-têtes par défaut pour les requêtes API
|
||||
static const Map<String, String> defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Identifier': 'app.geosector.fr',
|
||||
'X-Client-Type': kIsWeb ? 'web' : 'mobile',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// Civilités
|
||||
static const Map<int, String> civilites = {
|
||||
1: 'M.',
|
||||
2: 'Mme',
|
||||
};
|
||||
|
||||
// Types de règlements
|
||||
static const Map<int, Map<String, dynamic>> typesReglements = {
|
||||
0: {
|
||||
'titre': 'Pas de règlement',
|
||||
'couleur': 0xFF757575, // Gris foncé
|
||||
'icon_data': Icons.money_off,
|
||||
},
|
||||
1: {
|
||||
'titre': 'Espèce',
|
||||
'couleur': 0xFFFFC107, // Jaune foncé (ambre)
|
||||
'icon_data': Icons.toll,
|
||||
},
|
||||
2: {
|
||||
'titre': 'Chèque',
|
||||
'couleur': 0xFF8BC34A, // Vert citron
|
||||
'icon_data': Icons.wallet,
|
||||
},
|
||||
3: {
|
||||
'titre': 'CB',
|
||||
'couleur': 0xFF00B0FF, // Bleu flashy (bleu clair accent),
|
||||
'icon_data': Icons.credit_card,
|
||||
},
|
||||
};
|
||||
|
||||
// Types de passages
|
||||
static const Map<int, Map<String, dynamic>> typesPassages = {
|
||||
1: {
|
||||
'titres': 'Effectués',
|
||||
'titre': 'Effectué',
|
||||
'couleur1': 0xFF4CAF50, // Vert success
|
||||
'couleur2': 0xFF4CAF50, // Vert success
|
||||
'couleur3': 0xFF4CAF50, // Vert success
|
||||
'icon_data': Icons.task_alt,
|
||||
},
|
||||
2: {
|
||||
'titres': 'À finaliser',
|
||||
'titre': 'À finaliser',
|
||||
'couleur1': 0xFFFFFFFF, // Blanc
|
||||
'couleur2': 0xFFFF9800, // Orange
|
||||
'couleur3': 0xFFE65100, // Orange foncé
|
||||
'icon_data': Icons.refresh,
|
||||
},
|
||||
3: {
|
||||
'titres': 'Refusés',
|
||||
'titre': 'Refusé',
|
||||
'couleur1': 0xFFF44336, // Rouge
|
||||
'couleur2': 0xFFF44336, // Rouge
|
||||
'couleur3': 0xFFF44336, // Rouge
|
||||
'icon_data': Icons.block,
|
||||
},
|
||||
4: {
|
||||
'titres': 'Dons',
|
||||
'titre': 'Don',
|
||||
'couleur1': 0xFF03A9F4, // Bleu ciel
|
||||
'couleur2': 0xFF03A9F4, // Bleu ciel
|
||||
'couleur3': 0xFF03A9F4, // Bleu ciel
|
||||
'icon_data': Icons.volunteer_activism,
|
||||
},
|
||||
5: {
|
||||
'titres': 'Lots',
|
||||
'titre': 'Lot',
|
||||
'couleur1': 0xFF0D47A1, // Bleu foncé
|
||||
'couleur2': 0xFF0D47A1, // Bleu foncé
|
||||
'couleur3': 0xFF0D47A1, // Bleu foncé
|
||||
'icon_data': Icons.layers,
|
||||
},
|
||||
6: {
|
||||
'titres': 'Maisons vides',
|
||||
'titre': 'Maison vide',
|
||||
'couleur1': 0xFF9E9E9E, // Gris
|
||||
'couleur2': 0xFF9E9E9E, // Gris
|
||||
'couleur3': 0xFF9E9E9E, // Gris
|
||||
'icon_data': Icons.home_outlined,
|
||||
},
|
||||
};
|
||||
}
|
||||
137
flutt/lib/core/data/models/membre_model.dart
Normal file
137
flutt/lib/core/data/models/membre_model.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'membre_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 5) // Utilisation d'un typeId unique
|
||||
class MembreModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final int fkRole;
|
||||
|
||||
@HiveField(2)
|
||||
final int fkTitre;
|
||||
|
||||
@HiveField(3)
|
||||
final String firstName;
|
||||
|
||||
@HiveField(4)
|
||||
final String? sectName;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime? dateNaissance;
|
||||
|
||||
@HiveField(6)
|
||||
final DateTime? dateEmbauche;
|
||||
|
||||
@HiveField(7)
|
||||
final int chkActive;
|
||||
|
||||
@HiveField(8)
|
||||
final String name;
|
||||
|
||||
@HiveField(9)
|
||||
final String username;
|
||||
|
||||
@HiveField(10)
|
||||
final String email;
|
||||
|
||||
MembreModel({
|
||||
required this.id,
|
||||
required this.fkRole,
|
||||
required this.fkTitre,
|
||||
required this.firstName,
|
||||
this.sectName,
|
||||
this.dateNaissance,
|
||||
this.dateEmbauche,
|
||||
required this.chkActive,
|
||||
required this.name,
|
||||
required this.username,
|
||||
required this.email,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory MembreModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir le rôle en int, qu'il soit déjà int ou string
|
||||
final dynamic rawRole = json['fk_role'];
|
||||
final int fkRole = rawRole is String ? int.parse(rawRole) : rawRole as int;
|
||||
|
||||
// Convertir le titre en int, qu'il soit déjà int ou string
|
||||
final dynamic rawTitre = json['fk_titre'];
|
||||
final int fkTitre =
|
||||
rawTitre is String ? int.parse(rawTitre) : rawTitre as int;
|
||||
|
||||
// Convertir le chkActive en int, qu'il soit déjà int ou string
|
||||
final dynamic rawActive = json['chk_active'];
|
||||
final int chkActive =
|
||||
rawActive is String ? int.parse(rawActive) : rawActive as int;
|
||||
|
||||
return MembreModel(
|
||||
id: id,
|
||||
fkRole: fkRole,
|
||||
fkTitre: fkTitre,
|
||||
firstName: json['first_name'] ?? '',
|
||||
sectName: json['sect_name'],
|
||||
dateNaissance: json['date_naissance'] != null
|
||||
? DateTime.parse(json['date_naissance'])
|
||||
: null,
|
||||
dateEmbauche: json['date_embauche'] != null
|
||||
? DateTime.parse(json['date_embauche'])
|
||||
: null,
|
||||
chkActive: chkActive,
|
||||
name: json['name'] ?? '',
|
||||
username: json['username'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'fk_role': fkRole,
|
||||
'fk_titre': fkTitre,
|
||||
'first_name': firstName,
|
||||
'sect_name': sectName,
|
||||
'date_naissance': dateNaissance?.toIso8601String(),
|
||||
'date_embauche': dateEmbauche?.toIso8601String(),
|
||||
'chk_active': chkActive,
|
||||
'name': name,
|
||||
'username': username,
|
||||
'email': email,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
MembreModel copyWith({
|
||||
int? fkRole,
|
||||
int? fkTitre,
|
||||
String? firstName,
|
||||
String? sectName,
|
||||
DateTime? dateNaissance,
|
||||
DateTime? dateEmbauche,
|
||||
int? chkActive,
|
||||
String? name,
|
||||
String? username,
|
||||
String? email,
|
||||
}) {
|
||||
return MembreModel(
|
||||
id: this.id,
|
||||
fkRole: fkRole ?? this.fkRole,
|
||||
fkTitre: fkTitre ?? this.fkTitre,
|
||||
firstName: firstName ?? this.firstName,
|
||||
sectName: sectName ?? this.sectName,
|
||||
dateNaissance: dateNaissance ?? this.dateNaissance,
|
||||
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
|
||||
chkActive: chkActive ?? this.chkActive,
|
||||
name: name ?? this.name,
|
||||
username: username ?? this.username,
|
||||
email: email ?? this.email,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
flutt/lib/core/data/models/membre_model.g.dart
Normal file
71
flutt/lib/core/data/models/membre_model.g.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'membre_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class MembreModelAdapter extends TypeAdapter<MembreModel> {
|
||||
@override
|
||||
final int typeId = 5;
|
||||
|
||||
@override
|
||||
MembreModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return MembreModel(
|
||||
id: fields[0] as int,
|
||||
fkRole: fields[1] as int,
|
||||
fkTitre: fields[2] as int,
|
||||
firstName: fields[3] as String,
|
||||
sectName: fields[4] as String?,
|
||||
dateNaissance: fields[5] as DateTime?,
|
||||
dateEmbauche: fields[6] as DateTime?,
|
||||
chkActive: fields[7] as int,
|
||||
name: fields[8] as String,
|
||||
username: fields[9] as String,
|
||||
email: fields[10] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, MembreModel obj) {
|
||||
writer
|
||||
..writeByte(11)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.fkRole)
|
||||
..writeByte(2)
|
||||
..write(obj.fkTitre)
|
||||
..writeByte(3)
|
||||
..write(obj.firstName)
|
||||
..writeByte(4)
|
||||
..write(obj.sectName)
|
||||
..writeByte(5)
|
||||
..write(obj.dateNaissance)
|
||||
..writeByte(6)
|
||||
..write(obj.dateEmbauche)
|
||||
..writeByte(7)
|
||||
..write(obj.chkActive)
|
||||
..writeByte(8)
|
||||
..write(obj.name)
|
||||
..writeByte(9)
|
||||
..write(obj.username)
|
||||
..writeByte(10)
|
||||
..write(obj.email);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MembreModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
85
flutt/lib/core/data/models/operation_model.dart
Normal file
85
flutt/lib/core/data/models/operation_model.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'operation_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 1)
|
||||
class OperationModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final DateTime dateDebut;
|
||||
|
||||
@HiveField(3)
|
||||
final DateTime dateFin;
|
||||
|
||||
@HiveField(4)
|
||||
DateTime lastSyncedAt;
|
||||
|
||||
@HiveField(5)
|
||||
bool isActive;
|
||||
|
||||
@HiveField(6)
|
||||
bool isSynced;
|
||||
|
||||
OperationModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.dateDebut,
|
||||
required this.dateFin,
|
||||
required this.lastSyncedAt,
|
||||
this.isActive = true,
|
||||
this.isSynced = false,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory OperationModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
return OperationModel(
|
||||
id: id,
|
||||
name: json['name'],
|
||||
dateDebut: DateTime.parse(json['date_deb']),
|
||||
dateFin: DateTime.parse(json['date_fin']),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
'is_active': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
OperationModel copyWith({
|
||||
String? name,
|
||||
DateTime? dateDebut,
|
||||
DateTime? dateFin,
|
||||
bool? isActive,
|
||||
bool? isSynced,
|
||||
DateTime? lastSyncedAt,
|
||||
}) {
|
||||
return OperationModel(
|
||||
id: this.id,
|
||||
name: name ?? this.name,
|
||||
dateDebut: dateDebut ?? this.dateDebut,
|
||||
dateFin: dateFin ?? this.dateFin,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
);
|
||||
}
|
||||
}
|
||||
59
flutt/lib/core/data/models/operation_model.g.dart
Normal file
59
flutt/lib/core/data/models/operation_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'operation_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
OperationModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return OperationModel(
|
||||
id: fields[0] as int,
|
||||
name: fields[1] as String,
|
||||
dateDebut: fields[2] as DateTime,
|
||||
dateFin: fields[3] as DateTime,
|
||||
lastSyncedAt: fields[4] as DateTime,
|
||||
isActive: fields[5] as bool,
|
||||
isSynced: fields[6] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, OperationModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.dateDebut)
|
||||
..writeByte(3)
|
||||
..write(obj.dateFin)
|
||||
..writeByte(4)
|
||||
..write(obj.lastSyncedAt)
|
||||
..writeByte(5)
|
||||
..write(obj.isActive)
|
||||
..writeByte(6)
|
||||
..write(obj.isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OperationModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
291
flutt/lib/core/data/models/passage_model.dart
Normal file
291
flutt/lib/core/data/models/passage_model.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'passage_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 4)
|
||||
class PassageModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final int fkOperation;
|
||||
|
||||
@HiveField(2)
|
||||
final int fkSector;
|
||||
|
||||
@HiveField(3)
|
||||
final int fkUser;
|
||||
|
||||
@HiveField(4)
|
||||
final int fkType;
|
||||
|
||||
@HiveField(5)
|
||||
final String fkAdresse;
|
||||
|
||||
@HiveField(6)
|
||||
final DateTime passedAt;
|
||||
|
||||
@HiveField(7)
|
||||
final String numero;
|
||||
|
||||
@HiveField(8)
|
||||
final String rue;
|
||||
|
||||
@HiveField(9)
|
||||
final String rueBis;
|
||||
|
||||
@HiveField(10)
|
||||
final String ville;
|
||||
|
||||
@HiveField(11)
|
||||
final String residence;
|
||||
|
||||
@HiveField(12)
|
||||
final int fkHabitat;
|
||||
|
||||
@HiveField(13)
|
||||
final String appt;
|
||||
|
||||
@HiveField(14)
|
||||
final String niveau;
|
||||
|
||||
@HiveField(15)
|
||||
final String gpsLat;
|
||||
|
||||
@HiveField(16)
|
||||
final String gpsLng;
|
||||
|
||||
@HiveField(17)
|
||||
final String nomRecu;
|
||||
|
||||
@HiveField(18)
|
||||
final String remarque;
|
||||
|
||||
@HiveField(19)
|
||||
final String montant;
|
||||
|
||||
@HiveField(20)
|
||||
final int fkTypeReglement;
|
||||
|
||||
@HiveField(21)
|
||||
final String emailErreur;
|
||||
|
||||
@HiveField(22)
|
||||
final int nbPassages;
|
||||
|
||||
@HiveField(23)
|
||||
final String name;
|
||||
|
||||
@HiveField(24)
|
||||
final String email;
|
||||
|
||||
@HiveField(25)
|
||||
final String phone;
|
||||
|
||||
@HiveField(26)
|
||||
DateTime lastSyncedAt;
|
||||
|
||||
@HiveField(27)
|
||||
bool isActive;
|
||||
|
||||
@HiveField(28)
|
||||
bool isSynced;
|
||||
|
||||
PassageModel({
|
||||
required this.id,
|
||||
required this.fkOperation,
|
||||
required this.fkSector,
|
||||
required this.fkUser,
|
||||
required this.fkType,
|
||||
required this.fkAdresse,
|
||||
required this.passedAt,
|
||||
required this.numero,
|
||||
required this.rue,
|
||||
this.rueBis = '',
|
||||
required this.ville,
|
||||
this.residence = '',
|
||||
required this.fkHabitat,
|
||||
this.appt = '',
|
||||
this.niveau = '',
|
||||
required this.gpsLat,
|
||||
required this.gpsLng,
|
||||
this.nomRecu = '',
|
||||
this.remarque = '',
|
||||
required this.montant,
|
||||
required this.fkTypeReglement,
|
||||
this.emailErreur = '',
|
||||
required this.nbPassages,
|
||||
required this.name,
|
||||
this.email = '',
|
||||
this.phone = '',
|
||||
required this.lastSyncedAt,
|
||||
this.isActive = true,
|
||||
this.isSynced = false,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory PassageModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir les autres champs numériques
|
||||
final dynamic rawFkOperation = json['fk_operation'];
|
||||
final int fkOperation = rawFkOperation is String ? int.parse(rawFkOperation) : rawFkOperation as int;
|
||||
|
||||
final dynamic rawFkSector = json['fk_sector'];
|
||||
final int fkSector = rawFkSector is String ? int.parse(rawFkSector) : rawFkSector as int;
|
||||
|
||||
final dynamic rawFkUser = json['fk_user'];
|
||||
final int fkUser = rawFkUser is String ? int.parse(rawFkUser) : rawFkUser as int;
|
||||
|
||||
final dynamic rawFkType = json['fk_type'];
|
||||
final int fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
|
||||
|
||||
final dynamic rawFkHabitat = json['fk_habitat'];
|
||||
final int fkHabitat = rawFkHabitat is String ? int.parse(rawFkHabitat) : rawFkHabitat as int;
|
||||
|
||||
final dynamic rawFkTypeReglement = json['fk_type_reglement'];
|
||||
final int fkTypeReglement = rawFkTypeReglement is String ? int.parse(rawFkTypeReglement) : rawFkTypeReglement as int;
|
||||
|
||||
final dynamic rawNbPassages = json['nb_passages'];
|
||||
final int nbPassages = rawNbPassages is String ? int.parse(rawNbPassages) : rawNbPassages as int;
|
||||
|
||||
// Convertir la date
|
||||
final DateTime passedAt = DateTime.parse(json['passed_at']);
|
||||
|
||||
return PassageModel(
|
||||
id: id,
|
||||
fkOperation: fkOperation,
|
||||
fkSector: fkSector,
|
||||
fkUser: fkUser,
|
||||
fkType: fkType,
|
||||
fkAdresse: json['fk_adresse'] as String,
|
||||
passedAt: passedAt,
|
||||
numero: json['numero'] as String,
|
||||
rue: json['rue'] as String,
|
||||
rueBis: json['rue_bis'] as String? ?? '',
|
||||
ville: json['ville'] as String,
|
||||
residence: json['residence'] as String? ?? '',
|
||||
fkHabitat: fkHabitat,
|
||||
appt: json['appt'] as String? ?? '',
|
||||
niveau: json['niveau'] as String? ?? '',
|
||||
gpsLat: json['gps_lat'] as String,
|
||||
gpsLng: json['gps_lng'] as String,
|
||||
nomRecu: json['nom_recu'] as String? ?? '',
|
||||
remarque: json['remarque'] as String? ?? '',
|
||||
montant: json['montant'] as String,
|
||||
fkTypeReglement: fkTypeReglement,
|
||||
emailErreur: json['email_erreur'] as String? ?? '',
|
||||
nbPassages: nbPassages,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'fk_operation': fkOperation,
|
||||
'fk_sector': fkSector,
|
||||
'fk_user': fkUser,
|
||||
'fk_type': fkType,
|
||||
'fk_adresse': fkAdresse,
|
||||
'passed_at': passedAt.toIso8601String(),
|
||||
'numero': numero,
|
||||
'rue': rue,
|
||||
'rue_bis': rueBis,
|
||||
'ville': ville,
|
||||
'residence': residence,
|
||||
'fk_habitat': fkHabitat,
|
||||
'appt': appt,
|
||||
'niveau': niveau,
|
||||
'gps_lat': gpsLat,
|
||||
'gps_lng': gpsLng,
|
||||
'nom_recu': nomRecu,
|
||||
'remarque': remarque,
|
||||
'montant': montant,
|
||||
'fk_type_reglement': fkTypeReglement,
|
||||
'email_erreur': emailErreur,
|
||||
'nb_passages': nbPassages,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
PassageModel copyWith({
|
||||
int? id,
|
||||
int? fkOperation,
|
||||
int? fkSector,
|
||||
int? fkUser,
|
||||
int? fkType,
|
||||
String? fkAdresse,
|
||||
DateTime? passedAt,
|
||||
String? numero,
|
||||
String? rue,
|
||||
String? rueBis,
|
||||
String? ville,
|
||||
String? residence,
|
||||
int? fkHabitat,
|
||||
String? appt,
|
||||
String? niveau,
|
||||
String? gpsLat,
|
||||
String? gpsLng,
|
||||
String? nomRecu,
|
||||
String? remarque,
|
||||
String? montant,
|
||||
int? fkTypeReglement,
|
||||
String? emailErreur,
|
||||
int? nbPassages,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phone,
|
||||
DateTime? lastSyncedAt,
|
||||
bool? isActive,
|
||||
bool? isSynced,
|
||||
}) {
|
||||
return PassageModel(
|
||||
id: id ?? this.id,
|
||||
fkOperation: fkOperation ?? this.fkOperation,
|
||||
fkSector: fkSector ?? this.fkSector,
|
||||
fkUser: fkUser ?? this.fkUser,
|
||||
fkType: fkType ?? this.fkType,
|
||||
fkAdresse: fkAdresse ?? this.fkAdresse,
|
||||
passedAt: passedAt ?? this.passedAt,
|
||||
numero: numero ?? this.numero,
|
||||
rue: rue ?? this.rue,
|
||||
rueBis: rueBis ?? this.rueBis,
|
||||
ville: ville ?? this.ville,
|
||||
residence: residence ?? this.residence,
|
||||
fkHabitat: fkHabitat ?? this.fkHabitat,
|
||||
appt: appt ?? this.appt,
|
||||
niveau: niveau ?? this.niveau,
|
||||
gpsLat: gpsLat ?? this.gpsLat,
|
||||
gpsLng: gpsLng ?? this.gpsLng,
|
||||
nomRecu: nomRecu ?? this.nomRecu,
|
||||
remarque: remarque ?? this.remarque,
|
||||
montant: montant ?? this.montant,
|
||||
fkTypeReglement: fkTypeReglement ?? this.fkTypeReglement,
|
||||
emailErreur: emailErreur ?? this.emailErreur,
|
||||
nbPassages: nbPassages ?? this.nbPassages,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PassageModel(id: $id, fkOperation: $fkOperation, fkSector: $fkSector, fkUser: $fkUser, fkType: $fkType, adresse: $fkAdresse, ville: $ville, montant: $montant)';
|
||||
}
|
||||
}
|
||||
125
flutt/lib/core/data/models/passage_model.g.dart
Normal file
125
flutt/lib/core/data/models/passage_model.g.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'passage_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class PassageModelAdapter extends TypeAdapter<PassageModel> {
|
||||
@override
|
||||
final int typeId = 4;
|
||||
|
||||
@override
|
||||
PassageModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return PassageModel(
|
||||
id: fields[0] as int,
|
||||
fkOperation: fields[1] as int,
|
||||
fkSector: fields[2] as int,
|
||||
fkUser: fields[3] as int,
|
||||
fkType: fields[4] as int,
|
||||
fkAdresse: fields[5] as String,
|
||||
passedAt: fields[6] as DateTime,
|
||||
numero: fields[7] as String,
|
||||
rue: fields[8] as String,
|
||||
rueBis: fields[9] as String,
|
||||
ville: fields[10] as String,
|
||||
residence: fields[11] as String,
|
||||
fkHabitat: fields[12] as int,
|
||||
appt: fields[13] as String,
|
||||
niveau: fields[14] as String,
|
||||
gpsLat: fields[15] as String,
|
||||
gpsLng: fields[16] as String,
|
||||
nomRecu: fields[17] as String,
|
||||
remarque: fields[18] as String,
|
||||
montant: fields[19] as String,
|
||||
fkTypeReglement: fields[20] as int,
|
||||
emailErreur: fields[21] as String,
|
||||
nbPassages: fields[22] as int,
|
||||
name: fields[23] as String,
|
||||
email: fields[24] as String,
|
||||
phone: fields[25] as String,
|
||||
lastSyncedAt: fields[26] as DateTime,
|
||||
isActive: fields[27] as bool,
|
||||
isSynced: fields[28] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, PassageModel obj) {
|
||||
writer
|
||||
..writeByte(29)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.fkOperation)
|
||||
..writeByte(2)
|
||||
..write(obj.fkSector)
|
||||
..writeByte(3)
|
||||
..write(obj.fkUser)
|
||||
..writeByte(4)
|
||||
..write(obj.fkType)
|
||||
..writeByte(5)
|
||||
..write(obj.fkAdresse)
|
||||
..writeByte(6)
|
||||
..write(obj.passedAt)
|
||||
..writeByte(7)
|
||||
..write(obj.numero)
|
||||
..writeByte(8)
|
||||
..write(obj.rue)
|
||||
..writeByte(9)
|
||||
..write(obj.rueBis)
|
||||
..writeByte(10)
|
||||
..write(obj.ville)
|
||||
..writeByte(11)
|
||||
..write(obj.residence)
|
||||
..writeByte(12)
|
||||
..write(obj.fkHabitat)
|
||||
..writeByte(13)
|
||||
..write(obj.appt)
|
||||
..writeByte(14)
|
||||
..write(obj.niveau)
|
||||
..writeByte(15)
|
||||
..write(obj.gpsLat)
|
||||
..writeByte(16)
|
||||
..write(obj.gpsLng)
|
||||
..writeByte(17)
|
||||
..write(obj.nomRecu)
|
||||
..writeByte(18)
|
||||
..write(obj.remarque)
|
||||
..writeByte(19)
|
||||
..write(obj.montant)
|
||||
..writeByte(20)
|
||||
..write(obj.fkTypeReglement)
|
||||
..writeByte(21)
|
||||
..write(obj.emailErreur)
|
||||
..writeByte(22)
|
||||
..write(obj.nbPassages)
|
||||
..writeByte(23)
|
||||
..write(obj.name)
|
||||
..writeByte(24)
|
||||
..write(obj.email)
|
||||
..writeByte(25)
|
||||
..write(obj.phone)
|
||||
..writeByte(26)
|
||||
..write(obj.lastSyncedAt)
|
||||
..writeByte(27)
|
||||
..write(obj.isActive)
|
||||
..writeByte(28)
|
||||
..write(obj.isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PassageModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
85
flutt/lib/core/data/models/sector_model.dart
Normal file
85
flutt/lib/core/data/models/sector_model.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'sector_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
class SectorModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final String libelle;
|
||||
|
||||
@HiveField(2)
|
||||
final String color;
|
||||
|
||||
@HiveField(3)
|
||||
final String sector;
|
||||
|
||||
SectorModel({
|
||||
required this.id,
|
||||
required this.libelle,
|
||||
required this.color,
|
||||
required this.sector,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory SectorModel.fromJson(Map<String, dynamic> json) {
|
||||
return SectorModel(
|
||||
id: json['id'] is String ? int.parse(json['id']) : json['id'] as int,
|
||||
libelle: json['libelle'] as String,
|
||||
color: json['color'] as String,
|
||||
sector: json['sector'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'libelle': libelle,
|
||||
'color': color,
|
||||
'sector': sector,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
SectorModel copyWith({
|
||||
int? id,
|
||||
String? libelle,
|
||||
String? color,
|
||||
String? sector,
|
||||
}) {
|
||||
return SectorModel(
|
||||
id: id ?? this.id,
|
||||
libelle: libelle ?? this.libelle,
|
||||
color: color ?? this.color,
|
||||
sector: sector ?? this.sector,
|
||||
);
|
||||
}
|
||||
|
||||
// Obtenir les coordonnées du secteur sous forme de liste de points
|
||||
List<List<double>> getCoordinates() {
|
||||
final List<List<double>> coordinates = [];
|
||||
|
||||
// Le format est "lat1/lng1#lat2/lng2#lat3/lng3#..."
|
||||
final List<String> points = sector.split('#');
|
||||
|
||||
for (final String point in points) {
|
||||
if (point.isEmpty) continue;
|
||||
|
||||
final List<String> latLng = point.split('/');
|
||||
if (latLng.length == 2) {
|
||||
try {
|
||||
final double lat = double.parse(latLng[0]);
|
||||
final double lng = double.parse(latLng[1]);
|
||||
coordinates.add([lat, lng]);
|
||||
} catch (e) {
|
||||
// Ignorer les points mal formatés
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
}
|
||||
50
flutt/lib/core/data/models/sector_model.g.dart
Normal file
50
flutt/lib/core/data/models/sector_model.g.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sector_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SectorModelAdapter extends TypeAdapter<SectorModel> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
SectorModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SectorModel(
|
||||
id: fields[0] as int,
|
||||
libelle: fields[1] as String,
|
||||
color: fields[2] as String,
|
||||
sector: fields[3] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SectorModel obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.libelle)
|
||||
..writeByte(2)
|
||||
..write(obj.color)
|
||||
..writeByte(3)
|
||||
..write(obj.sector);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SectorModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
169
flutt/lib/core/data/models/user_model.dart
Normal file
169
flutt/lib/core/data/models/user_model.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'user_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class UserModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final String email;
|
||||
|
||||
@HiveField(2)
|
||||
String? name;
|
||||
|
||||
@HiveField(11)
|
||||
String? username;
|
||||
|
||||
@HiveField(10)
|
||||
String? firstName;
|
||||
|
||||
@HiveField(3)
|
||||
final int role;
|
||||
|
||||
@HiveField(4)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(5)
|
||||
DateTime lastSyncedAt;
|
||||
|
||||
@HiveField(6)
|
||||
bool isActive;
|
||||
|
||||
@HiveField(7)
|
||||
bool isSynced;
|
||||
|
||||
@HiveField(8)
|
||||
String? sessionId;
|
||||
|
||||
@HiveField(9)
|
||||
DateTime? sessionExpiry;
|
||||
|
||||
@HiveField(12)
|
||||
String? lastPath;
|
||||
|
||||
@HiveField(13)
|
||||
String? sectName;
|
||||
|
||||
@HiveField(14)
|
||||
String? interface;
|
||||
|
||||
UserModel({
|
||||
required this.id,
|
||||
required this.email,
|
||||
this.name,
|
||||
this.username,
|
||||
this.firstName,
|
||||
required this.role,
|
||||
required this.createdAt,
|
||||
required this.lastSyncedAt,
|
||||
this.isActive = true,
|
||||
this.isSynced = false,
|
||||
this.sessionId,
|
||||
this.sessionExpiry,
|
||||
this.lastPath,
|
||||
this.sectName,
|
||||
this.interface,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir le rôle en int, qu'il soit déjà int ou string
|
||||
final dynamic rawRole = json['role'];
|
||||
final int role = rawRole is String ? int.parse(rawRole) : rawRole as int;
|
||||
|
||||
return UserModel(
|
||||
id: id,
|
||||
email: json['email'],
|
||||
name: json['name'],
|
||||
username: json['username'],
|
||||
firstName: json['first_name'],
|
||||
role: role,
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: json['is_active'] ?? true,
|
||||
isSynced: true,
|
||||
sessionId: json['session_id'],
|
||||
sessionExpiry: json['session_expiry'] != null
|
||||
? DateTime.parse(json['session_expiry'])
|
||||
: null,
|
||||
sectName: json['sect_name'],
|
||||
interface: json['interface'],
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'username': username,
|
||||
'first_name': firstName,
|
||||
'role': role,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'is_active': isActive,
|
||||
'session_id': sessionId,
|
||||
'session_expiry': sessionExpiry?.toIso8601String(),
|
||||
'last_path': lastPath,
|
||||
'sect_name': sectName,
|
||||
'interface': interface,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
UserModel copyWith({
|
||||
String? email,
|
||||
String? name,
|
||||
String? username,
|
||||
String? firstName,
|
||||
int? role,
|
||||
bool? isActive,
|
||||
bool? isSynced,
|
||||
DateTime? lastSyncedAt,
|
||||
String? sessionId,
|
||||
DateTime? sessionExpiry,
|
||||
String? lastPath,
|
||||
String? sectName,
|
||||
String? interface,
|
||||
}) {
|
||||
return UserModel(
|
||||
id: this.id,
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
username: username ?? this.username,
|
||||
firstName: firstName ?? this.firstName,
|
||||
role: role ?? this.role,
|
||||
createdAt: this.createdAt,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
sessionExpiry: sessionExpiry ?? this.sessionExpiry,
|
||||
lastPath: lastPath ?? this.lastPath,
|
||||
sectName: sectName ?? this.sectName,
|
||||
interface: interface ?? this.interface,
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si la session est valide
|
||||
bool get hasValidSession {
|
||||
if (sessionId == null || sessionExpiry == null) {
|
||||
return false;
|
||||
}
|
||||
return sessionExpiry!.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
// Effacer les données de session
|
||||
UserModel clearSession() {
|
||||
return copyWith(
|
||||
sessionId: null,
|
||||
sessionExpiry: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
flutt/lib/core/data/models/user_model.g.dart
Normal file
83
flutt/lib/core/data/models/user_model.g.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class UserModelAdapter extends TypeAdapter<UserModel> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
UserModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return UserModel(
|
||||
id: fields[0] as int,
|
||||
email: fields[1] as String,
|
||||
name: fields[2] as String?,
|
||||
username: fields[11] as String?,
|
||||
firstName: fields[10] as String?,
|
||||
role: fields[3] as int,
|
||||
createdAt: fields[4] as DateTime,
|
||||
lastSyncedAt: fields[5] as DateTime,
|
||||
isActive: fields[6] as bool,
|
||||
isSynced: fields[7] as bool,
|
||||
sessionId: fields[8] as String?,
|
||||
sessionExpiry: fields[9] as DateTime?,
|
||||
lastPath: fields[12] as String?,
|
||||
sectName: fields[13] as String?,
|
||||
interface: fields[14] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, UserModel obj) {
|
||||
writer
|
||||
..writeByte(15)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.email)
|
||||
..writeByte(2)
|
||||
..write(obj.name)
|
||||
..writeByte(11)
|
||||
..write(obj.username)
|
||||
..writeByte(10)
|
||||
..write(obj.firstName)
|
||||
..writeByte(3)
|
||||
..write(obj.role)
|
||||
..writeByte(4)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(5)
|
||||
..write(obj.lastSyncedAt)
|
||||
..writeByte(6)
|
||||
..write(obj.isActive)
|
||||
..writeByte(7)
|
||||
..write(obj.isSynced)
|
||||
..writeByte(8)
|
||||
..write(obj.sessionId)
|
||||
..writeByte(9)
|
||||
..write(obj.sessionExpiry)
|
||||
..writeByte(12)
|
||||
..write(obj.lastPath)
|
||||
..writeByte(13)
|
||||
..write(obj.sectName)
|
||||
..writeByte(14)
|
||||
..write(obj.interface);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
208
flutt/lib/core/repositories/membre_repository.dart
Normal file
208
flutt/lib/core/repositories/membre_repository.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
|
||||
class MembreRepository extends ChangeNotifier {
|
||||
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
Box<MembreModel> get _membreBox =>
|
||||
Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
|
||||
final ApiService _apiService;
|
||||
bool _isLoading = false;
|
||||
|
||||
MembreRepository(this._apiService);
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
List<MembreModel> get membres => getAllMembres();
|
||||
|
||||
// Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen() async {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
debugPrint('Ouverture de la boîte ${AppKeys.membresBoxName}...');
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
debugPrint('Boîte ${AppKeys.membresBoxName} ouverte avec succès');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte ${AppKeys.membresBoxName}: $e');
|
||||
throw Exception(
|
||||
'Impossible d\'ouvrir la boîte ${AppKeys.membresBoxName}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer tous les membres
|
||||
List<MembreModel> getAllMembres() {
|
||||
try {
|
||||
return _membreBox.values.toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des membres: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer un membre par son ID
|
||||
MembreModel? getMembreById(int id) {
|
||||
try {
|
||||
return _membreBox.get(id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du membre: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour un membre
|
||||
Future<MembreModel> saveMembre(MembreModel membre) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _membreBox.put(membre.id, membre);
|
||||
notifyListeners();
|
||||
return membre;
|
||||
}
|
||||
|
||||
// Supprimer un membre
|
||||
Future<void> deleteMembre(int id) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _membreBox.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis l'API (uniquement pour l'interface admin)
|
||||
Future<List<MembreModel>> fetchMembresFromApi() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
if (!hasConnection) {
|
||||
debugPrint(
|
||||
'Pas de connexion Internet, utilisation des données locales');
|
||||
return getAllMembres();
|
||||
}
|
||||
|
||||
// Endpoint à adapter selon votre API
|
||||
final response = await _apiService.get('/membres');
|
||||
final List<dynamic> membresData = response.data['membres'];
|
||||
|
||||
// Vider la boîte avant d'ajouter les nouveaux membres
|
||||
await _ensureBoxIsOpen();
|
||||
await _membreBox.clear();
|
||||
|
||||
final List<MembreModel> membres = [];
|
||||
for (var membreData in membresData) {
|
||||
try {
|
||||
final membre = MembreModel.fromJson(membreData);
|
||||
await _membreBox.put(membre.id, membre);
|
||||
membres.add(membre);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement d\'un membre: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return membres;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la récupération des membres depuis l\'API: $e');
|
||||
return getAllMembres();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un membre via l'API
|
||||
Future<MembreModel?> createMembreViaApi(MembreModel membre) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
if (!hasConnection) {
|
||||
debugPrint('Pas de connexion Internet, impossible de créer le membre');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Endpoint à adapter selon votre API
|
||||
final response =
|
||||
await _apiService.post('/membres', data: membre.toJson());
|
||||
final membreData = response.data['membre'];
|
||||
|
||||
final newMembre = MembreModel.fromJson(membreData);
|
||||
await saveMembre(newMembre);
|
||||
|
||||
return newMembre;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la création du membre via l\'API: $e');
|
||||
return null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour un membre via l'API
|
||||
Future<MembreModel?> updateMembreViaApi(MembreModel membre) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
if (!hasConnection) {
|
||||
debugPrint(
|
||||
'Pas de connexion Internet, impossible de mettre à jour le membre');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Endpoint à adapter selon votre API
|
||||
final response =
|
||||
await _apiService.put('/membres/${membre.id}', data: membre.toJson());
|
||||
final membreData = response.data['membre'];
|
||||
|
||||
final updatedMembre = MembreModel.fromJson(membreData);
|
||||
await saveMembre(updatedMembre);
|
||||
|
||||
return updatedMembre;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour du membre via l\'API: $e');
|
||||
return null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer un membre via l'API
|
||||
Future<bool> deleteMembreViaApi(int id) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
if (!hasConnection) {
|
||||
debugPrint(
|
||||
'Pas de connexion Internet, impossible de supprimer le membre');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Endpoint à adapter selon votre API
|
||||
await _apiService.delete('/membres/$id');
|
||||
|
||||
// Supprimer localement
|
||||
await deleteMembre(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression du membre via l\'API: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
215
flutt/lib/core/repositories/operation_repository.dart
Normal file
215
flutt/lib/core/repositories/operation_repository.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
class OperationRepository extends ChangeNotifier {
|
||||
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
// et vérifier qu'elle est ouverte avant accès
|
||||
Box<OperationModel> get _operationBox {
|
||||
_ensureBoxIsOpen();
|
||||
return Hive.box<OperationModel>(AppKeys.operationsBoxName);
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen() async {
|
||||
final boxName = AppKeys.operationsBoxName;
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Ouverture de la boîte $boxName dans OperationRepository...');
|
||||
await Hive.openBox<OperationModel>(boxName);
|
||||
}
|
||||
}
|
||||
final ApiService _apiService;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
OperationRepository(this._apiService);
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
List<OperationModel> get operations => getAllOperations();
|
||||
|
||||
// Récupérer toutes les opérations
|
||||
List<OperationModel> getAllOperations() {
|
||||
return _operationBox.values.toList();
|
||||
}
|
||||
|
||||
// Récupérer une opération par son ID
|
||||
OperationModel? getOperationById(int id) {
|
||||
return _operationBox.get(id);
|
||||
}
|
||||
|
||||
// Sauvegarder une opération
|
||||
Future<void> saveOperation(OperationModel operation) async {
|
||||
await _operationBox.put(operation.id, operation);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Supprimer une opération
|
||||
Future<void> deleteOperation(int id) async {
|
||||
await _operationBox.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour des opérations à partir des données de l'API
|
||||
Future<void> processOperationsFromApi(List<dynamic> operationsData) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
for (var operationData in operationsData) {
|
||||
final operationJson = operationData as Map<String, dynamic>;
|
||||
final operationId = operationJson['id'] is String
|
||||
? int.parse(operationJson['id'])
|
||||
: operationJson['id'] as int;
|
||||
|
||||
// Vérifier si l'opération existe déjà
|
||||
OperationModel? existingOperation = getOperationById(operationId);
|
||||
|
||||
if (existingOperation == null) {
|
||||
// Créer une nouvelle opération
|
||||
final newOperation = OperationModel.fromJson(operationJson);
|
||||
await saveOperation(newOperation);
|
||||
} else {
|
||||
// Mettre à jour l'opération existante
|
||||
final updatedOperation = existingOperation.copyWith(
|
||||
name: operationJson['name'],
|
||||
dateDebut: DateTime.parse(operationJson['date_deb']),
|
||||
dateFin: DateTime.parse(operationJson['date_fin']),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: true,
|
||||
);
|
||||
await saveOperation(updatedOperation);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement des opérations: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une opération
|
||||
Future<bool> createOperation(String name, DateTime dateDebut, DateTime dateFin) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Préparer les données pour l'API
|
||||
final Map<String, dynamic> data = {
|
||||
'name': name,
|
||||
'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
};
|
||||
|
||||
// Appeler l'API pour créer l'opération
|
||||
final response = await _apiService.post('/operations', data: data);
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID de la nouvelle opération
|
||||
final operationId = response.data['id'] is String
|
||||
? int.parse(response.data['id'])
|
||||
: response.data['id'] as int;
|
||||
|
||||
// Créer l'opération localement
|
||||
final newOperation = OperationModel(
|
||||
id: operationId,
|
||||
name: name,
|
||||
dateDebut: dateDebut,
|
||||
dateFin: dateFin,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await saveOperation(newOperation);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la création de l\'opération: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une opération
|
||||
Future<bool> updateOperation(int id, {String? name, DateTime? dateDebut, DateTime? dateFin, bool? isActive}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Récupérer l'opération existante
|
||||
final existingOperation = getOperationById(id);
|
||||
if (existingOperation == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Préparer les données pour l'API
|
||||
final Map<String, dynamic> data = {
|
||||
'id': id,
|
||||
'name': name ?? existingOperation.name,
|
||||
'date_deb': (dateDebut ?? existingOperation.dateDebut).toIso8601String().split('T')[0],
|
||||
'date_fin': (dateFin ?? existingOperation.dateFin).toIso8601String().split('T')[0],
|
||||
'is_active': isActive ?? existingOperation.isActive,
|
||||
};
|
||||
|
||||
// Appeler l'API pour mettre à jour l'opération
|
||||
final response = await _apiService.put('/operations/$id', data: data);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Mettre à jour l'opération localement
|
||||
final updatedOperation = existingOperation.copyWith(
|
||||
name: name,
|
||||
dateDebut: dateDebut,
|
||||
dateFin: dateFin,
|
||||
isActive: isActive,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await saveOperation(updatedOperation);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour de l\'opération: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une opération via l'API
|
||||
Future<bool> deleteOperationViaApi(int id) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Appeler l'API pour supprimer l'opération
|
||||
final response = await _apiService.delete('/operations/$id');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
// Supprimer l'opération localement
|
||||
await deleteOperation(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression de l\'opération: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
381
flutt/lib/core/repositories/passage_repository.dart
Normal file
381
flutt/lib/core/repositories/passage_repository.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.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/constants/app_keys.dart';
|
||||
|
||||
class PassageRepository extends ChangeNotifier {
|
||||
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
// et vérifier qu'elle est ouverte avant accès
|
||||
Box<PassageModel> get _passageBox {
|
||||
_ensureBoxIsOpen();
|
||||
return Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen() async {
|
||||
final boxName = AppKeys.passagesBoxName;
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Ouverture de la boîte $boxName dans PassageRepository...');
|
||||
await Hive.openBox<PassageModel>(boxName);
|
||||
}
|
||||
}
|
||||
final ApiService _apiService;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
PassageRepository(this._apiService);
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
List<PassageModel> get passages => getAllPassages();
|
||||
|
||||
// Récupérer tous les passages
|
||||
List<PassageModel> getAllPassages() {
|
||||
return _passageBox.values.toList();
|
||||
}
|
||||
|
||||
// Récupérer un passage par son ID
|
||||
PassageModel? getPassageById(int id) {
|
||||
return _passageBox.get(id);
|
||||
}
|
||||
|
||||
// Récupérer les passages par secteur
|
||||
List<PassageModel> getPassagesBySector(int sectorId) {
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkSector == sectorId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Récupérer les passages par opération
|
||||
List<PassageModel> getPassagesByOperation(int operationId) {
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkOperation == operationId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Récupérer les passages par type
|
||||
List<PassageModel> getPassagesByType(int typeId) {
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkType == typeId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Récupérer les passages par type de règlement
|
||||
List<PassageModel> getPassagesByPaymentType(int paymentTypeId) {
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkTypeReglement == paymentTypeId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Sauvegarder un passage
|
||||
Future<void> savePassage(PassageModel passage) async {
|
||||
await _passageBox.put(passage.id, passage);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Supprimer un passage
|
||||
Future<void> deletePassage(int id) async {
|
||||
await _passageBox.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Traiter les passages reçus de l'API
|
||||
Future<void> processPassagesFromApi(List<dynamic> passagesData) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
for (var passageData in passagesData) {
|
||||
final passageJson = passageData as Map<String, dynamic>;
|
||||
final passageId = passageJson['id'] is String
|
||||
? int.parse(passageJson['id'])
|
||||
: passageJson['id'] as int;
|
||||
|
||||
// Vérifier si le passage existe déjà
|
||||
PassageModel? existingPassage = getPassageById(passageId);
|
||||
|
||||
if (existingPassage == null) {
|
||||
// Créer un nouveau passage
|
||||
final newPassage = PassageModel.fromJson(passageJson);
|
||||
await savePassage(newPassage);
|
||||
} else {
|
||||
// Mettre à jour le passage existant avec les nouvelles données
|
||||
final updatedPassage = PassageModel.fromJson(passageJson).copyWith(
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: existingPassage.isActive,
|
||||
isSynced: true,
|
||||
);
|
||||
await savePassage(updatedPassage);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement des passages: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un nouveau passage
|
||||
Future<bool> createPassage({
|
||||
required int fkOperation,
|
||||
required int fkSector,
|
||||
required int fkUser,
|
||||
required int fkType,
|
||||
required String fkAdresse,
|
||||
required DateTime passedAt,
|
||||
required String numero,
|
||||
required String rue,
|
||||
String rueBis = '',
|
||||
required String ville,
|
||||
String residence = '',
|
||||
required int fkHabitat,
|
||||
String appt = '',
|
||||
String niveau = '',
|
||||
required String gpsLat,
|
||||
required String gpsLng,
|
||||
String nomRecu = '',
|
||||
String remarque = '',
|
||||
required String montant,
|
||||
required int fkTypeReglement,
|
||||
String name = '',
|
||||
String email = '',
|
||||
String phone = '',
|
||||
}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Préparer les données pour l'API
|
||||
final Map<String, dynamic> data = {
|
||||
'fk_operation': fkOperation,
|
||||
'fk_sector': fkSector,
|
||||
'fk_user': fkUser,
|
||||
'fk_type': fkType,
|
||||
'fk_adresse': fkAdresse,
|
||||
'passed_at': passedAt.toIso8601String(),
|
||||
'numero': numero,
|
||||
'rue': rue,
|
||||
'rue_bis': rueBis,
|
||||
'ville': ville,
|
||||
'residence': residence,
|
||||
'fk_habitat': fkHabitat,
|
||||
'appt': appt,
|
||||
'niveau': niveau,
|
||||
'gps_lat': gpsLat,
|
||||
'gps_lng': gpsLng,
|
||||
'nom_recu': nomRecu,
|
||||
'remarque': remarque,
|
||||
'montant': montant,
|
||||
'fk_type_reglement': fkTypeReglement,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
};
|
||||
|
||||
// Appeler l'API pour créer le passage
|
||||
final response = await _apiService.post('/passages', data: data);
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID du nouveau passage
|
||||
final passageId = response.data['id'] is String
|
||||
? int.parse(response.data['id'])
|
||||
: response.data['id'] as int;
|
||||
|
||||
// Créer le modèle local
|
||||
final newPassage = PassageModel(
|
||||
id: passageId,
|
||||
fkOperation: fkOperation,
|
||||
fkSector: fkSector,
|
||||
fkUser: fkUser,
|
||||
fkType: fkType,
|
||||
fkAdresse: fkAdresse,
|
||||
passedAt: passedAt,
|
||||
numero: numero,
|
||||
rue: rue,
|
||||
rueBis: rueBis,
|
||||
ville: ville,
|
||||
residence: residence,
|
||||
fkHabitat: fkHabitat,
|
||||
appt: appt,
|
||||
niveau: niveau,
|
||||
gpsLat: gpsLat,
|
||||
gpsLng: gpsLng,
|
||||
nomRecu: nomRecu,
|
||||
remarque: remarque,
|
||||
montant: montant,
|
||||
fkTypeReglement: fkTypeReglement,
|
||||
nbPassages: 1, // Par défaut pour un nouveau passage
|
||||
name: name,
|
||||
email: email,
|
||||
phone: phone,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await savePassage(newPassage);
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('Erreur lors de la création du passage: ${response.statusMessage}');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la création du passage: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour un passage existant
|
||||
Future<bool> updatePassage(PassageModel passage) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Préparer les données pour l'API
|
||||
final Map<String, dynamic> data = passage.toJson();
|
||||
|
||||
// Appeler l'API pour mettre à jour le passage
|
||||
final response = await _apiService.put('/passages/${passage.id}', data: data);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Mettre à jour le modèle local
|
||||
final updatedPassage = passage.copyWith(
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await savePassage(updatedPassage);
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('Erreur lors de la mise à jour du passage: ${response.statusMessage}');
|
||||
|
||||
// Marquer comme non synchronisé mais sauvegarder localement
|
||||
final updatedPassage = passage.copyWith(
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
await savePassage(updatedPassage);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour du passage: $e');
|
||||
|
||||
// Marquer comme non synchronisé mais sauvegarder localement
|
||||
final updatedPassage = passage.copyWith(
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
await savePassage(updatedPassage);
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Synchroniser tous les passages non synchronisés
|
||||
Future<void> syncUnsyncedPassages() async {
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
|
||||
if (!hasConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
final unsyncedPassages = _passageBox.values.where((passage) => !passage.isSynced).toList();
|
||||
|
||||
if (unsyncedPassages.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
for (final passage in unsyncedPassages) {
|
||||
try {
|
||||
if (passage.id < 0) {
|
||||
// Nouveau passage créé localement, à envoyer à l'API
|
||||
await createPassage(
|
||||
fkOperation: passage.fkOperation,
|
||||
fkSector: passage.fkSector,
|
||||
fkUser: passage.fkUser,
|
||||
fkType: passage.fkType,
|
||||
fkAdresse: passage.fkAdresse,
|
||||
passedAt: passage.passedAt,
|
||||
numero: passage.numero,
|
||||
rue: passage.rue,
|
||||
rueBis: passage.rueBis,
|
||||
ville: passage.ville,
|
||||
residence: passage.residence,
|
||||
fkHabitat: passage.fkHabitat,
|
||||
appt: passage.appt,
|
||||
niveau: passage.niveau,
|
||||
gpsLat: passage.gpsLat,
|
||||
gpsLng: passage.gpsLng,
|
||||
nomRecu: passage.nomRecu,
|
||||
remarque: passage.remarque,
|
||||
montant: passage.montant,
|
||||
fkTypeReglement: passage.fkTypeReglement,
|
||||
name: passage.name,
|
||||
email: passage.email,
|
||||
phone: passage.phone,
|
||||
);
|
||||
|
||||
// Supprimer l'ancien passage avec ID temporaire
|
||||
await deletePassage(passage.id);
|
||||
} else {
|
||||
// Passage existant à mettre à jour
|
||||
await updatePassage(passage);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la synchronisation du passage ${passage.id}: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la synchronisation des passages: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les passages depuis l'API
|
||||
Future<void> fetchPassages() async {
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
|
||||
if (!hasConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
final response = await _apiService.get('/passages');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> passagesData = response.data;
|
||||
await processPassagesFromApi(passagesData);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des passages: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Vider tous les passages
|
||||
Future<void> clearAllPassages() async {
|
||||
await _passageBox.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
149
flutt/lib/core/repositories/sector_repository.dart
Normal file
149
flutt/lib/core/repositories/sector_repository.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
class SectorRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
SectorRepository(this._apiService);
|
||||
|
||||
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
// et vérifier qu'elle est ouverte avant accès
|
||||
Box<SectorModel> get _sectorsBox {
|
||||
_ensureBoxIsOpen();
|
||||
return Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen() async {
|
||||
final boxName = AppKeys.sectorsBoxName;
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
print('Ouverture de la boîte $boxName dans SectorRepository...');
|
||||
await Hive.openBox<SectorModel>(boxName);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer tous les secteurs depuis la base de données locale
|
||||
List<SectorModel> getAllSectors() {
|
||||
return _sectorsBox.values.toList();
|
||||
}
|
||||
|
||||
// Récupérer un secteur par son ID
|
||||
SectorModel? getSectorById(int id) {
|
||||
try {
|
||||
return _sectorsBox.values.firstWhere(
|
||||
(sector) => sector.id == id,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les secteurs dans la base de données locale
|
||||
Future<void> saveSectors(List<SectorModel> sectors) async {
|
||||
// Vider la box avant d'ajouter les nouveaux secteurs
|
||||
await _sectorsBox.clear();
|
||||
|
||||
// Ajouter les nouveaux secteurs
|
||||
for (final sector in sectors) {
|
||||
await _sectorsBox.put(sector.id, sector);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter ou mettre à jour un secteur
|
||||
Future<void> saveSector(SectorModel sector) async {
|
||||
await _sectorsBox.put(sector.id, sector);
|
||||
}
|
||||
|
||||
// Supprimer un secteur
|
||||
Future<void> deleteSector(int id) async {
|
||||
await _sectorsBox.delete(id);
|
||||
}
|
||||
|
||||
// Récupérer les secteurs depuis l'API
|
||||
Future<List<SectorModel>> fetchSectorsFromApi() async {
|
||||
try {
|
||||
final response = await _apiService.get(AppKeys.sectorsEndpoint);
|
||||
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
|
||||
|
||||
if (responseData['status'] == 'success' && responseData['sectors'] != null) {
|
||||
final List<dynamic> sectorsJson = responseData['sectors'];
|
||||
final List<SectorModel> sectors = sectorsJson
|
||||
.map((json) => SectorModel.fromJson(json))
|
||||
.toList();
|
||||
|
||||
// Sauvegarder les secteurs localement
|
||||
await saveSectors(sectors);
|
||||
|
||||
return sectors;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
// En cas d'erreur, retourner les secteurs locaux
|
||||
return getAllSectors();
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un nouveau secteur via l'API
|
||||
Future<SectorModel?> createSector(SectorModel sector) async {
|
||||
try {
|
||||
final response = await _apiService.post(
|
||||
AppKeys.sectorsEndpoint,
|
||||
data: sector.toJson(),
|
||||
);
|
||||
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
|
||||
|
||||
if (responseData['status'] == 'success' && responseData['sector'] != null) {
|
||||
final SectorModel newSector = SectorModel.fromJson(responseData['sector']);
|
||||
await saveSector(newSector);
|
||||
return newSector;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour un secteur via l'API
|
||||
Future<SectorModel?> updateSector(SectorModel sector) async {
|
||||
try {
|
||||
final response = await _apiService.put(
|
||||
'${AppKeys.sectorsEndpoint}/${sector.id}',
|
||||
data: sector.toJson(),
|
||||
);
|
||||
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
|
||||
|
||||
if (responseData['status'] == 'success' && responseData['sector'] != null) {
|
||||
final SectorModel updatedSector = SectorModel.fromJson(responseData['sector']);
|
||||
await saveSector(updatedSector);
|
||||
return updatedSector;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer un secteur via l'API
|
||||
Future<bool> deleteSectorFromApi(int id) async {
|
||||
try {
|
||||
final response = await _apiService.delete(
|
||||
'${AppKeys.sectorsEndpoint}/$id',
|
||||
);
|
||||
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
|
||||
|
||||
if (responseData['status'] == 'success') {
|
||||
await deleteSector(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
958
flutt/lib/core/repositories/user_repository.dart
Normal file
958
flutt/lib/core/repositories/user_repository.dart
Normal file
@@ -0,0 +1,958 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:js' as js;
|
||||
import 'package:geosector_app/core/services/hive_web_fix.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/sync_service.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
|
||||
class UserRepository extends ChangeNotifier {
|
||||
// Utilisation de getters lazy pour n'accéder aux boîtes que lorsque nécessaire
|
||||
Box<UserModel> get _userBox => Hive.box<UserModel>(AppKeys.usersBoxName);
|
||||
|
||||
// Getters pour les autres boîtes qui vérifient si elles sont ouvertes avant accès
|
||||
Box<OperationModel> get _operationBox {
|
||||
_ensureBoxIsOpen(AppKeys.operationsBoxName);
|
||||
return Hive.box<OperationModel>(AppKeys.operationsBoxName);
|
||||
}
|
||||
|
||||
Box<SectorModel> get _sectorBox {
|
||||
_ensureBoxIsOpen(AppKeys.sectorsBoxName);
|
||||
return Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
}
|
||||
|
||||
// Méthode pour initialiser les boîtes après connexion
|
||||
Future<void> _initializeBoxes() async {
|
||||
debugPrint('Initialisation des boîtes Hive nécessaires...');
|
||||
await _ensureBoxIsOpen(AppKeys.operationsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.sectorsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.passagesBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.membresBoxName);
|
||||
// Les boîtes de chat sont déjà initialisées au démarrage
|
||||
await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName);
|
||||
debugPrint('Toutes les boîtes Hive sont maintenant ouvertes');
|
||||
}
|
||||
|
||||
final ApiService _apiService;
|
||||
final SyncService? _syncService;
|
||||
final OperationRepository? _operationRepository;
|
||||
final SectorRepository? _sectorRepository;
|
||||
final PassageRepository? _passageRepository;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
UserRepository(this._apiService,
|
||||
{SyncService? syncService,
|
||||
OperationRepository? operationRepository,
|
||||
SectorRepository? sectorRepository,
|
||||
PassageRepository? passageRepository})
|
||||
: _syncService = syncService,
|
||||
_operationRepository = operationRepository,
|
||||
_sectorRepository = sectorRepository,
|
||||
_passageRepository = passageRepository {
|
||||
// Initialiser la session si un utilisateur est déjà connecté
|
||||
final currentUser = getCurrentUser();
|
||||
if (currentUser != null && currentUser.sessionId != null) {
|
||||
setSessionId(currentUser.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoggedIn => getCurrentUser() != null;
|
||||
// Vérifie si l'utilisateur a un rôle administrateur (2, 4 ou 9)
|
||||
bool isAdmin() {
|
||||
final user = getCurrentUser();
|
||||
if (user == null) return false;
|
||||
|
||||
final String interface = user.interface ?? 'user';
|
||||
return interface == 'admin';
|
||||
}
|
||||
|
||||
int? get userId => getCurrentUser()?.id;
|
||||
UserModel? get currentUser => getCurrentUser();
|
||||
|
||||
// Récupérer l'utilisateur actuellement connecté
|
||||
UserModel? getCurrentUser() {
|
||||
try {
|
||||
// Chercher un utilisateur avec une session active
|
||||
final activeUsers = _userBox.values
|
||||
.where((user) =>
|
||||
user.sessionId != null && // Vérifier que sessionId n'est pas null
|
||||
user.sessionId!
|
||||
.isNotEmpty && // Vérifier que sessionId n'est pas vide
|
||||
user.sessionExpiry != null &&
|
||||
user.sessionExpiry!.isAfter(DateTime.now()))
|
||||
.toList();
|
||||
|
||||
return activeUsers.isNotEmpty ? activeUsers.first : null;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le chemin de la page actuelle pour l'utilisateur connecté
|
||||
Future<void> updateLastPath(String path) async {
|
||||
final currentUser = getCurrentUser();
|
||||
if (currentUser != null) {
|
||||
final updatedUser = currentUser.copyWith(lastPath: path);
|
||||
await saveUser(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer le dernier chemin visité par l'utilisateur
|
||||
String? getLastPath() {
|
||||
final currentUser = getCurrentUser();
|
||||
return currentUser?.lastPath;
|
||||
}
|
||||
|
||||
// Configurer la session dans l'API
|
||||
void setSessionId(String? sessionId) {
|
||||
_apiService.setSessionId(sessionId);
|
||||
}
|
||||
|
||||
// Login API PHP
|
||||
Future<Map<String, dynamic>> loginAPI(String username, String password,
|
||||
{String type = 'admin'}) async {
|
||||
try {
|
||||
return await _apiService.login(username, password, type: type);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur login API: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Register API PHP - Uniquement pour les administrateurs
|
||||
Future<Map<String, dynamic>> registerAPI(String email, String name,
|
||||
String amicaleName, String postalCode, String cityName) async {
|
||||
try {
|
||||
final Map<String, dynamic> data = {
|
||||
'email': email,
|
||||
'name': name,
|
||||
'amicale_name': amicaleName,
|
||||
'postal_code': postalCode,
|
||||
'city_name': cityName
|
||||
};
|
||||
|
||||
final response =
|
||||
await _apiService.post(AppKeys.registerEndpoint, data: data);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur register API: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout API PHP
|
||||
Future<void> logoutAPI() async {
|
||||
try {
|
||||
await _apiService.logout();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur logout API: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode d'inscription (uniquement pour les administrateurs)
|
||||
Future<bool> register(String email, String password, String name,
|
||||
String amicaleName, String postalCode, String cityName) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Enregistrer l'administrateur via l'API
|
||||
final apiResult =
|
||||
await registerAPI(email, name, amicaleName, postalCode, cityName);
|
||||
|
||||
// Créer l'administrateur local
|
||||
final int userId = apiResult['user_id'] is String
|
||||
? int.parse(apiResult['user_id'])
|
||||
: apiResult['user_id'];
|
||||
final now = DateTime.now();
|
||||
final newAdmin = UserModel(
|
||||
id: userId,
|
||||
email: email,
|
||||
name: name,
|
||||
role: AppKeys.roleAdmin2,
|
||||
createdAt: now,
|
||||
lastSyncedAt: now,
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
sessionId: apiResult['session_id'],
|
||||
sessionExpiry: DateTime.parse(apiResult['session_expiry']),
|
||||
);
|
||||
|
||||
// Sauvegarder dans le repository local
|
||||
await saveUser(newAdmin);
|
||||
|
||||
// Configurer la session dans l'API
|
||||
setSessionId(newAdmin.sessionId);
|
||||
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur d\'inscription: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Login complet
|
||||
Future<bool> login(String username, String password,
|
||||
{String type = 'admin'}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
debugPrint('Début du processus de connexion pour: $username');
|
||||
|
||||
// Supprimer les références aux boîtes non définies dans AppKeys
|
||||
// pour éviter les erreurs de suppression de boîtes non référencées
|
||||
final nonDefinedBoxes = ['auth', 'locations', 'messages'];
|
||||
for (final boxName in nonDefinedBoxes) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Fermeture de la boîte non référencée: $boxName');
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
|
||||
// Supprimer la boîte du disque
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('Nettoyage: Box $boxName supprimée');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la suppression de la boîte non référencée $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// S'assurer que toutes les Hive boxes sont vides avant de se connecter
|
||||
// Vider toutes les boîtes Hive SAUF la boîte des utilisateurs
|
||||
debugPrint('Nettoyage des données existantes avant connexion...');
|
||||
|
||||
// Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive
|
||||
if (kIsWeb) {
|
||||
await HiveWebFix.safeCleanHiveBoxes(
|
||||
excludeBoxes: [AppKeys.usersBoxName]);
|
||||
}
|
||||
// Sur iOS, nettoyer les fichiers Hive directement
|
||||
else if (!kIsWeb && Platform.isIOS) {
|
||||
await _cleanHiveFilesOnIOS();
|
||||
}
|
||||
// Sur Android, nettoyer les fichiers Hive directement
|
||||
else if (!kIsWeb && Platform.isAndroid) {
|
||||
await _cleanHiveFilesOnAndroid();
|
||||
}
|
||||
|
||||
// Nettoyer les boîtes sans les fermer
|
||||
await _clearAndRecreateBoxes();
|
||||
|
||||
// Initialiser les boîtes nécessaires avant d'appeler l'API
|
||||
// Cela garantit que toutes les boîtes sont ouvertes avant le traitement des données
|
||||
await _initializeBoxes();
|
||||
|
||||
// Appeler l'API
|
||||
debugPrint('Appel de l\'API de connexion (type: $type)...');
|
||||
final apiResult = await loginAPI(username, password, type: type);
|
||||
|
||||
// Vérifier le statut de la réponse
|
||||
final status = apiResult['status'] as String?;
|
||||
final message = apiResult['message'] as String?;
|
||||
|
||||
// Si le statut n'est pas 'success', retourner false
|
||||
if (status != 'success') {
|
||||
debugPrint('Échec de connexion: $message');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('Connexion réussie, traitement des données...');
|
||||
|
||||
// [Reste de la méthode login inchangé...]
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de connexion: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen(String boxName) async {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Ouverture de la boîte $boxName...');
|
||||
if (boxName == AppKeys.passagesBoxName) {
|
||||
await Hive.openBox<PassageModel>(boxName);
|
||||
} else if (boxName == AppKeys.operationsBoxName) {
|
||||
await Hive.openBox<OperationModel>(boxName);
|
||||
} else if (boxName == AppKeys.sectorsBoxName) {
|
||||
await Hive.openBox<SectorModel>(boxName);
|
||||
} else if (boxName == AppKeys.usersBoxName) {
|
||||
await Hive.openBox<UserModel>(boxName);
|
||||
} else if (boxName == AppKeys.membresBoxName) {
|
||||
await Hive.openBox<MembreModel>(boxName);
|
||||
} else if (boxName == AppKeys.settingsBoxName) {
|
||||
await Hive.openBox(boxName);
|
||||
} else if (boxName == AppKeys.chatConversationsBoxName) {
|
||||
await Hive.openBox<ConversationModel>(boxName);
|
||||
} else if (boxName == AppKeys.chatMessagesBoxName) {
|
||||
await Hive.openBox<MessageModel>(boxName);
|
||||
} else {
|
||||
await Hive.openBox(boxName);
|
||||
}
|
||||
// Boîte ouverte avec succès
|
||||
} else {
|
||||
// La boîte est déjà ouverte
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture de la boîte $boxName: $e');
|
||||
throw Exception('Impossible d\'ouvrir la boîte $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour vider et recréer toutes les boîtes Hive sauf la boîte des utilisateurs
|
||||
Future<void> _clearAndRecreateBoxes() async {
|
||||
try {
|
||||
debugPrint('Début de la suppression complète des données Hive...');
|
||||
|
||||
// Supprimer les références aux boîtes non définies dans AppKeys
|
||||
// pour éviter les erreurs de suppression de boîtes non référencées
|
||||
final nonDefinedBoxes = ['auth', 'locations', 'messages'];
|
||||
for (final boxName in nonDefinedBoxes) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Fermeture de la boîte non référencée: $boxName');
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
|
||||
// Supprimer la boîte du disque
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('Nettoyage: Box $boxName supprimée');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la suppression de la boîte non référencée $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive
|
||||
if (kIsWeb) {
|
||||
await HiveWebFix.safeCleanHiveBoxes(
|
||||
excludeBoxes: [AppKeys.usersBoxName]);
|
||||
}
|
||||
// Sur iOS, nettoyer les fichiers Hive directement
|
||||
else if (Platform.isIOS) {
|
||||
await _cleanHiveFilesOnIOS();
|
||||
}
|
||||
// Sur Android, nettoyer les fichiers Hive directement
|
||||
else if (Platform.isAndroid) {
|
||||
await _cleanHiveFilesOnAndroid();
|
||||
}
|
||||
|
||||
// Liste des noms de boîtes à supprimer
|
||||
final boxesToDelete = [
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
];
|
||||
|
||||
// Vider chaque boîte sans la fermer
|
||||
for (final boxName in boxesToDelete) {
|
||||
try {
|
||||
debugPrint('Nettoyage de la boîte: $boxName');
|
||||
|
||||
// Vérifier si la boîte est déjà ouverte
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
// Vider la boîte sans la fermer
|
||||
debugPrint('Boîte $boxName déjà ouverte, vidage sans fermeture');
|
||||
if (boxName == AppKeys.passagesBoxName) {
|
||||
await Hive.box<PassageModel>(boxName).clear();
|
||||
} else if (boxName == AppKeys.operationsBoxName) {
|
||||
await Hive.box<OperationModel>(boxName).clear();
|
||||
} else if (boxName == AppKeys.sectorsBoxName) {
|
||||
await Hive.box<SectorModel>(boxName).clear();
|
||||
} else if (boxName == AppKeys.chatConversationsBoxName) {
|
||||
await Hive.box<ConversationModel>(boxName).clear();
|
||||
} else if (boxName == AppKeys.chatMessagesBoxName) {
|
||||
await Hive.box<MessageModel>(boxName).clear();
|
||||
}
|
||||
} else {
|
||||
// Supprimer la boîte du disque si elle n'est pas ouverte
|
||||
debugPrint('Boîte $boxName non ouverte, suppression du disque');
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du nettoyage de la boîte $boxName: $e');
|
||||
// Tenter de supprimer la boîte du disque en cas d'erreur
|
||||
try {
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
} catch (deleteError) {
|
||||
debugPrint(
|
||||
'Impossible de supprimer la boîte $boxName: $deleteError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attendre un court instant pour s'assurer que les opérations de suppression sont terminées
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Recréer les boîtes avec la méthode sécurisée
|
||||
debugPrint('Recréation des boîtes Hive...');
|
||||
|
||||
// Utiliser notre méthode pour s'assurer que les boîtes sont ouvertes
|
||||
try {
|
||||
// Passages
|
||||
await _ensureBoxIsOpen(AppKeys.passagesBoxName);
|
||||
|
||||
// Opérations
|
||||
await _ensureBoxIsOpen(AppKeys.operationsBoxName);
|
||||
|
||||
// Secteurs
|
||||
await _ensureBoxIsOpen(AppKeys.sectorsBoxName);
|
||||
|
||||
// Chat
|
||||
await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName);
|
||||
|
||||
// Vérifier l'intégrité des boîtes après recréation
|
||||
await _verifyHiveBoxesIntegrity();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la recréation des boîtes Hive: $e');
|
||||
// Tentative de récupération sur erreur
|
||||
if (kIsWeb) {
|
||||
debugPrint('Tentative de récupération sur le web...');
|
||||
await HiveWebFix.resetHiveCompletely();
|
||||
|
||||
// Réessayer d'ouvrir les boîtes
|
||||
await _ensureBoxIsOpen(AppKeys.passagesBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.operationsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.sectorsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName);
|
||||
await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la réinitialisation des boîtes Hive: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour vérifier l'intégrité des boîtes Hive après recréation
|
||||
Future<void> _verifyHiveBoxesIntegrity() async {
|
||||
try {
|
||||
debugPrint('Vérification de l\'intégrité des boîtes Hive...');
|
||||
|
||||
// Liste des boîtes à vérifier avec leur type
|
||||
final boxesToCheck = [
|
||||
{'name': AppKeys.passagesBoxName, 'type': 'passage'},
|
||||
{'name': AppKeys.operationsBoxName, 'type': 'operation'},
|
||||
{'name': AppKeys.sectorsBoxName, 'type': 'sector'},
|
||||
{'name': AppKeys.chatConversationsBoxName, 'type': 'conversation'},
|
||||
{'name': AppKeys.chatMessagesBoxName, 'type': 'message'},
|
||||
];
|
||||
|
||||
// Vérifier chaque boîte
|
||||
for (final boxInfo in boxesToCheck) {
|
||||
final boxName = boxInfo['name'] as String;
|
||||
final boxType = boxInfo['type'] as String;
|
||||
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
// Utiliser une approche spécifique au type pour éviter les erreurs de typage
|
||||
Box box;
|
||||
try {
|
||||
if (boxType == 'passage') {
|
||||
box = Hive.box<PassageModel>(boxName);
|
||||
} else if (boxType == 'operation') {
|
||||
box = Hive.box<OperationModel>(boxName);
|
||||
} else if (boxType == 'sector') {
|
||||
box = Hive.box<SectorModel>(boxName);
|
||||
} else if (boxType == 'conversation') {
|
||||
box = Hive.box<ConversationModel>(boxName);
|
||||
} else if (boxType == 'message') {
|
||||
box = Hive.box<MessageModel>(boxName);
|
||||
} else {
|
||||
box = Hive.box(boxName);
|
||||
}
|
||||
|
||||
final count = box.length;
|
||||
debugPrint('Boîte $boxName: $count éléments');
|
||||
|
||||
// Si la boîte contient des éléments, c'est anormal après recréation
|
||||
if (count > 0) {
|
||||
debugPrint(
|
||||
'ATTENTION: La boîte $boxName contient encore des données après recréation');
|
||||
// Essayer de vider la boîte une dernière fois
|
||||
await box.clear();
|
||||
debugPrint('Vidage forcé de la boîte $boxName effectué');
|
||||
}
|
||||
} catch (typeError) {
|
||||
debugPrint(
|
||||
'Erreur de typage lors de la vérification de $boxName: $typeError');
|
||||
|
||||
// Tentative alternative sans typage spécifique
|
||||
try {
|
||||
box = Hive.box(boxName);
|
||||
final count = box.length;
|
||||
debugPrint('Boîte $boxName (sans typage): $count éléments');
|
||||
|
||||
if (count > 0) {
|
||||
await box.clear();
|
||||
debugPrint(
|
||||
'Vidage forcé de la boîte $boxName (sans typage) effectué');
|
||||
}
|
||||
} catch (e2) {
|
||||
debugPrint(
|
||||
'Impossible de vérifier la boîte $boxName même sans typage: $e2');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Boîte $boxName non ouverte, impossible de vérifier l\'intégrité');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification de la boîte $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Vérification d\'intégrité terminée');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la vérification d\'intégrité des boîtes Hive: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode spéciale pour nettoyer IndexedDB sur le web
|
||||
Future<void> _clearIndexedDB() async {
|
||||
if (kIsWeb) {
|
||||
try {
|
||||
debugPrint('Nettoyage complet d\'IndexedDB sur le web...');
|
||||
// Utiliser JavaScript pour nettoyer IndexedDB
|
||||
js.context.callMethod('eval', [
|
||||
'''
|
||||
var request = indexedDB.deleteDatabase("geosector_app");
|
||||
request.onsuccess = function() { console.log("IndexedDB nettoyé avec succès"); };
|
||||
request.onerror = function() { console.log("Erreur lors du nettoyage d\'IndexedDB"); };
|
||||
'''
|
||||
]);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
debugPrint('Nettoyage d\'IndexedDB terminé');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du nettoyage d\'IndexedDB: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode spéciale pour nettoyer les fichiers Hive sur iOS
|
||||
Future<void> _cleanHiveFilesOnIOS() async {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
try {
|
||||
debugPrint('Nettoyage des fichiers Hive sur iOS...');
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final hiveDir = Directory('${appDir.path}/hive');
|
||||
|
||||
if (await hiveDir.exists()) {
|
||||
debugPrint('Suppression du répertoire Hive: ${hiveDir.path}');
|
||||
// Exclure le dossier des utilisateurs pour conserver les informations de session
|
||||
final entries = await hiveDir.list().toList();
|
||||
for (var entry in entries) {
|
||||
final name = entry.path.split('/').last;
|
||||
// Ne pas supprimer la boîte des utilisateurs
|
||||
if (!name.contains(AppKeys.usersBoxName)) {
|
||||
debugPrint('Suppression de: ${entry.path}');
|
||||
if (entry is Directory) {
|
||||
await entry.delete(recursive: true);
|
||||
} else if (entry is File) {
|
||||
await entry.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
debugPrint('Nettoyage des fichiers Hive sur iOS terminé');
|
||||
} else {
|
||||
debugPrint('Répertoire Hive non trouvé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du nettoyage des fichiers Hive sur iOS: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode spéciale pour nettoyer les fichiers Hive sur Android
|
||||
Future<void> _cleanHiveFilesOnAndroid() async {
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
try {
|
||||
debugPrint('Nettoyage des fichiers Hive sur Android...');
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final hiveDir = Directory('${appDir.path}');
|
||||
|
||||
if (await hiveDir.exists()) {
|
||||
debugPrint('Recherche des fichiers Hive dans: ${hiveDir.path}');
|
||||
// Sur Android, les fichiers Hive sont directement dans le répertoire de l'application
|
||||
final entries = await hiveDir.list().toList();
|
||||
int filesDeleted = 0;
|
||||
|
||||
for (var entry in entries) {
|
||||
final name = entry.path.split('/').last;
|
||||
// Ne supprimer que les fichiers Hive, mais pas la boîte des utilisateurs
|
||||
if (name.endsWith('.hive') &&
|
||||
!name.contains(AppKeys.usersBoxName)) {
|
||||
debugPrint('Suppression du fichier Hive: ${entry.path}');
|
||||
if (entry is File) {
|
||||
await entry.delete();
|
||||
filesDeleted++;
|
||||
|
||||
// Supprimer également les fichiers lock associés
|
||||
final lockFile = File('${entry.path}.lock');
|
||||
if (await lockFile.exists()) {
|
||||
await lockFile.delete();
|
||||
debugPrint('Suppression du fichier lock: ${lockFile.path}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Nettoyage des fichiers Hive sur Android terminé. $filesDeleted fichiers supprimés.');
|
||||
} else {
|
||||
debugPrint('Répertoire d\'application non trouvé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors du nettoyage des fichiers Hive sur Android: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout complet
|
||||
Future<bool> logout() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
debugPrint('Début du processus de déconnexion...');
|
||||
|
||||
// S'assurer que la boîte des utilisateurs est ouverte avant tout
|
||||
await _ensureBoxIsOpen(AppKeys.usersBoxName);
|
||||
|
||||
// Supprimer les références aux boîtes non définies dans AppKeys
|
||||
final nonDefinedBoxes = ['auth', 'locations', 'messages'];
|
||||
for (final boxName in nonDefinedBoxes) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Fermeture de la boîte non référencée: $boxName');
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
|
||||
// Supprimer la boîte du disque
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('Nettoyage: Box $boxName supprimée');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la suppression de la boîte non référencée $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel avant de nettoyer les données
|
||||
final currentUser = getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
debugPrint('Aucun utilisateur connecté, déconnexion terminée');
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('Déconnexion de l\'utilisateur: ${currentUser.email}');
|
||||
|
||||
// Appeler l'API pour déconnecter la session
|
||||
if (currentUser.sessionId != null) {
|
||||
debugPrint('Déconnexion de la session API...');
|
||||
await logoutAPI();
|
||||
}
|
||||
|
||||
// Effacer la session de l'utilisateur
|
||||
debugPrint('Mise à jour de l\'utilisateur pour effacer la session...');
|
||||
final updatedUser = currentUser.copyWith(
|
||||
sessionId: null,
|
||||
sessionExpiry: null,
|
||||
lastPath:
|
||||
null, // Réinitialiser le chemin pour revenir à l'écran de connexion
|
||||
);
|
||||
|
||||
// Sauvegarder l'utilisateur sans session
|
||||
await saveUser(updatedUser);
|
||||
|
||||
// Effacer la session de l'API
|
||||
setSessionId(null);
|
||||
|
||||
// Maintenant, nettoyer les données
|
||||
debugPrint('Nettoyage des données...');
|
||||
|
||||
// Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive
|
||||
if (kIsWeb) {
|
||||
await HiveWebFix.safeCleanHiveBoxes(
|
||||
excludeBoxes: [AppKeys.usersBoxName]);
|
||||
}
|
||||
// Sur iOS, nettoyer les fichiers Hive directement
|
||||
else if (!kIsWeb && Platform.isIOS) {
|
||||
await _cleanHiveFilesOnIOS();
|
||||
}
|
||||
// Sur Android, nettoyer les fichiers Hive directement
|
||||
else if (!kIsWeb && Platform.isAndroid) {
|
||||
await _cleanHiveFilesOnAndroid();
|
||||
}
|
||||
|
||||
// Vider les boîtes sans les fermer, y compris les boîtes de chat
|
||||
debugPrint('Suppression des données Hive...');
|
||||
await _clearAndRecreateBoxes();
|
||||
|
||||
// Vider spécifiquement les boîtes de chat si elles sont ouvertes
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.chatConversationsBoxName)) {
|
||||
await Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName).clear();
|
||||
debugPrint('Boîte conversations vidée');
|
||||
}
|
||||
if (Hive.isBoxOpen(AppKeys.chatMessagesBoxName)) {
|
||||
await Hive.box<MessageModel>(AppKeys.chatMessagesBoxName).clear();
|
||||
debugPrint('Boîte messages vidée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du vidage des boîtes de chat: $e');
|
||||
}
|
||||
|
||||
debugPrint('Déconnexion terminée avec succès');
|
||||
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de déconnexion: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir tous les utilisateurs locaux
|
||||
List<UserModel> getAllUsers() {
|
||||
return _userBox.values.toList();
|
||||
}
|
||||
|
||||
// Obtenir un utilisateur par son ID
|
||||
UserModel? getUserById(int id) {
|
||||
return _userBox.get(id);
|
||||
}
|
||||
|
||||
// Obtenir un utilisateur par son email
|
||||
UserModel? getUserByEmail(String email) {
|
||||
try {
|
||||
return _userBox.values.firstWhere(
|
||||
(user) => user.email == email,
|
||||
);
|
||||
} catch (e) {
|
||||
return null; // Utilisateur non trouvé
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour un utilisateur localement
|
||||
Future<UserModel> saveUser(UserModel user) async {
|
||||
await _userBox.put(user.id, user);
|
||||
notifyListeners(); // Notifier les changements pour mettre à jour l'UI
|
||||
return user;
|
||||
}
|
||||
|
||||
// Supprimer un utilisateur localement
|
||||
Future<void> deleteUser(String id) async {
|
||||
await _userBox.delete(id);
|
||||
}
|
||||
|
||||
// Créer un nouvel utilisateur localement et tenter de le synchroniser
|
||||
Future<UserModel> createUser({
|
||||
required String email,
|
||||
required String name,
|
||||
required int role,
|
||||
}) async {
|
||||
// Générer un ID numérique temporaire (timestamp)
|
||||
final int tempId = DateTime.now().millisecondsSinceEpoch;
|
||||
final now = DateTime.now();
|
||||
|
||||
final user = UserModel(
|
||||
id: tempId,
|
||||
email: email,
|
||||
name: name,
|
||||
role: role,
|
||||
createdAt: now,
|
||||
lastSyncedAt: now,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
await _userBox.put(user.id, user);
|
||||
|
||||
// Tenter de synchroniser si possible
|
||||
await syncUser(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Synchroniser un utilisateur spécifique avec le serveur
|
||||
Future<UserModel> syncUser(UserModel user) async {
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
|
||||
if (!hasConnection) {
|
||||
return user;
|
||||
}
|
||||
|
||||
UserModel syncedUser;
|
||||
|
||||
if (!user.isSynced) {
|
||||
// Si l'utilisateur n'est pas encore synchronisé, le créer sur le serveur
|
||||
syncedUser = await _apiService.createUser(user);
|
||||
} else {
|
||||
// Sinon, mettre à jour les informations
|
||||
syncedUser = await _apiService.updateUser(user);
|
||||
}
|
||||
|
||||
// Mettre à jour l'utilisateur local avec les informations du serveur
|
||||
final updatedUser = syncedUser.copyWith(
|
||||
isSynced: true,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _userBox.put(updatedUser.id, updatedUser);
|
||||
return updatedUser;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, garder l'utilisateur local tel quel
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Synchroniser tous les utilisateurs non synchronisés
|
||||
Future<void> syncAllUsers() async {
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
|
||||
if (!hasConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
final unsyncedUsers =
|
||||
_userBox.values.where((user) => !user.isSynced).toList();
|
||||
|
||||
if (unsyncedUsers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Synchroniser en batch
|
||||
final result = await _apiService.syncData(users: unsyncedUsers);
|
||||
|
||||
// Mettre à jour les utilisateurs locaux
|
||||
if (result['users'] != null) {
|
||||
for (final userData in result['users']) {
|
||||
final syncedUser = UserModel.fromJson(userData);
|
||||
await _userBox.put(
|
||||
syncedUser.id,
|
||||
syncedUser.copyWith(
|
||||
isSynced: true,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs de synchronisation
|
||||
print('Erreur de synchronisation des utilisateurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchir les données depuis le serveur
|
||||
Future<void> refreshFromServer() async {
|
||||
try {
|
||||
final hasConnection = await _apiService.hasInternetConnection();
|
||||
|
||||
if (!hasConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les utilisateurs du serveur
|
||||
final serverUsers = await _apiService.getUsers();
|
||||
|
||||
// Mettre à jour la base locale
|
||||
for (final serverUser in serverUsers) {
|
||||
final updatedUser = serverUser.copyWith(
|
||||
isSynced: true,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
await _userBox.put(updatedUser.id, updatedUser);
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs
|
||||
print('Erreur lors du rafraîchissement des données: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Synchroniser les données utilisateur
|
||||
Future<void> syncUserData() async {
|
||||
if (_syncService != null && currentUser != null) {
|
||||
await _syncService!.syncUserData(currentUser!.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer la dernière opération active (avec isActive == true)
|
||||
OperationModel? getCurrentOperation() {
|
||||
try {
|
||||
// Récupérer toutes les opérations
|
||||
final operations = _operationBox.values.toList();
|
||||
|
||||
// Filtrer pour ne garder que les opérations actives
|
||||
final activeOperations = operations.where((op) => op.isActive).toList();
|
||||
|
||||
// Si aucune opération active n'est trouvée, retourner null
|
||||
if (activeOperations.isEmpty) {
|
||||
return operations.isNotEmpty ? operations.last : null;
|
||||
}
|
||||
|
||||
// Retourner la dernière opération active
|
||||
return activeOperations.last;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de l\'opération actuelle: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer tous les secteurs de l'utilisateur
|
||||
List<SectorModel> getUserSectors() {
|
||||
try {
|
||||
return _sectorBox.values.toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des secteurs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer un secteur par son ID
|
||||
SectorModel? getSectorById(int id) {
|
||||
try {
|
||||
return _sectorBox.get(id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du secteur: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
flutt/lib/core/services/api_service.dart
Normal file
204
flutt/lib/core/services/api_service.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
|
||||
class ApiService {
|
||||
final Dio _dio = Dio();
|
||||
final String _baseUrl = AppKeys.baseApiUrl;
|
||||
String? _sessionId;
|
||||
|
||||
ApiService() {
|
||||
_dio.options.baseUrl = _baseUrl;
|
||||
_dio.options.connectTimeout = AppKeys.connectionTimeout;
|
||||
_dio.options.receiveTimeout = AppKeys.receiveTimeout;
|
||||
_dio.options.headers.addAll(AppKeys.defaultHeaders);
|
||||
|
||||
// Ajouter des intercepteurs pour l'authentification par session
|
||||
_dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
|
||||
// Ajouter le session_id comme token Bearer aux en-têtes si disponible
|
||||
if (_sessionId != null) {
|
||||
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
|
||||
}
|
||||
return handler.next(options);
|
||||
}, onError: (DioException error, handler) {
|
||||
// Gérer les erreurs d'authentification (401)
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Session expirée ou invalide
|
||||
_sessionId = null;
|
||||
}
|
||||
return handler.next(error);
|
||||
}));
|
||||
}
|
||||
|
||||
// Définir l'ID de session
|
||||
void setSessionId(String? sessionId) {
|
||||
_sessionId = sessionId;
|
||||
}
|
||||
|
||||
// Vérifier la connectivité réseau
|
||||
Future<bool> hasInternetConnection() async {
|
||||
final connectivityResult = await (Connectivity().checkConnectivity());
|
||||
return connectivityResult != ConnectivityResult.none;
|
||||
}
|
||||
|
||||
// Méthode POST générique
|
||||
Future<Response> post(String path, {dynamic data}) async {
|
||||
try {
|
||||
return await _dio.post(path, data: data);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode GET générique
|
||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
try {
|
||||
return await _dio.get(path, queryParameters: queryParameters);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode PUT générique
|
||||
Future<Response> put(String path, {dynamic data}) async {
|
||||
try {
|
||||
return await _dio.put(path, data: data);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode DELETE générique
|
||||
Future<Response> delete(String path) async {
|
||||
try {
|
||||
return await _dio.delete(path);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentification avec PHP session
|
||||
Future<Map<String, dynamic>> login(String username, String password, {String type = 'admin'}) async {
|
||||
try {
|
||||
final response = await _dio.post(AppKeys.loginEndpoint, data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'type': type, // Ajouter le type de connexion (user ou admin)
|
||||
});
|
||||
|
||||
// Vérifier la structure de la réponse
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final status = data['status'] as String?;
|
||||
|
||||
// Afficher le message en cas d'erreur
|
||||
if (status != 'success') {
|
||||
final message = data['message'] as String?;
|
||||
debugPrint('Erreur d\'authentification: $message');
|
||||
}
|
||||
|
||||
// Si le statut est 'success', récupérer le session_id
|
||||
if (status == 'success' && data.containsKey('session_id')) {
|
||||
final sessionId = data['session_id'];
|
||||
// Définir la session pour les futures requêtes
|
||||
if (sessionId != null) {
|
||||
setSessionId(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Déconnexion
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
if (_sessionId != null) {
|
||||
await _dio.post(AppKeys.logoutEndpoint);
|
||||
_sessionId = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Même en cas d'erreur, on réinitialise la session
|
||||
_sessionId = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Utilisateurs
|
||||
Future<List<UserModel>> getUsers() async {
|
||||
try {
|
||||
final response = await retry(
|
||||
() => _dio.get('/users'),
|
||||
retryIf: (e) => e is SocketException || e is TimeoutException,
|
||||
);
|
||||
|
||||
return (response.data as List)
|
||||
.map((json) => UserModel.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// Gérer les erreurs
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserModel> getUserById(int id) async {
|
||||
try {
|
||||
final response = await _dio.get('/users/$id');
|
||||
return UserModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserModel> createUser(UserModel user) async {
|
||||
try {
|
||||
final response = await _dio.post('/users', data: user.toJson());
|
||||
return UserModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<UserModel> updateUser(UserModel user) async {
|
||||
try {
|
||||
final response = await _dio.put('/users/${user.id}', data: user.toJson());
|
||||
return UserModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteUser(String id) async {
|
||||
try {
|
||||
await _dio.delete('/users/$id');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Espace réservé pour les futures méthodes de gestion des profils
|
||||
|
||||
// Espace réservé pour les futures méthodes de gestion des données
|
||||
|
||||
// Synchronisation en batch
|
||||
Future<Map<String, dynamic>> syncData({
|
||||
List<UserModel>? users,
|
||||
}) async {
|
||||
try {
|
||||
final Map<String, dynamic> payload = {
|
||||
if (users != null) 'users': users.map((u) => u.toJson()).toList(),
|
||||
};
|
||||
|
||||
final response = await _dio.post('/sync', data: payload);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
flutt/lib/core/services/auth_service.dart
Normal file
41
flutt/lib/core/services/auth_service.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_overlay.dart';
|
||||
|
||||
/// Service qui gère les opérations d'authentification avec affichage d'un overlay de chargement
|
||||
class AuthService {
|
||||
final UserRepository _userRepository;
|
||||
|
||||
AuthService(this._userRepository);
|
||||
|
||||
/// Méthode de connexion avec affichage d'un overlay de chargement
|
||||
Future<bool> login(BuildContext context, String username, String password,
|
||||
{String type = 'admin'}) async {
|
||||
return await LoadingOverlay.show(
|
||||
context: context,
|
||||
spinnerSize: 80.0, // Spinner plus grand
|
||||
strokeWidth: 6.0, // Trait plus épais
|
||||
future: _userRepository.login(username, password, type: type),
|
||||
);
|
||||
}
|
||||
|
||||
/// Méthode de déconnexion avec affichage d'un overlay de chargement
|
||||
Future<bool> logout(BuildContext context) async {
|
||||
return await LoadingOverlay.show(
|
||||
context: context,
|
||||
spinnerSize: 80.0, // Spinner plus grand
|
||||
strokeWidth: 6.0, // Trait plus épais
|
||||
future: _userRepository.logout(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si un utilisateur est connecté
|
||||
bool isLoggedIn() {
|
||||
return _userRepository.isLoggedIn;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur connecté est un administrateur
|
||||
bool isAdmin() {
|
||||
return _userRepository.isAdmin();
|
||||
}
|
||||
}
|
||||
157
flutt/lib/core/services/connectivity_service.dart
Normal file
157
flutt/lib/core/services/connectivity_service.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// Service qui gère la surveillance de l'état de connectivité de l'appareil
|
||||
class ConnectivityService extends ChangeNotifier {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
|
||||
List<ConnectivityResult> _connectionStatus = [ConnectivityResult.none];
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Indique si l'appareil est connecté à Internet
|
||||
bool get isConnected {
|
||||
// Vérifie si la liste contient au moins un type de connexion autre que 'none'
|
||||
return _connectionStatus.any((result) => result != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
/// Indique si l'appareil est connecté via WiFi
|
||||
bool get isWifi => _connectionStatus.contains(ConnectivityResult.wifi);
|
||||
|
||||
/// Indique si l'appareil est connecté via données mobiles (4G, 5G, etc.)
|
||||
bool get isMobile => _connectionStatus.contains(ConnectivityResult.mobile);
|
||||
|
||||
/// Retourne le type de connexion actuel (WiFi, données mobiles, etc.)
|
||||
List<ConnectivityResult> get connectionStatus => _connectionStatus;
|
||||
|
||||
/// Retourne le premier type de connexion actif (pour compatibilité avec l'ancien code)
|
||||
ConnectivityResult get primaryConnectionStatus {
|
||||
// Retourne le premier type de connexion qui n'est pas 'none', ou 'none' si tous sont 'none'
|
||||
return _connectionStatus.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient une description textuelle du type de connexion
|
||||
String get connectionType {
|
||||
// Si aucune connexion n'est disponible
|
||||
if (!isConnected) {
|
||||
return 'Aucune connexion';
|
||||
}
|
||||
|
||||
// Utiliser le premier type de connexion actif
|
||||
ConnectivityResult primaryStatus = primaryConnectionStatus;
|
||||
|
||||
switch (primaryStatus) {
|
||||
case ConnectivityResult.wifi:
|
||||
return 'WiFi';
|
||||
case ConnectivityResult.mobile:
|
||||
return 'Données mobiles';
|
||||
case ConnectivityResult.ethernet:
|
||||
return 'Ethernet';
|
||||
case ConnectivityResult.bluetooth:
|
||||
return 'Bluetooth';
|
||||
case ConnectivityResult.vpn:
|
||||
return 'VPN';
|
||||
case ConnectivityResult.none:
|
||||
return 'Aucune connexion';
|
||||
default:
|
||||
return 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructeur du service de connectivité
|
||||
ConnectivityService() {
|
||||
_initConnectivity();
|
||||
}
|
||||
|
||||
/// Initialise le service et commence à écouter les changements de connectivité
|
||||
Future<void> _initConnectivity() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
// En version web, on considère par défaut que la connexion est disponible
|
||||
// car la vérification de connectivité est moins fiable sur le web
|
||||
if (kIsWeb) {
|
||||
_connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web
|
||||
} else {
|
||||
_connectionStatus = await _connectivity.checkConnectivity();
|
||||
}
|
||||
|
||||
// S'abonner aux changements de connectivité
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation du service de connectivité: $e');
|
||||
|
||||
// En cas d'erreur en version web, on suppose que la connexion est disponible
|
||||
// car l'application web ne peut pas fonctionner sans connexion de toute façon
|
||||
if (kIsWeb) {
|
||||
_connectionStatus = [ConnectivityResult.wifi];
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Met à jour l'état de la connexion lorsqu'il change
|
||||
void _updateConnectionStatus(List<ConnectivityResult> results) {
|
||||
// Vérifier si la liste des résultats a changé
|
||||
bool hasChanged = false;
|
||||
|
||||
// Si les listes ont des longueurs différentes, elles sont différentes
|
||||
if (_connectionStatus.length != results.length) {
|
||||
hasChanged = true;
|
||||
} else {
|
||||
// Vérifier si les éléments sont différents
|
||||
for (int i = 0; i < _connectionStatus.length; i++) {
|
||||
if (i >= results.length || _connectionStatus[i] != results[i]) {
|
||||
hasChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
_connectionStatus = results;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie manuellement l'état actuel de la connexion
|
||||
Future<List<ConnectivityResult>> checkConnectivity() async {
|
||||
try {
|
||||
// En version web, on considère par défaut que la connexion est disponible
|
||||
if (kIsWeb) {
|
||||
// En version web, on peut tenter de faire une requête réseau légère pour vérifier la connectivité
|
||||
// mais pour l'instant, on suppose que la connexion est disponible
|
||||
final results = [ConnectivityResult.wifi];
|
||||
_updateConnectionStatus(results);
|
||||
return results;
|
||||
} else {
|
||||
// Version mobile - utiliser l'API standard
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_updateConnectionStatus(results);
|
||||
return results;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification de la connectivité: $e');
|
||||
// En cas d'erreur, on conserve l'état actuel
|
||||
return _connectionStatus;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
try {
|
||||
_connectivitySubscription.cancel();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'annulation de l\'abonnement de connectivité: $e');
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
182
flutt/lib/core/services/hive_web_fix.dart
Normal file
182
flutt/lib/core/services/hive_web_fix.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js' as js;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Service pour gérer les problèmes spécifiques à Hive en version web
|
||||
class HiveWebFix {
|
||||
/// Nettoie en toute sécurité les boîtes Hive en version web
|
||||
/// Cette méthode est plus sûre que de supprimer directement IndexedDB
|
||||
static Future<void> safeCleanHiveBoxes({List<String>? excludeBoxes}) async {
|
||||
if (!kIsWeb) return;
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'HiveWebFix: Nettoyage sécurisé des boîtes Hive en version web');
|
||||
|
||||
// Liste des boîtes à nettoyer
|
||||
final boxesToClean = [
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
];
|
||||
|
||||
// Exclure certaines boîtes si spécifié
|
||||
if (excludeBoxes != null) {
|
||||
boxesToClean.removeWhere((box) => excludeBoxes.contains(box));
|
||||
}
|
||||
|
||||
// Nettoyer chaque boîte individuellement au lieu de supprimer IndexedDB
|
||||
for (final boxName in boxesToClean) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('HiveWebFix: Nettoyage de la boîte $boxName');
|
||||
final box = Hive.box(boxName);
|
||||
await box.clear();
|
||||
debugPrint('HiveWebFix: Boîte $boxName nettoyée avec succès');
|
||||
} else {
|
||||
debugPrint(
|
||||
'HiveWebFix: La boîte $boxName n\'est pas ouverte, ouverture temporaire');
|
||||
final box = await Hive.openBox(boxName);
|
||||
await box.clear();
|
||||
await box.close();
|
||||
debugPrint('HiveWebFix: Boîte $boxName nettoyée et fermée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'HiveWebFix: Erreur lors du nettoyage de la boîte $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('HiveWebFix: Nettoyage sécurisé terminé');
|
||||
} catch (e) {
|
||||
debugPrint('HiveWebFix: Erreur lors du nettoyage sécurisé: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie l'intégrité des boîtes Hive et tente de les réparer si nécessaire
|
||||
static Future<bool> checkAndRepairHiveBoxes() async {
|
||||
if (!kIsWeb) return true;
|
||||
|
||||
try {
|
||||
debugPrint('HiveWebFix: Vérification de l\'intégrité des boîtes Hive');
|
||||
|
||||
// Vérifier si IndexedDB est accessible
|
||||
final isIndexedDBAvailable = js.context.hasProperty('indexedDB');
|
||||
if (!isIndexedDBAvailable) {
|
||||
debugPrint(
|
||||
'HiveWebFix: IndexedDB n\'est pas disponible dans ce navigateur');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Liste des boîtes essentielles
|
||||
final essentialBoxes = [
|
||||
AppKeys.usersBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
];
|
||||
|
||||
// Vérifier chaque boîte essentielle
|
||||
for (final boxName in essentialBoxes) {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint(
|
||||
'HiveWebFix: Ouverture de la boîte essentielle $boxName');
|
||||
await Hive.openBox(boxName);
|
||||
}
|
||||
|
||||
// Vérifier si la boîte est accessible
|
||||
final box = Hive.box(boxName);
|
||||
// Tenter une opération simple pour vérifier l'intégrité
|
||||
final length = box.length;
|
||||
debugPrint(
|
||||
'HiveWebFix: Boîte $boxName accessible avec $length éléments');
|
||||
} catch (e) {
|
||||
debugPrint('HiveWebFix: Erreur d\'accès à la boîte $boxName: $e');
|
||||
|
||||
// Tenter de réparer en réinitialisant Hive
|
||||
try {
|
||||
debugPrint(
|
||||
'HiveWebFix: Tentative de réparation de la boîte $boxName');
|
||||
// Fermer la boîte si elle est ouverte
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
|
||||
// Réouvrir la boîte
|
||||
await Hive.openBox(boxName);
|
||||
debugPrint('HiveWebFix: Boîte $boxName réparée avec succès');
|
||||
} catch (repairError) {
|
||||
debugPrint(
|
||||
'HiveWebFix: Échec de la réparation de la boîte $boxName: $repairError');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('HiveWebFix: Toutes les boîtes essentielles sont intègres');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('HiveWebFix: Erreur lors de la vérification d\'intégrité: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialise complètement Hive en cas de problème grave
|
||||
/// À utiliser en dernier recours car cela supprimera toutes les données
|
||||
static Future<void> resetHiveCompletely() async {
|
||||
if (!kIsWeb) return;
|
||||
|
||||
try {
|
||||
debugPrint('HiveWebFix: Réinitialisation complète de Hive');
|
||||
|
||||
// Fermer toutes les boîtes ouvertes
|
||||
final boxesToClose = [
|
||||
AppKeys.usersBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
];
|
||||
|
||||
for (final boxName in boxesToClose) {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('HiveWebFix: Fermeture de la boîte $boxName');
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer IndexedDB avec une approche plus sûre
|
||||
js.context.callMethod('eval', [
|
||||
'''
|
||||
(function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var request = indexedDB.deleteDatabase("geosector_app");
|
||||
request.onsuccess = function() {
|
||||
console.log("IndexedDB nettoyé avec succès");
|
||||
resolve(true);
|
||||
};
|
||||
request.onerror = function(event) {
|
||||
console.log("Erreur lors du nettoyage d'IndexedDB", event);
|
||||
reject(event);
|
||||
};
|
||||
});
|
||||
})();
|
||||
'''
|
||||
]);
|
||||
|
||||
// Attendre un peu pour s'assurer que la suppression est terminée
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Réinitialiser Hive
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Réenregistrer les adaptateurs
|
||||
// Note: Cette partie devrait être gérée par le code principal de l'application
|
||||
|
||||
debugPrint('HiveWebFix: Réinitialisation complète terminée');
|
||||
} catch (e) {
|
||||
debugPrint('HiveWebFix: Erreur lors de la réinitialisation complète: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
164
flutt/lib/core/services/location_service.dart
Normal file
164
flutt/lib/core/services/location_service.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service de géolocalisation pour gérer les permissions et l'accès à la position
|
||||
class LocationService {
|
||||
/// Vérifie si les services de localisation sont activés
|
||||
static Future<bool> isLocationServiceEnabled() async {
|
||||
// En version web, on considère que les services de localisation sont toujours activés
|
||||
// car la vérification est gérée différemment par le navigateur
|
||||
if (kIsWeb) {
|
||||
return true;
|
||||
}
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
}
|
||||
|
||||
/// Vérifie et demande les permissions de localisation
|
||||
/// Retourne true si l'autorisation est accordée, false sinon
|
||||
static Future<bool> checkAndRequestPermission() async {
|
||||
// En version web, on considère que les permissions sont toujours accordées
|
||||
// car la gestion des permissions est différente et gérée par le navigateur
|
||||
if (kIsWeb) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier si les services de localisation sont activés
|
||||
bool serviceEnabled = await isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
// Les services de localisation ne sont pas activés, on ne peut pas demander la permission
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier le statut actuel de la permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
// Demander la permission
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
// La permission a été refusée
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
// La permission a été refusée définitivement
|
||||
return false;
|
||||
}
|
||||
|
||||
// La permission est accordée (whileInUse ou always)
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des permissions de localisation: $e');
|
||||
// En cas d'erreur, on retourne false pour être sûr
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la position actuelle de l'utilisateur
|
||||
/// Retourne null si la position ne peut pas être obtenue
|
||||
static Future<LatLng?> getCurrentPosition() async {
|
||||
try {
|
||||
// En version web, la géolocalisation fonctionne différemment
|
||||
// et peut être bloquée par le navigateur si le site n'est pas en HTTPS
|
||||
if (kIsWeb) {
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
return LatLng(position.latitude, position.longitude);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'obtention de la position en version web: $e');
|
||||
// En version web, en cas d'erreur, on peut retourner une position par défaut
|
||||
// ou null selon les besoins de l'application
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Version mobile
|
||||
// Vérifier si l'autorisation est accordée
|
||||
bool hasPermission = await checkAndRequestPermission();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obtenir la position actuelle
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
|
||||
return LatLng(position.latitude, position.longitude);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'obtention de la position: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'application peut accéder à la position de l'utilisateur
|
||||
/// Retourne un message d'erreur si l'accès n'est pas possible, null sinon
|
||||
static Future<String?> getLocationErrorMessage() async {
|
||||
// En version web, on considère qu'il n'y a pas d'erreur de localisation
|
||||
// car la gestion des permissions est gérée par le navigateur
|
||||
if (kIsWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier si les services de localisation sont activés
|
||||
bool serviceEnabled = await isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
return 'Les services de localisation sont désactivés. Veuillez les activer dans les paramètres de votre appareil.';
|
||||
}
|
||||
|
||||
// Vérifier le statut actuel de la permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
return 'L\'accès à la localisation a été refusé. Cette application ne peut pas fonctionner sans cette autorisation.';
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
return 'L\'accès à la localisation a été définitivement refusé. Veuillez l\'autoriser dans les paramètres de votre appareil.';
|
||||
}
|
||||
|
||||
return null; // Pas d'erreur
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des erreurs de localisation: $e');
|
||||
// En cas d'erreur, on retourne null pour ne pas bloquer l'application
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre les paramètres de l'application pour permettre à l'utilisateur de modifier les autorisations
|
||||
static Future<void> openAppSettings() async {
|
||||
// En version web, cette fonctionnalité n'est pas disponible
|
||||
if (kIsWeb) {
|
||||
debugPrint('Ouverture des paramètres de l\'application non disponible en version web');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Geolocator.openAppSettings();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture des paramètres de l\'application: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre les paramètres de localisation de l'appareil
|
||||
static Future<void> openLocationSettings() async {
|
||||
// En version web, cette fonctionnalité n'est pas disponible
|
||||
if (kIsWeb) {
|
||||
debugPrint('Ouverture des paramètres de localisation non disponible en version web');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Geolocator.openLocationSettings();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture des paramètres de localisation: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
194
flutt/lib/core/services/passage_data_service.dart
Normal file
194
flutt/lib/core/services/passage_data_service.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
/// Service pour charger et filtrer les données de passages
|
||||
class PassageDataService {
|
||||
final PassageRepository passageRepository;
|
||||
final UserRepository userRepository;
|
||||
|
||||
PassageDataService({
|
||||
required this.passageRepository,
|
||||
required this.userRepository,
|
||||
});
|
||||
|
||||
/// Charge les données de passage depuis Hive
|
||||
///
|
||||
/// [daysToShow] : Nombre de jours à afficher
|
||||
/// [excludePassageTypes] : Types de passages à exclure
|
||||
/// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel)
|
||||
/// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur
|
||||
List<Map<String, dynamic>> loadPassageData({
|
||||
required int daysToShow,
|
||||
List<int> excludePassageTypes = const [2],
|
||||
int? userId,
|
||||
bool showAllPassages = false,
|
||||
}) {
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion
|
||||
final filteredPassages = passages
|
||||
.where((p) => !excludePassageTypes.contains(p.fkType))
|
||||
.toList();
|
||||
|
||||
if (filteredPassages.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Déterminer si on filtre par utilisateur ou si on prend tous les passages
|
||||
final passagesToUse = showAllPassages
|
||||
? filteredPassages
|
||||
: _filterPassagesByUser(filteredPassages, userId);
|
||||
|
||||
if (passagesToUse.isEmpty) {
|
||||
debugPrint('Aucun passage trouvé après filtrage');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Trouver la date du passage le plus récent
|
||||
passagesToUse.sort((a, b) => b.passedAt.compareTo(a.passedAt));
|
||||
final DateTime referenceDate = passagesToUse.first.passedAt;
|
||||
debugPrint(
|
||||
'Date de référence pour le graphique: ${DateFormat('dd/MM/yyyy').format(referenceDate)}');
|
||||
|
||||
// Définir la date de début (N jours avant la date de référence)
|
||||
final startDate = referenceDate.subtract(Duration(days: daysToShow - 1));
|
||||
debugPrint(
|
||||
'Date de début pour le graphique: ${DateFormat('dd/MM/yyyy').format(startDate)}');
|
||||
debugPrint(
|
||||
'Plage de dates du graphique: ${DateFormat('dd/MM/yyyy').format(startDate)} - ${DateFormat('dd/MM/yyyy').format(referenceDate)}');
|
||||
|
||||
// Regrouper les passages par date et type
|
||||
final Map<String, Map<int, int>> passagesByDateAndType = {};
|
||||
|
||||
// Initialiser le dictionnaire avec les N derniers jours
|
||||
for (int i = daysToShow - 1; i >= 0; i--) {
|
||||
final date = referenceDate.subtract(Duration(days: i));
|
||||
final dateStr =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
passagesByDateAndType[dateStr] = {};
|
||||
}
|
||||
|
||||
// Ajouter tous les types de passage possibles pour chaque date
|
||||
for (final dateStr in passagesByDateAndType.keys) {
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
// Exclure les types dans la liste d'exclusion
|
||||
if (!excludePassageTypes.contains(typeId)) {
|
||||
passagesByDateAndType[dateStr]![typeId] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcourir les passages et les regrouper par date et type
|
||||
for (final passage in passagesToUse) {
|
||||
if (passage.passedAt
|
||||
.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
passage.passedAt
|
||||
.isBefore(referenceDate.add(const Duration(days: 1)))) {
|
||||
final dateStr =
|
||||
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}-${passage.passedAt.day.toString().padLeft(2, '0')}';
|
||||
final typeId = passage.fkType;
|
||||
|
||||
// Vérifier que le type n'est pas exclu
|
||||
if (!excludePassageTypes.contains(typeId)) {
|
||||
// Si la date existe dans notre dictionnaire, mettre à jour le compteur
|
||||
if (passagesByDateAndType.containsKey(dateStr)) {
|
||||
if (!passagesByDateAndType[dateStr]!.containsKey(typeId)) {
|
||||
passagesByDateAndType[dateStr]![typeId] = 0;
|
||||
}
|
||||
passagesByDateAndType[dateStr]![typeId] =
|
||||
(passagesByDateAndType[dateStr]![typeId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir les données au format attendu par le graphique
|
||||
final List<Map<String, dynamic>> result = [];
|
||||
passagesByDateAndType.forEach((dateStr, typesCounts) {
|
||||
typesCounts.forEach((typeId, count) {
|
||||
result.add({
|
||||
'date': dateStr,
|
||||
'type_passage': typeId,
|
||||
'nb': count,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Filtre les passages par utilisateur
|
||||
List<dynamic> _filterPassagesByUser(List<dynamic> passages, int? userId) {
|
||||
// Récupérer l'ID de l'utilisateur actuel si nécessaire
|
||||
final int? currentUserId = userId ?? userRepository.getCurrentUser()?.id;
|
||||
|
||||
// Filtrer les passages pour l'utilisateur actuel
|
||||
final userPassages = passages
|
||||
.where((p) => currentUserId == null || p.fkUser == currentUserId)
|
||||
.toList();
|
||||
|
||||
if (userPassages.isEmpty) {
|
||||
debugPrint('Aucun passage trouvé pour l\'utilisateur $currentUserId');
|
||||
}
|
||||
|
||||
return userPassages;
|
||||
}
|
||||
|
||||
/// Charge et prépare les données pour le graphique en camembert
|
||||
///
|
||||
/// [excludePassageTypes] : Types de passages à exclure
|
||||
/// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel)
|
||||
/// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur
|
||||
Map<int, int> loadPassageDataForPieChart({
|
||||
List<int> excludePassageTypes = const [2],
|
||||
int? userId,
|
||||
bool showAllPassages = false,
|
||||
}) {
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion
|
||||
final filteredPassages = passages
|
||||
.where((p) => !excludePassageTypes.contains(p.fkType))
|
||||
.toList();
|
||||
|
||||
if (filteredPassages.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Déterminer si on filtre par utilisateur ou si on prend tous les passages
|
||||
final passagesToUse = showAllPassages
|
||||
? filteredPassages
|
||||
: _filterPassagesByUser(filteredPassages, userId);
|
||||
|
||||
if (passagesToUse.isEmpty) {
|
||||
debugPrint('Aucun passage trouvé après filtrage');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Compter les passages par type
|
||||
final Map<int, int> passagesByType = {};
|
||||
|
||||
// Initialiser les compteurs pour tous les types de passage
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
// Exclure les types dans la liste d'exclusion
|
||||
if (!excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Compter les passages par type
|
||||
for (final passage in passagesToUse) {
|
||||
final typeId = passage.fkType;
|
||||
if (!excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return passagesByType;
|
||||
}
|
||||
}
|
||||
96
flutt/lib/core/services/sync_service.dart
Normal file
96
flutt/lib/core/services/sync_service.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
class SyncService {
|
||||
final UserRepository _userRepository;
|
||||
|
||||
StreamSubscription? _connectivitySubscription;
|
||||
Timer? _periodicSyncTimer;
|
||||
|
||||
bool _isSyncing = false;
|
||||
final Duration _syncInterval = const Duration(minutes: 15);
|
||||
|
||||
SyncService({
|
||||
required UserRepository userRepository,
|
||||
}) : _userRepository = userRepository {
|
||||
_initConnectivityListener();
|
||||
_initPeriodicSync();
|
||||
}
|
||||
|
||||
// Initialiser l'écouteur de connectivité
|
||||
void _initConnectivityListener() {
|
||||
_connectivitySubscription = Connectivity()
|
||||
.onConnectivityChanged
|
||||
.listen((List<ConnectivityResult> results) {
|
||||
// Vérifier si au moins un type de connexion est disponible
|
||||
if (results.any((result) => result != ConnectivityResult.none)) {
|
||||
// Lorsque la connexion est rétablie, déclencher une synchronisation
|
||||
syncAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiser la synchronisation périodique
|
||||
void _initPeriodicSync() {
|
||||
_periodicSyncTimer = Timer.periodic(_syncInterval, (timer) {
|
||||
syncAll();
|
||||
});
|
||||
}
|
||||
|
||||
// Synchroniser toutes les données
|
||||
Future<void> syncAll() async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
_isSyncing = true;
|
||||
|
||||
try {
|
||||
// Synchroniser les utilisateurs
|
||||
await _userRepository.syncAllUsers();
|
||||
} catch (e) {
|
||||
// Gérer les erreurs de synchronisation
|
||||
print('Erreur lors de la synchronisation: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Synchroniser uniquement les données d'un utilisateur spécifique
|
||||
Future<void> syncUserData(int userId) async {
|
||||
try {
|
||||
// Cette méthode pourrait être étendue à l'avenir pour synchroniser d'autres données utilisateur
|
||||
await _userRepository.refreshFromServer();
|
||||
} catch (e) {
|
||||
print('Erreur lors de la synchronisation des données utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Forcer le rafraîchissement depuis le serveur
|
||||
Future<void> forceRefresh() async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
_isSyncing = true;
|
||||
|
||||
try {
|
||||
// Rafraîchir depuis le serveur
|
||||
await _userRepository.refreshFromServer();
|
||||
} catch (e) {
|
||||
print('Erreur lors du rafraîchissement forcé: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir l'état de synchronisation
|
||||
Map<String, dynamic> getSyncStatus() {
|
||||
return {
|
||||
'isSyncing': _isSyncing,
|
||||
};
|
||||
}
|
||||
|
||||
// Nettoyer les ressources
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_periodicSyncTimer?.cancel();
|
||||
}
|
||||
}
|
||||
122
flutt/lib/core/theme/app_theme.dart
Normal file
122
flutt/lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Nouvelles couleurs du thème
|
||||
static const Color primaryColor = Color(0xFF2E4057); // Bleu foncé/gris
|
||||
static const Color secondaryColor = Color(0xFF048BA8); // Bleu turquoise
|
||||
static const Color accentColor = Color(0xFFF18F01); // Orange
|
||||
static const Color backgroundLightColor = Color(0xFFF9FAFB);
|
||||
static const Color backgroundDarkColor = Color(0xFF111827);
|
||||
static const Color textLightColor = Color(0xFF1F2937);
|
||||
static const Color textDarkColor = Color(0xFFF9FAFB);
|
||||
|
||||
// Thème clair
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
tertiary: accentColor,
|
||||
background: backgroundLightColor,
|
||||
surface: Colors.white,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onBackground: textLightColor,
|
||||
onSurface: textLightColor,
|
||||
),
|
||||
textTheme: GoogleFonts.poppinsTextTheme(ThemeData.light().textTheme),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Thème sombre
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
tertiary: accentColor,
|
||||
background: backgroundDarkColor,
|
||||
surface: const Color(0xFF1F2937),
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onBackground: textDarkColor,
|
||||
onSurface: textDarkColor,
|
||||
),
|
||||
textTheme: GoogleFonts.poppinsTextTheme(ThemeData.dark().textTheme),
|
||||
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: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF374151),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
color: const Color(0xFF1F2937),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
flutt/lib/main.dart
Normal file
60
flutt/lib/main.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
// Import centralisé pour les modèles chat
|
||||
import 'package:geosector_app/chat/models/chat_adapters.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Configurer le routage par chemin (URLs sans #)
|
||||
setUrlStrategy(PathUrlStrategy());
|
||||
|
||||
// Initialiser Hive
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Enregistrer les adaptateurs Hive pour les modèles principaux
|
||||
Hive.registerAdapter(UserModelAdapter());
|
||||
Hive.registerAdapter(OperationModelAdapter());
|
||||
Hive.registerAdapter(SectorModelAdapter());
|
||||
Hive.registerAdapter(PassageModelAdapter());
|
||||
Hive.registerAdapter(MembreModelAdapter());
|
||||
|
||||
// Enregistrer les adaptateurs Hive pour le chat
|
||||
Hive.registerAdapter(ConversationModelAdapter());
|
||||
Hive.registerAdapter(MessageModelAdapter());
|
||||
Hive.registerAdapter(ParticipantModelAdapter());
|
||||
Hive.registerAdapter(AnonymousUserModelAdapter());
|
||||
Hive.registerAdapter(AudienceTargetModelAdapter());
|
||||
Hive.registerAdapter(NotificationSettingsAdapter());
|
||||
|
||||
// Ouvrir uniquement les boîtes essentielles au démarrage
|
||||
// La boîte des utilisateurs est nécessaire pour vérifier si un utilisateur est déjà connecté
|
||||
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
|
||||
// Boîte pour les préférences utilisateur générales
|
||||
await Hive.openBox(AppKeys.settingsBoxName);
|
||||
|
||||
// Ouvrir les boîtes de chat également au démarrage pour le cache local
|
||||
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
|
||||
// Les autres boîtes (operations, sectors, passages) seront ouvertes après connexion
|
||||
// dans UserRepository.login() via la méthode _ensureBoxIsOpen()
|
||||
|
||||
// Définir l'orientation de l'application
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Lancer l'application directement sans AppProviders
|
||||
runApp(const GeoSectorApp());
|
||||
}
|
||||
118
flutt/lib/presentation/MIGRATION.md
Normal file
118
flutt/lib/presentation/MIGRATION.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Guide de migration vers la nouvelle structure
|
||||
|
||||
Ce document explique comment migrer l'application GeoSector vers la nouvelle structure de dossiers.
|
||||
|
||||
## Nouvelle structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── presentation/ # Tout ce qui concerne l'interface utilisateur
|
||||
│ ├── admin/ # Pages et widgets spécifiques à l'interface administrateur
|
||||
│ ├── user/ # Pages et widgets spécifiques à l'interface utilisateur
|
||||
│ │ └── pages/ # Pages de l'interface utilisateur
|
||||
│ ├── auth/ # Pages et widgets liés à l'authentification
|
||||
│ ├── public/ # Pages et widgets accessibles sans authentification
|
||||
│ └── widgets/ # Widgets partagés utilisés dans plusieurs parties de l'application
|
||||
├── core/ # Logique métier et services (reste inchangé)
|
||||
│ ├── constants/ # Constantes de l'application
|
||||
│ ├── data/ # Modèles de données
|
||||
│ ├── repositories/ # Repositories pour accéder aux données
|
||||
│ ├── services/ # Services de l'application
|
||||
│ └── theme/ # Thème de l'application
|
||||
└── shared/ # Code partagé entre les différentes parties de l'application
|
||||
```
|
||||
|
||||
## Étapes de migration
|
||||
|
||||
### 1. Widgets communs
|
||||
|
||||
Les widgets communs ont déjà été migrés vers `lib/presentation/widgets/` :
|
||||
|
||||
- `dashboard_app_bar.dart`
|
||||
- `dashboard_layout.dart`
|
||||
- `responsive_navigation.dart`
|
||||
|
||||
### 2. Pages administrateur
|
||||
|
||||
Migrer les pages administrateur de `lib/features/admin/` vers `lib/presentation/admin/` :
|
||||
|
||||
- `admin_dashboard_page.dart` (déjà migré)
|
||||
- `admin_statistics_page.dart`
|
||||
- `admin_history_page.dart`
|
||||
- `admin_communication_page.dart`
|
||||
- `admin_map_page.dart`
|
||||
|
||||
### 3. Pages utilisateur
|
||||
|
||||
Migrer les pages utilisateur de `lib/features/user/presentation/pages/` vers `lib/presentation/user/pages/` :
|
||||
|
||||
- Créer le dossier `lib/presentation/user/pages/`
|
||||
- Migrer les fichiers suivants :
|
||||
- `user_dashboard_home_page.dart`
|
||||
- `user_statistics_page.dart`
|
||||
- `user_history_page.dart`
|
||||
- `user_communication_page.dart`
|
||||
- `user_map_page.dart`
|
||||
|
||||
### 4. Pages d'authentification
|
||||
|
||||
Migrer les pages d'authentification de `lib/features/auth/presentation/` vers `lib/presentation/auth/` :
|
||||
|
||||
- `login_page.dart`
|
||||
- `register_page.dart`
|
||||
- `forgot_password_page.dart`
|
||||
- etc.
|
||||
|
||||
### 5. Pages publiques
|
||||
|
||||
Migrer les pages publiques de `lib/features/public/presentation/` vers `lib/presentation/public/` :
|
||||
|
||||
- `landing_page.dart`
|
||||
- `about_page.dart`
|
||||
- etc.
|
||||
|
||||
### 6. Mise à jour des imports
|
||||
|
||||
Après avoir migré tous les fichiers, il faudra mettre à jour les imports dans tous les fichiers pour refléter la nouvelle structure.
|
||||
|
||||
Exemple :
|
||||
```dart
|
||||
// Ancien import
|
||||
import 'package:geosector_app/core/widgets/dashboard_app_bar.dart';
|
||||
|
||||
// Nouvel import
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
|
||||
```
|
||||
|
||||
### 7. Mise à jour des routes
|
||||
|
||||
Mettre à jour le fichier de routes (`lib/core/routes/app_router.dart`) pour refléter les nouveaux chemins des pages.
|
||||
|
||||
### 8. Tests
|
||||
|
||||
Après avoir effectué toutes les migrations, exécuter les tests pour s'assurer que tout fonctionne correctement.
|
||||
|
||||
## Avantages de la nouvelle structure
|
||||
|
||||
1. **Séparation claire des responsabilités** : La nouvelle structure sépare clairement la présentation (UI) de la logique métier (core).
|
||||
2. **Organisation par fonctionnalité** : Les fichiers sont organisés par fonctionnalité (admin, user, auth, public) plutôt que par type (pages, widgets).
|
||||
3. **Facilité de maintenance** : Il est plus facile de trouver et de modifier les fichiers liés à une fonctionnalité spécifique.
|
||||
4. **Évolutivité** : La nouvelle structure est plus évolutive et permet d'ajouter facilement de nouvelles fonctionnalités.
|
||||
|
||||
## Approche progressive
|
||||
|
||||
La migration peut être effectuée progressivement, en commençant par les widgets communs, puis en migrant les pages une par une. Cela permet de continuer à développer l'application pendant la migration.
|
||||
|
||||
## Exemple de migration d'une page
|
||||
|
||||
Voici un exemple de migration de la page `admin_dashboard_page.dart` :
|
||||
|
||||
1. Copier le fichier de `lib/features/admin/admin_dashboard_page.dart` vers `lib/presentation/admin/admin_dashboard_page.dart`
|
||||
2. Mettre à jour les imports dans le nouveau fichier
|
||||
3. Mettre à jour les références à ce fichier dans d'autres fichiers
|
||||
4. Tester que tout fonctionne correctement
|
||||
5. Supprimer l'ancien fichier une fois que tout fonctionne
|
||||
|
||||
## Conclusion
|
||||
|
||||
Cette migration permettra d'améliorer la structure de l'application et de faciliter son évolution future. Elle peut être effectuée progressivement pour minimiser l'impact sur le développement en cours.
|
||||
26
flutt/lib/presentation/README.md
Normal file
26
flutt/lib/presentation/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Structure de présentation
|
||||
|
||||
Ce dossier contient tous les éléments liés à l'interface utilisateur de l'application, organisés comme suit :
|
||||
|
||||
## Sous-dossiers
|
||||
|
||||
- `/admin` : Pages et widgets spécifiques à l'interface administrateur
|
||||
- `/user` : Pages et widgets spécifiques à l'interface utilisateur
|
||||
- `/auth` : Pages et widgets liés à l'authentification
|
||||
- `/public` : Pages et widgets accessibles sans authentification
|
||||
- `/widgets` : Widgets partagés utilisés dans plusieurs parties de l'application
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
Chaque sous-dossier peut contenir :
|
||||
- Des pages (écrans complets)
|
||||
- Des widgets spécifiques à cette section
|
||||
- Des modèles de données d'UI
|
||||
- Des utilitaires d'UI spécifiques
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
- Les widgets réutilisables dans plusieurs sections doivent être placés dans `/widgets`
|
||||
- Les widgets spécifiques à une section doivent être placés dans le sous-dossier correspondant
|
||||
- Utiliser des imports relatifs pour les fichiers du même module
|
||||
- Utiliser des imports absolus pour les fichiers d'autres modules
|
||||
557
flutt/lib/presentation/admin/admin_communication_page.dart
Normal file
557
flutt/lib/presentation/admin/admin_communication_page.dart
Normal file
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_sidebar.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_input.dart';
|
||||
|
||||
class AdminCommunicationPage extends StatefulWidget {
|
||||
const AdminCommunicationPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminCommunicationPage> createState() => _AdminCommunicationPageState();
|
||||
}
|
||||
|
||||
class _AdminCommunicationPageState extends State<AdminCommunicationPage> {
|
||||
int selectedContactId = 0;
|
||||
String selectedContactName = '';
|
||||
bool isTeamChat = true;
|
||||
String messageText = '';
|
||||
bool isReplying = false;
|
||||
Map<String, dynamic>? replyingTo;
|
||||
|
||||
// Données simulées pour les conversations d'équipe
|
||||
final List<Map<String, dynamic>> teamContacts = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Équipe',
|
||||
'isGroup': true,
|
||||
'lastMessage': 'Réunion à 14h aujourd\'hui',
|
||||
'time': DateTime.now().subtract(const Duration(minutes: 30)),
|
||||
'unread': 2,
|
||||
'online': true,
|
||||
'avatar': 'assets/images/team.png',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': 'Jean Dupont',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'Je serai présent demain',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 1)),
|
||||
'unread': 0,
|
||||
'online': true,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'name': 'Marie Martin',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'Secteur Sud terminé',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 3)),
|
||||
'unread': 1,
|
||||
'online': false,
|
||||
'avatar': 'assets/images/avatar2.png',
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'name': 'Pierre Legrand',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'J\'ai une question sur mon secteur',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1)),
|
||||
'unread': 0,
|
||||
'online': false,
|
||||
'avatar': 'assets/images/avatar3.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Données simulées pour les conversations clients
|
||||
final List<Map<String, dynamic>> clientContacts = [
|
||||
{
|
||||
'id': 101,
|
||||
'name': 'Martin Durand',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'Merci pour votre passage',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 5)),
|
||||
'unread': 0,
|
||||
'online': false,
|
||||
'avatar': null,
|
||||
'email': 'martin.durand@example.com',
|
||||
},
|
||||
{
|
||||
'id': 102,
|
||||
'name': 'Sophie Lambert',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'Question concernant le reçu',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1)),
|
||||
'unread': 3,
|
||||
'online': false,
|
||||
'avatar': null,
|
||||
'email': 'sophie.lambert@example.com',
|
||||
},
|
||||
{
|
||||
'id': 103,
|
||||
'name': 'Thomas Bernard',
|
||||
'isGroup': false,
|
||||
'lastMessage': 'Rendez-vous manqué',
|
||||
'time': DateTime.now().subtract(const Duration(days: 2)),
|
||||
'unread': 0,
|
||||
'online': false,
|
||||
'avatar': null,
|
||||
'email': 'thomas.bernard@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
// Messages simulés pour la conversation sélectionnée
|
||||
final Map<int, List<Map<String, dynamic>>> chatMessages = {
|
||||
1: [
|
||||
{
|
||||
'id': 1,
|
||||
'senderId': 2,
|
||||
'senderName': 'Jean Dupont',
|
||||
'message':
|
||||
'Bonjour à tous, comment avance la collecte dans vos secteurs ?',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'senderId': 3,
|
||||
'senderName': 'Marie Martin',
|
||||
'message': 'J\'ai terminé le secteur Sud avec 45 passages réalisés !',
|
||||
'time': DateTime.now()
|
||||
.subtract(const Duration(days: 1, hours: 2, minutes: 30)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar2.png',
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'senderId': 4,
|
||||
'senderName': 'Pierre Legrand',
|
||||
'message':
|
||||
'Secteur Est en cours, j\'ai réalisé 28 passages pour l\'instant.',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar3.png',
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message':
|
||||
'Parfait, n\'oubliez pas la réunion de demain à 14h pour faire le point !',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 1)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'senderId': 2,
|
||||
'senderName': 'Jean Dupont',
|
||||
'message': 'Je serai présent 👍',
|
||||
'time': DateTime.now().subtract(const Duration(minutes: 30)),
|
||||
'isRead': false,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
],
|
||||
2: [
|
||||
{
|
||||
'id': 101,
|
||||
'senderId': 2,
|
||||
'senderName': 'Jean Dupont',
|
||||
'message':
|
||||
'Bonjour, est-ce que je peux commencer le secteur Ouest demain ?',
|
||||
'time': DateTime.now().subtract(const Duration(days: 2)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
{
|
||||
'id': 102,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message': 'Bonjour Jean, oui bien sûr. Les documents sont prêts.',
|
||||
'time': DateTime.now()
|
||||
.subtract(const Duration(days: 2))
|
||||
.add(const Duration(minutes: 15)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 103,
|
||||
'senderId': 2,
|
||||
'senderName': 'Jean Dupont',
|
||||
'message': 'Merci ! Je passerai les récupérer ce soir.',
|
||||
'time': DateTime.now()
|
||||
.subtract(const Duration(days: 2))
|
||||
.add(const Duration(minutes: 20)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
{
|
||||
'id': 104,
|
||||
'senderId': 2,
|
||||
'senderName': 'Jean Dupont',
|
||||
'message': 'Je serai présent à la réunion de demain.',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 1)),
|
||||
'isRead': true,
|
||||
'avatar': 'assets/images/avatar1.png',
|
||||
},
|
||||
],
|
||||
101: [
|
||||
{
|
||||
'id': 201,
|
||||
'senderId': 101,
|
||||
'senderName': 'Martin Durand',
|
||||
'message':
|
||||
'Bonjour, je voulais vous remercier pour votre passage. J\'ai bien reçu le reçu par email.',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 5)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 202,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message':
|
||||
'Bonjour M. Durand, je vous remercie pour votre contribution. N\'hésitez pas si vous avez des questions.',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 4)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 203,
|
||||
'senderId': 101,
|
||||
'senderName': 'Martin Durand',
|
||||
'message': 'Tout est parfait, merci !',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 5)),
|
||||
'isRead': true,
|
||||
},
|
||||
],
|
||||
102: [
|
||||
{
|
||||
'id': 301,
|
||||
'senderId': 102,
|
||||
'senderName': 'Sophie Lambert',
|
||||
'message':
|
||||
'Bonjour, je n\'ai pas reçu le reçu suite à mon paiement d\'hier. Pouvez-vous vérifier ?',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 302,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message':
|
||||
'Bonjour Mme Lambert, je m\'excuse pour ce désagrément. Je vérifie cela immédiatement.',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 303,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message':
|
||||
'Il semble qu\'il y ait eu un problème technique. Je viens de renvoyer le reçu à votre adresse email. Pourriez-vous vérifier si vous l\'avez bien reçu ?',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1, hours: 1)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 304,
|
||||
'senderId': 102,
|
||||
'senderName': 'Sophie Lambert',
|
||||
'message':
|
||||
'Je n\'ai toujours rien reçu. Mon email est-il correct ? C\'est sophie.lambert@example.com',
|
||||
'time': DateTime.now().subtract(const Duration(days: 1)),
|
||||
'isRead': true,
|
||||
},
|
||||
{
|
||||
'id': 305,
|
||||
'senderId': 102,
|
||||
'senderName': 'Sophie Lambert',
|
||||
'message': 'Est-ce que vous pouvez réessayer ?',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 5)),
|
||||
'isRead': false,
|
||||
},
|
||||
{
|
||||
'id': 306,
|
||||
'senderId': 102,
|
||||
'senderName': 'Sophie Lambert',
|
||||
'message': 'Toujours pas de nouvelles...',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 3)),
|
||||
'isRead': false,
|
||||
},
|
||||
{
|
||||
'id': 307,
|
||||
'senderId': 102,
|
||||
'senderName': 'Sophie Lambert',
|
||||
'message': 'Pouvez-vous me contacter dès que possible ?',
|
||||
'time': DateTime.now().subtract(const Duration(hours: 1)),
|
||||
'isRead': false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Sidebar des contacts (fixe sur desktop, conditional sur mobile)
|
||||
if (isDesktop || selectedContactId == 0)
|
||||
SizedBox(
|
||||
width: isDesktop ? 320 : screenWidth,
|
||||
child: ChatSidebar(
|
||||
teamContacts: teamContacts,
|
||||
clientContacts: clientContacts,
|
||||
isTeamChat: isTeamChat,
|
||||
selectedContactId: selectedContactId,
|
||||
onContactSelected: (contactId, contactName, isTeam) {
|
||||
setState(() {
|
||||
selectedContactId = contactId;
|
||||
selectedContactName = contactName;
|
||||
isTeamChat = isTeam;
|
||||
replyingTo = null;
|
||||
isReplying = false;
|
||||
});
|
||||
},
|
||||
onToggleGroup: (isTeam) {
|
||||
setState(() {
|
||||
isTeamChat = isTeam;
|
||||
selectedContactId = 0;
|
||||
selectedContactName = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Vue des messages (conditionnelle sur mobile)
|
||||
if (isDesktop || selectedContactId != 0)
|
||||
Expanded(
|
||||
child: selectedContactId == 0
|
||||
? const Center(
|
||||
child: Text('Sélectionnez une conversation pour commencer'),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// En-tête de la conversation
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingL,
|
||||
vertical: AppTheme.spacingM,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!isDesktop)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selectedContactId = 0;
|
||||
selectedContactName = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor:
|
||||
AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage:
|
||||
_getAvatarForContact(selectedContactId),
|
||||
child: _getAvatarForContact(selectedContactId) ==
|
||||
null
|
||||
? Text(
|
||||
selectedContactName.isNotEmpty
|
||||
? selectedContactName[0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedContactName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (!isTeamChat && selectedContactId > 100)
|
||||
Text(
|
||||
_getEmailForContact(selectedContactId),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
// Afficher les détails du contact
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Messages
|
||||
Expanded(
|
||||
child: ChatMessages(
|
||||
messages: chatMessages[selectedContactId] ?? [],
|
||||
currentUserId: 0,
|
||||
onReply: (message) {
|
||||
setState(() {
|
||||
isReplying = true;
|
||||
replyingTo = message;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Zone de réponse
|
||||
if (isReplying)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Réponse à ${replyingTo?['senderName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
replyingTo?['message'] ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isReplying = false;
|
||||
replyingTo = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Zone de saisie du message
|
||||
ChatInput(
|
||||
onMessageSent: (text) {
|
||||
setState(() {
|
||||
// Ajouter le message à la conversation
|
||||
if (chatMessages[selectedContactId] != null) {
|
||||
final newMessageId =
|
||||
chatMessages[selectedContactId]!.last['id'] +
|
||||
1;
|
||||
|
||||
chatMessages[selectedContactId]!.add({
|
||||
'id': newMessageId,
|
||||
'senderId': 0,
|
||||
'senderName': 'Vous',
|
||||
'message': text,
|
||||
'time': DateTime.now(),
|
||||
'isRead': false,
|
||||
'replyTo': isReplying ? replyingTo : null,
|
||||
});
|
||||
|
||||
// Mise à jour du dernier message pour le contact
|
||||
final contactsList =
|
||||
isTeamChat ? teamContacts : clientContacts;
|
||||
final contactIndex = contactsList.indexWhere(
|
||||
(c) => c['id'] == selectedContactId);
|
||||
|
||||
if (contactIndex != -1) {
|
||||
contactsList[contactIndex]['lastMessage'] =
|
||||
text;
|
||||
contactsList[contactIndex]['time'] =
|
||||
DateTime.now();
|
||||
contactsList[contactIndex]['unread'] = 0;
|
||||
}
|
||||
|
||||
isReplying = false;
|
||||
replyingTo = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ImageProvider? _getAvatarForContact(int contactId) {
|
||||
String? avatarPath;
|
||||
|
||||
if (isTeamChat) {
|
||||
final contact = teamContacts.firstWhere(
|
||||
(c) => c['id'] == contactId,
|
||||
orElse: () => {'avatar': null},
|
||||
);
|
||||
avatarPath = contact['avatar'];
|
||||
} else {
|
||||
final contact = clientContacts.firstWhere(
|
||||
(c) => c['id'] == contactId,
|
||||
orElse: () => {'avatar': null},
|
||||
);
|
||||
avatarPath = contact['avatar'];
|
||||
}
|
||||
|
||||
return avatarPath != null ? AssetImage(avatarPath) : null;
|
||||
}
|
||||
|
||||
String _getEmailForContact(int contactId) {
|
||||
if (!isTeamChat) {
|
||||
final contact = clientContacts.firstWhere(
|
||||
(c) => c['id'] == contactId,
|
||||
orElse: () => {'email': ''},
|
||||
);
|
||||
return contact['email'] ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
887
flutt/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
887
flutt/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
@@ -0,0 +1,887 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
class AdminDashboardHomePage extends StatefulWidget {
|
||||
const AdminDashboardHomePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminDashboardHomePage> createState() => _AdminDashboardHomePageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
// Données pour le tableau de bord
|
||||
int totalPassages = 0;
|
||||
double totalAmounts = 0.0;
|
||||
List<Map<String, dynamic>> memberStats = [];
|
||||
bool isDataLoaded = false;
|
||||
bool isLoading = true;
|
||||
|
||||
// Données pour les graphiques
|
||||
List<PaymentData> paymentData = [];
|
||||
Map<int, int> passagesByType = {};
|
||||
|
||||
// Future pour initialiser les boîtes Hive
|
||||
late Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les boîtes Hive avant de charger les données
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
// Charger les données une fois les boîtes initialisées
|
||||
_loadDashboardData();
|
||||
});
|
||||
}
|
||||
|
||||
// Méthode pour initialiser les boîtes Hive nécessaires
|
||||
Future<void> _initHiveBoxes() async {
|
||||
try {
|
||||
debugPrint('Initialisation des boîtes Hive...');
|
||||
|
||||
// Ouvrir la boîte des opérations si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
debugPrint('Ouverture de la boîte operations...');
|
||||
try {
|
||||
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint('Boîte operations ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte operations: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte operations déjà ouverte');
|
||||
}
|
||||
|
||||
// Ouvrir la boîte des passages si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
debugPrint('Ouverture de la boîte passages...');
|
||||
try {
|
||||
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
|
||||
debugPrint('Boîte passages ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte passages: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte passages déjà ouverte');
|
||||
}
|
||||
|
||||
// Ouvrir la boîte des secteurs si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
debugPrint('Ouverture de la boîte sectors...');
|
||||
try {
|
||||
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
|
||||
debugPrint('Boîte sectors ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte sectors: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte sectors déjà ouverte');
|
||||
}
|
||||
|
||||
debugPrint('Initialisation des boîtes Hive terminée');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
|
||||
// Ne pas propager l'erreur, mais retourner normalement
|
||||
// pour éviter que le FutureBuilder ne reste bloqué en état d'erreur
|
||||
}
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique de paiement
|
||||
void _preparePaymentData(List<dynamic> passages) {
|
||||
// Réinitialiser les données
|
||||
paymentData = [];
|
||||
|
||||
// Compter les montants par type de règlement
|
||||
Map<int, double> paymentAmounts = {};
|
||||
|
||||
// Initialiser les compteurs pour tous les types de règlement
|
||||
for (final typeId in AppKeys.typesReglements.keys) {
|
||||
paymentAmounts[typeId] = 0.0;
|
||||
}
|
||||
|
||||
// Calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
if (passage.fkTypeReglement != null &&
|
||||
passage.montant != null &&
|
||||
passage.montant.isNotEmpty) {
|
||||
final typeId = passage.fkTypeReglement;
|
||||
final amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les objets PaymentData
|
||||
paymentAmounts.forEach((typeId, amount) {
|
||||
if (amount > 0 && AppKeys.typesReglements.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesReglements[typeId]!;
|
||||
paymentData.add(PaymentData(
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
title: typeInfo['titre'] as String,
|
||||
color: Color(typeInfo['couleur'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadDashboardData() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
debugPrint('Chargement des données du tableau de bord...');
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
// Pas besoin de Provider.of car les instances sont déjà disponibles
|
||||
|
||||
// S'assurer que la boîte des opérations est ouverte avant d'y accéder
|
||||
OperationModel? currentOperation;
|
||||
try {
|
||||
// Vérifier si la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
debugPrint(
|
||||
'Ouverture de la boîte operations dans _loadDashboardData...');
|
||||
try {
|
||||
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint(
|
||||
'Boîte operations ouverte avec succès dans _loadDashboardData');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte operations dans _loadDashboardData: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'opération en cours
|
||||
debugPrint('Récupération de l\'opération en cours...');
|
||||
currentOperation = userRepository.getCurrentOperation();
|
||||
debugPrint('Opération récupérée: ${currentOperation?.id ?? "null"}');
|
||||
} catch (boxError) {
|
||||
debugPrint('Erreur lors de la récupération de l\'opération: $boxError');
|
||||
// Afficher un message d'erreur ou gérer l'erreur de manière appropriée
|
||||
}
|
||||
|
||||
if (currentOperation != null) {
|
||||
// Charger les passages pour l'opération en cours
|
||||
final passages =
|
||||
passageRepository.getPassagesByOperation(currentOperation.id);
|
||||
|
||||
// Calculer le nombre total de passages
|
||||
totalPassages = passages.length;
|
||||
|
||||
// Calculer le montant total collecté
|
||||
totalAmounts = passages.fold(
|
||||
0.0,
|
||||
(sum, passage) =>
|
||||
sum +
|
||||
(passage.montant != null && passage.montant.isNotEmpty
|
||||
? double.tryParse(passage.montant) ?? 0.0
|
||||
: 0.0));
|
||||
|
||||
// Préparer les données pour le graphique de paiement
|
||||
_preparePaymentData(passages);
|
||||
|
||||
// Compter les passages par type
|
||||
passagesByType = {};
|
||||
for (final passage in passages) {
|
||||
final typeId = passage.fkType;
|
||||
passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Charger les statistiques par membre
|
||||
memberStats = [];
|
||||
final Map<int, int> memberCounts = {};
|
||||
|
||||
// Compter les passages par membre
|
||||
for (final passage in passages) {
|
||||
if (passage.fkUser != null) {
|
||||
memberCounts[passage.fkUser!] =
|
||||
(memberCounts[passage.fkUser!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les informations des membres
|
||||
for (final entry in memberCounts.entries) {
|
||||
final user = userRepository.getUserById(entry.key);
|
||||
if (user != null) {
|
||||
memberStats.add({
|
||||
'name': '${user.firstName ?? ''} ${user.name ?? ''}'.trim(),
|
||||
'count': entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les membres par nombre de passages (décroissant)
|
||||
memberStats
|
||||
.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isDataLoaded = true;
|
||||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des données: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('Building AdminDashboardHomePage');
|
||||
return FutureBuilder<void>(
|
||||
future: _initFuture,
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un indicateur de chargement pendant l'initialisation des boîtes Hive
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
debugPrint('FutureBuilder: ConnectionState.waiting');
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
// Même si nous avons une erreur, nous continuons à afficher le contenu
|
||||
// car nous avons modifié _initHiveBoxes pour ne pas propager les erreurs
|
||||
if (snapshot.hasError) {
|
||||
debugPrint('FutureBuilder: hasError - ${snapshot.error}');
|
||||
// Nous affichons un message d'erreur mais continuons à afficher le contenu
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Erreur lors de l\'initialisation: ${snapshot.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
_loadDashboardData();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('FutureBuilder: Initialisation réussie');
|
||||
}
|
||||
|
||||
// L'initialisation a réussi, afficher le contenu
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null
|
||||
? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}'
|
||||
: 'Synthèse de l\'opération';
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
// Réduire la taille de police en version web
|
||||
fontSize: isDesktop ? 18 : null,
|
||||
),
|
||||
overflow: TextOverflow
|
||||
.ellipsis, // Tronquer avec ... si trop long
|
||||
maxLines: 1, // Forcer une seule ligne
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Bouton de rafraîchissement
|
||||
if (!isLoading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Rafraîchir les données',
|
||||
onPressed: _loadDashboardData,
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
|
||||
if (isLoading && !isDataLoaded)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
|
||||
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
|
||||
if (isDataLoaded || isLoading) ...[
|
||||
// Cartes de synthèse
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SectorDistributionCard(
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
SectorDistributionCard(
|
||||
height: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: const ActivityChart(
|
||||
height: 350,
|
||||
loadFromHive: true,
|
||||
showAllPassages:
|
||||
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
),
|
||||
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
|
||||
// child: ActivityChart(
|
||||
// height: 350,
|
||||
// loadFromHive: true,
|
||||
// showAllPassages: true,
|
||||
// title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
// daysToShow: 15,
|
||||
// operationId: userRepository.getCurrentOperation()?.id,
|
||||
// ),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPassageTypeCard(context),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildPaymentTypeCard(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPassageTypeCard(context),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildPaymentTypeCard(context),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions rapides
|
||||
Text(
|
||||
'Actions rapides',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.buttonPrimaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Envoyer un message',
|
||||
Icons.message_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Gérer les secteurs',
|
||||
Icons.map_outlined,
|
||||
AppTheme.accentColor,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartCard(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Widget chart,
|
||||
) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
chart,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par type de passage avec liste
|
||||
Widget _buildPassageTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalPassages passages',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: const PassagePieChart(
|
||||
size: 180,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeInfo = entry.value;
|
||||
final int count = passagesByType[typeId] ?? 0;
|
||||
final Color color =
|
||||
Color(typeInfo['couleur2'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count ${typeInfo['titres']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par mode de paiement
|
||||
Widget _buildPaymentTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par mode de paiement',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: PaymentPieChart(
|
||||
size: 180,
|
||||
payments: paymentData,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
enable3DEffect: true,
|
||||
effect3DIntensity: 1.5,
|
||||
enableEnhancedExplode: false, // Désactiver l'explosion
|
||||
useGradient: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types de règlement à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...[1, 2, 3].map((typeId) {
|
||||
// Uniquement les types 1, 2 et 3
|
||||
if (!AppKeys.typesReglements.containsKey(typeId)) {
|
||||
return const SizedBox
|
||||
.shrink(); // Ignorer si le type n'existe pas
|
||||
}
|
||||
|
||||
final Map<String, dynamic> typeInfo =
|
||||
AppKeys.typesReglements[typeId]!;
|
||||
|
||||
// Calculer le montant total pour ce type de règlement
|
||||
double amount = 0.0;
|
||||
for (final payment in paymentData) {
|
||||
if (payment.typeId == typeId) {
|
||||
amount = payment.amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ne pas afficher si le montant est 0
|
||||
if (amount <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final Color color =
|
||||
Color(typeInfo['couleur'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${amount.toStringAsFixed(2)} € ${typeInfo['titre']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
IconData icon,
|
||||
Color color,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingL,
|
||||
vertical: AppTheme.spacingM,
|
||||
),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
flutt/lib/presentation/admin/admin_dashboard_page.dart
Normal file
183
flutt/lib/presentation/admin/admin_dashboard_page.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
// Import des pages admin
|
||||
import 'admin_dashboard_home_page.dart';
|
||||
import 'admin_statistics_page.dart';
|
||||
import 'admin_history_page.dart';
|
||||
import 'admin_communication_page.dart';
|
||||
import 'admin_map_page.dart';
|
||||
import 'admin_entite.dart';
|
||||
|
||||
class AdminDashboardPage extends StatefulWidget {
|
||||
const AdminDashboardPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardPageState extends State<AdminDashboardPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Index de la page Amicale et membres (utilisé pour la navigation conditionnelle)
|
||||
static const int entitePageIndex = 5;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
try {
|
||||
debugPrint('Initialisation de AdminDashboardPage');
|
||||
|
||||
// Vérifier que userRepository est correctement initialisé
|
||||
if (userRepository == null) {
|
||||
debugPrint('ERREUR: userRepository est null dans AdminDashboardPage');
|
||||
} else {
|
||||
debugPrint('userRepository est correctement initialisé');
|
||||
|
||||
// Vérifier l'utilisateur courant
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_pages = [
|
||||
const AdminDashboardHomePage(),
|
||||
const AdminStatisticsPage(),
|
||||
const AdminHistoryPage(),
|
||||
const AdminCommunicationPage(),
|
||||
const AdminMapPage(),
|
||||
// La page AdminEntitePage est maintenant accessible uniquement via le menu Paramètres
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
try {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger l'index de page sélectionné
|
||||
final savedIndex = _settingsBox.get('adminSelectedPageIndex');
|
||||
|
||||
// Vérifier si l'index sauvegardé est valide
|
||||
if (savedIndex != null && savedIndex is int) {
|
||||
debugPrint('Index sauvegardé trouvé: $savedIndex');
|
||||
|
||||
// S'assurer que l'index est dans les limites valides
|
||||
if (savedIndex >= 0 && savedIndex < _pages.length) {
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
debugPrint('Index sauvegardé valide, utilisé: $_selectedIndex');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Index sauvegardé invalide ($savedIndex), utilisation de l\'index par défaut: 0',
|
||||
);
|
||||
// Réinitialiser l'index sauvegardé à 0 si invalide
|
||||
_settingsBox.put('adminSelectedPageIndex', 0);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Aucun index sauvegardé trouvé, utilisation de l\'index par défaut: 0',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres utilisateur
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'index de page sélectionné
|
||||
_settingsBox.put('adminSelectedPageIndex', _selectedIndex);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DashboardLayout(
|
||||
title: 'Tableau de bord Administration',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: _buildNavigationDestinations(),
|
||||
showNewPassageButton: false,
|
||||
isAdmin: true,
|
||||
body: _pages[_selectedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la liste des destinations de navigation
|
||||
List<NavigationDestination> _buildNavigationDestinations() {
|
||||
// Destinations de base toujours présentes
|
||||
final List<NavigationDestination> destinations = [
|
||||
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',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
];
|
||||
|
||||
// Nous ne voulons plus ajouter la destination "Amicale et membres" ici
|
||||
// car elle est accessible uniquement via le menu Paramètres
|
||||
|
||||
return destinations;
|
||||
}
|
||||
}
|
||||
56
flutt/lib/presentation/admin/admin_entite.dart
Normal file
56
flutt/lib/presentation/admin/admin_entite.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Page d'administration de l'amicale et des membres
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
class AdminEntitePage extends StatelessWidget {
|
||||
const AdminEntitePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Mon amicale et ses membres',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Page en construction',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cette section permettra la gestion des amicales et de leurs membres.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
877
flutt/lib/presentation/admin/admin_history_page.dart
Normal file
877
flutt/lib/presentation/admin/admin_history_page.dart
Normal file
@@ -0,0 +1,877 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
|
||||
class AdminHistoryPage extends StatefulWidget {
|
||||
const AdminHistoryPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
|
||||
}
|
||||
|
||||
class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
// État des filtres
|
||||
String searchQuery = '';
|
||||
String selectedSector = 'Tous';
|
||||
String selectedUser = 'Tous';
|
||||
String selectedType = 'Tous';
|
||||
String selectedPaymentMethod = 'Tous';
|
||||
String selectedPeriod = 'Dernier mois'; // Période par défaut
|
||||
DateTimeRange? selectedDateRange;
|
||||
|
||||
// IDs pour les filtres
|
||||
int? selectedSectorId;
|
||||
int? selectedUserId;
|
||||
|
||||
// Listes pour les filtres
|
||||
List<SectorModel> _sectors = [];
|
||||
List<UserModel> _users = [];
|
||||
|
||||
// Repositories
|
||||
late PassageRepository _passageRepository;
|
||||
late SectorRepository _sectorRepository;
|
||||
late UserRepository _userRepository;
|
||||
|
||||
// Passages formatés
|
||||
List<Map<String, dynamic>> _formattedPassages = [];
|
||||
|
||||
// État de chargement
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les filtres
|
||||
_initializeFilters();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Récupérer les repositories une seule fois
|
||||
_loadRepositories();
|
||||
}
|
||||
|
||||
// Charger les repositories et les données
|
||||
void _loadRepositories() {
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
_passageRepository = passageRepository;
|
||||
_userRepository = userRepository;
|
||||
_sectorRepository = sectorRepository;
|
||||
|
||||
// Charger les secteurs et les utilisateurs
|
||||
_loadSectorsAndUsers();
|
||||
|
||||
// Charger les passages
|
||||
_loadPassages();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors du chargement des repositories: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les secteurs et les utilisateurs
|
||||
void _loadSectorsAndUsers() {
|
||||
try {
|
||||
// Récupérer la liste des secteurs
|
||||
_sectors = _sectorRepository.getAllSectors();
|
||||
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
||||
|
||||
// Récupérer la liste des utilisateurs
|
||||
_users = _userRepository.getAllUsers();
|
||||
debugPrint('Nombre d\'utilisateurs récupérés: ${_users.length}');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des secteurs et utilisateurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les passages
|
||||
void _loadPassages() {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les passages
|
||||
final List<PassageModel> allPassages =
|
||||
_passageRepository.getAllPassages();
|
||||
|
||||
// Convertir les passages en format attendu par PassagesListWidget
|
||||
_formattedPassages = _formatPassagesForWidget(
|
||||
allPassages, _sectorRepository, _userRepository);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors du chargement des passages: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser les filtres
|
||||
void _initializeFilters() {
|
||||
// Par défaut, on n'applique pas de filtre par utilisateur ou secteur
|
||||
selectedSectorId = null;
|
||||
selectedUserId = null;
|
||||
|
||||
// Période par défaut : dernier mois
|
||||
selectedPeriod = 'Dernier mois';
|
||||
|
||||
// Plage de dates par défaut : dernier mois
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime oneMonthAgo = DateTime(now.year, now.month - 1, now.day);
|
||||
selectedDateRange = DateTimeRange(start: oneMonthAgo, end: now);
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par secteur
|
||||
void _updateSectorFilter(String sectorName, int? sectorId) {
|
||||
setState(() {
|
||||
selectedSector = sectorName;
|
||||
selectedSectorId = sectorId;
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par utilisateur
|
||||
void _updateUserFilter(String userName, int? userId) {
|
||||
setState(() {
|
||||
selectedUser = userName;
|
||||
selectedUserId = userId;
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par période
|
||||
void _updatePeriodFilter(String period) {
|
||||
setState(() {
|
||||
selectedPeriod = period;
|
||||
|
||||
// Mettre à jour la plage de dates en fonction de la période
|
||||
final DateTime now = DateTime.now();
|
||||
|
||||
switch (period) {
|
||||
case 'Derniers 15 jours':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 15)),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Dernière semaine':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 7)),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Dernier mois':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: DateTime(now.year, now.month - 1, now.day),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Tous':
|
||||
selectedDateRange = null;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Afficher un widget de chargement ou d'erreur si nécessaire
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
backgroundColor:
|
||||
Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return _buildErrorWidget(_errorMessage);
|
||||
}
|
||||
|
||||
// Retourner le widget principal avec les données chargées
|
||||
return Scaffold(
|
||||
backgroundColor:
|
||||
const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Historique des passages',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres supplémentaires (secteur, utilisateur, période)
|
||||
_buildAdditionalFilters(context),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Widget de liste des passages
|
||||
Expanded(
|
||||
child: PassagesListWidget(
|
||||
passages: _formattedPassages,
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showActions: true,
|
||||
initialSearchQuery: searchQuery,
|
||||
initialTypeFilter: selectedType,
|
||||
initialPaymentFilter: selectedPaymentMethod,
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtres par utilisateur et secteur
|
||||
filterByUserId: selectedUserId,
|
||||
filterBySectorId: selectedSectorId,
|
||||
// Période par défaut (dernier mois)
|
||||
periodFilter: 'lastMonth',
|
||||
// Plage de dates personnalisée si définie
|
||||
dateRange: selectedDateRange,
|
||||
onPassageSelected: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onReceiptView: (passage) {
|
||||
_showReceiptDialog(context, passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action pour modifier le passage
|
||||
// Cette fonctionnalité pourrait être implémentée ultérieurement
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Widget d'erreur pour afficher un message d'erreur
|
||||
Widget _buildErrorWidget(String message) {
|
||||
return Scaffold(
|
||||
backgroundColor:
|
||||
const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Recharger la page
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir les passages du modèle Hive vers le format attendu par le widget
|
||||
List<Map<String, dynamic>> _formatPassagesForWidget(
|
||||
List<PassageModel> passages,
|
||||
SectorRepository sectorRepository,
|
||||
UserRepository userRepository) {
|
||||
return passages.map((passage) {
|
||||
// Récupérer le secteur associé au passage
|
||||
final SectorModel? sector =
|
||||
sectorRepository.getSectorById(passage.fkSector);
|
||||
|
||||
// Récupérer l'utilisateur associé au passage
|
||||
final UserModel? user = userRepository.getUserById(passage.fkUser);
|
||||
|
||||
// Construire l'adresse complète
|
||||
final String address =
|
||||
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
|
||||
|
||||
// Déterminer si le passage a une erreur d'envoi de reçu
|
||||
final bool hasError = passage.emailErreur.isNotEmpty;
|
||||
|
||||
return {
|
||||
'id': passage.id,
|
||||
'date': passage.passedAt,
|
||||
'address': address,
|
||||
'fkSector': passage.fkSector,
|
||||
'sector': sector?.libelle ?? 'Secteur inconnu',
|
||||
'fkUser': passage.fkUser,
|
||||
'user': user?.name ?? 'Utilisateur inconnu',
|
||||
'type': passage.fkType,
|
||||
'amount': double.tryParse(passage.montant) ?? 0.0,
|
||||
'payment': passage.fkTypeReglement,
|
||||
'email': passage.email,
|
||||
'hasReceipt': passage.nomRecu.isNotEmpty,
|
||||
'hasError': hasError,
|
||||
'notes': passage.remarque,
|
||||
'name': passage.name,
|
||||
'phone': passage.phone,
|
||||
// Ajouter d'autres champs nécessaires pour le widget
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _showReceiptDialog(BuildContext context, Map<String, dynamic> passage) {
|
||||
final int passageId = passage['id'] as int;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Reçu du passage #$passageId'),
|
||||
content: const SizedBox(
|
||||
width: 500,
|
||||
height: 600,
|
||||
child: Center(
|
||||
child: Text('Aperçu du reçu PDF'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Action pour télécharger le reçu
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Télécharger'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDetailsDialog(BuildContext context, Map<String, dynamic> passage) {
|
||||
final int passageId = passage['id'] as int;
|
||||
final DateTime date = passage['date'] as DateTime;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Détails du passage #$passageId'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('Date',
|
||||
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'),
|
||||
_buildDetailRow('Adresse', passage['address'] as String),
|
||||
_buildDetailRow('Secteur', passage['sector'] as String),
|
||||
_buildDetailRow('Collecteur', passage['user'] as String),
|
||||
_buildDetailRow(
|
||||
'Type',
|
||||
AppKeys.typesPassages[passage['type']]?['titre'] ??
|
||||
'Inconnu'),
|
||||
_buildDetailRow('Montant', '${passage['amount']} €'),
|
||||
_buildDetailRow(
|
||||
'Mode de paiement',
|
||||
AppKeys.typesReglements[passage['payment']]?['titre'] ??
|
||||
'Inconnu'),
|
||||
_buildDetailRow('Email', passage['email'] as String),
|
||||
_buildDetailRow(
|
||||
'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'),
|
||||
_buildDetailRow(
|
||||
'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'),
|
||||
_buildDetailRow(
|
||||
'Notes',
|
||||
(passage['notes'] as String).isEmpty
|
||||
? '-'
|
||||
: passage['notes'] as String),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Historique des actions',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHistoryItem(
|
||||
date,
|
||||
passage['user'] as String,
|
||||
'Création du passage',
|
||||
),
|
||||
if (passage['hasReceipt'])
|
||||
_buildHistoryItem(
|
||||
date.add(const Duration(minutes: 5)),
|
||||
'Système',
|
||||
'Envoi du reçu par email',
|
||||
),
|
||||
if (passage['hasError'])
|
||||
_buildHistoryItem(
|
||||
date.add(const Duration(minutes: 6)),
|
||||
'Système',
|
||||
'Erreur lors de l\'envoi du reçu',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Action pour modifier le passage
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
'$label :',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(DateTime date, String user, String action) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
Text('$user - $action'),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des filtres supplémentaires
|
||||
Widget _buildAdditionalFilters(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres avancés',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Disposition des filtres en fonction de la taille de l'écran
|
||||
isDesktop
|
||||
? Row(
|
||||
children: [
|
||||
// Filtre par secteur
|
||||
Expanded(
|
||||
child: _buildSectorFilter(theme, _sectors),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par utilisateur
|
||||
Expanded(
|
||||
child: _buildUserFilter(theme, _users),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par période
|
||||
Expanded(
|
||||
child: _buildPeriodFilter(theme),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// Filtre par secteur
|
||||
_buildSectorFilter(theme, _sectors),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par utilisateur
|
||||
_buildUserFilter(theme, _users),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par période
|
||||
_buildPeriodFilter(theme),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par secteur
|
||||
Widget _buildSectorFilter(ThemeData theme, List<SectorModel> sectors) {
|
||||
// Vérifier si la liste des secteurs est vide ou si selectedSector n'est pas dans la liste
|
||||
bool isSelectedSectorValid = selectedSector == 'Tous' ||
|
||||
sectors.any((s) => s.libelle == selectedSector);
|
||||
|
||||
// Si selectedSector n'est pas valide, le réinitialiser à 'Tous'
|
||||
if (!isSelectedSectorValid) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
selectedSector = 'Tous';
|
||||
selectedSectorId = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Secteur',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: isSelectedSectorValid ? selectedSector : 'Tous',
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les secteurs'),
|
||||
),
|
||||
...sectors.map((sector) {
|
||||
final String libelle = sector.libelle.isNotEmpty
|
||||
? sector.libelle
|
||||
: 'Secteur ${sector.id}';
|
||||
return DropdownMenuItem<String>(
|
||||
value: libelle,
|
||||
child: Text(
|
||||
libelle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
if (value == 'Tous') {
|
||||
_updateSectorFilter('Tous', null);
|
||||
} else {
|
||||
try {
|
||||
// Trouver le secteur correspondant
|
||||
final sector = sectors.firstWhere(
|
||||
(s) => s.libelle == value,
|
||||
orElse: () => sectors.isNotEmpty
|
||||
? sectors.first
|
||||
: throw Exception('Liste de secteurs vide'),
|
||||
);
|
||||
// Convertir sector.id en int? si nécessaire
|
||||
_updateSectorFilter(value, sector.id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sélection du secteur: $e');
|
||||
_updateSectorFilter('Tous', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par utilisateur
|
||||
Widget _buildUserFilter(ThemeData theme, List<UserModel> users) {
|
||||
// Vérifier si la liste des utilisateurs est vide ou si selectedUser n'est pas dans la liste
|
||||
bool isSelectedUserValid = selectedUser == 'Tous' ||
|
||||
users.any((u) => (u.name ?? 'Utilisateur inconnu') == selectedUser);
|
||||
|
||||
// Si selectedUser n'est pas valide, le réinitialiser à 'Tous'
|
||||
if (!isSelectedUserValid) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
selectedUser = 'Tous';
|
||||
selectedUserId = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Utilisateur',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: isSelectedUserValid ? selectedUser : 'Tous',
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les utilisateurs'),
|
||||
),
|
||||
...users.map((user) {
|
||||
// S'assurer que user.name n'est pas null
|
||||
final String userName = user.name ?? 'Utilisateur inconnu';
|
||||
return DropdownMenuItem<String>(
|
||||
value: userName,
|
||||
child: Text(
|
||||
userName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
if (value == 'Tous') {
|
||||
_updateUserFilter('Tous', null);
|
||||
} else {
|
||||
try {
|
||||
// Trouver l'utilisateur correspondant
|
||||
final user = users.firstWhere(
|
||||
(u) => (u.name ?? 'Utilisateur inconnu') == value,
|
||||
orElse: () => users.isNotEmpty
|
||||
? users.first
|
||||
: throw Exception('Liste d\'utilisateurs vide'),
|
||||
);
|
||||
// S'assurer que user.name et user.id ne sont pas null
|
||||
final String userName =
|
||||
user.name ?? 'Utilisateur inconnu';
|
||||
final int? userId = user.id;
|
||||
_updateUserFilter(userName, userId);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la sélection de l\'utilisateur: $e');
|
||||
_updateUserFilter('Tous', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par période
|
||||
Widget _buildPeriodFilter(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Période',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedPeriod,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: const [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Toutes les périodes'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Derniers 15 jours',
|
||||
child: Text('Derniers 15 jours'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Dernière semaine',
|
||||
child: Text('Dernière semaine'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Dernier mois',
|
||||
child: Text('Dernier mois'),
|
||||
),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
_updatePeriodFilter(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Afficher la plage de dates sélectionnée si elle existe
|
||||
if (selectedDateRange != null && selectedPeriod != 'Tous')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.date_range,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showResendConfirmation(BuildContext context, int passageId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Renvoyer le reçu'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir renvoyer le reçu du passage #$passageId ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Action pour renvoyer le reçu
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Reçu du passage #$passageId renvoyé avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Renvoyer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
898
flutt/lib/presentation/admin/admin_map_page.dart
Normal file
898
flutt/lib/presentation/admin/admin_map_page.dart
Normal file
@@ -0,0 +1,898 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/mapbox_map.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';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import '../../shared/app_theme.dart';
|
||||
|
||||
class AdminMapPage extends StatefulWidget {
|
||||
const AdminMapPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminMapPage> createState() => _AdminMapPageState();
|
||||
}
|
||||
|
||||
class _AdminMapPageState extends State<AdminMapPage> {
|
||||
// Contrôleur de carte
|
||||
final MapController _mapController = MapController();
|
||||
|
||||
// Position actuelle et zoom
|
||||
LatLng _currentPosition =
|
||||
const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes
|
||||
double _currentZoom = 12.0; // Zoom initial
|
||||
|
||||
// Données des secteurs et passages
|
||||
final List<Map<String, dynamic>> _sectors = [];
|
||||
final List<Map<String, dynamic>> _passages = [];
|
||||
|
||||
// États
|
||||
bool _editMode = false;
|
||||
int? _selectedSectorId;
|
||||
List<DropdownMenuItem<int?>> _sectorItems = [];
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initSettings().then((_) {
|
||||
_loadSectors();
|
||||
_loadPassages();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger le secteur sélectionné
|
||||
_selectedSectorId = _settingsBox.get('admin_selectedSectorId');
|
||||
|
||||
// Charger la position et le zoom
|
||||
final double? savedLat = _settingsBox.get('admin_mapLat');
|
||||
final double? savedLng = _settingsBox.get('admin_mapLng');
|
||||
final double? savedZoom = _settingsBox.get('admin_mapZoom');
|
||||
|
||||
if (savedLat != null && savedLng != null) {
|
||||
_currentPosition = LatLng(savedLat, savedLng);
|
||||
}
|
||||
|
||||
if (savedZoom != null) {
|
||||
_currentZoom = savedZoom;
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres utilisateur
|
||||
void _saveSettings() {
|
||||
// Sauvegarder le secteur sélectionné
|
||||
if (_selectedSectorId != null) {
|
||||
_settingsBox.put('admin_selectedSectorId', _selectedSectorId);
|
||||
}
|
||||
|
||||
// Sauvegarder la position et le zoom actuels
|
||||
_settingsBox.put('admin_mapLat', _currentPosition.latitude);
|
||||
_settingsBox.put('admin_mapLng', _currentPosition.longitude);
|
||||
_settingsBox.put('admin_mapZoom', _currentZoom);
|
||||
}
|
||||
|
||||
// Charger les secteurs depuis la boîte Hive
|
||||
void _loadSectors() {
|
||||
try {
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
final sectors = sectorsBox.values.toList();
|
||||
|
||||
setState(() {
|
||||
_sectors.clear();
|
||||
|
||||
for (final sector in sectors) {
|
||||
final List<List<double>> coordinates = sector.getCoordinates();
|
||||
final List<LatLng> points =
|
||||
coordinates.map((coord) => LatLng(coord[0], coord[1])).toList();
|
||||
|
||||
if (points.isNotEmpty) {
|
||||
_sectors.add({
|
||||
'id': sector.id,
|
||||
'name': sector.libelle,
|
||||
'color': _hexToColor(sector.color),
|
||||
'points': points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Si un secteur était sélectionné précédemment, le centrer
|
||||
// Mettre à jour les items de la combobox de secteurs
|
||||
_updateSectorItems();
|
||||
|
||||
if (_selectedSectorId != null &&
|
||||
_sectors.any((s) => s['id'] == _selectedSectorId)) {
|
||||
_centerMapOnSpecificSector(_selectedSectorId!);
|
||||
}
|
||||
// Sinon, centrer la carte sur tous les secteurs
|
||||
else if (_sectors.isNotEmpty) {
|
||||
_centerMapOnSectors();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des secteurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les passages depuis la boîte Hive
|
||||
void _loadPassages() {
|
||||
try {
|
||||
// Récupérer la boîte des passages
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
|
||||
// Créer une nouvelle liste temporaire
|
||||
final List<Map<String, dynamic>> newPassages = [];
|
||||
|
||||
// Parcourir tous les passages dans la boîte
|
||||
for (var i = 0; i < passagesBox.length; i++) {
|
||||
final passage = passagesBox.getAt(i);
|
||||
if (passage != null) {
|
||||
// Vérifier si les coordonnées GPS sont valides
|
||||
final lat = double.tryParse(passage.gpsLat);
|
||||
final lng = double.tryParse(passage.gpsLng);
|
||||
|
||||
// Filtrer par secteur si un secteur est sélectionné
|
||||
if (_selectedSectorId != null &&
|
||||
passage.fkSector != _selectedSectorId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lat != null && lng != null) {
|
||||
// Obtenir la couleur du type de passage
|
||||
Color passageColor = Colors.grey; // Couleur par défaut
|
||||
|
||||
// Vérifier si le type de passage existe dans AppKeys.typesPassages
|
||||
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
|
||||
// Utiliser la couleur1 du type de passage
|
||||
final colorValue =
|
||||
AppKeys.typesPassages[passage.fkType]!['couleur1'] as int;
|
||||
passageColor = Color(colorValue);
|
||||
|
||||
// Ajouter le passage à la liste temporaire
|
||||
newPassages.add({
|
||||
'id': passage.id,
|
||||
'position': LatLng(lat, lng),
|
||||
'type': passage.fkType,
|
||||
'color': passageColor,
|
||||
'model': passage, // Ajouter le modèle complet
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour la liste des passages dans l'état
|
||||
setState(() {
|
||||
_passages.clear();
|
||||
_passages.addAll(newPassages);
|
||||
});
|
||||
|
||||
// Sauvegarder les paramètres après chargement des passages
|
||||
_saveSettings();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des passages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir une couleur hexadécimale en Color
|
||||
Color _hexToColor(String hexColor) {
|
||||
// Supprimer le # si présent
|
||||
final String colorStr =
|
||||
hexColor.startsWith('#') ? hexColor.substring(1) : hexColor;
|
||||
|
||||
// Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères)
|
||||
final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr;
|
||||
|
||||
// Convertir en entier et créer la couleur
|
||||
return Color(int.parse(fullColorStr, radix: 16));
|
||||
}
|
||||
|
||||
// Centrer la carte sur tous les secteurs
|
||||
void _centerMapOnSectors() {
|
||||
if (_sectors.isEmpty) return;
|
||||
|
||||
// Trouver les limites de tous les secteurs
|
||||
double minLat = 90.0;
|
||||
double maxLat = -90.0;
|
||||
double minLng = 180.0;
|
||||
double maxLng = -180.0;
|
||||
|
||||
for (final sector in _sectors) {
|
||||
final points = sector['points'] as List<LatLng>;
|
||||
for (final point in points) {
|
||||
minLat = point.latitude < minLat ? point.latitude : minLat;
|
||||
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
|
||||
minLng = point.longitude < minLng ? point.longitude : minLng;
|
||||
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles
|
||||
// avec une marge autour (5% de la taille totale)
|
||||
final latPadding = (maxLat - minLat) * 0.05;
|
||||
final lngPadding = (maxLng - minLng) * 0.05;
|
||||
|
||||
minLat -= latPadding;
|
||||
maxLat += latPadding;
|
||||
minLng -= lngPadding;
|
||||
maxLng += lngPadding;
|
||||
|
||||
// Calculer le centre
|
||||
final centerLat = (minLat + maxLat) / 2;
|
||||
final centerLng = (minLng + maxLng) / 2;
|
||||
|
||||
// Calculer le zoom approprié en tenant compte des dimensions de l'écran
|
||||
final mapWidth = MediaQuery.of(context).size.width;
|
||||
final mapHeight = MediaQuery.of(context).size.height *
|
||||
0.7; // Estimation de la hauteur de la carte
|
||||
final zoom = _calculateOptimalZoom(
|
||||
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
|
||||
|
||||
// Centrer la carte sur ces limites avec animation
|
||||
_mapController.move(LatLng(centerLat, centerLng), zoom);
|
||||
|
||||
// Mettre à jour l'état pour refléter la nouvelle position
|
||||
setState(() {
|
||||
_currentPosition = LatLng(centerLat, centerLng);
|
||||
_currentZoom = zoom;
|
||||
});
|
||||
|
||||
debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom');
|
||||
}
|
||||
|
||||
// Mettre à jour les items de la combobox de secteurs
|
||||
void _updateSectorItems() {
|
||||
// Créer l'item "Tous les secteurs"
|
||||
final List<DropdownMenuItem<int?>> items = [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Tous les secteurs'),
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter tous les secteurs
|
||||
for (final sector in _sectors) {
|
||||
items.add(
|
||||
DropdownMenuItem<int?>(
|
||||
value: sector['id'] as int,
|
||||
child: Text(sector['name'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_sectorItems = items;
|
||||
});
|
||||
}
|
||||
|
||||
// Centrer la carte sur un secteur spécifique
|
||||
void _centerMapOnSpecificSector(int sectorId) {
|
||||
final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId);
|
||||
if (sectorIndex == -1) return;
|
||||
|
||||
// Mettre à jour le secteur sélectionné
|
||||
_selectedSectorId = sectorId;
|
||||
|
||||
final sector = _sectors[sectorIndex];
|
||||
final points = sector['points'] as List<LatLng>;
|
||||
final sectorName = sector['name'] as String;
|
||||
|
||||
debugPrint(
|
||||
'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points');
|
||||
|
||||
if (points.isEmpty) {
|
||||
debugPrint('Aucun point dans ce secteur!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Trouver les limites du secteur
|
||||
double minLat = 90.0;
|
||||
double maxLat = -90.0;
|
||||
double minLng = 180.0;
|
||||
double maxLng = -180.0;
|
||||
|
||||
for (final point in points) {
|
||||
minLat = point.latitude < minLat ? point.latitude : minLat;
|
||||
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
|
||||
minLng = point.longitude < minLng ? point.longitude : minLng;
|
||||
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
|
||||
}
|
||||
|
||||
// Vérifier si les coordonnées sont valides
|
||||
if (minLat >= maxLat || minLng >= maxLng) {
|
||||
debugPrint('Coordonnées invalides pour le secteur $sectorName');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer la taille du secteur
|
||||
final latSpan = maxLat - minLat;
|
||||
final lngSpan = maxLng - minLng;
|
||||
|
||||
// Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible
|
||||
final double latPadding, lngPadding;
|
||||
if (latSpan < 0.01 || lngSpan < 0.01) {
|
||||
// Pour les très petits secteurs, utiliser un padding très réduit
|
||||
latPadding = 0.0003;
|
||||
lngPadding = 0.0003;
|
||||
} else if (latSpan < 0.05 || lngSpan < 0.05) {
|
||||
// Pour les petits secteurs, padding réduit
|
||||
latPadding = 0.0005;
|
||||
lngPadding = 0.0005;
|
||||
} else {
|
||||
// Pour les secteurs plus grands, utiliser un pourcentage minimal
|
||||
latPadding = latSpan * 0.03; // 3% au lieu de 10%
|
||||
lngPadding = lngSpan * 0.03;
|
||||
}
|
||||
|
||||
minLat -= latPadding;
|
||||
maxLat += latPadding;
|
||||
minLng -= lngPadding;
|
||||
maxLng += lngPadding;
|
||||
|
||||
// Calculer le centre
|
||||
final centerLat = (minLat + maxLat) / 2;
|
||||
final centerLng = (minLng + maxLng) / 2;
|
||||
|
||||
// Déterminer le zoom approprié en fonction de la taille du secteur
|
||||
double zoom;
|
||||
|
||||
// Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé
|
||||
if (latSpan < 0.01 && lngSpan < 0.01) {
|
||||
zoom = 16.0; // Zoom élevé pour les petits quartiers
|
||||
} else if (latSpan < 0.02 && lngSpan < 0.02) {
|
||||
zoom = 15.0; // Zoom élevé pour les petits quartiers
|
||||
} else if (latSpan < 0.05 && lngSpan < 0.05) {
|
||||
zoom =
|
||||
13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers)
|
||||
} else if (latSpan < 0.1 && lngSpan < 0.1) {
|
||||
zoom = 12.0; // Zoom pour les grands secteurs (ville)
|
||||
} else {
|
||||
// Pour les secteurs plus grands, calculer le zoom
|
||||
final mapWidth = MediaQuery.of(context).size.width;
|
||||
final mapHeight = MediaQuery.of(context).size.height * 0.7;
|
||||
zoom = _calculateOptimalZoom(
|
||||
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
|
||||
}
|
||||
|
||||
// Centrer la carte sur le secteur avec animation
|
||||
_mapController.move(LatLng(centerLat, centerLng), zoom);
|
||||
|
||||
// Mettre à jour l'état pour refléter la nouvelle position
|
||||
setState(() {
|
||||
_currentPosition = LatLng(centerLat, centerLng);
|
||||
_currentZoom = zoom;
|
||||
});
|
||||
|
||||
// Recharger les passages pour appliquer le filtre par secteur
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte
|
||||
double _calculateOptimalZoom(double minLat, double maxLat, double minLng,
|
||||
double maxLng, double mapWidth, double mapHeight) {
|
||||
// Vérifier si les coordonnées sont valides
|
||||
if (minLat >= maxLat || minLng >= maxLng) {
|
||||
debugPrint('Coordonnées invalides pour le calcul du zoom');
|
||||
return 12.0; // Valeur par défaut raisonnable
|
||||
}
|
||||
|
||||
// Calculer la taille en degrés
|
||||
final latSpan = maxLat - minLat;
|
||||
final lngSpan = maxLng - minLng;
|
||||
|
||||
// Ajouter un facteur de sécurité pour éviter les divisions par zéro
|
||||
if (latSpan < 0.0000001 || lngSpan < 0.0000001) {
|
||||
return 15.0; // Zoom élevé pour un point très précis
|
||||
}
|
||||
|
||||
// Formule simplifiée pour le calcul du zoom
|
||||
double zoom;
|
||||
|
||||
if (latSpan < 0.005 || lngSpan < 0.005) {
|
||||
// Très petite zone (quartier)
|
||||
zoom = 16.0;
|
||||
} else if (latSpan < 0.01 || lngSpan < 0.01) {
|
||||
// Petite zone (quartier)
|
||||
zoom = 15.0;
|
||||
} else if (latSpan < 0.02 || lngSpan < 0.02) {
|
||||
// Petite zone (plusieurs quartiers)
|
||||
zoom = 14.0;
|
||||
} else if (latSpan < 0.05 || lngSpan < 0.05) {
|
||||
// Zone moyenne (ville)
|
||||
zoom = 13.0;
|
||||
} else if (latSpan < 0.2 || lngSpan < 0.2) {
|
||||
// Grande zone (agglomération)
|
||||
zoom = 11.0;
|
||||
} else if (latSpan < 0.5 || lngSpan < 0.5) {
|
||||
// Très grande zone (département)
|
||||
zoom = 9.0;
|
||||
} else if (latSpan < 2.0 || lngSpan < 2.0) {
|
||||
// Région
|
||||
zoom = 7.0;
|
||||
} else if (latSpan < 5.0 || lngSpan < 5.0) {
|
||||
// Pays
|
||||
zoom = 5.0;
|
||||
} else {
|
||||
// Continent ou plus
|
||||
zoom = 3.0;
|
||||
}
|
||||
|
||||
return zoom;
|
||||
}
|
||||
|
||||
// Obtenir la position actuelle de l'utilisateur
|
||||
Future<void> _getUserLocation() async {
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Recherche de votre position...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Obtenir la position actuelle via le service de géolocalisation
|
||||
final position = await LocationService.getCurrentPosition();
|
||||
|
||||
if (position != null) {
|
||||
// Mettre à jour la position sur la carte
|
||||
_updateMapPosition(position, zoom: 17);
|
||||
|
||||
// Sauvegarder la nouvelle position
|
||||
_settingsBox.put('admin_mapLat', position.latitude);
|
||||
_settingsBox.put('admin_mapLng', position.longitude);
|
||||
|
||||
// Informer l'utilisateur
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Position actualisée'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Informer l'utilisateur en cas d'échec
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour mettre à jour la position sur la carte
|
||||
void _updateMapPosition(LatLng position, {double? zoom}) {
|
||||
_mapController.move(
|
||||
position,
|
||||
zoom ?? _mapController.camera.zoom,
|
||||
);
|
||||
|
||||
// Mettre à jour les variables d'état
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
if (zoom != null) {
|
||||
_currentZoom = zoom;
|
||||
}
|
||||
});
|
||||
|
||||
// Sauvegarder les paramètres après mise à jour de la position
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
// Méthode pour construire les marqueurs des passages
|
||||
List<Marker> _buildMarkers() {
|
||||
if (_passages.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _passages.map((passage) {
|
||||
final int passageType = passage['type'] as int;
|
||||
final Color color1 =
|
||||
passage['color'] as Color; // couleur1 du type de passage
|
||||
|
||||
// Récupérer la couleur2 du type de passage
|
||||
Color color2 = Colors.white; // Couleur par défaut
|
||||
if (AppKeys.typesPassages.containsKey(passageType)) {
|
||||
final colorValue =
|
||||
AppKeys.typesPassages[passageType]!['couleur2'] as int;
|
||||
color2 = Color(colorValue);
|
||||
}
|
||||
|
||||
return Marker(
|
||||
point: passage['position'] as LatLng,
|
||||
width: 14.0,
|
||||
height: 14.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showPassageInfo(passage);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color1,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: color2,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Méthode pour construire les polygones des secteurs
|
||||
List<Polygon> _buildPolygons() {
|
||||
if (_sectors.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _sectors.map((sector) {
|
||||
final bool isSelected = _selectedSectorId == sector['id'];
|
||||
final Color sectorColor = sector['color'] as Color;
|
||||
|
||||
return Polygon(
|
||||
points: sector['points'] as List<LatLng>,
|
||||
color: isSelected
|
||||
? sectorColor.withOpacity(0.5)
|
||||
: sectorColor.withOpacity(0.3),
|
||||
borderColor: isSelected ? sectorColor : sectorColor.withOpacity(0.8),
|
||||
borderStrokeWidth: isSelected ? 3.0 : 2.0,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Afficher les informations d'un passage lorsqu'on clique dessus
|
||||
void _showPassageInfo(Map<String, dynamic> passage) {
|
||||
final PassageModel passageModel = passage['model'] as PassageModel;
|
||||
final int type = passageModel.fkType;
|
||||
|
||||
// Construire l'adresse complète
|
||||
final String adresse =
|
||||
'${passageModel.numero}, ${passageModel.rueBis} ${passageModel.rue}';
|
||||
|
||||
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
|
||||
String? etageInfo;
|
||||
String? apptInfo;
|
||||
String? residenceInfo;
|
||||
if (passageModel.fkHabitat == 2) {
|
||||
if (passageModel.niveau.isNotEmpty) {
|
||||
etageInfo = 'Etage ${passageModel.niveau}';
|
||||
}
|
||||
if (passageModel.appt.isNotEmpty) {
|
||||
apptInfo = 'appt. ${passageModel.appt}';
|
||||
}
|
||||
if (passageModel.residence.isNotEmpty) {
|
||||
residenceInfo = passageModel.residence;
|
||||
}
|
||||
}
|
||||
|
||||
// Formater la date (uniquement si le type n'est pas 2)
|
||||
String dateInfo = '';
|
||||
if (type != 2) {
|
||||
dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}';
|
||||
}
|
||||
|
||||
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
|
||||
String? nomInfo;
|
||||
if (type != 6 && passageModel.name.isNotEmpty) {
|
||||
nomInfo = passageModel.name;
|
||||
}
|
||||
|
||||
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
|
||||
Widget? reglementInfo;
|
||||
if (type == 1 || type == 5) {
|
||||
final int typeReglementId = passageModel.fkTypeReglement;
|
||||
final String montant = passageModel.montant;
|
||||
|
||||
// Récupérer les informations du type de règlement
|
||||
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
|
||||
final Map<String, dynamic> typeReglement =
|
||||
AppKeys.typesReglements[typeReglementId]!;
|
||||
final String titre = typeReglement['titre'] as String;
|
||||
final Color couleur = Color(typeReglement['couleur'] as int);
|
||||
final IconData iconData = typeReglement['icon_data'] as IconData;
|
||||
|
||||
reglementInfo = Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconData, color: couleur, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('$titre: $montant €',
|
||||
style:
|
||||
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher une bulle d'information
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Adresse: $adresse'),
|
||||
if (residenceInfo != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(residenceInfo)
|
||||
],
|
||||
if (etageInfo != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(etageInfo)
|
||||
],
|
||||
if (apptInfo != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(apptInfo)
|
||||
],
|
||||
if (dateInfo.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(dateInfo)
|
||||
],
|
||||
if (nomInfo != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('Nom: $nomInfo')
|
||||
],
|
||||
if (reglementInfo != null) reglementInfo,
|
||||
],
|
||||
),
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Bouton d'édition
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Logique pour éditer le passage
|
||||
debugPrint('Éditer le passage ${passageModel.id}');
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
color: Colors.blue,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
|
||||
// Bouton de suppression
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Logique pour supprimer le passage
|
||||
debugPrint('Supprimer le passage ${passageModel.id}');
|
||||
},
|
||||
icon: const Icon(Icons.delete),
|
||||
color: Colors.red,
|
||||
tooltip: 'Supprimer',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Bouton de fermeture
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Formater une date
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
// Widget pour les boutons d'action
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
Color color = Colors.blue,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
heroTag: tooltip, // Nécessaire pour éviter les conflits de hero tags
|
||||
onPressed: onPressed,
|
||||
backgroundColor: onPressed != null ? color : Colors.grey,
|
||||
tooltip: tooltip,
|
||||
mini: true,
|
||||
child: Icon(icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte MapBox
|
||||
MapboxMap(
|
||||
initialPosition: _currentPosition,
|
||||
initialZoom: _currentZoom,
|
||||
mapController: _mapController,
|
||||
markers: _buildMarkers(),
|
||||
polygons: _buildPolygons(),
|
||||
showControls: true,
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
_currentPosition = event.camera.center;
|
||||
_currentZoom = event.camera.zoom;
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Bouton Mode édition en haut à droite
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: _buildActionButton(
|
||||
icon: Icons.edit,
|
||||
tooltip: 'Mode édition',
|
||||
color: _editMode ? Colors.green : Colors.blue,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_editMode = !_editMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons d'action sous le bouton Mode édition
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 80, // Positionner sous le bouton Mode édition
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (_editMode) ...[
|
||||
_buildActionButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Ajouter un secteur',
|
||||
onPressed: () {
|
||||
// Action pour ajouter un secteur
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildActionButton(
|
||||
icon: Icons.edit,
|
||||
tooltip: 'Modifier le secteur sélectionné',
|
||||
onPressed: _selectedSectorId != null
|
||||
? () {
|
||||
// Action pour modifier le secteur sélectionné
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildActionButton(
|
||||
icon: Icons.delete,
|
||||
tooltip: 'Supprimer le secteur sélectionné',
|
||||
color: Colors.red,
|
||||
onPressed: _selectedSectorId != null
|
||||
? () {
|
||||
// Action pour supprimer le secteur sélectionné
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton Ma position en bas à droite
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: _buildActionButton(
|
||||
icon: Icons.my_location,
|
||||
tooltip: 'Ma position',
|
||||
onPressed: () {
|
||||
_getUserLocation();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Combobox de sélection de secteurs
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 16,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
width: 220, // Largeur fixe pour accommoder les noms longs
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.location_on, size: 18, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButton<int?>(
|
||||
value: _selectedSectorId,
|
||||
hint: const Text('Tous les secteurs'),
|
||||
isExpanded: true,
|
||||
underline: Container(), // Supprimer la ligne sous le dropdown
|
||||
icon: Icon(Icons.arrow_drop_down, color: Colors.blue),
|
||||
items: _sectorItems,
|
||||
onChanged: (int? sectorId) {
|
||||
setState(() {
|
||||
_selectedSectorId = sectorId;
|
||||
});
|
||||
|
||||
if (sectorId != null) {
|
||||
_centerMapOnSpecificSector(sectorId);
|
||||
} else {
|
||||
// Si "Tous les secteurs" est sélectionné
|
||||
_centerMapOnSectors();
|
||||
// Recharger tous les passages sans filtrage par secteur
|
||||
_loadPassages();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
529
flutt/lib/presentation/admin/admin_statistics_page.dart
Normal file
529
flutt/lib/presentation/admin/admin_statistics_page.dart
Normal file
@@ -0,0 +1,529 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import '../../shared/app_theme.dart';
|
||||
|
||||
class AdminStatisticsPage extends StatefulWidget {
|
||||
const AdminStatisticsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
|
||||
}
|
||||
|
||||
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
// Filtres
|
||||
String _selectedPeriod = 'Jour';
|
||||
String _selectedFilterType = 'Secteur';
|
||||
String _selectedSector = 'Tous';
|
||||
String _selectedUser = 'Tous';
|
||||
int _daysToShow = 15;
|
||||
|
||||
// Liste des périodes et types de filtre
|
||||
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
|
||||
final List<String> _filterTypes = ['Secteur', 'Membre'];
|
||||
|
||||
// Données simulées pour les secteurs et membres (à remplacer par des données réelles)
|
||||
final List<String> _sectors = [
|
||||
'Tous',
|
||||
'Secteur Nord',
|
||||
'Secteur Sud',
|
||||
'Secteur Est',
|
||||
'Secteur Ouest'
|
||||
];
|
||||
final List<String> _members = [
|
||||
'Tous',
|
||||
'Jean Dupont',
|
||||
'Marie Martin',
|
||||
'Pierre Legrand',
|
||||
'Sophie Petit',
|
||||
'Lucas Moreau'
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre et description
|
||||
Text(
|
||||
'Analyse des statistiques',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingS),
|
||||
Text(
|
||||
'Visualisez les statistiques de passages et de collecte pour votre amicale.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Filtres
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
isDesktop
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(child: _buildPeriodDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildDaysDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildFilterTypeDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildFilterDropdown()),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPeriodDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildDaysDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildFilterTypeDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildFilterDropdown(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité principal
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Évolution des passages',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
ActivityChart(
|
||||
height: 350,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
title: '',
|
||||
daysToShow: _daysToShow,
|
||||
periodType: _selectedPeriod,
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
: null,
|
||||
// Si on filtre par secteur, on devrait passer l'ID du secteur
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassagePieChart(
|
||||
size: 300,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: const Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: const Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: const Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
],
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassagePieChart(
|
||||
size: 300,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: const Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: const Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: const Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
],
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique combiné (si disponible)
|
||||
_buildChartCard(
|
||||
'Comparaison passages/montants',
|
||||
const SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Text('Graphique combiné à implémenter'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Actions',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Exporter les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.file_download),
|
||||
label: const Text('Exporter les statistiques'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Imprimer les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text('Imprimer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.buttonSecondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Partager les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Partager'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour la période
|
||||
Widget _buildPeriodDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Période',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedPeriod,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _periods.map((String period) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: period,
|
||||
child: Text(period),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedPeriod = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le nombre de jours
|
||||
Widget _buildDaysDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nombre de jours',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _daysToShow,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: [7, 15, 30, 60, 90, 180, 365].map((int days) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: days,
|
||||
child: Text('$days jours'),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_daysToShow = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le type de filtre
|
||||
Widget _buildFilterTypeDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Filtrer par',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedFilterType,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _filterTypes.map((String type) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: type,
|
||||
child: Text(type),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedFilterType = newValue;
|
||||
// Réinitialiser les filtres spécifiques
|
||||
_selectedSector = 'Tous';
|
||||
_selectedUser = 'Tous';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le filtre spécifique (secteur ou membre)
|
||||
Widget _buildFilterDropdown() {
|
||||
final List<String> items =
|
||||
_selectedFilterType == 'Secteur' ? _sectors : _members;
|
||||
final String value =
|
||||
_selectedFilterType == 'Secteur' ? _selectedSector : _selectedUser;
|
||||
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: _selectedFilterType,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: items.map((String item) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
if (_selectedFilterType == 'Secteur') {
|
||||
_selectedSector = newValue;
|
||||
} else {
|
||||
_selectedUser = newValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Widget pour envelopper un graphique dans une carte
|
||||
Widget _buildChartCard(String title, Widget chart) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
chart,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour obtenir l'ID utilisateur à partir de son nom
|
||||
int? _getUserIdFromName(String name) {
|
||||
// Dans un cas réel, cela nécessiterait une requête au repository
|
||||
// Pour l'exemple, on utilise une correspondance simple
|
||||
if (name == 'Jean Dupont') return 1;
|
||||
if (name == 'Marie Martin') return 2;
|
||||
if (name == 'Pierre Legrand') return 3;
|
||||
if (name == 'Sophie Petit') return 4;
|
||||
if (name == 'Lucas Moreau') return 5;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
640
flutt/lib/presentation/auth/login_page.dart
Normal file
640
flutt/lib/presentation/auth/login_page.dart
Normal file
@@ -0,0 +1,640 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:go_router/src/state.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/core/services/location_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/core/services/auth_service.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
final String? loginType;
|
||||
|
||||
const LoginPage({super.key, this.loginType});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _usernameFocusNode = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
// Type de connexion (utilisateur ou administrateur)
|
||||
String? _loginType;
|
||||
|
||||
// État des permissions de géolocalisation
|
||||
bool _checkingPermission = true;
|
||||
bool _hasLocationPermission = false;
|
||||
String? _locationErrorMessage;
|
||||
|
||||
// État de la connexion Internet
|
||||
bool _isConnected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Récupérer le type de connexion depuis les paramètres du widget
|
||||
_loginType = widget.loginType ?? 'admin'; // Par défaut admin
|
||||
print('DEBUG: LoginType initial depuis widget: $_loginType');
|
||||
|
||||
// Vérifier explicitement si le type est 'user'
|
||||
if (_loginType != null && _loginType!.trim().toLowerCase() == 'user') {
|
||||
_loginType = 'user';
|
||||
print('DEBUG: LoginType confirmé comme user');
|
||||
} else {
|
||||
_loginType = 'admin';
|
||||
print('DEBUG: LoginType confirmé comme admin');
|
||||
}
|
||||
|
||||
// Vérifier les permissions de géolocalisation au démarrage seulement sur mobile
|
||||
if (!kIsWeb) {
|
||||
_checkLocationPermission();
|
||||
} else {
|
||||
// En version web, on considère que les permissions sont accordées
|
||||
setState(() {
|
||||
_checkingPermission = false;
|
||||
_hasLocationPermission = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiser l'état de la connexion
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnected = connectivityService.isConnected;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Pré-remplir le champ username avec l'identifiant du dernier utilisateur connecté
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final users = userRepository.getAllUsers();
|
||||
|
||||
if (users.isNotEmpty) {
|
||||
// Trouver l'utilisateur le plus récent (celui avec la date de dernière connexion la plus récente)
|
||||
users.sort((a, b) => (b.lastSyncedAt).compareTo(a.lastSyncedAt));
|
||||
final lastUser = users.first;
|
||||
|
||||
// Utiliser le username s'il existe, sinon utiliser l'email comme fallback
|
||||
if (lastUser.username != null && lastUser.username!.isNotEmpty) {
|
||||
_usernameController.text = lastUser.username!;
|
||||
// Déplacer le focus sur le champ mot de passe puisque le username est déjà rempli
|
||||
_usernameFocusNode.unfocus();
|
||||
} else if (lastUser.email.isNotEmpty) {
|
||||
_usernameController.text = lastUser.email;
|
||||
_usernameFocusNode.unfocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie les permissions de géolocalisation
|
||||
Future<void> _checkLocationPermission() async {
|
||||
// Ne pas vérifier les permissions en version web
|
||||
if (kIsWeb) {
|
||||
setState(() {
|
||||
_hasLocationPermission = true;
|
||||
_checkingPermission = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_checkingPermission = true;
|
||||
});
|
||||
|
||||
// Vérifier si les services de localisation sont activés et si l'application a la permission
|
||||
final hasPermission = await LocationService.checkAndRequestPermission();
|
||||
final errorMessage = await LocationService.getLocationErrorMessage();
|
||||
|
||||
setState(() {
|
||||
_hasLocationPermission = hasPermission;
|
||||
_locationErrorMessage = errorMessage;
|
||||
_checkingPermission = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_usernameFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Construit l'écran de chargement pendant la vérification des permissions
|
||||
Widget _buildLoadingScreen(ThemeData theme) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/geosector-logo-200.png',
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Vérification des permissions...',
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'écran de demande de permission de géolocalisation
|
||||
Widget _buildLocationPermissionScreen(ThemeData theme) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo et titre
|
||||
Image.asset(
|
||||
'assets/images/geosector-logo-200.png',
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Accès à la localisation requis',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_disabled,
|
||||
color: theme.colorScheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_locationErrorMessage ??
|
||||
'L\'accès à la localisation est nécessaire pour utiliser cette application.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Cette application utilise votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. Sans cette permission, l\'application ne peut pas fonctionner correctement.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Instructions pour activer la localisation
|
||||
Text(
|
||||
'Comment activer la localisation :',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInstructionStep(
|
||||
theme, 1, 'Ouvrez les paramètres de votre appareil'),
|
||||
_buildInstructionStep(theme, 2,
|
||||
'Accédez aux paramètres de confidentialité ou de localisation'),
|
||||
_buildInstructionStep(theme, 3,
|
||||
'Recherchez GEOSECTOR dans la liste des applications'),
|
||||
_buildInstructionStep(theme, 4,
|
||||
'Activez l\'accès à la localisation pour cette application'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons d'action
|
||||
CustomButton(
|
||||
onPressed: () async {
|
||||
// Ouvrir les paramètres de l'application
|
||||
await LocationService.openAppSettings();
|
||||
},
|
||||
text: 'Ouvrir les paramètres de l\'application',
|
||||
icon: Icons.settings,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomButton(
|
||||
onPressed: () async {
|
||||
// Ouvrir les paramètres de localisation
|
||||
await LocationService.openLocationSettings();
|
||||
},
|
||||
text: 'Ouvrir les paramètres de localisation',
|
||||
icon: Icons.location_on,
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomButton(
|
||||
onPressed: () {
|
||||
// Vérifier à nouveau les permissions
|
||||
_checkLocationPermission();
|
||||
},
|
||||
text: 'Vérifier à nouveau',
|
||||
icon: Icons.refresh,
|
||||
backgroundColor: theme.colorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une étape d'instruction pour activer la localisation
|
||||
Widget _buildInstructionStep(
|
||||
ThemeData theme, int stepNumber, String instruction) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
instruction,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
|
||||
|
||||
// Utiliser l'instance globale de userRepository
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
// Afficher l'écran de permission de géolocalisation si l'utilisateur n'a pas accordé la permission (sauf en version web)
|
||||
if (!kIsWeb && _checkingPermission) {
|
||||
return _buildLoadingScreen(theme);
|
||||
} else if (!kIsWeb && !_hasLocationPermission) {
|
||||
return _buildLocationPermissionScreen(theme);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo et titre
|
||||
Image.asset(
|
||||
'assets/images/geosector-logo-200.png',
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
(_loginType != null &&
|
||||
_loginType!.trim().toLowerCase() == 'user')
|
||||
? 'Connexion Utilisateur'
|
||||
: 'Connexion Administrateur',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: (_loginType != null &&
|
||||
_loginType!.trim().toLowerCase() == 'user')
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Ajouter un texte de débogage
|
||||
Text(
|
||||
'Type de connexion détecté: $_loginType',
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Bienvenue sur GEOSECTOR',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Indicateur de connectivité
|
||||
ConnectivityIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Formulaire de connexion
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _usernameController,
|
||||
label: 'Identifiant',
|
||||
hintText: 'Entrez votre identifiant',
|
||||
prefixIcon: Icons.person_outline,
|
||||
keyboardType: TextInputType.text,
|
||||
autofocus: true,
|
||||
focusNode: _usernameFocusNode,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre identifiant';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _passwordController,
|
||||
label: 'Mot de passe',
|
||||
hintText: 'Entrez votre mot de passe',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscurePassword,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre mot de passe';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) async {
|
||||
if (!userRepository.isLoading &&
|
||||
_formKey.currentState!.validate()) {
|
||||
// S'assurer que le type est toujours défini
|
||||
final loginType = _loginType ?? 'admin';
|
||||
final actualType =
|
||||
(loginType.trim().toLowerCase() == 'user')
|
||||
? 'user'
|
||||
: 'admin';
|
||||
print('DEBUG: Login avec type: $actualType');
|
||||
|
||||
final success = await userRepository.login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
type: actualType,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
if (userRepository.isAdmin()) {
|
||||
context.go('/admin');
|
||||
} else {
|
||||
context.go('/user');
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Mot de passe oublié
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page de récupération de mot de passe
|
||||
},
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bouton de connexion
|
||||
CustomButton(
|
||||
onPressed: (userRepository.isLoading || !_isConnected)
|
||||
? null
|
||||
: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Vérifier à nouveau les permissions de géolocalisation avant de se connecter (sauf en version web)
|
||||
if (!kIsWeb) {
|
||||
await _checkLocationPermission();
|
||||
|
||||
// Si l'utilisateur n'a toujours pas accordé les permissions, ne pas continuer
|
||||
if (!_hasLocationPermission) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'L\'accès à la localisation est nécessaire pour utiliser cette application.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier la connexion Internet
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
|
||||
if (!connectivityService.isConnected) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// S'assurer que le type est toujours défini
|
||||
final loginType = _loginType ?? 'admin';
|
||||
final actualType =
|
||||
(loginType.trim().toLowerCase() ==
|
||||
'user')
|
||||
? 'user'
|
||||
: 'admin';
|
||||
print(
|
||||
'DEBUG: Login bouton avec type: $actualType');
|
||||
|
||||
// Utiliser le service d'authentification avec l'overlay de chargement
|
||||
final authService =
|
||||
AuthService(userRepository);
|
||||
final success = await authService.login(
|
||||
context,
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
type: actualType,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
if (userRepository.isAdmin()) {
|
||||
context.go('/admin');
|
||||
} else {
|
||||
context.go('/user');
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
text: _isConnected
|
||||
? 'Se connecter'
|
||||
: 'Connexion Internet requise',
|
||||
isLoading: userRepository.isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Inscription administrateur uniquement
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Pas encore de compte ?',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/register');
|
||||
},
|
||||
child: Text(
|
||||
'Inscription Administrateur',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.tertiary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Lien vers la page publique
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/public');
|
||||
},
|
||||
child: Text(
|
||||
'Retour au site GEOSECTOR',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
flutt/lib/presentation/auth/register_page.dart
Normal file
315
flutt/lib/presentation/auth/register_page.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends State<RegisterPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _amicaleNameController = TextEditingController();
|
||||
final _postalCodeController = TextEditingController();
|
||||
final _cityNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
// État de la connexion Internet
|
||||
bool _isConnected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialiser l'état de la connexion
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
// Utiliser l'instance globale de connectivityService définie dans app.dart
|
||||
setState(() {
|
||||
_isConnected = connectivityService.isConnected;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_amicaleNameController.dispose();
|
||||
_postalCodeController.dispose();
|
||||
_cityNameController.dispose();
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo et titre
|
||||
Image.asset(
|
||||
'assets/images/geosector-logo-200.png',
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Inscription Administrateur',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enregistrez votre amicale sur GeoSector',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Indicateur de connectivité
|
||||
ConnectivityIndicator(
|
||||
onConnectivityChanged: (isConnected) {
|
||||
if (mounted && _isConnected != isConnected) {
|
||||
setState(() {
|
||||
_isConnected = isConnected;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Formulaire d'inscription
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: 'Nom complet',
|
||||
hintText: 'Entrez votre nom complet',
|
||||
prefixIcon: Icons.person_outline,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre nom complet';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email',
|
||||
hintText: 'Entrez votre email',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _amicaleNameController,
|
||||
label: 'Nom de l\'amicale',
|
||||
hintText: 'Entrez le nom de votre amicale',
|
||||
prefixIcon: Icons.local_fire_department,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer le nom de votre amicale';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _postalCodeController,
|
||||
label: 'Code postal de l\'amicale',
|
||||
hintText: 'Entrez le code postal de votre amicale',
|
||||
prefixIcon: Icons.location_on_outlined,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre code postal';
|
||||
}
|
||||
if (!RegExp(r'^[0-9]{5}$').hasMatch(value)) {
|
||||
return 'Le code postal doit contenir 5 chiffres';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _cityNameController,
|
||||
label: 'Commune de l\'amicale',
|
||||
hintText:
|
||||
'Entrez le nom de la commune de votre amicale',
|
||||
prefixIcon: Icons.location_city_outlined,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer le nom de la commune de votre amicale';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton d'inscription
|
||||
CustomButton(
|
||||
onPressed: (userRepository.isLoading || !_isConnected)
|
||||
? null
|
||||
: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Vérifier la connexion Internet avant de soumettre
|
||||
// Utiliser l'instance globale de connectivityService définie dans app.dart
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
|
||||
if (!connectivityService.isConnected) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. L\'inscription nécessite une connexion active.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration:
|
||||
const Duration(seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final success =
|
||||
await userRepository.register(
|
||||
_emailController.text.trim(),
|
||||
'', // Mot de passe vide, sera généré par le serveur
|
||||
_nameController.text.trim(),
|
||||
_amicaleNameController.text.trim(),
|
||||
_postalCodeController.text,
|
||||
_cityNameController.text.trim(),
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
context.go('/user');
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Échec de l\'inscription. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
text: _isConnected
|
||||
? 'Enregistrer mon amicale'
|
||||
: 'Connexion Internet requise',
|
||||
isLoading: userRepository.isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Déjà un compte
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Déjà un compte ?',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/login');
|
||||
},
|
||||
child: Text(
|
||||
'Se connecter',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Lien vers la page publique
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/public');
|
||||
},
|
||||
child: Text(
|
||||
'Revenir sur le site GEOSECTOR',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
223
flutt/lib/presentation/auth/splash_page.dart
Normal file
223
flutt/lib/presentation/auth/splash_page.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'dart:async';
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
bool _isInitializing = true;
|
||||
String _statusMessage = "Initialisation...";
|
||||
double _progress = 0.0;
|
||||
|
||||
final List<String> _initializationSteps = [
|
||||
"Initialisation des services...",
|
||||
"Vérification de l'authentification...",
|
||||
"Chargement des données...",
|
||||
"Préparation de l'interface...",
|
||||
"Démarrage de GeoSector..."
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
// Simuler le processus d'initialisation
|
||||
_startInitialization();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startInitialization() async {
|
||||
// Simuler les étapes d'initialisation
|
||||
for (int i = 0; i < _initializationSteps.length; i++) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[i];
|
||||
_progress = (i + 1) / _initializationSteps.length;
|
||||
});
|
||||
}
|
||||
// Attendre pour simuler le chargement
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
});
|
||||
|
||||
// Lancer l'animation finale
|
||||
_animationController.forward();
|
||||
|
||||
// Attendre la fin de l'animation avant de rediriger
|
||||
Timer(const Duration(milliseconds: 1500), () {
|
||||
_redirectToAppropriateScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectToAppropriateScreen() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
if (userRepository.isLoggedIn) {
|
||||
if (userRepository.isAdmin()) {
|
||||
context.go('/admin');
|
||||
} else {
|
||||
context.go('/user');
|
||||
}
|
||||
} else {
|
||||
context.go('/public');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.primary.withOpacity(0.8),
|
||||
theme.colorScheme.secondary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo animé
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
height: _isInitializing ? size.height * 0.3 : size.height * 0.35,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isInitializing ? 0.8 : 1.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: AnimatedScale(
|
||||
scale: _isInitializing ? 0.9 : 1.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.elasticOut,
|
||||
child: Image.asset(
|
||||
'assets/images/geosector-logo-200.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Titre avec animation fade-in
|
||||
AnimatedOpacity(
|
||||
opacity: _isInitializing ? 0.9 : 1.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Text(
|
||||
'GeoSector',
|
||||
style: theme.textTheme.headlineLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
offset: const Offset(2, 2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Message de bienvenue
|
||||
AnimatedOpacity(
|
||||
opacity: _isInitializing ? 0.8 : 1.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Text(
|
||||
'Bienvenue sur GEOSECTOR',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Indicateur de chargement
|
||||
if (_isInitializing) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: Colors.white.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.colorScheme.tertiary,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_statusMessage,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Animation de succès quand l'initialisation est terminée
|
||||
ScaleTransition(
|
||||
scale: CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.elasticOut),
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1296
flutt/lib/presentation/public/landing_page.dart
Normal file
1296
flutt/lib/presentation/public/landing_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
262
flutt/lib/presentation/user/user_communication_page.dart
Normal file
262
flutt/lib/presentation/user/user_communication_page.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/chat/widgets/conversations_list.dart';
|
||||
import 'package:geosector_app/chat/widgets/chat_screen.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
|
||||
class UserCommunicationPage extends StatefulWidget {
|
||||
const UserCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserCommunicationPage> createState() => _UserCommunicationPageState();
|
||||
}
|
||||
|
||||
class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
String? _selectedConversationId;
|
||||
late Box<ConversationModel> _conversationsBox;
|
||||
bool _hasConversations = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkConversations();
|
||||
}
|
||||
|
||||
Future<void> _checkConversations() async {
|
||||
try {
|
||||
_conversationsBox = Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
setState(() {
|
||||
_hasConversations = _conversationsBox.values.isNotEmpty;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête du chat
|
||||
Container(
|
||||
height: 70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.05),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 26,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Messages d\'équipe',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_hasConversations) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'5 en ligne',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.secondaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconSize: 28,
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: () {
|
||||
// TODO: Créer une nouvelle conversation
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: _hasConversations
|
||||
? Row(
|
||||
children: [
|
||||
// Liste des conversations (gauche)
|
||||
Container(
|
||||
width: 320,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ConversationsList(
|
||||
onConversationSelected: (conversation) {
|
||||
setState(() {
|
||||
// TODO: obtenir l'ID de la conversation à partir de l'objet conversation
|
||||
_selectedConversationId = 'test-conversation-id';
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Zone de conversation (droite)
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: _selectedConversationId != null
|
||||
? ChatScreen(conversationId: _selectedConversationId!)
|
||||
: _buildEmptyState(theme),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildNoConversationsMessage(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ThemeData theme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Sélectionnez une conversation',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choisissez une conversation dans la liste\npour commencer à discuter',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoConversationsMessage(ThemeData theme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 100,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune conversation',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'avez pas encore de conversations.\nCommencez une discussion avec votre équipe !',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Créer une nouvelle conversation
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Démarrer une conversation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
656
flutt/lib/presentation/user/user_dashboard_home_page.dart
Normal file
656
flutt/lib/presentation/user/user_dashboard_home_page.dart
Normal file
@@ -0,0 +1,656 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
|
||||
class UserDashboardHomePage extends StatefulWidget {
|
||||
const UserDashboardHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<UserDashboardHomePage> createState() => _UserDashboardHomePageState();
|
||||
}
|
||||
|
||||
class _UserDashboardHomePageState extends State<UserDashboardHomePage> {
|
||||
// Formater une date au format JJ/MM/YYYY
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Tableau de bord',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
// Récupérer l'opération actuelle
|
||||
final operation = userRepository.getCurrentOperation();
|
||||
if (operation != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'${operation.name} (${_formatDate(operation.dateDebut)}-${_formatDate(operation.dateFin)})',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Synthèse des passages
|
||||
_buildSummaryCards(isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphique des passages
|
||||
_buildPassagesChart(context, theme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Derniers passages
|
||||
_buildRecentPassages(context, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des cartes de synthèse
|
||||
Widget _buildSummaryCards(bool isDesktop) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildCombinedPassagesCard(context, isDesktop),
|
||||
const SizedBox(height: 16),
|
||||
_buildCombinedPaymentsCard(isDesktop),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les règlements (liste + graphique)
|
||||
Widget _buildCombinedPaymentsCard(bool isDesktop) {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Pas de log ici pour éviter les logs excessifs
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Compteur pour les passages avec montant > 0
|
||||
int passagesWithPaymentCount = 0;
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (0: Pas de règlement)
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
// Type de règlement inconnu, ajouté à la catégorie 'Pas de règlement'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le total des règlements
|
||||
final double totalPayments =
|
||||
paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
|
||||
|
||||
// Convertir les montants en objets PaymentData pour le graphique
|
||||
final List<PaymentData> paymentDataList =
|
||||
PaymentUtils.getPaymentDataFromAmounts(paymentAmounts);
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Symbole euro en arrière-plan
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.euro_symbol,
|
||||
size: 180,
|
||||
color: Colors.blue.withOpacity(0.07), // Bleuté et estompé
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payments,
|
||||
color: AppTheme.accentColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Règlements sur $passagesWithPaymentCount passages',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalPayments.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des règlements (côté gauche)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesReglements.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData = entry.value;
|
||||
final double amount =
|
||||
paymentAmounts[typeId] ?? 0.0;
|
||||
final Color color =
|
||||
Color(typeData['couleur'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
typeData['icon_data'] as IconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titre'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${amount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert (côté droit)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: PaymentPieChart(
|
||||
payments: paymentDataList,
|
||||
size: double
|
||||
.infinity, // Utiliser tout l'espace disponible
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
enable3DEffect: true, // Activer l'effet 3D
|
||||
effect3DIntensity:
|
||||
1.5, // Intensité de l'effet 3D plus forte
|
||||
enableEnhancedExplode:
|
||||
true, // Activer l'effet d'explosion amélioré
|
||||
useGradient:
|
||||
true, // Utiliser des dégradés pour renforcer l'effet 3D
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les passages (liste + graphique)
|
||||
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Pas de log ici pour éviter les logs excessifs
|
||||
|
||||
// Compter les passages par type
|
||||
final Map<int, int> passagesCounts = {
|
||||
1: 0, // Effectués
|
||||
2: 0, // À finaliser
|
||||
3: 0, // Refusés
|
||||
4: 0, // Dons
|
||||
5: 0, // Lots
|
||||
6: 0, // Maisons vides
|
||||
};
|
||||
|
||||
// Créer un map pour compter les types de passages
|
||||
final Map<int, int> typesCount = {};
|
||||
final Map<int, int> userTypesCount = {};
|
||||
|
||||
// Parcourir les passages et les compter par type
|
||||
for (final passage in passages) {
|
||||
final typeId = passage.fkType;
|
||||
final int passageUserId = passage.fkUser;
|
||||
|
||||
// Compter les occurrences de chaque type pour le débogage
|
||||
typesCount[typeId] = (typesCount[typeId] ?? 0) + 1;
|
||||
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel ou est de type 2
|
||||
bool shouldCount = typeId == 2 ||
|
||||
(currentUserId != null && passageUserId == currentUserId);
|
||||
|
||||
if (shouldCount) {
|
||||
// Compter pour les statistiques de l'utilisateur
|
||||
userTypesCount[typeId] = (userTypesCount[typeId] ?? 0) + 1;
|
||||
|
||||
// Ajouter au compteur des passages par type
|
||||
if (passagesCounts.containsKey(typeId)) {
|
||||
passagesCounts[typeId] = passagesCounts[typeId]! + 1;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (2: À finaliser)
|
||||
passagesCounts[2] = passagesCounts[2]! + 1;
|
||||
// Type de passage inconnu ajouté à 'A finaliser'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas de log ici pour éviter les logs excessifs
|
||||
|
||||
// Calculer le total des passages pour l'utilisateur (somme des valeurs dans userTypesCount)
|
||||
final int totalUserPassages =
|
||||
userTypesCount.values.fold(0, (sum, count) => sum + count);
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0,
|
||||
8.0), // Réduire les paddings vertical pour donner plus d'espace au graphique
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.route,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Builder(builder: (context) {
|
||||
// Récupérer les secteurs de l'utilisateur
|
||||
final userSectors = userRepository.getUserSectors();
|
||||
final int sectorCount = userSectors.length;
|
||||
|
||||
// Déterminer le titre en fonction du nombre de secteurs
|
||||
String title = 'Passages';
|
||||
if (sectorCount > 1) {
|
||||
title = 'Passages sur mes $sectorCount secteurs';
|
||||
}
|
||||
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Text(
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des passages (côté gauche)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData = entry.value;
|
||||
final int count = passagesCounts[typeId] ?? 0;
|
||||
final Color color = Color(typeData['couleur2']
|
||||
as int); // Utiliser la deuxième couleur
|
||||
final IconData iconData = typeData['icon_data']
|
||||
as IconData; // Utiliser l'icône définie dans AppKeys
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titres'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert (côté droit)
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
passagesByType: passagesCounts,
|
||||
size: double
|
||||
.infinity, // Utiliser tout l'espace disponible
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
showLegend: false, // Désactiver la légende
|
||||
isDonut: true, // Activer le format donut
|
||||
innerRadius: '50%' // Rayon interne du donut
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du graphique des passages
|
||||
Widget _buildPassagesChart(BuildContext context, ThemeData theme) {
|
||||
// Définir les types de passages à exclure
|
||||
// Selon la mémoire, le type 2 correspond aux passages "A finaliser"
|
||||
// et nous voulons les exclure du comptage pour l'utilisateur actuel
|
||||
final List<int> excludePassageTypes = [2];
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0,
|
||||
8.0), // Réduire les paddings vertical pour donner plus d'espace
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre supprimé car déjà présent dans le widget ActivityChart
|
||||
SizedBox(
|
||||
height:
|
||||
350, // Augmentation de la hauteur à 350px pour résoudre le problème de l'axe Y
|
||||
child: ActivityChart(
|
||||
// Utiliser le chargement depuis Hive directement dans le widget
|
||||
loadFromHive: true,
|
||||
// Ne pas filtrer par utilisateur (afficher tous les passages)
|
||||
showAllPassages: true,
|
||||
// Exclure les passages de type 2 (A finaliser)
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
// Afficher les 15 derniers jours
|
||||
daysToShow: 15,
|
||||
periodType: 'Jour',
|
||||
height:
|
||||
350, // Augmentation de la hauteur à 350px aussi dans le widget
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction de la liste des derniers passages
|
||||
Widget _buildRecentPassages(BuildContext context, ThemeData theme) {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Récupérer tous les passages et les trier par date (les plus récents d'abord)
|
||||
final allPassages = passageRepository.getAllPassages();
|
||||
allPassages.sort((a, b) => b.passedAt.compareTo(a.passedAt));
|
||||
|
||||
// Limiter aux 10 passages les plus récents
|
||||
final recentPassagesModels = allPassages.take(10).toList();
|
||||
|
||||
// Convertir les modèles de passage au format attendu par le widget PassagesListWidget
|
||||
final List<Map<String, dynamic>> recentPassages =
|
||||
recentPassagesModels.map((passage) {
|
||||
// Construire l'adresse complète à partir des champs disponibles
|
||||
final String address =
|
||||
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
|
||||
|
||||
// Convertir le montant en double
|
||||
final double amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
|
||||
return {
|
||||
'id': passage.id.toString(),
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': passage.passedAt,
|
||||
'type': passage.fkType,
|
||||
'payment': passage.fkTypeReglement,
|
||||
'name': passage.name,
|
||||
'notes': passage.remarque,
|
||||
'hasReceipt': passage.nomRecu.isNotEmpty,
|
||||
'hasError': passage.emailErreur.isNotEmpty,
|
||||
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Derniers passages',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page d'historique
|
||||
},
|
||||
child: const Text('Voir tout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Utilisation du widget commun PassagesListWidget
|
||||
PassagesListWidget(
|
||||
passages: recentPassages,
|
||||
showFilters: false,
|
||||
showSearch: false,
|
||||
showActions: true, // Activer l'affichage des boutons d'action
|
||||
maxPassages: 10,
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtrer par utilisateur courant
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
// Période par défaut (derniers 15 jours)
|
||||
periodFilter: 'last15',
|
||||
onPassageSelected: (passage) {
|
||||
// Action lors de la sélection d'un passage
|
||||
debugPrint('Passage sélectionné: ${passage['id']}');
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
// Action lors de l'affichage des détails
|
||||
debugPrint('Affichage des détails: ${passage['id']}');
|
||||
},
|
||||
// Callback pour le bouton de modification
|
||||
onPassageEdit: (passage) {
|
||||
// Action lors de la modification d'un passage
|
||||
debugPrint('Modification du passage: ${passage['id']}');
|
||||
// Ici, vous pourriez ouvrir un formulaire d'édition
|
||||
},
|
||||
// Callback pour le bouton de reçu (uniquement pour les passages de type 1)
|
||||
onReceiptView: (passage) {
|
||||
// Action lors de la demande d'affichage du reçu
|
||||
debugPrint('Affichage du reçu pour le passage: ${passage['id']}');
|
||||
// Ici, vous pourriez générer et afficher un PDF
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
381
flutt/lib/presentation/user/user_dashboard_page.dart
Normal file
381
flutt/lib/presentation/user/user_dashboard_page.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/auth_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
|
||||
// Import des pages utilisateur
|
||||
import 'user_dashboard_home_page.dart';
|
||||
import 'user_statistics_page.dart';
|
||||
import 'user_history_page.dart';
|
||||
import 'user_communication_page.dart';
|
||||
import 'user_map_page.dart';
|
||||
|
||||
class UserDashboardPage extends StatefulWidget {
|
||||
const UserDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserDashboardPage> createState() => _UserDashboardPageState();
|
||||
}
|
||||
|
||||
class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pages = [
|
||||
const UserDashboardHomePage(),
|
||||
const UserStatisticsPage(),
|
||||
const UserHistoryPage(),
|
||||
const UserCommunicationPage(),
|
||||
const UserMapPage(),
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
}
|
||||
|
||||
// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
try {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger l'index de page sélectionné
|
||||
final savedIndex = _settingsBox.get('selectedPageIndex');
|
||||
if (savedIndex != null &&
|
||||
savedIndex is int &&
|
||||
savedIndex >= 0 &&
|
||||
savedIndex < _pages.length) {
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres utilisateur
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'index de page sélectionné
|
||||
_settingsBox.put('selectedPageIndex', _selectedIndex);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final hasOperation = userRepository.getCurrentOperation() != null;
|
||||
final hasSectors = userRepository.getUserSectors().isNotEmpty;
|
||||
final isStandardUser = userRepository.currentUser != null &&
|
||||
userRepository.currentUser!.role ==
|
||||
'1'; // Rôle 1 = utilisateur standard
|
||||
|
||||
// Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial
|
||||
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
|
||||
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
|
||||
|
||||
// Définir les actions supplémentaires pour l'AppBar
|
||||
List<Widget>? additionalActions;
|
||||
if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) {
|
||||
additionalActions = [
|
||||
// Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.logout, color: Colors.white),
|
||||
label: const Text('Se déconnecter',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: () async {
|
||||
final authService = AuthService(userRepository);
|
||||
await authService.logout(context);
|
||||
if (mounted) {
|
||||
context.go('/login');
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Espacement à droite
|
||||
];
|
||||
}
|
||||
|
||||
return shouldShowNoOperationMessage
|
||||
? _buildNoOperationMessage(context)
|
||||
: (shouldShowNoSectorMessage
|
||||
? _buildNoSectorMessage(context)
|
||||
: DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Accueil',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
],
|
||||
additionalActions: additionalActions,
|
||||
onNewPassagePressed: () => _showPassageForm(context),
|
||||
body: _pages[_selectedIndex],
|
||||
));
|
||||
}
|
||||
|
||||
// Message pour les utilisateurs sans opération assignée
|
||||
Widget _buildNoOperationMessage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 80,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune opération assignée',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Message pour les utilisateurs sans secteur assigné
|
||||
Widget _buildNoSectorMessage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.map_outlined,
|
||||
size: 80,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucun secteur assigné',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Affiche le formulaire de passage
|
||||
void _showPassageForm(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
'Nouveau passage',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Adresse',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type de passage',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text('Effectué'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 2,
|
||||
child: Text('À finaliser'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 3,
|
||||
child: Text('Refusé'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 4,
|
||||
child: Text('Don'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 5,
|
||||
child: Text('Lot'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 6,
|
||||
child: Text('Maison vide'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Commentaire',
|
||||
prefixIcon: const Icon(Icons.comment),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Enregistrer le passage
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Passage enregistré avec succès'),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
572
flutt/lib/presentation/user/user_history_page.dart
Normal file
572
flutt/lib/presentation/user/user_history_page.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
class UserHistoryPage extends StatefulWidget {
|
||||
const UserHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserHistoryPage> createState() => _UserHistoryPageState();
|
||||
}
|
||||
|
||||
class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
// Liste qui contiendra les passages convertis
|
||||
List<Map<String, dynamic>> _convertedPassages = [];
|
||||
|
||||
// Variables pour indiquer l'état de chargement
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger les passages depuis la box Hive au démarrage
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Méthode pour charger les passages depuis le repository
|
||||
Future<void> _loadPassages() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Utiliser la propriété passages qui gère déjà l'ouverture de la box
|
||||
final List<PassageModel> allPassages = passageRepository.passages;
|
||||
|
||||
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
|
||||
|
||||
// Filtrer pour exclure les passages de type 2
|
||||
List<PassageModel> filtered = [];
|
||||
for (var passage in allPassages) {
|
||||
try {
|
||||
if (passage.fkType != 2) {
|
||||
filtered.add(passage);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du filtrage du passage: $e');
|
||||
// Si nous ne pouvons pas accéder à fkType, ne pas ajouter ce passage
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Nombre de passages après filtrage (fkType != 2): ${filtered.length}');
|
||||
|
||||
// Afficher la distribution des types de passages pour le débogage
|
||||
final Map<int, int> typeCount = {};
|
||||
for (var passage in filtered) {
|
||||
typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
typeCount.forEach((type, count) {
|
||||
debugPrint('Type de passage $type: $count passages');
|
||||
});
|
||||
|
||||
// Afficher la plage de dates pour le débogage
|
||||
if (filtered.isNotEmpty) {
|
||||
// Trier par date pour trouver min et max
|
||||
final sortedByDate = List<PassageModel>.from(filtered);
|
||||
sortedByDate.sort((a, b) => a.passedAt.compareTo(b.passedAt));
|
||||
|
||||
final DateTime minDate = sortedByDate.first.passedAt;
|
||||
final DateTime maxDate = sortedByDate.last.passedAt;
|
||||
|
||||
// Log détaillé pour débogage
|
||||
debugPrint(
|
||||
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
|
||||
|
||||
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
|
||||
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
|
||||
for (int i = sortedByDate.length - 1;
|
||||
i >= 0 && i >= sortedByDate.length - 5;
|
||||
i--) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
// Vérifier la distribution des passages par mois
|
||||
final Map<String, int> monthCount = {};
|
||||
for (var passage in filtered) {
|
||||
final String monthKey =
|
||||
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}';
|
||||
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
|
||||
final sortedMonths = monthCount.keys.toList()..sort();
|
||||
for (var month in sortedMonths) {
|
||||
debugPrint('$month: ${monthCount[month]} passages');
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir les modèles en Maps pour l'affichage avec gestion d'erreurs
|
||||
List<Map<String, dynamic>> passagesMap = [];
|
||||
for (var passage in filtered) {
|
||||
try {
|
||||
final Map<String, dynamic> passageMap =
|
||||
_convertPassageModelToMap(passage);
|
||||
if (passageMap != null) {
|
||||
passagesMap.add(passageMap);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la conversion du passage en map: $e');
|
||||
// Ignorer ce passage et continuer
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
|
||||
|
||||
// Trier par date (plus récent en premier) avec gestion d'erreurs
|
||||
try {
|
||||
passagesMap.sort((a, b) {
|
||||
try {
|
||||
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la comparaison des dates: $e');
|
||||
return 0; // Garder l'ordre actuel en cas d'erreur
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du tri des passages: $e');
|
||||
// Continuer sans tri en cas d'erreur
|
||||
}
|
||||
|
||||
// Debug: vérifier la plage de dates après conversion et tri
|
||||
if (passagesMap.isNotEmpty) {
|
||||
debugPrint('\n--- PLAGE DE DATES APRÈS CONVERSION ET TRI ---');
|
||||
final firstDate = passagesMap.last['date'] as DateTime;
|
||||
final lastDate = passagesMap.first['date'] as DateTime;
|
||||
debugPrint('Premier passage: ${firstDate.toString()}');
|
||||
debugPrint('Dernier passage: ${lastDate.toString()}');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_convertedPassages = passagesMap;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors du chargement des passages: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
debugPrint(_errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir un modèle de passage en Map pour l'affichage avec gestion renforcée des erreurs
|
||||
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
|
||||
try {
|
||||
// Le passage ne peut pas être null en Dart non-nullable,
|
||||
// mais nous gardons cette structure pour faciliter la gestion des erreurs
|
||||
|
||||
// Construire l'adresse complète avec gestion des erreurs
|
||||
String address = 'Adresse non disponible';
|
||||
try {
|
||||
address = _buildFullAddress(passage);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la construction de l\'adresse: $e');
|
||||
}
|
||||
|
||||
// Convertir le montant en double avec sécurité
|
||||
double amount = 0.0;
|
||||
try {
|
||||
if (passage.montant.isNotEmpty) {
|
||||
amount = double.parse(passage.montant);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}: $e');
|
||||
}
|
||||
|
||||
// Récupérer la date avec gestion d'erreur
|
||||
DateTime date;
|
||||
try {
|
||||
date = passage.passedAt;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la date: $e');
|
||||
date = DateTime.now();
|
||||
}
|
||||
|
||||
// Récupérer le type avec gestion d'erreur
|
||||
int type;
|
||||
try {
|
||||
type = passage.fkType;
|
||||
// Si le type n'est pas dans les types connus, utiliser 0 comme valeur par défaut
|
||||
if (!AppKeys.typesPassages.containsKey(type)) {
|
||||
type = 0; // Type inconnu
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type: $e');
|
||||
type = 0;
|
||||
}
|
||||
|
||||
// Récupérer le type de règlement avec gestion d'erreur
|
||||
int payment;
|
||||
try {
|
||||
payment = passage.fkTypeReglement;
|
||||
// Si le type de règlement n'est pas dans les types connus, utiliser 0 comme valeur par défaut
|
||||
if (!AppKeys.typesReglements.containsKey(payment)) {
|
||||
payment = 0; // Type de règlement inconnu
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type de règlement: $e');
|
||||
payment = 0;
|
||||
}
|
||||
|
||||
// Gérer les champs optionnels
|
||||
String name = '';
|
||||
try {
|
||||
name = passage.name;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du nom: $e');
|
||||
}
|
||||
|
||||
String notes = '';
|
||||
try {
|
||||
notes = passage.remarque;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des remarques: $e');
|
||||
}
|
||||
|
||||
// Vérifier si un reçu est disponible avec gestion d'erreur
|
||||
bool hasReceipt = false;
|
||||
try {
|
||||
hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification du reçu: $e');
|
||||
}
|
||||
|
||||
// Vérifier s'il y a une erreur avec gestion d'erreur
|
||||
bool hasError = false;
|
||||
try {
|
||||
hasError = passage.emailErreur.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des erreurs: $e');
|
||||
}
|
||||
|
||||
// Log pour débogage
|
||||
debugPrint(
|
||||
'Conversion passage ID: ${passage.id}, Type: $type, Date: $date');
|
||||
|
||||
return {
|
||||
'id': passage.id.toString(),
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': date,
|
||||
'type': type,
|
||||
'payment': payment,
|
||||
'name': name,
|
||||
'notes': notes,
|
||||
'hasReceipt': hasReceipt,
|
||||
'hasError': hasError,
|
||||
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE lors de la conversion du passage: $e');
|
||||
// Retourner un objet valide par défaut pour éviter les erreurs
|
||||
// Récupérer l'ID de l'utilisateur courant pour l'objet par défaut
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
|
||||
return {
|
||||
'id': 'error',
|
||||
'address': 'Adresse non disponible',
|
||||
'amount': 0.0,
|
||||
'date': DateTime.now(),
|
||||
'type': 0,
|
||||
'payment': 0,
|
||||
'name': 'Nom non disponible',
|
||||
'notes': '',
|
||||
'hasReceipt': false,
|
||||
'hasError': true,
|
||||
'fkUser': currentUserId, // Ajouter l'ID de l'utilisateur courant
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Construire l'adresse complète à partir des composants
|
||||
String _buildFullAddress(PassageModel passage) {
|
||||
final List<String> addressParts = [];
|
||||
|
||||
// Numéro et rue
|
||||
if (passage.numero.isNotEmpty) {
|
||||
addressParts.add('${passage.numero} ${passage.rue}');
|
||||
} else {
|
||||
addressParts.add(passage.rue);
|
||||
}
|
||||
|
||||
// Complément rue bis
|
||||
if (passage.rueBis.isNotEmpty) {
|
||||
addressParts.add(passage.rueBis);
|
||||
}
|
||||
|
||||
// Résidence/Bâtiment
|
||||
if (passage.residence.isNotEmpty) {
|
||||
addressParts.add(passage.residence);
|
||||
}
|
||||
|
||||
// Appartement
|
||||
if (passage.appt.isNotEmpty) {
|
||||
addressParts.add('Appt ${passage.appt}');
|
||||
}
|
||||
|
||||
// Niveau
|
||||
if (passage.niveau.isNotEmpty) {
|
||||
addressParts.add('Niveau ${passage.niveau}');
|
||||
}
|
||||
|
||||
// Ville
|
||||
if (passage.ville.isNotEmpty) {
|
||||
addressParts.add(passage.ville);
|
||||
}
|
||||
|
||||
return addressParts.join(', ');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour afficher les détails d'un passage
|
||||
void _showPassageDetails(Map<String, dynamic> passage) {
|
||||
// Récupérer les informations du type de passage et du type de règlement
|
||||
final typePassage =
|
||||
AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
|
||||
final typeReglement =
|
||||
AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Détails du passage'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('Adresse', passage['address']),
|
||||
_buildDetailRow('Nom', passage['name']),
|
||||
_buildDetailRow('Date',
|
||||
'${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'),
|
||||
_buildDetailRow('Type', typePassage['titre']),
|
||||
_buildDetailRow('Règlement', typeReglement['titre']),
|
||||
_buildDetailRow('Montant', '${passage['amount']}€'),
|
||||
if (passage['notes'] != null &&
|
||||
passage['notes'].toString().isNotEmpty)
|
||||
_buildDetailRow('Notes', passage['notes']),
|
||||
if (passage['hasReceipt'] == true)
|
||||
_buildDetailRow('Reçu', 'Disponible'),
|
||||
if (passage['hasError'] == true)
|
||||
_buildDetailRow('Erreur', 'Détectée', isError: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Fermer'),
|
||||
),
|
||||
if (passage['hasReceipt'] == true)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showReceipt(passage);
|
||||
},
|
||||
child: Text('Voir le reçu'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_editPassage(passage);
|
||||
},
|
||||
child: Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour éditer un passage
|
||||
void _editPassage(Map<String, dynamic> passage) {
|
||||
// Implémenter l'ouverture d'un formulaire d'édition
|
||||
// Cette méthode pourrait naviguer vers une page d'édition
|
||||
debugPrint('Édition du passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => EditPassagePage(passage: passage)));
|
||||
}
|
||||
|
||||
// Méthode pour afficher un reçu
|
||||
void _showReceipt(Map<String, dynamic> passage) {
|
||||
// Implémenter l'affichage ou la génération d'un reçu
|
||||
// Cette méthode pourrait générer un PDF et l'afficher
|
||||
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReceiptPage(passage: passage)));
|
||||
}
|
||||
|
||||
// Helper pour construire une ligne de détails
|
||||
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text('$label:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: isError ? TextStyle(color: Colors.red) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Variable pour gérer la recherche
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec bouton de rafraîchissement
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Historique des passages',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadPassages,
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Affichage du chargement ou des erreurs
|
||||
if (_isLoading)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_errorMessage.isNotEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: theme.textTheme.titleLarge
|
||||
?.copyWith(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_errorMessage),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadPassages,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
// Utilisation du widget PassagesListWidget pour afficher la liste des passages
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
// Stat rapide pour l'utilisateur
|
||||
if (_convertedPassages.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'${_convertedPassages.length} passages au total (${_convertedPassages.where((p) => (p['date'] as DateTime).isAfter(DateTime(2024, 12, 13))).length} de décembre 2024)',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
// Widget de liste des passages
|
||||
Expanded(
|
||||
child: PassagesListWidget(
|
||||
passages: _convertedPassages,
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showActions: true,
|
||||
initialSearchQuery: _searchQuery,
|
||||
initialTypeFilter:
|
||||
'Tous', // Toujours commencer avec 'Tous' pour voir tous les types
|
||||
initialPaymentFilter: 'Tous',
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtrer par utilisateur courant
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
// Désactiver les filtres de date implicites
|
||||
key: ValueKey(
|
||||
'passages_list_${DateTime.now().millisecondsSinceEpoch}'),
|
||||
onPassageSelected: (passage) {
|
||||
// Action lors de la sélection d'un passage
|
||||
debugPrint('Passage sélectionné: ${passage['id']}');
|
||||
_showPassageDetails(passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
// Action lors de l'affichage des détails
|
||||
debugPrint('Affichage des détails: ${passage['id']}');
|
||||
_showPassageDetails(passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action lors de la modification d'un passage
|
||||
debugPrint('Modification du passage: ${passage['id']}');
|
||||
_editPassage(passage);
|
||||
},
|
||||
onReceiptView: (passage) {
|
||||
// Action lors de la demande d'affichage du reçu
|
||||
debugPrint(
|
||||
'Affichage du reçu pour le passage: ${passage['id']}');
|
||||
_showReceipt(passage);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1084
flutt/lib/presentation/user/user_map_page.dart
Normal file
1084
flutt/lib/presentation/user/user_map_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
581
flutt/lib/presentation/user/user_statistics_page.dart
Normal file
581
flutt/lib/presentation/user/user_statistics_page.dart
Normal file
@@ -0,0 +1,581 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
class UserStatisticsPage extends StatefulWidget {
|
||||
const UserStatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserStatisticsPage> createState() => _UserStatisticsPageState();
|
||||
}
|
||||
|
||||
class _UserStatisticsPageState extends State<UserStatisticsPage> {
|
||||
// Période sélectionnée
|
||||
String _selectedPeriod = 'Semaine';
|
||||
|
||||
// Secteur sélectionné (0 = tous les secteurs)
|
||||
int _selectedSectorId = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Statistiques',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres
|
||||
_buildFilters(theme, isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphiques
|
||||
_buildCharts(theme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de passage
|
||||
_buildPassageTypeSummary(theme, isDesktop),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de règlement
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des filtres
|
||||
Widget _buildFilters(ThemeData theme, bool isDesktop) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
// Sélection de la période
|
||||
_buildFilterSection(
|
||||
'Période',
|
||||
['Jour', 'Semaine', 'Mois', 'Année'],
|
||||
_selectedPeriod,
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
},
|
||||
theme,
|
||||
),
|
||||
|
||||
// Sélection du secteur (si l'utilisateur a plusieurs secteurs)
|
||||
_buildSectorSelector(context, theme),
|
||||
|
||||
// Bouton d'application des filtres
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Actualiser les statistiques avec les filtres sélectionnés
|
||||
setState(() {
|
||||
// Dans une implémentation réelle, on chargerait ici les données
|
||||
// filtrées par période et secteur
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.filter_list),
|
||||
label: const Text('Appliquer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du sélecteur de secteur
|
||||
Widget _buildSectorSelector(BuildContext context, ThemeData theme) {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Récupérer les secteurs de l'utilisateur
|
||||
final sectors = userRepository.getUserSectors();
|
||||
|
||||
// Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur
|
||||
if (sectors.length <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Créer la liste des options avec "Tous" comme première option
|
||||
final List<DropdownMenuItem<int>> items = [
|
||||
const DropdownMenuItem<int>(
|
||||
value: 0,
|
||||
child: Text('Tous les secteurs'),
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter les secteurs de l'utilisateur
|
||||
for (final sector in sectors) {
|
||||
items.add(
|
||||
DropdownMenuItem<int>(
|
||||
value: sector.id,
|
||||
child: Text(sector.libelle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Secteur',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: DropdownButton<int>(
|
||||
value: _selectedSectorId,
|
||||
isExpanded: true,
|
||||
items: items,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedSectorId = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
hint: const Text('Sélectionner un secteur'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une section de filtre
|
||||
Widget _buildFilterSection(
|
||||
String title,
|
||||
List<String> options,
|
||||
String selectedValue,
|
||||
Function(String) onChanged,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<String>(
|
||||
segments: options.map((option) {
|
||||
return ButtonSegment<String>(
|
||||
value: option,
|
||||
label: Text(option),
|
||||
);
|
||||
}).toList(),
|
||||
selected: {selectedValue},
|
||||
onSelectionChanged: (Set<String> selection) {
|
||||
onChanged(selection.first);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppTheme.secondaryColor;
|
||||
}
|
||||
return theme.colorScheme.surface;
|
||||
},
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return Colors.white;
|
||||
}
|
||||
return theme.colorScheme.onSurface;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction des graphiques
|
||||
Widget _buildCharts(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Passages et règlements par $_selectedPeriod',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: _buildActivityChart(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du graphique d'activité
|
||||
Widget _buildActivityChart(ThemeData theme) {
|
||||
// Générer des données fictives pour les passages
|
||||
final now = DateTime.now();
|
||||
final List<Map<String, dynamic>> passageData = [];
|
||||
|
||||
// Récupérer le secteur sélectionné (si applicable)
|
||||
final String sectorLabel = _selectedSectorId == 0
|
||||
? 'Tous les secteurs'
|
||||
: userRepository.getSectorById(_selectedSectorId)?.libelle ??
|
||||
'Secteur inconnu';
|
||||
|
||||
// Déterminer la plage de dates en fonction de la période sélectionnée
|
||||
DateTime startDate;
|
||||
int daysToGenerate;
|
||||
|
||||
switch (_selectedPeriod) {
|
||||
case 'Jour':
|
||||
startDate = DateTime(now.year, now.month, now.day);
|
||||
daysToGenerate = 1;
|
||||
break;
|
||||
case 'Semaine':
|
||||
// Début de la semaine (lundi)
|
||||
final weekday = now.weekday;
|
||||
startDate = now.subtract(Duration(days: weekday - 1));
|
||||
daysToGenerate = 7;
|
||||
break;
|
||||
case 'Mois':
|
||||
// Début du mois
|
||||
startDate = DateTime(now.year, now.month, 1);
|
||||
// Calculer le nombre de jours dans le mois
|
||||
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
|
||||
daysToGenerate = lastDayOfMonth;
|
||||
break;
|
||||
case 'Année':
|
||||
// Début de l'année
|
||||
startDate = DateTime(now.year, 1, 1);
|
||||
daysToGenerate = 365;
|
||||
break;
|
||||
default:
|
||||
startDate = DateTime(now.year, now.month, now.day);
|
||||
daysToGenerate = 7;
|
||||
}
|
||||
|
||||
// Générer des données pour la période sélectionnée
|
||||
for (int i = 0; i < daysToGenerate; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
|
||||
// Générer des données pour chaque type de passage
|
||||
for (int typeId = 1; typeId <= 6; typeId++) {
|
||||
// Générer un nombre de passages basé sur le jour et le type
|
||||
final count = (typeId == 1 || typeId == 2)
|
||||
? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2
|
||||
: (date.day % 4); // Moins pour les autres types
|
||||
|
||||
if (count > 0) {
|
||||
passageData.add({
|
||||
'date': date.toIso8601String(),
|
||||
'type_passage': typeId,
|
||||
'nb': count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher le secteur sélectionné si ce n'est pas "Tous"
|
||||
if (_selectedSectorId != 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Text(
|
||||
'Secteur: $sectorLabel',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ActivityChart(
|
||||
passageData: passageData,
|
||||
periodType: _selectedPeriod,
|
||||
height: 300,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du résumé par type de passage
|
||||
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PassagePieChart(
|
||||
passagesByType: {
|
||||
1: 60, // Effectués
|
||||
2: 15, // À finaliser
|
||||
3: 10, // Refusés
|
||||
4: 8, // Dons
|
||||
5: 5, // Lots
|
||||
6: 2, // Maisons vides
|
||||
},
|
||||
size: 140,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
isDonut: true, // Activer le format donut
|
||||
innerRadius: '50%' // Rayon interne du donut
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem(
|
||||
'Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem('Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du résumé par type de règlement
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de règlement',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: [
|
||||
_buildPieChartSection(
|
||||
'Espèces', 30, const Color(0xFF4CAF50), 0),
|
||||
_buildPieChartSection(
|
||||
'Chèques', 45, const Color(0xFF2196F3), 1),
|
||||
_buildPieChartSection(
|
||||
'CB', 25, const Color(0xFFF44336), 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une section de graphique circulaire
|
||||
PieChartSectionData _buildPieChartSection(
|
||||
String title, double value, Color color, int index) {
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: value,
|
||||
title: '$value%',
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'un élément de légende
|
||||
Widget _buildLegendItem(String title, String value, Color color) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
476
flutt/lib/presentation/widgets/charts/activity_chart.dart
Normal file
476
flutt/lib/presentation/widgets/charts/activity_chart.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_service.dart';
|
||||
|
||||
/// Widget de graphique d'activité affichant les passages
|
||||
class ActivityChart extends StatefulWidget {
|
||||
/// Liste des données de passage par date et type (si fournie directement)
|
||||
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
|
||||
final List<Map<String, dynamic>>? passageData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
final String periodType;
|
||||
|
||||
/// Hauteur du graphique
|
||||
final double height;
|
||||
|
||||
/// Nombre de jours à afficher (par défaut 15)
|
||||
final int daysToShow;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure (par défaut [2] = "À finaliser")
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Indique si les données doivent être chargées depuis la Hive box
|
||||
final bool loadFromHive;
|
||||
|
||||
/// Callback appelé lorsque la période change
|
||||
final Function(int days)? onPeriodChanged;
|
||||
|
||||
/// Titre du graphique
|
||||
final String title;
|
||||
|
||||
/// Afficher les étiquettes de valeur
|
||||
final bool showDataLabels;
|
||||
|
||||
/// Largeur des colonnes (en pourcentage)
|
||||
final double columnWidth;
|
||||
|
||||
/// Espacement entre les colonnes (en pourcentage)
|
||||
final double columnSpacing;
|
||||
|
||||
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
|
||||
final bool showAllPassages;
|
||||
|
||||
const ActivityChart({
|
||||
super.key,
|
||||
this.passageData,
|
||||
this.periodType = 'Jour',
|
||||
this.height = 350,
|
||||
this.daysToShow = 15,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.loadFromHive = false,
|
||||
this.onPeriodChanged,
|
||||
this.title = 'Dernière activité enregistrée sur 15 jours',
|
||||
this.showDataLabels = true,
|
||||
this.columnWidth = 0.8,
|
||||
this.columnSpacing = 0.2,
|
||||
this.showAllPassages = false,
|
||||
}) : assert(loadFromHive || passageData != null,
|
||||
'Soit loadFromHive doit être true, soit passageData doit être fourni');
|
||||
|
||||
@override
|
||||
State<ActivityChart> createState() => _ActivityChartState();
|
||||
}
|
||||
|
||||
/// Classe pour stocker les données d'activité par date
|
||||
class ActivityData {
|
||||
final DateTime date;
|
||||
final String dateStr;
|
||||
final Map<int, int> passagesByType;
|
||||
final int totalPassages;
|
||||
|
||||
ActivityData({
|
||||
required this.date,
|
||||
required this.dateStr,
|
||||
required this.passagesByType,
|
||||
}) : totalPassages =
|
||||
passagesByType.values.fold(0, (sum, count) => sum + count);
|
||||
}
|
||||
|
||||
class _ActivityChartState extends State<ActivityChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
// Données pour les graphiques
|
||||
List<Map<String, dynamic>> _passageData = [];
|
||||
List<ActivityData> _chartData = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasData = false;
|
||||
bool _dataLoaded = false;
|
||||
|
||||
// Période sélectionnée en jours
|
||||
int _selectedDays = 15;
|
||||
|
||||
// Contrôleur de zoom pour le graphique
|
||||
late ZoomPanBehavior _zoomPanBehavior;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
);
|
||||
|
||||
// Initialiser la période sélectionnée avec la valeur par défaut du widget
|
||||
_selectedDays = widget.daysToShow;
|
||||
|
||||
// Initialiser le contrôleur de zoom
|
||||
_zoomPanBehavior = ZoomPanBehavior(
|
||||
enablePinching: true,
|
||||
enableDoubleTapZooming: true,
|
||||
enablePanning: true,
|
||||
zoomMode: ZoomMode.x,
|
||||
);
|
||||
|
||||
_loadData();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
/// Trouve la date du passage le plus récent
|
||||
DateTime _getMostRecentDate() {
|
||||
final allDates = [
|
||||
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
|
||||
];
|
||||
if (allDates.isEmpty) {
|
||||
return DateTime.now();
|
||||
}
|
||||
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
// Si les données ont déjà été chargées, ne pas les recharger
|
||||
if (_dataLoaded) return;
|
||||
|
||||
// Marquer comme chargé immédiatement pour éviter les appels multiples
|
||||
_dataLoaded = true;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (widget.loadFromHive) {
|
||||
// Charger les données depuis Hive
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Éviter de recharger si le widget a été démonté entre-temps
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
_passageData = passageDataService.loadPassageData(
|
||||
daysToShow: _selectedDays,
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
_prepareChartData();
|
||||
|
||||
// Mettre à jour l'état une seule fois après avoir préparé les données
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Utiliser les données fournies directement
|
||||
_passageData = widget.passageData ?? [];
|
||||
_prepareChartData();
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ActivityChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool periodChanged = oldWidget.periodType != widget.periodType ||
|
||||
oldWidget.daysToShow != widget.daysToShow;
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: oldWidget.passageData != widget.passageData;
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
|
||||
// Si des paramètres importants ont changé, recharger les données
|
||||
if (periodChanged || dataSourceChanged || filteringChanged) {
|
||||
_selectedDays = widget.daysToShow;
|
||||
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
|
||||
// pour éviter les appels multiples et les problèmes de cycle de vie
|
||||
|
||||
/// Prépare les données pour le graphique
|
||||
void _prepareChartData() {
|
||||
try {
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_passageData.isEmpty) {
|
||||
_chartData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtenir toutes les dates uniques
|
||||
final Set<String> uniqueDatesSet = {};
|
||||
for (final data in _passageData) {
|
||||
if (data.containsKey('date') && data['date'] != null) {
|
||||
uniqueDatesSet.add(data['date'] as String);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les dates
|
||||
final List<String> uniqueDates = uniqueDatesSet.toList();
|
||||
uniqueDates.sort();
|
||||
|
||||
// Créer les données pour chaque date
|
||||
_chartData = [];
|
||||
for (final dateStr in uniqueDates) {
|
||||
final passagesByType = <int, int>{};
|
||||
|
||||
// Initialiser tous les types de passage possibles
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Remplir les données de passage
|
||||
for (final data in _passageData) {
|
||||
if (data.containsKey('date') &&
|
||||
data['date'] == dateStr &&
|
||||
data.containsKey('type_passage') &&
|
||||
data.containsKey('nb')) {
|
||||
final typeId = data['type_passage'] as int;
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = data['nb'] as int;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Convertir la date en objet DateTime
|
||||
final dateParts = dateStr.split('-');
|
||||
if (dateParts.length == 3) {
|
||||
final year = int.parse(dateParts[0]);
|
||||
final month = int.parse(dateParts[1]);
|
||||
final day = int.parse(dateParts[2]);
|
||||
|
||||
final date = DateTime(year, month, day);
|
||||
|
||||
// Ajouter les données à la liste
|
||||
_chartData.add(ActivityData(
|
||||
date: date,
|
||||
dateStr: dateStr,
|
||||
passagesByType: passagesByType,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les données par date
|
||||
_chartData.sort((a, b) => a.date.compareTo(b.date));
|
||||
} catch (e) {
|
||||
// Erreur silencieuse pour éviter les logs excessifs
|
||||
_chartData = [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_hasData || _chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Préparer les données si nécessaire
|
||||
if (_chartData.isEmpty) {
|
||||
_prepareChartData();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre (conservé)
|
||||
if (widget.title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Graphique (occupe maintenant plus d'espace)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: SfCartesianChart(
|
||||
plotAreaBorderWidth: 0,
|
||||
legend: Legend(
|
||||
isVisible: true,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
),
|
||||
primaryXAxis: DateTimeAxis(
|
||||
dateFormat: DateFormat('dd/MM'),
|
||||
intervalType: DateTimeIntervalType.days,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
// Définir explicitement la plage de dates à afficher
|
||||
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
|
||||
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
|
||||
// Assurer que tous les jours sont affichés
|
||||
interval: 1,
|
||||
axisLabelFormatter: (AxisLabelRenderDetails details) {
|
||||
return ChartAxisLabel(details.text, details.textStyle);
|
||||
},
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
majorGridLines: const MajorGridLines(
|
||||
width: 0.5,
|
||||
color: Colors.grey,
|
||||
dashArray: <double>[5, 5], // Motif de pointillés
|
||||
),
|
||||
title: const AxisTitle(
|
||||
text: 'Passages',
|
||||
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
series: _buildSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
zoomPanBehavior: _zoomPanBehavior,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les séries de données pour le graphique
|
||||
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
|
||||
final List<CartesianSeries<ActivityData, DateTime>> series = [];
|
||||
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_chartData.isEmpty) {
|
||||
return series;
|
||||
}
|
||||
|
||||
// Obtenir tous les types de passage (sauf ceux exclus)
|
||||
final passageTypes = AppKeys.typesPassages.keys
|
||||
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
|
||||
.toList();
|
||||
|
||||
// Créer les séries pour les passages (colonnes empilées)
|
||||
for (final typeId in passageTypes) {
|
||||
// Vérifier que le type existe dans AppKeys
|
||||
if (!AppKeys.typesPassages.containsKey(typeId)) {
|
||||
continue;
|
||||
}
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
|
||||
// Vérifier que les clés nécessaires existent
|
||||
if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final typeColor = Color(typeInfo['couleur1'] as int);
|
||||
final typeName = typeInfo['titre'] as String;
|
||||
|
||||
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
|
||||
int totalForType = 0;
|
||||
for (final data in _chartData) {
|
||||
totalForType += data.passagesByType[typeId] ?? 0;
|
||||
}
|
||||
|
||||
// On peut décider de ne pas afficher les types sans données
|
||||
final addZeroValueTypes = false;
|
||||
|
||||
// Ajouter la série pour ce type
|
||||
if (totalForType > 0 || addZeroValueTypes) {
|
||||
series.add(
|
||||
StackedColumnSeries<ActivityData, DateTime>(
|
||||
name: typeName,
|
||||
dataSource: _chartData,
|
||||
xValueMapper: (ActivityData data, _) => data.date,
|
||||
yValueMapper: (ActivityData data, _) {
|
||||
final value = data.passagesByType.containsKey(typeId)
|
||||
? data.passagesByType[typeId]!
|
||||
: 0;
|
||||
return value;
|
||||
},
|
||||
color: typeColor,
|
||||
width: widget.columnWidth,
|
||||
spacing: widget.columnSpacing,
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: widget.showDataLabels,
|
||||
labelAlignment: ChartDataLabelAlignment.middle,
|
||||
textStyle: const TextStyle(fontSize: 8, color: Colors.white),
|
||||
),
|
||||
markerSettings: const MarkerSettings(isVisible: false),
|
||||
animationDuration: 1500,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
}
|
||||
11
flutt/lib/presentation/widgets/charts/charts.dart
Normal file
11
flutt/lib/presentation/widgets/charts/charts.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
|
||||
library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_pie_chart.dart';
|
||||
export 'payment_utils.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_pie_chart.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
313
flutt/lib/presentation/widgets/charts/combined_chart.dart
Normal file
313
flutt/lib/presentation/widgets/charts/combined_chart.dart
Normal file
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Widget de graphique combiné pour afficher les passages et règlements
|
||||
class CombinedChart extends StatelessWidget {
|
||||
/// Liste des données de passage par type
|
||||
final List<Map<String, dynamic>> passageData;
|
||||
|
||||
/// Liste des données de règlement par type
|
||||
final List<Map<String, dynamic>> paymentData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
final String periodType;
|
||||
|
||||
/// Hauteur du graphique
|
||||
final double height;
|
||||
|
||||
/// Largeur des barres
|
||||
final double barWidth;
|
||||
|
||||
/// Rayon des points sur les lignes
|
||||
final double dotRadius;
|
||||
|
||||
/// Épaisseur des lignes
|
||||
final double lineWidth;
|
||||
|
||||
/// Montant maximum pour l'axe Y des règlements
|
||||
final double? maxYAmount;
|
||||
|
||||
/// Nombre maximum pour l'axe Y des passages
|
||||
final int? maxYCount;
|
||||
|
||||
const CombinedChart({
|
||||
super.key,
|
||||
required this.passageData,
|
||||
required this.paymentData,
|
||||
this.periodType = 'Jour',
|
||||
this.height = 300,
|
||||
this.barWidth = 16,
|
||||
this.dotRadius = 4,
|
||||
this.lineWidth = 3,
|
||||
this.maxYAmount,
|
||||
this.maxYCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Convertir les données brutes en modèles structurés
|
||||
final passagesByType = PassageUtils.getPassageDataByType(passageData);
|
||||
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
|
||||
|
||||
// Extraire les dates uniques pour l'axe X
|
||||
final List<DateTime> allDates = [];
|
||||
for (final data in passageData) {
|
||||
final DateTime date = data['date'] is DateTime
|
||||
? data['date']
|
||||
: DateTime.parse(data['date']);
|
||||
if (!allDates.any((d) =>
|
||||
d.year == date.year && d.month == date.month && d.day == date.day)) {
|
||||
allDates.add(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les dates
|
||||
allDates.sort((a, b) => a.compareTo(b));
|
||||
|
||||
// Calculer le maximum pour les axes Y
|
||||
double maxAmount = 0;
|
||||
for (final typeData in paymentsByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.amount > maxAmount) {
|
||||
maxAmount = data.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int maxCount = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.count > maxCount) {
|
||||
maxCount = data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utiliser les maximums fournis ou calculés
|
||||
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
|
||||
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: effectiveMaxYCount.toDouble(),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
tooltipPadding: const EdgeInsets.all(8),
|
||||
tooltipMargin: 8,
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final date = allDates[group.x.toInt()];
|
||||
final formattedDate = DateFormat('dd/MM').format(date);
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BarTooltipItem(
|
||||
'$formattedDate: $totalPassages passages',
|
||||
TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value >= 0 && value < allDates.length) {
|
||||
final date = allDates[value.toInt()];
|
||||
final formattedDate =
|
||||
PassageUtils.formatDateForChart(date, periodType);
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
value.toInt().toString(),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
|
||||
final amountValue =
|
||||
(value / effectiveMaxYCount) * effectiveMaxYAmount;
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
'${amountValue.toInt()}€',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: theme.dividerColor.withOpacity(0.2),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
drawVerticalLine: false,
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _createBarGroups(allDates, passagesByType),
|
||||
extraLinesData: ExtraLinesData(
|
||||
horizontalLines: [],
|
||||
verticalLines: [],
|
||||
extraLinesOnTop: true,
|
||||
),
|
||||
),
|
||||
swapAnimationDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer les groupes de barres pour les passages
|
||||
List<BarChartGroupData> _createBarGroups(
|
||||
List<DateTime> allDates,
|
||||
List<List<PassageData>> passagesByType,
|
||||
) {
|
||||
final List<BarChartGroupData> groups = [];
|
||||
|
||||
for (int i = 0; i < allDates.length; i++) {
|
||||
final date = allDates[i];
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un groupe de barres pour cette date
|
||||
groups.add(
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: totalPassages.toDouble(),
|
||||
color: Colors.blue.shade700,
|
||||
width: barWidth,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(6),
|
||||
topRight: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de légende pour le graphique combiné
|
||||
class CombinedChartLegend extends StatelessWidget {
|
||||
const CombinedChartLegend({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
|
||||
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', const Color(0xFFF44336)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer un élément de légende
|
||||
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
|
||||
borderRadius: isBar ? BorderRadius.circular(3) : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
flutt/lib/presentation/widgets/charts/passage_data.dart
Normal file
99
flutt/lib/presentation/widgets/charts/passage_data.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Modèle de données pour représenter un passage avec sa date, son type et son nombre
|
||||
class PassageData {
|
||||
/// Date du passage
|
||||
final DateTime date;
|
||||
|
||||
/// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.)
|
||||
final int typeId;
|
||||
|
||||
/// Nombre de passages
|
||||
final int count;
|
||||
|
||||
/// Couleur associée au type de passage
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de passage (chemin vers le fichier d'icône)
|
||||
final String iconPath;
|
||||
|
||||
/// Titre du type de passage
|
||||
final String title;
|
||||
|
||||
const PassageData({
|
||||
required this.date,
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.color,
|
||||
required this.iconPath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
/// Crée une instance de PassageData à partir d'une date au format ISO 8601
|
||||
factory PassageData.fromIsoDate({
|
||||
required String isoDate,
|
||||
required int typeId,
|
||||
required int count,
|
||||
required Color color,
|
||||
required String iconPath,
|
||||
required String title,
|
||||
}) {
|
||||
return PassageData(
|
||||
date: DateTime.parse(isoDate),
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour représenter un règlement avec sa date, son type et son montant
|
||||
class PaymentAmountData {
|
||||
/// Date du règlement
|
||||
final DateTime date;
|
||||
|
||||
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
|
||||
final int typeId;
|
||||
|
||||
/// Montant du règlement
|
||||
final double amount;
|
||||
|
||||
/// Couleur associée au type de règlement
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData)
|
||||
final dynamic iconData;
|
||||
|
||||
/// Titre du type de règlement
|
||||
final String title;
|
||||
|
||||
/// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601
|
||||
factory PaymentAmountData.fromIsoDate({
|
||||
required String isoDate,
|
||||
required int typeId,
|
||||
required double amount,
|
||||
required Color color,
|
||||
required dynamic iconData,
|
||||
required String title,
|
||||
}) {
|
||||
return PaymentAmountData(
|
||||
date: DateTime.parse(isoDate),
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentAmountData({
|
||||
required this.date,
|
||||
required this.typeId,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.iconData,
|
||||
required this.title,
|
||||
});
|
||||
}
|
||||
459
flutt/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file
459
flutt/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file
@@ -0,0 +1,459 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_service.dart';
|
||||
|
||||
/// Modèle de données pour le graphique en camembert des passages
|
||||
class PassageChartData {
|
||||
/// Identifiant du type de passage
|
||||
final int typeId;
|
||||
|
||||
/// Nombre de passages de ce type
|
||||
final int count;
|
||||
|
||||
/// Titre du type de passage
|
||||
final String title;
|
||||
|
||||
/// Couleur associée au type de passage
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de passage
|
||||
final IconData icon;
|
||||
|
||||
PassageChartData({
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.title,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des passages par type
|
||||
class PassagePieChart extends StatefulWidget {
|
||||
/// Liste des données de passages par type sous forme de Map avec typeId et count
|
||||
/// Si loadFromHive est true, ce paramètre est ignoré
|
||||
final Map<int, int> passagesByType;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Charger les données depuis Hive
|
||||
final bool loadFromHive;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
|
||||
final bool showAllPassages;
|
||||
|
||||
const PassagePieChart({
|
||||
super.key,
|
||||
this.passagesByType = const {},
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.loadFromHive = false,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.showAllPassages = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassagePieChart> createState() => _PassagePieChartState();
|
||||
}
|
||||
|
||||
class _PassagePieChartState extends State<PassagePieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
/// Données de passages par type
|
||||
late Map<int, int> _passagesByType;
|
||||
|
||||
/// Variables pour la mise en cache et l'optimisation
|
||||
bool _dataLoaded = false;
|
||||
bool _isLoading = false;
|
||||
List<PassageChartData>? _cachedChartData;
|
||||
List<CircularChartAnnotation>? _cachedAnnotations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passagesByType = widget.passagesByType;
|
||||
|
||||
// Initialiser le contrôleur d'animation
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
// Si nous n'utilisons pas Hive, préparer les données immédiatement
|
||||
if (!widget.loadFromHive) {
|
||||
_prepareChartData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
|
||||
_isLoading = true; // Prévenir les chargements multiples
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
final bool visualChanged = oldWidget.size != widget.size ||
|
||||
oldWidget.labelSize != widget.labelSize ||
|
||||
oldWidget.showPercentage != widget.showPercentage ||
|
||||
oldWidget.showIcons != widget.showIcons ||
|
||||
oldWidget.showLegend != widget.showLegend ||
|
||||
oldWidget.isDonut != widget.isDonut ||
|
||||
oldWidget.innerRadius != widget.innerRadius;
|
||||
|
||||
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
|
||||
if (dataSourceChanged || filteringChanged) {
|
||||
_cachedChartData = null;
|
||||
_cachedAnnotations = null;
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
|
||||
if (!widget.loadFromHive) {
|
||||
_passagesByType = widget.passagesByType;
|
||||
_prepareChartData();
|
||||
} else if (!_isLoading) {
|
||||
_dataLoaded = false;
|
||||
_isLoading = true;
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
|
||||
else if (visualChanged) {
|
||||
_cachedAnnotations = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
|
||||
void _loadPassageDataFromHive(BuildContext context) {
|
||||
// Éviter les appels multiples
|
||||
if (_isLoading && _dataLoaded) return;
|
||||
|
||||
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Vérifier si le widget est toujours monté
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
final data = passageDataService.loadPassageDataForPieChart(
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
// Mettre à jour les données et les états
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_passagesByType = data;
|
||||
_dataLoaded = true;
|
||||
_isLoading = false;
|
||||
_cachedChartData =
|
||||
null; // Forcer la régénération des données du graphique
|
||||
_cachedAnnotations = null;
|
||||
});
|
||||
|
||||
// Préparer les données du graphique
|
||||
_prepareChartData();
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert avec mise en cache
|
||||
List<PassageChartData> _prepareChartData() {
|
||||
// Utiliser les données en cache si disponibles
|
||||
if (_cachedChartData != null) {
|
||||
return _cachedChartData!;
|
||||
}
|
||||
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
_passagesByType.forEach((typeId, count) {
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
chartData.add(PassageChartData(
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
title: typeInfo['titre'] as String,
|
||||
color: Color(typeInfo['couleur2'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Mettre en cache les données générées
|
||||
_cachedChartData = chartData;
|
||||
|
||||
return chartData;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
|
||||
if (widget.loadFromHive && !_dataLoaded) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartData();
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique avec mise en cache
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PassageChartData> chartData) {
|
||||
// Utiliser les annotations en cache si disponibles
|
||||
if (_cachedAnnotations != null) {
|
||||
return _cachedAnnotations!;
|
||||
}
|
||||
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
int total = chartData.fold(0, (sum, item) => sum + item.count);
|
||||
if (total == 0) return []; // Éviter la division par zéro
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.count / total;
|
||||
|
||||
// Calculer l'angle central de ce segment
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
// Ajouter une annotation pour l'icône
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
// Mettre en cache les annotations générées
|
||||
_cachedAnnotations = annotations;
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
214
flutt/lib/presentation/widgets/charts/passage_utils.dart
Normal file
214
flutt/lib/presentation/widgets/charts/passage_utils.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Utilitaires pour les passages et règlements
|
||||
class PassageUtils {
|
||||
/// Convertit les données de passage brutes en liste de PassageData
|
||||
///
|
||||
/// [passageData] est une liste d'objets contenant date, type_passage et nb
|
||||
static List<List<PassageData>> getPassageDataByType(
|
||||
List<Map<String, dynamic>> passageData) {
|
||||
// Créer un Map pour stocker les données par type de passage
|
||||
final Map<int, List<PassageData>> passagesByType = {};
|
||||
|
||||
// Initialiser les listes pour chaque type de passage
|
||||
for (final entry in AppKeys.typesPassages.entries) {
|
||||
passagesByType[entry.key] = [];
|
||||
}
|
||||
|
||||
// Grouper les passages par type
|
||||
for (final data in passageData) {
|
||||
final int typeId = data['type_passage'];
|
||||
final int count = data['nb'];
|
||||
|
||||
if (AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeData = AppKeys.typesPassages[typeId]!;
|
||||
final Color color = Color(typeData['couleur1'] as int);
|
||||
final String iconPath = typeData['icone'] as String;
|
||||
final String title = typeData['titre'] as String;
|
||||
|
||||
// Utiliser la méthode factory qui gère les dates au format ISO 8601
|
||||
if (data['date'] is String) {
|
||||
passagesByType[typeId]!.add(
|
||||
PassageData.fromIsoDate(
|
||||
isoDate: data['date'],
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback pour les objets DateTime (pour compatibilité)
|
||||
final DateTime date = data['date'] as DateTime;
|
||||
passagesByType[typeId]!.add(
|
||||
PassageData(
|
||||
date: date,
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
color: color,
|
||||
iconPath: iconPath,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en liste de listes
|
||||
return passagesByType.values.toList();
|
||||
}
|
||||
|
||||
/// Convertit les données de règlement brutes en liste de PaymentAmountData
|
||||
///
|
||||
/// [paymentData] est une liste d'objets contenant date, type_reglement et montant
|
||||
static List<List<PaymentAmountData>> getPaymentDataByType(
|
||||
List<Map<String, dynamic>> paymentData) {
|
||||
// Créer un Map pour stocker les données par type de règlement
|
||||
final Map<int, List<PaymentAmountData>> paymentsByType = {};
|
||||
|
||||
// Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement")
|
||||
for (final entry in AppKeys.typesReglements.entries) {
|
||||
if (entry.key > 0) {
|
||||
// Ignorer le type 0 (Pas de règlement)
|
||||
paymentsByType[entry.key] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Grouper les règlements par type
|
||||
for (final data in paymentData) {
|
||||
final int typeId = data['type_reglement'];
|
||||
final double amount = data['montant'] is double
|
||||
? data['montant']
|
||||
: double.parse(data['montant'].toString());
|
||||
|
||||
if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) {
|
||||
final typeData = AppKeys.typesReglements[typeId]!;
|
||||
final Color color = Color(typeData['couleur'] as int);
|
||||
final dynamic iconData = _getIconForPaymentType(typeId);
|
||||
final String title = typeData['titre'] as String;
|
||||
|
||||
// Utiliser la méthode factory qui gère les dates au format ISO 8601
|
||||
if (data['date'] is String) {
|
||||
paymentsByType[typeId]!.add(
|
||||
PaymentAmountData.fromIsoDate(
|
||||
isoDate: data['date'],
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback pour les objets DateTime (pour compatibilité)
|
||||
final DateTime date = data['date'] as DateTime;
|
||||
paymentsByType[typeId]!.add(
|
||||
PaymentAmountData(
|
||||
date: date,
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: color,
|
||||
iconData: iconData,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en liste de listes
|
||||
return paymentsByType.values.toList();
|
||||
}
|
||||
|
||||
/// Génère des données de passage fictives pour les 14 derniers jours
|
||||
static List<Map<String, dynamic>> generateMockPassageData() {
|
||||
final List<Map<String, dynamic>> mockData = [];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = 13; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
|
||||
// Ajouter des données pour chaque type de passage
|
||||
for (int typeId = 1; typeId <= 6; typeId++) {
|
||||
// Générer un nombre aléatoire de passages entre 0 et 5
|
||||
final count = (typeId == 1 || typeId == 2)
|
||||
? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2
|
||||
: (date.day % 3); // Moins pour les autres types
|
||||
|
||||
if (count > 0) {
|
||||
mockData.add({
|
||||
'date': date,
|
||||
'type_passage': typeId,
|
||||
'nb': count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
/// Génère des données de règlement fictives pour les 14 derniers jours
|
||||
static List<Map<String, dynamic>> generateMockPaymentData() {
|
||||
final List<Map<String, dynamic>> mockData = [];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = 13; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
|
||||
// Ajouter des données pour chaque type de règlement
|
||||
for (int typeId = 1; typeId <= 3; typeId++) {
|
||||
// Générer un montant aléatoire
|
||||
final amount = (typeId * 100.0) + (date.day * 10.0);
|
||||
|
||||
mockData.add({
|
||||
'date': date,
|
||||
'type_reglement': typeId,
|
||||
'montant': amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
/// Obtenir l'icône correspondant au type de règlement
|
||||
/// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys
|
||||
static IconData _getIconForPaymentType(int typeId) {
|
||||
switch (typeId) {
|
||||
case 1: // Espèces
|
||||
return Icons.payments;
|
||||
case 2: // Chèque
|
||||
return Icons.money;
|
||||
case 3: // CB
|
||||
return Icons.credit_card;
|
||||
default:
|
||||
return Icons.euro;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formater une date pour l'affichage dans les graphiques
|
||||
static String formatDateForChart(DateTime date, String periodType) {
|
||||
switch (periodType.toLowerCase()) {
|
||||
case 'jour':
|
||||
return DateFormat('dd/MM').format(date);
|
||||
case 'semaine':
|
||||
// Calculer le numéro de la semaine dans l'année
|
||||
final firstDayOfYear = DateTime(date.year, 1, 1);
|
||||
final dayOfYear = date.difference(firstDayOfYear).inDays;
|
||||
final weekNumber =
|
||||
((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil();
|
||||
return 'S$weekNumber';
|
||||
case 'mois':
|
||||
return DateFormat('MMM').format(date);
|
||||
case 'année':
|
||||
return DateFormat('yyyy').format(date);
|
||||
default:
|
||||
return DateFormat('dd/MM').format(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
flutt/lib/presentation/widgets/charts/payment_data.dart
Normal file
27
flutt/lib/presentation/widgets/charts/payment_data.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Modèle de données pour représenter un type de règlement avec son montant
|
||||
class PaymentData {
|
||||
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
|
||||
final int typeId;
|
||||
|
||||
/// Montant du règlement
|
||||
final double amount;
|
||||
|
||||
/// Couleur associée au type de règlement
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de règlement
|
||||
final IconData icon;
|
||||
|
||||
/// Titre du type de règlement
|
||||
final String title;
|
||||
|
||||
const PaymentData({
|
||||
required this.typeId,
|
||||
required this.amount,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
});
|
||||
}
|
||||
404
flutt/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file
404
flutt/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file
@@ -0,0 +1,404 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des règlements
|
||||
class PaymentPieChart extends StatefulWidget {
|
||||
/// Liste des données de règlement à afficher dans le graphique
|
||||
final List<PaymentData> payments;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Activer l'effet 3D
|
||||
final bool enable3DEffect;
|
||||
|
||||
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
|
||||
final double effect3DIntensity;
|
||||
|
||||
/// Activer l'effet d'explosion plus prononcé
|
||||
final bool enableEnhancedExplode;
|
||||
|
||||
/// Utiliser un dégradé pour simuler l'effet 3D
|
||||
final bool useGradient;
|
||||
|
||||
const PaymentPieChart({
|
||||
super.key,
|
||||
required this.payments,
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.enable3DEffect = false,
|
||||
this.effect3DIntensity = 1.0,
|
||||
this.enableEnhancedExplode = false,
|
||||
this.useGradient = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentPieChart> createState() => _PaymentPieChartState();
|
||||
}
|
||||
|
||||
class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PaymentPieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
|
||||
bool shouldResetAnimation = false;
|
||||
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
// Comparer les éléments importants uniquement
|
||||
for (int i = 0; i < oldWidget.payments.length; i++) {
|
||||
if (i >= widget.payments.length) break;
|
||||
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
||||
oldWidget.payments[i].title != widget.payments[i].title) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert
|
||||
List<PaymentData> _prepareChartData() {
|
||||
// Filtrer les règlements avec un montant > 0
|
||||
return widget.payments.where((payment) => payment.amount > 0).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartData = _prepareChartData();
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
// Paramètres pour améliorer l'effet 3D
|
||||
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
|
||||
// Ajouter un effet de bordure pour renforcer l'effet 3D
|
||||
borderWidth: widget.enable3DEffect ? 0.5 : 0,
|
||||
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D en ajoutant des nuances
|
||||
Color _create3DColor(Color baseColor, double intensity) {
|
||||
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
|
||||
// Augmenter la luminosité pour simuler un éclairage
|
||||
final adjustedLightness =
|
||||
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
// Augmenter légèrement la saturation pour des couleurs plus vives
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
return hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
}
|
||||
|
||||
/// Crée une palette de couleurs pour l'effet 3D
|
||||
List<Color> _create3DPalette(List<PaymentData> chartData) {
|
||||
List<Color> palette = [];
|
||||
|
||||
// Créer des variations de couleurs pour chaque segment
|
||||
for (var i = 0; i < chartData.length; i++) {
|
||||
var data = chartData[i];
|
||||
|
||||
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
|
||||
final angle = (i / chartData.length) * 2 * math.pi;
|
||||
|
||||
// Créer un effet d'ombre et de lumière en fonction de l'angle
|
||||
final hslColor = HSLColor.fromColor(data.color);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle
|
||||
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
|
||||
final adjustedLightness = (hslColor.lightness -
|
||||
0.1 * widget.effect3DIntensity +
|
||||
lightAdjustment)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Ajuster la saturation pour plus de profondeur
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
final enhancedColor = hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
|
||||
palette.add(enhancedColor);
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D plus avancé
|
||||
Color _createEnhanced3DColor(Color baseColor, double angle) {
|
||||
// Simuler un effet de lumière directionnel
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
|
||||
final adjustedLightness = hslColor.lightness +
|
||||
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
|
||||
|
||||
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PaymentData> chartData) {
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.amount / total;
|
||||
|
||||
// Calculer l'angle central de ce segment
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
// Ajouter une annotation pour l'icône
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
33
flutt/lib/presentation/widgets/charts/payment_utils.dart
Normal file
33
flutt/lib/presentation/widgets/charts/payment_utils.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
|
||||
/// Utilitaires pour les paiements et règlements
|
||||
class PaymentUtils {
|
||||
/// Convertit les données de règlement depuis les constantes AppKeys
|
||||
///
|
||||
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
|
||||
static List<PaymentData> getPaymentDataFromAmounts(
|
||||
Map<int, double> paymentAmounts) {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
// Parcourir tous les types de règlements définis dans AppKeys
|
||||
AppKeys.typesReglements.forEach((typeId, typeData) {
|
||||
// Vérifier si nous avons un montant pour ce type de règlement
|
||||
final double amount = paymentAmounts[typeId] ?? 0.0;
|
||||
|
||||
// Créer un objet PaymentData pour ce type de règlement
|
||||
final PaymentData paymentData = PaymentData(
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: Color(typeData['couleur'] as int),
|
||||
icon: typeData['icon_data'] as IconData,
|
||||
title: typeData['titre'] as String,
|
||||
);
|
||||
|
||||
paymentDataList.add(paymentData);
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
}
|
||||
}
|
||||
219
flutt/lib/presentation/widgets/chat/chat_input.dart
Normal file
219
flutt/lib/presentation/widgets/chat/chat_input.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour la zone de saisie des messages
|
||||
class ChatInput extends StatefulWidget {
|
||||
final Function(String) onMessageSent;
|
||||
|
||||
const ChatInput({
|
||||
Key? key,
|
||||
required this.onMessageSent,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
bool _isComposing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Bouton pour ajouter des pièces jointes
|
||||
IconButton(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
color: AppTheme.primaryColor,
|
||||
onPressed: () {
|
||||
// Afficher les options de pièces jointes
|
||||
_showAttachmentOptions(context);
|
||||
},
|
||||
),
|
||||
|
||||
// Champ de saisie du message
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Écrivez votre message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_isComposing = text.isNotEmpty;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'envoi
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isComposing ? Icons.send : Icons.mic,
|
||||
color: _isComposing ? AppTheme.primaryColor : Colors.grey[600],
|
||||
),
|
||||
onPressed: _isComposing
|
||||
? () {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
widget.onMessageSent(text);
|
||||
_controller.clear();
|
||||
setState(() {
|
||||
_isComposing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
: () {
|
||||
// Activer la reconnaissance vocale
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher les options de pièces jointes
|
||||
void _showAttachmentOptions(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Ajouter une pièce jointe',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.photo,
|
||||
'Photo',
|
||||
Colors.green,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Sélectionner une photo
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.camera_alt,
|
||||
'Caméra',
|
||||
Colors.blue,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Prendre une photo
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.insert_drive_file,
|
||||
'Document',
|
||||
Colors.orange,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Sélectionner un document
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
context,
|
||||
Icons.location_on,
|
||||
'Position',
|
||||
Colors.red,
|
||||
() {
|
||||
Navigator.pop(context);
|
||||
// Partager la position
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construire une option de pièce jointe
|
||||
Widget _buildAttachmentOption(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[800],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
flutt/lib/presentation/widgets/chat/chat_messages.dart
Normal file
245
flutt/lib/presentation/widgets/chat/chat_messages.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher les messages d'une conversation
|
||||
class ChatMessages extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final int currentUserId;
|
||||
final Function(Map<String, dynamic>) onReply;
|
||||
|
||||
const ChatMessages({
|
||||
Key? key,
|
||||
required this.messages,
|
||||
required this.currentUserId,
|
||||
required this.onReply,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return messages.isEmpty
|
||||
? const Center(
|
||||
child: Text('Aucun message dans cette conversation'),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
itemCount: messages.length,
|
||||
reverse:
|
||||
false, // Afficher les messages du plus ancien au plus récent
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isCurrentUser = message['senderId'] == currentUserId;
|
||||
final hasReply = message['replyTo'] != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: isCurrentUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher le message auquel on répond
|
||||
if (hasReply) ...[
|
||||
Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: isCurrentUser ? 0 : 40,
|
||||
right: isCurrentUser ? 40 : 0,
|
||||
bottom: 4,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Réponse à ${message['replyTo']['senderName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message['replyTo']['message'],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Message principal
|
||||
Row(
|
||||
mainAxisAlignment: isCurrentUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: message['avatar'] != null
|
||||
? AssetImage(message['avatar'] as String)
|
||||
: null,
|
||||
child: message['avatar'] == null
|
||||
? Text(
|
||||
message['senderName'].isNotEmpty
|
||||
? message['senderName'][0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Contenu du message
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: isCurrentUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom de l'expéditeur (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 4, bottom: 2),
|
||||
child: Text(
|
||||
message['senderName'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bulle de message
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentUser
|
||||
? AppTheme.primaryColor
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
message['message'],
|
||||
style: TextStyle(
|
||||
color: isCurrentUser
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Heure et statut
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message['time']),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (isCurrentUser)
|
||||
Icon(
|
||||
message['isRead']
|
||||
? Icons.done_all
|
||||
: Icons.done,
|
||||
size: 12,
|
||||
color: message['isRead']
|
||||
? Colors.blue
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Menu d'actions (seulement pour les messages des autres)
|
||||
if (!isCurrentUser)
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'reply',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.reply, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Répondre'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.content_copy, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Copier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'reply') {
|
||||
onReply(message);
|
||||
} else if (value == 'copy') {
|
||||
// Copier le message
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Formater l'heure du message
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
219
flutt/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file
219
flutt/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher la barre latérale des contacts
|
||||
class ChatSidebar extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> teamContacts;
|
||||
final List<Map<String, dynamic>> clientContacts;
|
||||
final bool isTeamChat;
|
||||
final int selectedContactId;
|
||||
final Function(int, String, bool) onContactSelected;
|
||||
final Function(bool) onToggleGroup;
|
||||
|
||||
const ChatSidebar({
|
||||
Key? key,
|
||||
required this.teamContacts,
|
||||
required this.clientContacts,
|
||||
required this.isTeamChat,
|
||||
required this.selectedContactId,
|
||||
required this.onContactSelected,
|
||||
required this.onToggleGroup,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// En-tête avec les onglets
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
context,
|
||||
'Équipe',
|
||||
isTeamChat,
|
||||
() => onToggleGroup(true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingS),
|
||||
Expanded(
|
||||
child: _buildTabButton(
|
||||
context,
|
||||
'Clients',
|
||||
!isTeamChat,
|
||||
() => onToggleGroup(false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des contacts
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.grey[100],
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
// Afficher les contacts appropriés en fonction de l'onglet sélectionné
|
||||
...isTeamChat
|
||||
? teamContacts.map(
|
||||
(contact) => _buildContactItem(context, contact, true))
|
||||
: clientContacts.map((contact) =>
|
||||
_buildContactItem(context, contact, false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construire un bouton d'onglet
|
||||
Widget _buildTabButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
bool isSelected,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? AppTheme.primaryColor : Colors.grey[200],
|
||||
foregroundColor: isSelected ? Colors.white : Colors.black,
|
||||
elevation: isSelected ? 2 : 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
// Construire un élément de contact
|
||||
Widget _buildContactItem(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> contact,
|
||||
bool isTeam,
|
||||
) {
|
||||
final bool isSelected = contact['id'] == selectedContactId;
|
||||
final bool hasUnread = (contact['unread'] as int) > 0;
|
||||
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.withOpacity(0.1),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: contact['avatar'] != null
|
||||
? AssetImage(contact['avatar'] as String)
|
||||
: null,
|
||||
child: contact['avatar'] == null
|
||||
? Text(
|
||||
(contact['name'] as String).isNotEmpty
|
||||
? (contact['name'] as String)[0].toUpperCase()
|
||||
: '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact['name'] as String,
|
||||
style: TextStyle(
|
||||
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (contact['online'] == true)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
contact['lastMessage'] as String,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
|
||||
color: hasUnread ? Colors.black87 : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(contact['time'] as DateTime),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: hasUnread ? AppTheme.primaryColor : Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
(contact['unread'] as int).toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => onContactSelected(
|
||||
contact['id'] as int,
|
||||
contact['name'] as String,
|
||||
isTeam,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Formater l'heure du dernier message
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final messageDate = DateTime(time.year, time.month, time.day);
|
||||
|
||||
if (messageDate == today) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
} else if (messageDate == yesterday) {
|
||||
return 'Hier';
|
||||
} else {
|
||||
return '${time.day}/${time.month}';
|
||||
}
|
||||
}
|
||||
}
|
||||
156
flutt/lib/presentation/widgets/connectivity_indicator.dart
Normal file
156
flutt/lib/presentation/widgets/connectivity_indicator.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
/// Widget qui affiche l'état de la connexion Internet
|
||||
class ConnectivityIndicator extends StatelessWidget {
|
||||
/// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté
|
||||
final bool showErrorMessage;
|
||||
|
||||
/// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles)
|
||||
final bool showConnectionType;
|
||||
|
||||
/// Callback appelé lorsque l'état de la connexion change
|
||||
final Function(bool isConnected)? onConnectivityChanged;
|
||||
|
||||
const ConnectivityIndicator({
|
||||
super.key,
|
||||
this.showErrorMessage = true,
|
||||
this.showConnectionType = true,
|
||||
this.onConnectivityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Utiliser l'instance globale de connectivityService définie dans app.dart
|
||||
final isConnected = connectivityService.isConnected;
|
||||
final connectionType = connectivityService.connectionType;
|
||||
final connectionStatus = connectivityService.connectionStatus;
|
||||
|
||||
// Appeler le callback si fourni, mais pas directement dans le build
|
||||
// pour éviter les problèmes de rendu
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (onConnectivityChanged != null) {
|
||||
onConnectivityChanged!(isConnected);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isConnected && showErrorMessage) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wifi_off,
|
||||
color: theme.colorScheme.error,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isConnected && showConnectionType) {
|
||||
// Obtenir la couleur et l'icône en fonction du type de connexion
|
||||
final color = _getConnectionColor(connectionStatus, theme);
|
||||
final icon = _getConnectionIcon(connectionStatus);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
connectionType,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Retourne l'icône correspondant au type de connexion
|
||||
IconData _getConnectionIcon(List<ConnectivityResult> statusList) {
|
||||
// Utiliser le premier type de connexion qui n'est pas 'none'
|
||||
ConnectivityResult status = statusList.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none);
|
||||
|
||||
switch (status) {
|
||||
case ConnectivityResult.wifi:
|
||||
return Icons.wifi;
|
||||
case ConnectivityResult.mobile:
|
||||
return Icons.signal_cellular_alt;
|
||||
case ConnectivityResult.ethernet:
|
||||
return Icons.lan;
|
||||
case ConnectivityResult.bluetooth:
|
||||
return Icons.bluetooth;
|
||||
case ConnectivityResult.vpn:
|
||||
return Icons.vpn_key;
|
||||
default:
|
||||
return Icons.wifi_off;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la couleur correspondant au type de connexion
|
||||
Color _getConnectionColor(
|
||||
List<ConnectivityResult> statusList, ThemeData theme) {
|
||||
// Utiliser le premier type de connexion qui n'est pas 'none'
|
||||
ConnectivityResult status = statusList.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none);
|
||||
|
||||
switch (status) {
|
||||
case ConnectivityResult.wifi:
|
||||
return Colors.green;
|
||||
case ConnectivityResult.mobile:
|
||||
return Colors.blue;
|
||||
case ConnectivityResult.ethernet:
|
||||
return Colors.purple;
|
||||
case ConnectivityResult.bluetooth:
|
||||
return Colors.indigo;
|
||||
case ConnectivityResult.vpn:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return theme.colorScheme.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
flutt/lib/presentation/widgets/custom_button.dart
Normal file
71
flutt/lib/presentation/widgets/custom_button.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final bool isLoading;
|
||||
final double? width;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.text,
|
||||
this.icon,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
textColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
flutt/lib/presentation/widgets/custom_text_field.dart
Normal file
134
flutt/lib/presentation/widgets/custom_text_field.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String? hintText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final Function(String)? onChanged;
|
||||
final bool autofocus;
|
||||
final FocusNode? focusNode;
|
||||
final String? errorText;
|
||||
final Color? fillColor;
|
||||
final String? helperText;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
this.hintText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.autofocus = false,
|
||||
this.focusNode,
|
||||
this.errorText,
|
||||
this.fillColor,
|
||||
this.helperText,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label.isNotEmpty) ...[
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
errorText: errorText,
|
||||
helperText: helperText,
|
||||
helperStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
fillColor: fillColor ?? theme.inputDecorationTheme.fillColor,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
169
flutt/lib/presentation/widgets/dashboard_app_bar.dart
Normal file
169
flutt/lib/presentation/widgets/dashboard_app_bar.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// AppBar personnalisée pour les tableaux de bord
|
||||
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Le titre principal de l'AppBar (généralement le nom de l'application)
|
||||
final String title;
|
||||
|
||||
/// Le titre de la page actuelle (optionnel)
|
||||
final String? pageTitle;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
|
||||
final VoidCallback? onNewPassagePressed;
|
||||
|
||||
/// Indique si l'utilisateur est un administrateur
|
||||
final bool isAdmin;
|
||||
|
||||
/// Callback appelé lorsque le bouton de déconnexion est pressé
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardAppBar({
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.pageTitle,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
actions: _buildActions(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du logo dans l'AppBar
|
||||
Widget _buildLogo() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Image.asset(
|
||||
'assets/images/geosector-logo-80.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction des actions de l'AppBar
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final List<Widget> actions = [];
|
||||
|
||||
// Ajouter l'indicateur de connectivité
|
||||
actions.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
child: const ConnectivityIndicator(
|
||||
showErrorMessage: false,
|
||||
showConnectionType: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter les actions supplémentaires si elles existent
|
||||
if (additionalActions != null && additionalActions!.isNotEmpty) {
|
||||
actions.addAll(additionalActions!);
|
||||
} else if (showNewPassageButton) {
|
||||
// Ajouter le bouton "Nouveau passage" en haut à droite
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: onNewPassagePressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter le bouton de déconnexion
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Déconnexion',
|
||||
onPressed: onLogoutPressed ??
|
||||
() {
|
||||
// Si aucun callback n'est fourni, utiliser le userRepository global
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content:
|
||||
const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
// Appeler la méthode logout du userRepository
|
||||
// qui nettoie les hive boxes, lance la requête API logout
|
||||
// et supprime user.sessionId
|
||||
await userRepository.logout();
|
||||
|
||||
// Rediriger vers la landing page
|
||||
if (context.mounted) {
|
||||
// Utiliser go_router pour la navigation
|
||||
context.go('/public');
|
||||
}
|
||||
},
|
||||
child: const Text('Déconnexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
actions.add(const SizedBox(width: 8)); // Espacement à droite
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Construction du titre de l'AppBar
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
// Si aucun titre de page n'est fourni, afficher simplement le titre principal
|
||||
if (pageTitle == null) {
|
||||
return Text(title);
|
||||
}
|
||||
|
||||
// Construire un titre composé en fonction du rôle de l'utilisateur
|
||||
final String prefix = isAdmin ? 'Administration' : title;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
142
flutt/lib/presentation/widgets/dashboard_layout.dart
Normal file
142
flutt/lib/presentation/widgets/dashboard_layout.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
|
||||
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
|
||||
|
||||
/// Layout commun pour les tableaux de bord utilisateur et administrateur
|
||||
/// Combine DashboardAppBar et ResponsiveNavigation
|
||||
class DashboardLayout extends StatelessWidget {
|
||||
/// Le contenu principal à afficher
|
||||
final Widget body;
|
||||
|
||||
/// Le titre de la page
|
||||
final String title;
|
||||
|
||||
/// L'index de la page sélectionnée
|
||||
final int selectedIndex;
|
||||
|
||||
/// Callback appelé lorsqu'un élément de navigation est sélectionné
|
||||
final Function(int) onDestinationSelected;
|
||||
|
||||
/// Liste des destinations de navigation
|
||||
final List<NavigationDestination> destinations;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
|
||||
final VoidCallback? onNewPassagePressed;
|
||||
|
||||
/// Widgets à afficher en bas de la sidebar
|
||||
final List<Widget>? sidebarBottomItems;
|
||||
|
||||
/// Indique si l'utilisateur est un administrateur
|
||||
final bool isAdmin;
|
||||
|
||||
/// Callback appelé lorsque le bouton de déconnexion est pressé
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardLayout({
|
||||
Key? key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.destinations,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@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)'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: DashboardAppBar(
|
||||
title: title,
|
||||
pageTitle: destinations[selectedIndex].label,
|
||||
additionalActions: additionalActions,
|
||||
showNewPassageButton: showNewPassageButton,
|
||||
onNewPassagePressed: onNewPassagePressed,
|
||||
isAdmin: isAdmin,
|
||||
onLogoutPressed: onLogoutPressed,
|
||||
),
|
||||
body: ResponsiveNavigation(
|
||||
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 car il est déjà dans l'AppBar
|
||||
showNewPassageButton: false,
|
||||
onNewPassagePressed: onNewPassagePressed,
|
||||
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),
|
||||
const Text(
|
||||
'Une erreur est survenue',
|
||||
style: TextStyle(fontSize: 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
flutt/lib/presentation/widgets/help_dialog.dart
Normal file
110
flutt/lib/presentation/widgets/help_dialog.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget d'aide commun pour toute l'application
|
||||
/// Affiche une boîte de dialogue modale avec une aide contextuelle
|
||||
/// basée sur la page courante
|
||||
class HelpDialog extends StatelessWidget {
|
||||
/// Nom de la page courante pour laquelle l'aide est demandée
|
||||
final String currentPage;
|
||||
|
||||
const HelpDialog({
|
||||
Key? key,
|
||||
required this.currentPage,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Affiche la boîte de dialogue d'aide
|
||||
static void show(BuildContext context, String currentPage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => HelpDialog(currentPage: currentPage),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
// Calculer la largeur de la boîte de dialogue
|
||||
// 90% de la largeur de l'écran pour les mobiles
|
||||
// 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px)
|
||||
final dialogWidth = isDesktop
|
||||
? size.width * 0.5 > 600
|
||||
? 600.0
|
||||
: size.width * 0.5
|
||||
: size.width * 0.9;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
// Définir la largeur de la boîte de dialogue
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de l'aide avec le nom de la page courante
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aide - Page $currentPage',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
// Contenu de l'aide (à personnaliser selon la page)
|
||||
Text(
|
||||
'Contenu d\'aide pour la page "$currentPage".',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Cette section sera personnalisée avec des instructions spécifiques pour chaque page de l\'application.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Bouton pour fermer la boîte de dialogue
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
flutt/lib/presentation/widgets/loading_overlay.dart
Normal file
88
flutt/lib/presentation/widgets/loading_overlay.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget d'overlay de chargement qui affiche un spinner centré avec un message optionnel
|
||||
/// Utilisé pour les opérations longues comme la connexion, déconnexion et synchronisation
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
final String? message;
|
||||
final Color backgroundColor;
|
||||
final Color spinnerColor;
|
||||
final Color textColor;
|
||||
final double spinnerSize;
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingOverlay({
|
||||
Key? key,
|
||||
this.message,
|
||||
this.backgroundColor = Colors.black54,
|
||||
this.spinnerColor = Colors.white,
|
||||
this.textColor = Colors.white,
|
||||
this.spinnerSize = 60.0,
|
||||
this.strokeWidth = 5.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: backgroundColor,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: spinnerSize,
|
||||
height: spinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(spinnerColor),
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Méthode statique pour afficher l'overlay de chargement
|
||||
static Future<T> show<T>({
|
||||
required BuildContext context,
|
||||
required Future<T> future,
|
||||
String? message,
|
||||
double spinnerSize = 60.0,
|
||||
double strokeWidth = 5.0,
|
||||
}) async {
|
||||
// Afficher l'overlay
|
||||
final overlayEntry = OverlayEntry(
|
||||
builder: (context) => LoadingOverlay(
|
||||
message: message,
|
||||
spinnerSize: spinnerSize,
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(overlayEntry);
|
||||
|
||||
try {
|
||||
// Attendre que le future se termine
|
||||
final result = await future;
|
||||
// Supprimer l'overlay
|
||||
overlayEntry.remove();
|
||||
return result;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, supprimer l'overlay et relancer l'erreur
|
||||
overlayEntry.remove();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
200
flutt/lib/presentation/widgets/mapbox_map.dart
Normal file
200
flutt/lib/presentation/widgets/mapbox_map.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Widget de carte réutilisable utilisant Mapbox
|
||||
///
|
||||
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
|
||||
/// des fonctionnalités pour afficher des marqueurs, des polygones et des contrôles.
|
||||
class MapboxMap extends StatefulWidget {
|
||||
/// Position initiale de la carte
|
||||
final LatLng initialPosition;
|
||||
|
||||
/// Niveau de zoom initial
|
||||
final double initialZoom;
|
||||
|
||||
/// Liste des marqueurs à afficher
|
||||
final List<Marker>? markers;
|
||||
|
||||
/// Liste des polygones à afficher
|
||||
final List<Polygon>? polygons;
|
||||
|
||||
/// Contrôleur de carte externe (optionnel)
|
||||
final MapController? mapController;
|
||||
|
||||
/// Callback appelé lorsque la carte est déplacée
|
||||
final void Function(MapEvent)? onMapEvent;
|
||||
|
||||
/// Afficher les boutons de contrôle (zoom, localisation)
|
||||
final bool showControls;
|
||||
|
||||
/// Style de la carte Mapbox (optionnel)
|
||||
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
|
||||
final String? mapStyle;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
this.initialZoom = 13.0,
|
||||
this.markers,
|
||||
this.polygons,
|
||||
this.mapController,
|
||||
this.onMapEvent,
|
||||
this.showControls = true,
|
||||
this.mapStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MapboxMap> createState() => _MapboxMapState();
|
||||
}
|
||||
|
||||
class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Contrôleur de carte interne
|
||||
late final MapController _mapController;
|
||||
|
||||
/// Niveau de zoom actuel
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = widget.mapController ?? MapController();
|
||||
_currentZoom = widget.initialZoom;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Ne pas disposer le contrôleur s'il a été fourni de l'extérieur
|
||||
if (widget.mapController == null) {
|
||||
_mapController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Construit un bouton de contrôle de carte
|
||||
Widget _buildMapButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 20),
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
final String mapboxToken = AppKeys.mapboxApiKey;
|
||||
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
|
||||
final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte principale
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialPosition,
|
||||
initialZoom: widget.initialZoom,
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
|
||||
_currentZoom = _mapController.camera.zoom;
|
||||
});
|
||||
}
|
||||
|
||||
// Appeler le callback externe si fourni
|
||||
if (widget.onMapEvent != null) {
|
||||
widget.onMapEvent!(event);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// Tuiles de la carte (Mapbox)
|
||||
TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
maxNativeZoom: 19,
|
||||
additionalOptions: {
|
||||
'accessToken': mapboxToken,
|
||||
},
|
||||
),
|
||||
|
||||
// Polygones
|
||||
if (widget.polygons != null && widget.polygons!.isNotEmpty)
|
||||
PolygonLayer(polygons: widget.polygons!),
|
||||
|
||||
// Marqueurs
|
||||
if (widget.markers != null && widget.markers!.isNotEmpty)
|
||||
MarkerLayer(markers: widget.markers!),
|
||||
],
|
||||
),
|
||||
|
||||
// Boutons de contrôle
|
||||
if (widget.showControls)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Bouton de zoom +
|
||||
_buildMapButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
_mapController.camera.center,
|
||||
_mapController.camera.zoom + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bouton de zoom -
|
||||
_buildMapButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
_mapController.camera.center,
|
||||
_mapController.camera.zoom - 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bouton de localisation
|
||||
_buildMapButton(
|
||||
icon: Icons.my_location,
|
||||
onPressed: () {
|
||||
_mapController.move(
|
||||
widget.initialPosition,
|
||||
15,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user