Restructuration majeure du projet: migration de flutt vers app, ajout de l'API et mise à jour du site web
This commit is contained in:
266
app/lib/app.dart
Normal file
266
app/lib/app.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
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/repositories/amicale_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/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 amicaleRepository = AmicaleRepository(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
|
||||
// Gestionnaire de redirection global - intercepte toutes les navigations
|
||||
redirect: (context, state) {
|
||||
// Détection manuelle des paramètres d'URL pour le Web
|
||||
if (kIsWeb && state.uri.path == '/login') {
|
||||
try {
|
||||
// Obtenir le paramètre 'type' de l'URL actuelle
|
||||
final typeParam = state.uri.queryParameters['type'];
|
||||
|
||||
// Obtenir l'URL brute du navigateur pour comparer
|
||||
final rawUri = Uri.parse(Uri.base.toString());
|
||||
final rawTypeParam = rawUri.queryParameters['type'];
|
||||
|
||||
print('APP ROUTER: state.uri = ${state.uri}, type = $typeParam');
|
||||
print('APP ROUTER: rawUri = $rawUri, type = $rawTypeParam');
|
||||
|
||||
// Pas de redirection si on a déjà le paramètre type
|
||||
if (typeParam != null) {
|
||||
print('APP ROUTER: Param type déjà présent, pas de redirection');
|
||||
return null; // Pas de redirection
|
||||
}
|
||||
|
||||
// Si un paramètre type=user est présent dans l'URL brute mais pas dans l'état
|
||||
if (rawTypeParam == 'user' && typeParam == null) {
|
||||
print(
|
||||
'APP ROUTER: Paramètre détecté dans l\'URL brute, redirection vers /login?type=user');
|
||||
return '/login?type=user';
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des paramètres d\'URL: $e');
|
||||
}
|
||||
}
|
||||
// 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')) {
|
||||
userRepository.updateLastPath(state.uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est sur la page de splash
|
||||
if (state.uri.toString() == '/') {
|
||||
// Laisser l'utilisateur sur la page de splash, la redirection sera gérée par SplashPage
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est sur une page d'authentification
|
||||
final isLoggedIn = userRepository.isLoggedIn;
|
||||
final isOnLoginPage = state.uri.toString().startsWith('/login');
|
||||
final isOnRegisterPage = state.uri.toString() == '/register';
|
||||
final isOnAdminRegisterPage = state.uri.toString() == '/admin-register';
|
||||
|
||||
// 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)) {
|
||||
// Récupérer le rôle de l'utilisateur directement
|
||||
final user = userRepository.getCurrentUser();
|
||||
if (user != null) {
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(user.role as String) ?? 1;
|
||||
} else {
|
||||
roleValue = user.role as int;
|
||||
}
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint(
|
||||
'Router: Redirection vers /admin (rôle $roleValue > 1)');
|
||||
return '/admin';
|
||||
} else {
|
||||
debugPrint(
|
||||
'Router: Redirection vers /user (rôle $roleValue = 1)');
|
||||
return '/user';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si l'utilisateur est connecté mais essaie d'accéder à la mauvaise page selon son rôle
|
||||
if (isLoggedIn) {
|
||||
final user = userRepository.getCurrentUser();
|
||||
if (user != null) {
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(user.role as String) ?? 1;
|
||||
} else {
|
||||
roleValue = user.role as int;
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est sur la bonne page en fonction de son rôle
|
||||
final isOnUserPage = state.uri.toString().startsWith('/user');
|
||||
final isOnAdminPage = state.uri.toString().startsWith('/admin');
|
||||
|
||||
// Admin (rôle > 1) essayant d'accéder à une page utilisateur
|
||||
if (roleValue > 1 && isOnUserPage) {
|
||||
debugPrint(
|
||||
'Router: Redirection d\'admin (rôle $roleValue) vers /admin');
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
// Utilisateur standard (rôle = 1) essayant d'accéder à une page admin
|
||||
if (roleValue == 1 && isOnAdminPage) {
|
||||
debugPrint(
|
||||
'Router: Redirection d\'utilisateur (rôle $roleValue) vers /user');
|
||||
return '/user';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Splash screen
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
|
||||
// Page de connexion utilisateur dédiée
|
||||
GoRoute(
|
||||
path: '/login/user',
|
||||
builder: (context, state) {
|
||||
print('ROUTER: Accès direct à la route login user');
|
||||
return const LoginPage(
|
||||
key: Key('login_page_user'),
|
||||
loginType: 'user',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Pages d'authentification standard
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) {
|
||||
// Ajouter des logs de débogage détaillés pour comprendre les paramètres
|
||||
print('ROUTER DEBUG: Uri complète = ${state.uri}');
|
||||
print('ROUTER DEBUG: Path = ${state.uri.path}');
|
||||
print('ROUTER DEBUG: Query params = ${state.uri.queryParameters}');
|
||||
print(
|
||||
'ROUTER DEBUG: Has type? ${state.uri.queryParameters.containsKey("type")}');
|
||||
|
||||
// Donner la priorité aux paramètres d'URL puis aux extras
|
||||
String? loginType;
|
||||
|
||||
// 1. Essayer d'abord les paramètres d'URL (pour les liens externes)
|
||||
final queryParams = state.uri.queryParameters;
|
||||
loginType = queryParams['type'];
|
||||
print('ROUTER DEBUG: Type from query params = $loginType');
|
||||
|
||||
// 2. Si aucun type dans les paramètres d'URL, vérifier les extras (pour la navigation interne)
|
||||
if (loginType == null &&
|
||||
state.extra != null &&
|
||||
state.extra is Map<String, dynamic>) {
|
||||
final extras = state.extra as Map<String, dynamic>;
|
||||
loginType = extras['type']?.toString();
|
||||
print('ROUTER DEBUG: Type from extras = $loginType');
|
||||
}
|
||||
|
||||
// 3. Normaliser et valider le type
|
||||
if (loginType != null) {
|
||||
loginType = loginType.trim().toLowerCase();
|
||||
// Vérifier explicitement que c'est 'user', sinon mettre 'admin'
|
||||
if (loginType != 'user') {
|
||||
loginType = 'admin';
|
||||
}
|
||||
} else {
|
||||
// Si aucun type n'est spécifié, retourner la page de splash
|
||||
print(
|
||||
'ROUTER: Aucun type spécifié, utilisation de la page splash');
|
||||
return const SplashPage();
|
||||
}
|
||||
|
||||
print('ROUTER: Type de connexion final: $loginType');
|
||||
|
||||
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
app/lib/chat/README.md
Normal file
82
app/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
app/lib/chat/chat.dart
Normal file
35
app/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
app/lib/chat/chat_updated.md
Normal file
510
app/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
app/lib/chat/constants/chat_constants.dart
Normal file
50
app/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
app/lib/chat/example_integration/mqtt_integration_example.dart
Normal file
166
app/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
app/lib/chat/models/anonymous_user_model.dart
Normal file
104
app/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
app/lib/chat/models/anonymous_user_model.g.dart
Normal file
59
app/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
app/lib/chat/models/audience_target_model.dart
Normal file
138
app/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
app/lib/chat/models/audience_target_model.g.dart
Normal file
59
app/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
app/lib/chat/models/chat_adapters.dart
Normal file
15
app/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
app/lib/chat/models/chat_config.dart
Normal file
104
app/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
app/lib/chat/models/conversation_model.dart
Normal file
139
app/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
app/lib/chat/models/conversation_model.g.dart
Normal file
68
app/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
app/lib/chat/models/message_model.dart
Normal file
140
app/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
app/lib/chat/models/message_model.g.dart
Normal file
71
app/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
app/lib/chat/models/notification_settings.dart
Normal file
160
app/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
app/lib/chat/models/notification_settings.g.dart
Normal file
68
app/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
app/lib/chat/models/participant_model.dart
Normal file
118
app/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
app/lib/chat/models/participant_model.g.dart
Normal file
65
app/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
app/lib/chat/pages/chat_page.dart
Normal file
79
app/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
app/lib/chat/repositories/chat_repository.dart
Normal file
364
app/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
app/lib/chat/scripts/chat_tables.sql
Normal file
213
app/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
app/lib/chat/scripts/mqtt_notification_sender.php
Normal file
323
app/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
app/lib/chat/scripts/send_notification.php
Normal file
263
app/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
app/lib/chat/services/chat_api_service.dart
Normal file
97
app/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
app/lib/chat/services/notifications/README_MQTT.md
Normal file
214
app/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
app/lib/chat/services/notifications/mqtt_config.dart
Normal file
74
app/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
app/lib/chat/services/offline_queue_service.dart
Normal file
46
app/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
app/lib/chat/widgets/chat_input.dart
Normal file
98
app/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
app/lib/chat/widgets/chat_screen.dart
Normal file
80
app/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
app/lib/chat/widgets/conversations_list.dart
Normal file
78
app/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
app/lib/chat/widgets/message_bubble.dart
Normal file
69
app/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
app/lib/chat/widgets/notification_settings_widget.dart
Normal file
159
app/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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/lib/core/constants/app_keys.dart
Normal file
192
app/lib/core/constants/app_keys.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
/// 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 amicaleBoxName = 'amicale';
|
||||
static const String clientsBoxName = 'clients';
|
||||
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 userSectorBoxName = 'user_sector';
|
||||
static const String chatConversationsBoxName = 'chat_conversations';
|
||||
static const String chatMessagesBoxName = 'chat_messages';
|
||||
static const String regionsBoxName = 'regions';
|
||||
|
||||
// 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 pour les différents environnements
|
||||
static const String baseApiUrlDev = 'https://dapp.geosector.fr/api';
|
||||
static const String baseApiUrlRec = 'https://rapp.geosector.fr/api';
|
||||
static const String baseApiUrlProd = 'https://app.geosector.fr/api';
|
||||
|
||||
// Identifiants d'application pour les différents environnements
|
||||
static const String appIdentifierDev = 'dapp.geosector.fr';
|
||||
static const String appIdentifierRec = 'rapp.geosector.fr';
|
||||
static const String appIdentifierProd = 'app.geosector.fr';
|
||||
|
||||
// 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 mapboxApiKeyDev =
|
||||
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY21hanVmNjN5MTM5djJtczdsMW92cjQ0ciJ9.pUCMuvWPB3cuBaPh4ywTAw';
|
||||
static const String mapboxApiKeyRec =
|
||||
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY21hanVlZ3FiMGx0NDJpc2k4YnkxaWZ2dSJ9.OqGJtjlWRgB4fIjECCB8WA';
|
||||
static const String mapboxApiKeyProd =
|
||||
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY204dTNhNmd0MGV1ZzJqc2pnNnB0NjYxdSJ9.TA5Mvliyn91Oi01F_2Yuxw';
|
||||
|
||||
// Méthode pour obtenir la clé API Mapbox en fonction de l'environnement actuel
|
||||
static String getMapboxApiKey(String environment) {
|
||||
// Utiliser l'environnement passé en paramètre pour déterminer quelle clé retourner
|
||||
switch (environment) {
|
||||
case 'DEV':
|
||||
return mapboxApiKeyDev;
|
||||
case 'REC':
|
||||
return mapboxApiKeyRec;
|
||||
case 'PROD':
|
||||
default:
|
||||
return mapboxApiKeyProd;
|
||||
}
|
||||
}
|
||||
|
||||
// Pour la compatibilité avec le code existant, on garde un getter qui utilise
|
||||
// l'environnement actuel (à utiliser uniquement si l'ApiService n'est pas disponible)
|
||||
static String get mapboxApiKey {
|
||||
// Note: Cette implémentation est une solution de secours et devrait être évitée
|
||||
// Il est préférable d'utiliser getMapboxApiKey(apiService.getCurrentEnvironment())
|
||||
|
||||
// Détection basique de l'environnement basée sur l'URL en mode web
|
||||
if (kIsWeb) {
|
||||
// Essayer d'accéder à l'URL actuelle (fonctionne uniquement en mode web)
|
||||
try {
|
||||
final String currentUrl = Uri.base.toString().toLowerCase();
|
||||
|
||||
if (currentUrl.contains('dapp.geosector.fr')) {
|
||||
return mapboxApiKeyDev;
|
||||
} else if (currentUrl.contains('rapp.geosector.fr')) {
|
||||
return mapboxApiKeyRec;
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, utiliser la clé de production par défaut
|
||||
print('Erreur lors de la détection de l\'environnement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Par défaut, retourner la clé de production
|
||||
return mapboxApiKeyProd;
|
||||
}
|
||||
|
||||
// Headers
|
||||
static const String sessionHeader = 'Authorization';
|
||||
|
||||
// En-têtes par défaut pour les requêtes API
|
||||
// Note: Ces en-têtes seront complétés dynamiquement dans ApiService
|
||||
static const Map<String, String> defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'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 (basés sur la maquette Figma)
|
||||
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': 0xFFB87333, // Couleur cuivrée
|
||||
'icon_data': Icons.payments_outlined,
|
||||
},
|
||||
2: {
|
||||
'titre': 'Chèque',
|
||||
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
|
||||
'icon_data': Icons.account_balance_wallet_outlined,
|
||||
},
|
||||
3: {
|
||||
'titre': 'CB',
|
||||
'couleur': 0xFF0099FF, // Bleu flashy
|
||||
'icon_data': Icons.credit_card,
|
||||
},
|
||||
};
|
||||
|
||||
// Types de passages (basés sur la maquette Figma)
|
||||
static const Map<int, Map<String, dynamic>> typesPassages = {
|
||||
1: {
|
||||
'titres': 'Effectués',
|
||||
'titre': 'Effectué',
|
||||
'couleur1': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur2': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur3': 0xFF00E09D, // Vert (Figma)
|
||||
'icon_data': Icons.task_alt,
|
||||
},
|
||||
2: {
|
||||
'titres': 'À finaliser',
|
||||
'titre': 'À finaliser',
|
||||
'couleur1': 0xFFFFFFFF, // Blanc
|
||||
'couleur2': 0xFFF7A278, // Orange (Figma)
|
||||
'couleur3': 0xFFE65100, // Orange foncé
|
||||
'icon_data': Icons.refresh,
|
||||
},
|
||||
3: {
|
||||
'titres': 'Refusés',
|
||||
'titre': 'Refusé',
|
||||
'couleur1': 0xFFE41B13, // Rouge (Figma)
|
||||
'couleur2': 0xFFE41B13, // Rouge (Figma)
|
||||
'couleur3': 0xFFE41B13, // Rouge (Figma)
|
||||
'icon_data': Icons.block,
|
||||
},
|
||||
4: {
|
||||
'titres': 'Dons',
|
||||
'titre': 'Don',
|
||||
'couleur1': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur2': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur3': 0xFF395AA7, // Bleu (Figma)
|
||||
'icon_data': Icons.volunteer_activism,
|
||||
},
|
||||
5: {
|
||||
'titres': 'Lots',
|
||||
'titre': 'Lot',
|
||||
'couleur1': 0xFF20335E, // Bleu foncé (Figma)
|
||||
'couleur2': 0xFF20335E, // Bleu foncé (Figma)
|
||||
'couleur3': 0xFF20335E, // Bleu foncé (Figma)
|
||||
'icon_data': Icons.layers,
|
||||
},
|
||||
6: {
|
||||
'titres': 'Maisons vides',
|
||||
'titre': 'Maison vide',
|
||||
'couleur1': 0xFFB8B8B8, // Gris (Figma)
|
||||
'couleur2': 0xFFB8B8B8, // Gris (Figma)
|
||||
'couleur3': 0xFFB8B8B8, // Gris (Figma)
|
||||
'icon_data': Icons.home_outlined,
|
||||
},
|
||||
};
|
||||
}
|
||||
14
app/lib/core/constants/reponse-login.json
Normal file
14
app/lib/core/constants/reponse-login.json
Normal file
File diff suppressed because one or more lines are too long
249
app/lib/core/data/models/amicale_model.dart
Normal file
249
app/lib/core/data/models/amicale_model.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'amicale_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 11)
|
||||
class AmicaleModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final String adresse1;
|
||||
|
||||
@HiveField(3)
|
||||
final String adresse2;
|
||||
|
||||
@HiveField(4)
|
||||
final String codePostal;
|
||||
|
||||
@HiveField(5)
|
||||
final String ville;
|
||||
|
||||
@HiveField(6)
|
||||
final int? fkRegion;
|
||||
|
||||
@HiveField(7)
|
||||
final String? libRegion;
|
||||
|
||||
@HiveField(8)
|
||||
final int? fkType;
|
||||
|
||||
@HiveField(9)
|
||||
final String phone;
|
||||
|
||||
@HiveField(10)
|
||||
final String mobile;
|
||||
|
||||
@HiveField(11)
|
||||
final String email;
|
||||
|
||||
@HiveField(12)
|
||||
final String gpsLat;
|
||||
|
||||
@HiveField(13)
|
||||
final String gpsLng;
|
||||
|
||||
@HiveField(14)
|
||||
final String stripeId;
|
||||
|
||||
@HiveField(15)
|
||||
final bool chkDemo;
|
||||
|
||||
@HiveField(16)
|
||||
final bool chkCopieMailRecu;
|
||||
|
||||
@HiveField(17)
|
||||
final bool chkAcceptSms;
|
||||
|
||||
@HiveField(18)
|
||||
final bool chkActive;
|
||||
|
||||
@HiveField(19)
|
||||
final bool chkStripe;
|
||||
|
||||
@HiveField(20)
|
||||
final DateTime? createdAt;
|
||||
|
||||
@HiveField(21)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
AmicaleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.adresse1 = '',
|
||||
this.adresse2 = '',
|
||||
this.codePostal = '',
|
||||
this.ville = '',
|
||||
this.fkRegion,
|
||||
this.libRegion,
|
||||
this.fkType,
|
||||
this.phone = '',
|
||||
this.mobile = '',
|
||||
this.email = '',
|
||||
this.gpsLat = '',
|
||||
this.gpsLng = '',
|
||||
this.stripeId = '',
|
||||
this.chkDemo = false,
|
||||
this.chkCopieMailRecu = false,
|
||||
this.chkAcceptSms = false,
|
||||
this.chkActive = true,
|
||||
this.chkStripe = false,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory AmicaleModel.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 fk_region en int si présent
|
||||
final dynamic rawFkRegion = json['fk_region'];
|
||||
final int? fkRegion = rawFkRegion != null
|
||||
? (rawFkRegion is String ? int.parse(rawFkRegion) : rawFkRegion as int)
|
||||
: null;
|
||||
|
||||
// Convertir fk_type en int si présent
|
||||
final dynamic rawFkType = json['fk_type'];
|
||||
final int? fkType = rawFkType != null
|
||||
? (rawFkType is String ? int.parse(rawFkType) : rawFkType as int)
|
||||
: null;
|
||||
|
||||
// Convertir les booléens
|
||||
final bool chkDemo = json['chk_demo'] == 1 || json['chk_demo'] == true;
|
||||
final bool chkCopieMailRecu =
|
||||
json['chk_copie_mail_recu'] == 1 || json['chk_copie_mail_recu'] == true;
|
||||
final bool chkAcceptSms =
|
||||
json['chk_accept_sms'] == 1 || json['chk_accept_sms'] == true;
|
||||
final bool chkActive =
|
||||
json['chk_active'] == 1 || json['chk_active'] == true;
|
||||
final bool chkStripe =
|
||||
json['chk_stripe'] == 1 || json['chk_stripe'] == true;
|
||||
|
||||
// Traiter les dates si présentes
|
||||
DateTime? createdAt;
|
||||
if (json['created_at'] != null && json['created_at'] != '') {
|
||||
try {
|
||||
createdAt = DateTime.parse(json['created_at']);
|
||||
} catch (e) {
|
||||
createdAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? updatedAt;
|
||||
if (json['updated_at'] != null && json['updated_at'] != '') {
|
||||
try {
|
||||
updatedAt = DateTime.parse(json['updated_at']);
|
||||
} catch (e) {
|
||||
updatedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return AmicaleModel(
|
||||
id: id,
|
||||
name: json['name'] ?? '',
|
||||
adresse1: json['adresse1'] ?? '',
|
||||
adresse2: json['adresse2'] ?? '',
|
||||
codePostal: json['code_postal'] ?? '',
|
||||
ville: json['ville'] ?? '',
|
||||
fkRegion: fkRegion,
|
||||
libRegion: json['lib_region'],
|
||||
fkType: fkType,
|
||||
phone: json['phone'] ?? '',
|
||||
mobile: json['mobile'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
gpsLat: json['gps_lat'] ?? '',
|
||||
gpsLng: json['gps_lng'] ?? '',
|
||||
stripeId: json['stripe_id'] ?? '',
|
||||
chkDemo: chkDemo,
|
||||
chkCopieMailRecu: chkCopieMailRecu,
|
||||
chkAcceptSms: chkAcceptSms,
|
||||
chkActive: chkActive,
|
||||
chkStripe: chkStripe,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'adresse1': adresse1,
|
||||
'adresse2': adresse2,
|
||||
'code_postal': codePostal,
|
||||
'ville': ville,
|
||||
'fk_region': fkRegion,
|
||||
'lib_region': libRegion,
|
||||
'fk_type': fkType,
|
||||
'phone': phone,
|
||||
'mobile': mobile,
|
||||
'email': email,
|
||||
'gps_lat': gpsLat,
|
||||
'gps_lng': gpsLng,
|
||||
'stripe_id': stripeId,
|
||||
'chk_demo': chkDemo ? 1 : 0,
|
||||
'chk_copie_mail_recu': chkCopieMailRecu ? 1 : 0,
|
||||
'chk_accept_sms': chkAcceptSms ? 1 : 0,
|
||||
'chk_active': chkActive ? 1 : 0,
|
||||
'chk_stripe': chkStripe ? 1 : 0,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
AmicaleModel copyWith({
|
||||
String? name,
|
||||
String? adresse1,
|
||||
String? adresse2,
|
||||
String? codePostal,
|
||||
String? ville,
|
||||
int? fkRegion,
|
||||
String? libRegion,
|
||||
int? fkType,
|
||||
String? phone,
|
||||
String? mobile,
|
||||
String? email,
|
||||
String? gpsLat,
|
||||
String? gpsLng,
|
||||
String? stripeId,
|
||||
bool? chkDemo,
|
||||
bool? chkCopieMailRecu,
|
||||
bool? chkAcceptSms,
|
||||
bool? chkActive,
|
||||
bool? chkStripe,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return AmicaleModel(
|
||||
id: this.id,
|
||||
name: name ?? this.name,
|
||||
adresse1: adresse1 ?? this.adresse1,
|
||||
adresse2: adresse2 ?? this.adresse2,
|
||||
codePostal: codePostal ?? this.codePostal,
|
||||
ville: ville ?? this.ville,
|
||||
fkRegion: fkRegion ?? this.fkRegion,
|
||||
libRegion: libRegion ?? this.libRegion,
|
||||
fkType: fkType ?? this.fkType,
|
||||
phone: phone ?? this.phone,
|
||||
mobile: mobile ?? this.mobile,
|
||||
email: email ?? this.email,
|
||||
gpsLat: gpsLat ?? this.gpsLat,
|
||||
gpsLng: gpsLng ?? this.gpsLng,
|
||||
stripeId: stripeId ?? this.stripeId,
|
||||
chkDemo: chkDemo ?? this.chkDemo,
|
||||
chkCopieMailRecu: chkCopieMailRecu ?? this.chkCopieMailRecu,
|
||||
chkAcceptSms: chkAcceptSms ?? this.chkAcceptSms,
|
||||
chkActive: chkActive ?? this.chkActive,
|
||||
chkStripe: chkStripe ?? this.chkStripe,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
104
app/lib/core/data/models/amicale_model.g.dart
Normal file
104
app/lib/core/data/models/amicale_model.g.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'amicale_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
|
||||
@override
|
||||
final int typeId = 11;
|
||||
|
||||
@override
|
||||
AmicaleModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AmicaleModel(
|
||||
id: fields[0] as int,
|
||||
name: fields[1] as String,
|
||||
adresse1: fields[2] as String,
|
||||
adresse2: fields[3] as String,
|
||||
codePostal: fields[4] as String,
|
||||
ville: fields[5] as String,
|
||||
fkRegion: fields[6] as int?,
|
||||
libRegion: fields[7] as String?,
|
||||
fkType: fields[8] as int?,
|
||||
phone: fields[9] as String,
|
||||
mobile: fields[10] as String,
|
||||
email: fields[11] as String,
|
||||
gpsLat: fields[12] as String,
|
||||
gpsLng: fields[13] as String,
|
||||
stripeId: fields[14] as String,
|
||||
chkDemo: fields[15] as bool,
|
||||
chkCopieMailRecu: fields[16] as bool,
|
||||
chkAcceptSms: fields[17] as bool,
|
||||
chkActive: fields[18] as bool,
|
||||
chkStripe: fields[19] as bool,
|
||||
createdAt: fields[20] as DateTime?,
|
||||
updatedAt: fields[21] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AmicaleModel obj) {
|
||||
writer
|
||||
..writeByte(22)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.adresse1)
|
||||
..writeByte(3)
|
||||
..write(obj.adresse2)
|
||||
..writeByte(4)
|
||||
..write(obj.codePostal)
|
||||
..writeByte(5)
|
||||
..write(obj.ville)
|
||||
..writeByte(6)
|
||||
..write(obj.fkRegion)
|
||||
..writeByte(7)
|
||||
..write(obj.libRegion)
|
||||
..writeByte(8)
|
||||
..write(obj.fkType)
|
||||
..writeByte(9)
|
||||
..write(obj.phone)
|
||||
..writeByte(10)
|
||||
..write(obj.mobile)
|
||||
..writeByte(11)
|
||||
..write(obj.email)
|
||||
..writeByte(12)
|
||||
..write(obj.gpsLat)
|
||||
..writeByte(13)
|
||||
..write(obj.gpsLng)
|
||||
..writeByte(14)
|
||||
..write(obj.stripeId)
|
||||
..writeByte(15)
|
||||
..write(obj.chkDemo)
|
||||
..writeByte(16)
|
||||
..write(obj.chkCopieMailRecu)
|
||||
..writeByte(17)
|
||||
..write(obj.chkAcceptSms)
|
||||
..writeByte(18)
|
||||
..write(obj.chkActive)
|
||||
..writeByte(19)
|
||||
..write(obj.chkStripe)
|
||||
..writeByte(20)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(21)
|
||||
..write(obj.updatedAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AmicaleModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
200
app/lib/core/data/models/client_model.dart
Normal file
200
app/lib/core/data/models/client_model.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'client_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 10)
|
||||
class ClientModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
|
||||
@HiveField(2)
|
||||
final String? adresse1;
|
||||
|
||||
@HiveField(3)
|
||||
final String? adresse2;
|
||||
|
||||
@HiveField(4)
|
||||
final String? codePostal;
|
||||
|
||||
@HiveField(5)
|
||||
final String? ville;
|
||||
|
||||
@HiveField(6)
|
||||
final int? fkRegion;
|
||||
|
||||
@HiveField(7)
|
||||
final String? libRegion;
|
||||
|
||||
@HiveField(8)
|
||||
final int? fkType;
|
||||
|
||||
@HiveField(9)
|
||||
final String? phone;
|
||||
|
||||
@HiveField(10)
|
||||
final String? mobile;
|
||||
|
||||
@HiveField(11)
|
||||
final String? email;
|
||||
|
||||
@HiveField(12)
|
||||
final String? gpsLat;
|
||||
|
||||
@HiveField(13)
|
||||
final String? gpsLng;
|
||||
|
||||
@HiveField(14)
|
||||
final String? stripeId;
|
||||
|
||||
@HiveField(15)
|
||||
final bool? chkDemo;
|
||||
|
||||
@HiveField(16)
|
||||
final bool? chkCopieMailRecu;
|
||||
|
||||
@HiveField(17)
|
||||
final bool? chkAcceptSms;
|
||||
|
||||
@HiveField(18)
|
||||
final bool? chkActive;
|
||||
|
||||
ClientModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.adresse1,
|
||||
this.adresse2,
|
||||
this.codePostal,
|
||||
this.ville,
|
||||
this.fkRegion,
|
||||
this.libRegion,
|
||||
this.fkType,
|
||||
this.phone,
|
||||
this.mobile,
|
||||
this.email,
|
||||
this.gpsLat,
|
||||
this.gpsLng,
|
||||
this.stripeId,
|
||||
this.chkDemo,
|
||||
this.chkCopieMailRecu,
|
||||
this.chkAcceptSms,
|
||||
this.chkActive,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory ClientModel.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 fk_region en int si présent
|
||||
int? fkRegion;
|
||||
if (json['fk_region'] != null) {
|
||||
final dynamic rawFkRegion = json['fk_region'];
|
||||
fkRegion =
|
||||
rawFkRegion is String ? int.parse(rawFkRegion) : rawFkRegion as int;
|
||||
}
|
||||
|
||||
// Convertir fk_type en int si présent
|
||||
int? fkType;
|
||||
if (json['fk_type'] != null) {
|
||||
final dynamic rawFkType = json['fk_type'];
|
||||
fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
|
||||
}
|
||||
|
||||
return ClientModel(
|
||||
id: id,
|
||||
name: json['name'] ?? '',
|
||||
adresse1: json['adresse1'],
|
||||
adresse2: json['adresse2'],
|
||||
codePostal: json['code_postal'],
|
||||
ville: json['ville'],
|
||||
fkRegion: fkRegion,
|
||||
libRegion: json['lib_region'],
|
||||
fkType: fkType,
|
||||
phone: json['phone'],
|
||||
mobile: json['mobile'],
|
||||
email: json['email'],
|
||||
gpsLat: json['gps_lat'],
|
||||
gpsLng: json['gps_lng'],
|
||||
stripeId: json['stripe_id'],
|
||||
chkDemo: json['chk_demo'] == 1 || json['chk_demo'] == true,
|
||||
chkCopieMailRecu: json['chk_copie_mail_recu'] == 1 ||
|
||||
json['chk_copie_mail_recu'] == true,
|
||||
chkAcceptSms:
|
||||
json['chk_accept_sms'] == 1 || json['chk_accept_sms'] == true,
|
||||
chkActive: json['chk_active'] == 1 || json['chk_active'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'adresse1': adresse1,
|
||||
'adresse2': adresse2,
|
||||
'code_postal': codePostal,
|
||||
'ville': ville,
|
||||
'fk_region': fkRegion,
|
||||
'lib_region': libRegion,
|
||||
'fk_type': fkType,
|
||||
'phone': phone,
|
||||
'mobile': mobile,
|
||||
'email': email,
|
||||
'gps_lat': gpsLat,
|
||||
'gps_lng': gpsLng,
|
||||
'stripe_id': stripeId,
|
||||
'chk_demo': chkDemo,
|
||||
'chk_copie_mail_recu': chkCopieMailRecu,
|
||||
'chk_accept_sms': chkAcceptSms,
|
||||
'chk_active': chkActive,
|
||||
};
|
||||
}
|
||||
|
||||
// Copier avec de nouvelles valeurs
|
||||
ClientModel copyWith({
|
||||
String? name,
|
||||
String? adresse1,
|
||||
String? adresse2,
|
||||
String? codePostal,
|
||||
String? ville,
|
||||
int? fkRegion,
|
||||
String? libRegion,
|
||||
int? fkType,
|
||||
String? phone,
|
||||
String? mobile,
|
||||
String? email,
|
||||
String? gpsLat,
|
||||
String? gpsLng,
|
||||
String? stripeId,
|
||||
bool? chkDemo,
|
||||
bool? chkCopieMailRecu,
|
||||
bool? chkAcceptSms,
|
||||
bool? chkActive,
|
||||
}) {
|
||||
return ClientModel(
|
||||
id: this.id,
|
||||
name: name ?? this.name,
|
||||
adresse1: adresse1 ?? this.adresse1,
|
||||
adresse2: adresse2 ?? this.adresse2,
|
||||
codePostal: codePostal ?? this.codePostal,
|
||||
ville: ville ?? this.ville,
|
||||
fkRegion: fkRegion ?? this.fkRegion,
|
||||
libRegion: libRegion ?? this.libRegion,
|
||||
fkType: fkType ?? this.fkType,
|
||||
phone: phone ?? this.phone,
|
||||
mobile: mobile ?? this.mobile,
|
||||
email: email ?? this.email,
|
||||
gpsLat: gpsLat ?? this.gpsLat,
|
||||
gpsLng: gpsLng ?? this.gpsLng,
|
||||
stripeId: stripeId ?? this.stripeId,
|
||||
chkDemo: chkDemo ?? this.chkDemo,
|
||||
chkCopieMailRecu: chkCopieMailRecu ?? this.chkCopieMailRecu,
|
||||
chkAcceptSms: chkAcceptSms ?? this.chkAcceptSms,
|
||||
chkActive: chkActive ?? this.chkActive,
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/lib/core/data/models/client_model.g.dart
Normal file
95
app/lib/core/data/models/client_model.g.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'client_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ClientModelAdapter extends TypeAdapter<ClientModel> {
|
||||
@override
|
||||
final int typeId = 10;
|
||||
|
||||
@override
|
||||
ClientModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ClientModel(
|
||||
id: fields[0] as int,
|
||||
name: fields[1] as String,
|
||||
adresse1: fields[2] as String?,
|
||||
adresse2: fields[3] as String?,
|
||||
codePostal: fields[4] as String?,
|
||||
ville: fields[5] as String?,
|
||||
fkRegion: fields[6] as int?,
|
||||
libRegion: fields[7] as String?,
|
||||
fkType: fields[8] as int?,
|
||||
phone: fields[9] as String?,
|
||||
mobile: fields[10] as String?,
|
||||
email: fields[11] as String?,
|
||||
gpsLat: fields[12] as String?,
|
||||
gpsLng: fields[13] as String?,
|
||||
stripeId: fields[14] as String?,
|
||||
chkDemo: fields[15] as bool?,
|
||||
chkCopieMailRecu: fields[16] as bool?,
|
||||
chkAcceptSms: fields[17] as bool?,
|
||||
chkActive: fields[18] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ClientModel obj) {
|
||||
writer
|
||||
..writeByte(19)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.adresse1)
|
||||
..writeByte(3)
|
||||
..write(obj.adresse2)
|
||||
..writeByte(4)
|
||||
..write(obj.codePostal)
|
||||
..writeByte(5)
|
||||
..write(obj.ville)
|
||||
..writeByte(6)
|
||||
..write(obj.fkRegion)
|
||||
..writeByte(7)
|
||||
..write(obj.libRegion)
|
||||
..writeByte(8)
|
||||
..write(obj.fkType)
|
||||
..writeByte(9)
|
||||
..write(obj.phone)
|
||||
..writeByte(10)
|
||||
..write(obj.mobile)
|
||||
..writeByte(11)
|
||||
..write(obj.email)
|
||||
..writeByte(12)
|
||||
..write(obj.gpsLat)
|
||||
..writeByte(13)
|
||||
..write(obj.gpsLng)
|
||||
..writeByte(14)
|
||||
..write(obj.stripeId)
|
||||
..writeByte(15)
|
||||
..write(obj.chkDemo)
|
||||
..writeByte(16)
|
||||
..write(obj.chkCopieMailRecu)
|
||||
..writeByte(17)
|
||||
..write(obj.chkAcceptSms)
|
||||
..writeByte(18)
|
||||
..write(obj.chkActive);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ClientModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
137
app/lib/core/data/models/membre_model.dart
Normal file
137
app/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
app/lib/core/data/models/membre_model.g.dart
Normal file
71
app/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
app/lib/core/data/models/operation_model.dart
Normal file
85
app/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
app/lib/core/data/models/operation_model.g.dart
Normal file
59
app/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
app/lib/core/data/models/passage_model.dart
Normal file
291
app/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
app/lib/core/data/models/passage_model.g.dart
Normal file
125
app/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;
|
||||
}
|
||||
89
app/lib/core/data/models/region_model.dart
Normal file
89
app/lib/core/data/models/region_model.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'region_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 7) // Assurez-vous que cet ID est unique
|
||||
class RegionModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id;
|
||||
|
||||
@HiveField(1)
|
||||
final int fkPays;
|
||||
|
||||
@HiveField(2)
|
||||
final String libelle;
|
||||
|
||||
@HiveField(3)
|
||||
final String? libelleLong;
|
||||
|
||||
@HiveField(4)
|
||||
final String? tableOsm;
|
||||
|
||||
@HiveField(5)
|
||||
final String? departements;
|
||||
|
||||
@HiveField(6)
|
||||
final bool chkActive;
|
||||
|
||||
RegionModel({
|
||||
required this.id,
|
||||
required this.fkPays,
|
||||
required this.libelle,
|
||||
this.libelleLong,
|
||||
this.tableOsm,
|
||||
this.departements,
|
||||
this.chkActive = true,
|
||||
});
|
||||
|
||||
// Constructeur de copie
|
||||
RegionModel copyWith({
|
||||
int? id,
|
||||
int? fkPays,
|
||||
String? libelle,
|
||||
String? libelleLong,
|
||||
String? tableOsm,
|
||||
String? departements,
|
||||
bool? chkActive,
|
||||
}) {
|
||||
return RegionModel(
|
||||
id: id ?? this.id,
|
||||
fkPays: fkPays ?? this.fkPays,
|
||||
libelle: libelle ?? this.libelle,
|
||||
libelleLong: libelleLong ?? this.libelleLong,
|
||||
tableOsm: tableOsm ?? this.tableOsm,
|
||||
departements: departements ?? this.departements,
|
||||
chkActive: chkActive ?? this.chkActive,
|
||||
);
|
||||
}
|
||||
|
||||
// Conversion depuis JSON
|
||||
factory RegionModel.fromJson(Map<String, dynamic> json) {
|
||||
return RegionModel(
|
||||
id: json['id'] as int,
|
||||
fkPays: json['fk_pays'] as int,
|
||||
libelle: json['libelle'] as String,
|
||||
libelleLong: json['libelle_long'] as String?,
|
||||
tableOsm: json['table_osm'] as String?,
|
||||
departements: json['departements'] as String?,
|
||||
chkActive: json['chk_active'] == 1 || json['chk_active'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
// Conversion vers JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'fk_pays': fkPays,
|
||||
'libelle': libelle,
|
||||
'libelle_long': libelleLong,
|
||||
'table_osm': tableOsm,
|
||||
'departements': departements,
|
||||
'chk_active': chkActive ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RegionModel(id: $id, libelle: $libelle)';
|
||||
}
|
||||
}
|
||||
59
app/lib/core/data/models/region_model.g.dart
Normal file
59
app/lib/core/data/models/region_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'region_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class RegionModelAdapter extends TypeAdapter<RegionModel> {
|
||||
@override
|
||||
final int typeId = 7;
|
||||
|
||||
@override
|
||||
RegionModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return RegionModel(
|
||||
id: fields[0] as int,
|
||||
fkPays: fields[1] as int,
|
||||
libelle: fields[2] as String,
|
||||
libelleLong: fields[3] as String?,
|
||||
tableOsm: fields[4] as String?,
|
||||
departements: fields[5] as String?,
|
||||
chkActive: fields[6] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, RegionModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.fkPays)
|
||||
..writeByte(2)
|
||||
..write(obj.libelle)
|
||||
..writeByte(3)
|
||||
..write(obj.libelleLong)
|
||||
..writeByte(4)
|
||||
..write(obj.tableOsm)
|
||||
..writeByte(5)
|
||||
..write(obj.departements)
|
||||
..writeByte(6)
|
||||
..write(obj.chkActive);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is RegionModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
85
app/lib/core/data/models/sector_model.dart
Normal file
85
app/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
app/lib/core/data/models/sector_model.g.dart
Normal file
50
app/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;
|
||||
}
|
||||
242
app/lib/core/data/models/user_model.dart
Normal file
242
app/lib/core/data/models/user_model.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
int? fkEntite;
|
||||
|
||||
@HiveField(15)
|
||||
int? fkTitre;
|
||||
|
||||
@HiveField(16)
|
||||
String? phone;
|
||||
|
||||
@HiveField(17)
|
||||
String? mobile;
|
||||
|
||||
@HiveField(18)
|
||||
DateTime? dateNaissance;
|
||||
|
||||
@HiveField(19)
|
||||
DateTime? dateEmbauche;
|
||||
|
||||
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.fkEntite,
|
||||
this.fkTitre,
|
||||
this.phone,
|
||||
this.mobile,
|
||||
this.dateNaissance,
|
||||
this.dateEmbauche,
|
||||
});
|
||||
|
||||
// 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'] ?? json['fk_role'];
|
||||
final int role = rawRole is String ? int.parse(rawRole) : rawRole as int;
|
||||
|
||||
// Convertir fk_entite en int si présent
|
||||
final dynamic rawFkEntite = json['fk_entite'];
|
||||
final int? fkEntite = rawFkEntite != null
|
||||
? (rawFkEntite is String ? int.parse(rawFkEntite) : rawFkEntite as int)
|
||||
: null;
|
||||
|
||||
// Convertir fk_titre en int si présent
|
||||
final dynamic rawFkTitre = json['fk_titre'];
|
||||
final int? fkTitre = rawFkTitre != null
|
||||
? (rawFkTitre is String ? int.parse(rawFkTitre) : rawFkTitre as int)
|
||||
: null;
|
||||
|
||||
// Traiter les dates si présentes
|
||||
DateTime? dateNaissance;
|
||||
if (json['date_naissance'] != null && json['date_naissance'] != '') {
|
||||
try {
|
||||
dateNaissance = DateTime.parse(json['date_naissance']);
|
||||
} catch (e) {
|
||||
dateNaissance = null;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? dateEmbauche;
|
||||
if (json['date_embauche'] != null && json['date_embauche'] != '') {
|
||||
try {
|
||||
dateEmbauche = DateTime.parse(json['date_embauche']);
|
||||
} catch (e) {
|
||||
dateEmbauche = null;
|
||||
}
|
||||
}
|
||||
|
||||
return UserModel(
|
||||
id: id,
|
||||
email: json['email'] ?? '',
|
||||
name: json['name'],
|
||||
username: json['username'],
|
||||
firstName: json['first_name'],
|
||||
role: role,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
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'],
|
||||
fkEntite: fkEntite,
|
||||
fkTitre: fkTitre,
|
||||
phone: json['phone'],
|
||||
mobile: json['mobile'],
|
||||
dateNaissance: dateNaissance,
|
||||
dateEmbauche: dateEmbauche,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'fk_entite': fkEntite,
|
||||
'fk_titre': fkTitre,
|
||||
'phone': phone,
|
||||
'mobile': mobile,
|
||||
'date_naissance': dateNaissance?.toIso8601String(),
|
||||
'date_embauche': dateEmbauche?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
int? fkEntite,
|
||||
int? fkTitre,
|
||||
String? phone,
|
||||
String? mobile,
|
||||
DateTime? dateNaissance,
|
||||
DateTime? dateEmbauche,
|
||||
}) {
|
||||
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,
|
||||
fkEntite: fkEntite ?? this.fkEntite,
|
||||
fkTitre: fkTitre ?? this.fkTitre,
|
||||
phone: phone ?? this.phone,
|
||||
mobile: mobile ?? this.mobile,
|
||||
dateNaissance: dateNaissance ?? this.dateNaissance,
|
||||
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/lib/core/data/models/user_model.g.dart
Normal file
98
app/lib/core/data/models/user_model.g.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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?,
|
||||
fkEntite: fields[14] as int?,
|
||||
fkTitre: fields[15] as int?,
|
||||
phone: fields[16] as String?,
|
||||
mobile: fields[17] as String?,
|
||||
dateNaissance: fields[18] as DateTime?,
|
||||
dateEmbauche: fields[19] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, UserModel obj) {
|
||||
writer
|
||||
..writeByte(20)
|
||||
..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.fkEntite)
|
||||
..writeByte(15)
|
||||
..write(obj.fkTitre)
|
||||
..writeByte(16)
|
||||
..write(obj.phone)
|
||||
..writeByte(17)
|
||||
..write(obj.mobile)
|
||||
..writeByte(18)
|
||||
..write(obj.dateNaissance)
|
||||
..writeByte(19)
|
||||
..write(obj.dateEmbauche);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
80
app/lib/core/data/models/user_sector_model.dart
Normal file
80
app/lib/core/data/models/user_sector_model.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'user_sector_model.g.dart';
|
||||
|
||||
/// Modèle pour stocker les associations entre utilisateurs et secteurs
|
||||
///
|
||||
/// Cette classe représente l'association entre un utilisateur et un secteur,
|
||||
/// telle que reçue de l'API dans la réponse users_sectors.
|
||||
@HiveType(
|
||||
typeId: 7) // Assurez-vous que cet ID est unique parmi vos modèles Hive
|
||||
class UserSectorModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int id; // ID de l'utilisateur
|
||||
|
||||
@HiveField(1)
|
||||
final String? firstName;
|
||||
|
||||
@HiveField(2)
|
||||
final String? sectName;
|
||||
|
||||
@HiveField(3)
|
||||
final int fkSector; // ID du secteur
|
||||
|
||||
@HiveField(4)
|
||||
final String? name;
|
||||
|
||||
UserSectorModel({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.sectName,
|
||||
required this.fkSector,
|
||||
this.name,
|
||||
});
|
||||
|
||||
/// Crée un modèle UserSectorModel à partir d'un objet JSON
|
||||
factory UserSectorModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserSectorModel(
|
||||
id: json['id'] is String ? int.parse(json['id']) : json['id'],
|
||||
firstName: json['first_name'],
|
||||
sectName: json['sect_name'],
|
||||
fkSector: json['fk_sector'] is String
|
||||
? int.parse(json['fk_sector'])
|
||||
: json['fk_sector'],
|
||||
name: json['name'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit le modèle en objet JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'first_name': firstName,
|
||||
'sect_name': sectName,
|
||||
'fk_sector': fkSector,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
/// Crée une copie du modèle avec des valeurs potentiellement modifiées
|
||||
UserSectorModel copyWith({
|
||||
int? id,
|
||||
String? firstName,
|
||||
String? sectName,
|
||||
int? fkSector,
|
||||
String? name,
|
||||
}) {
|
||||
return UserSectorModel(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
sectName: sectName ?? this.sectName,
|
||||
fkSector: fkSector ?? this.fkSector,
|
||||
name: name ?? this.name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserSectorModel(id: $id, firstName: $firstName, sectName: $sectName, fkSector: $fkSector, name: $name)';
|
||||
}
|
||||
}
|
||||
53
app/lib/core/data/models/user_sector_model.g.dart
Normal file
53
app/lib/core/data/models/user_sector_model.g.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_sector_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class UserSectorModelAdapter extends TypeAdapter<UserSectorModel> {
|
||||
@override
|
||||
final int typeId = 7;
|
||||
|
||||
@override
|
||||
UserSectorModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return UserSectorModel(
|
||||
id: fields[0] as int,
|
||||
firstName: fields[1] as String?,
|
||||
sectName: fields[2] as String?,
|
||||
fkSector: fields[3] as int,
|
||||
name: fields[4] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, UserSectorModel obj) {
|
||||
writer
|
||||
..writeByte(5)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.firstName)
|
||||
..writeByte(2)
|
||||
..write(obj.sectName)
|
||||
..writeByte(3)
|
||||
..write(obj.fkSector)
|
||||
..writeByte(4)
|
||||
..write(obj.name);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserSectorModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
75
app/lib/core/models/loading_state.dart
Normal file
75
app/lib/core/models/loading_state.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
/// Modèle pour suivre l'état du chargement des données
|
||||
class LoadingState {
|
||||
/// Progression globale (0.0 à 1.0)
|
||||
final double progress;
|
||||
|
||||
/// Description de l'étape en cours
|
||||
final String? stepDescription;
|
||||
|
||||
/// Message principal
|
||||
final String? message;
|
||||
|
||||
/// Indique si le chargement est terminé
|
||||
final bool isCompleted;
|
||||
|
||||
/// Indique si une erreur s'est produite
|
||||
final bool hasError;
|
||||
|
||||
/// Message d'erreur éventuel
|
||||
final String? errorMessage;
|
||||
|
||||
const LoadingState({
|
||||
this.progress = 0.0,
|
||||
this.stepDescription,
|
||||
this.message,
|
||||
this.isCompleted = false,
|
||||
this.hasError = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Crée un nouvel état de chargement avec les valeurs mises à jour
|
||||
LoadingState copyWith({
|
||||
double? progress,
|
||||
String? stepDescription,
|
||||
String? message,
|
||||
bool? isCompleted,
|
||||
bool? hasError,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return LoadingState(
|
||||
progress: progress ?? this.progress,
|
||||
stepDescription: stepDescription ?? this.stepDescription,
|
||||
message: message ?? this.message,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
hasError: hasError ?? this.hasError,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// État initial du chargement
|
||||
static const initial = LoadingState(
|
||||
progress: 0.0,
|
||||
message: 'Chargement en cours...',
|
||||
isCompleted: false,
|
||||
hasError: false,
|
||||
);
|
||||
|
||||
/// État de chargement terminé avec succès
|
||||
static const completed = LoadingState(
|
||||
progress: 1.0,
|
||||
message: 'Chargement terminé',
|
||||
isCompleted: true,
|
||||
hasError: false,
|
||||
);
|
||||
|
||||
/// Crée un état d'erreur
|
||||
static LoadingState error(String message) {
|
||||
return LoadingState(
|
||||
progress: 0.0,
|
||||
message: 'Erreur de chargement',
|
||||
errorMessage: message,
|
||||
isCompleted: true,
|
||||
hasError: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
295
app/lib/core/repositories/amicale_repository.dart
Normal file
295
app/lib/core/repositories/amicale_repository.dart
Normal file
@@ -0,0 +1,295 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.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/amicale_model.dart';
|
||||
|
||||
class AmicaleRepository extends ChangeNotifier {
|
||||
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
Box<AmicaleModel> get _amicaleBox =>
|
||||
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
|
||||
final ApiService _apiService;
|
||||
bool _isLoading = false;
|
||||
|
||||
AmicaleRepository(this._apiService);
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// 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.amicaleBoxName)) {
|
||||
debugPrint('Ouverture de la boîte amicale...');
|
||||
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture de la boîte amicale: $e');
|
||||
throw Exception('Impossible d\'ouvrir la boîte amicale: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer toutes les amicales
|
||||
List<AmicaleModel> getAllAmicales() {
|
||||
try {
|
||||
_ensureBoxIsOpen();
|
||||
return _amicaleBox.values.toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des amicales: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer une amicale par son ID
|
||||
AmicaleModel? getAmicaleById(int id) {
|
||||
try {
|
||||
_ensureBoxIsOpen();
|
||||
return _amicaleBox.get(id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de l\'amicale: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'amicale de l'utilisateur connecté (basé sur fkEntite)
|
||||
AmicaleModel? getAmicaleByUserId(int userId, int fkEntite) {
|
||||
try {
|
||||
_ensureBoxIsOpen();
|
||||
return _amicaleBox.get(fkEntite);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la récupération de l\'amicale de l\'utilisateur: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour une amicale localement
|
||||
Future<AmicaleModel> saveAmicale(AmicaleModel amicale) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _amicaleBox.put(amicale.id, amicale);
|
||||
notifyListeners(); // Notifier les changements pour mettre à jour l'UI
|
||||
return amicale;
|
||||
}
|
||||
|
||||
// Supprimer une amicale localement
|
||||
Future<void> deleteAmicale(int id) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _amicaleBox.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Vider la boîte des amicales
|
||||
Future<void> clearAmicales() async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _amicaleBox.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Traiter les données des amicales reçues de l'API
|
||||
Future<void> processAmicalesData(dynamic amicalesData) async {
|
||||
try {
|
||||
debugPrint('Traitement des données des amicales...');
|
||||
debugPrint('Détails amicale: $amicalesData');
|
||||
|
||||
// Vérifier que les données sont au bon format
|
||||
if (amicalesData == null) {
|
||||
debugPrint('Aucune donnée d\'amicale à traiter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vider la boîte avant d'ajouter les nouvelles données
|
||||
await _ensureBoxIsOpen();
|
||||
await _amicaleBox.clear();
|
||||
|
||||
int count = 0;
|
||||
|
||||
// Cas 1: Les données sont une liste d'amicales
|
||||
if (amicalesData is List) {
|
||||
for (final amicaleData in amicalesData) {
|
||||
try {
|
||||
final amicale = AmicaleModel.fromJson(amicaleData);
|
||||
await _amicaleBox.put(amicale.id, amicale);
|
||||
count++;
|
||||
debugPrint('Amicale traitée: ${amicale.name} (ID: ${amicale.id})');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement d\'une amicale: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cas 2: Les données sont un objet avec une clé 'data' contenant une liste
|
||||
else if (amicalesData is Map && amicalesData.containsKey('data')) {
|
||||
final amicalesList = amicalesData['data'] as List<dynamic>;
|
||||
for (final amicaleData in amicalesList) {
|
||||
try {
|
||||
final amicale = AmicaleModel.fromJson(amicaleData);
|
||||
await _amicaleBox.put(amicale.id, amicale);
|
||||
count++;
|
||||
debugPrint('Amicale traitée: ${amicale.name} (ID: ${amicale.id})');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement d\'une amicale: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cas 3: Les données sont un objet amicale unique (pas une liste)
|
||||
else if (amicalesData is Map) {
|
||||
try {
|
||||
// Convertir Map<dynamic, dynamic> en Map<String, dynamic>
|
||||
final Map<String, dynamic> amicaleMap = {};
|
||||
amicalesData.forEach((key, value) {
|
||||
if (key is String) {
|
||||
amicaleMap[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
final amicale = AmicaleModel.fromJson(amicaleMap);
|
||||
await _amicaleBox.put(amicale.id, amicale);
|
||||
count++;
|
||||
debugPrint(
|
||||
'Amicale unique traitée: ${amicale.name} (ID: ${amicale.id})');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement de l\'amicale unique: $e');
|
||||
debugPrint('Exception détaillée: $e');
|
||||
}
|
||||
} else {
|
||||
debugPrint('Format de données d\'amicale non reconnu');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('$count amicales traitées et stockées');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement des amicales: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les amicales depuis l'API
|
||||
Future<List<AmicaleModel>> fetchAmicalesFromApi() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.get('/amicales');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final amicalesData = response.data;
|
||||
await processAmicalesData(amicalesData);
|
||||
return getAllAmicales();
|
||||
} else {
|
||||
debugPrint(
|
||||
'Erreur lors de la récupération des amicales: ${response.statusCode}');
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des amicales: $e');
|
||||
return [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer une amicale spécifique depuis l'API
|
||||
Future<AmicaleModel?> fetchAmicaleByIdFromApi(int id) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.get('/amicales/$id');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final amicaleData = response.data;
|
||||
final amicale = AmicaleModel.fromJson(amicaleData);
|
||||
await saveAmicale(amicale);
|
||||
return amicale;
|
||||
} else {
|
||||
debugPrint(
|
||||
'Erreur lors de la récupération de l\'amicale: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de l\'amicale: $e');
|
||||
return null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une amicale via l'API
|
||||
Future<AmicaleModel?> updateAmicaleViaApi(AmicaleModel amicale) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.put(
|
||||
'/amicales/${amicale.id}',
|
||||
data: amicale.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final updatedAmicaleData = response.data;
|
||||
final updatedAmicale = AmicaleModel.fromJson(updatedAmicaleData);
|
||||
await saveAmicale(updatedAmicale);
|
||||
return updatedAmicale;
|
||||
} else {
|
||||
debugPrint(
|
||||
'Erreur lors de la mise à jour de l\'amicale: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour de l\'amicale: $e');
|
||||
return null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les amicales par nom
|
||||
List<AmicaleModel> searchAmicalesByName(String query) {
|
||||
if (query.isEmpty) {
|
||||
return getAllAmicales();
|
||||
}
|
||||
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
return _amicaleBox.values
|
||||
.where((amicale) => amicale.name.toLowerCase().contains(lowercaseQuery))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les amicales par type
|
||||
List<AmicaleModel> getAmicalesByType(int type) {
|
||||
return _amicaleBox.values
|
||||
.where((amicale) => amicale.fkType == type)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les amicales par région
|
||||
List<AmicaleModel> getAmicalesByRegion(int regionId) {
|
||||
return _amicaleBox.values
|
||||
.where((amicale) => amicale.fkRegion == regionId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les amicales actives
|
||||
List<AmicaleModel> getActiveAmicales() {
|
||||
return _amicaleBox.values.where((amicale) => amicale.chkActive).toList();
|
||||
}
|
||||
|
||||
// Filtrer les amicales par code postal
|
||||
List<AmicaleModel> getAmicalesByPostalCode(String postalCode) {
|
||||
return _amicaleBox.values
|
||||
.where((amicale) => amicale.codePostal == postalCode)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les amicales par ville
|
||||
List<AmicaleModel> getAmicalesByCity(String city) {
|
||||
final lowercaseCity = city.toLowerCase();
|
||||
return _amicaleBox.values
|
||||
.where((amicale) => amicale.ville.toLowerCase().contains(lowercaseCity))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
179
app/lib/core/repositories/client_repository.dart
Normal file
179
app/lib/core/repositories/client_repository.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.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/client_model.dart';
|
||||
|
||||
class ClientRepository extends ChangeNotifier {
|
||||
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
|
||||
Box<ClientModel> get _clientBox =>
|
||||
Hive.box<ClientModel>(AppKeys.clientsBoxName);
|
||||
|
||||
final ApiService _apiService;
|
||||
bool _isLoading = false;
|
||||
|
||||
ClientRepository(this._apiService);
|
||||
|
||||
// Getters
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// 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.clientsBoxName)) {
|
||||
debugPrint('Ouverture de la boîte clients...');
|
||||
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture de la boîte clients: $e');
|
||||
throw Exception('Impossible d\'ouvrir la boîte clients: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer tous les clients
|
||||
List<ClientModel> getAllClients() {
|
||||
try {
|
||||
_ensureBoxIsOpen();
|
||||
return _clientBox.values.toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des clients: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer un client par son ID
|
||||
ClientModel? getClientById(int id) {
|
||||
try {
|
||||
_ensureBoxIsOpen();
|
||||
return _clientBox.get(id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du client: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour un client localement
|
||||
Future<ClientModel> saveClient(ClientModel client) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _clientBox.put(client.id, client);
|
||||
notifyListeners(); // Notifier les changements pour mettre à jour l'UI
|
||||
return client;
|
||||
}
|
||||
|
||||
// Supprimer un client localement
|
||||
Future<void> deleteClient(int id) async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _clientBox.delete(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Vider la boîte des clients
|
||||
Future<void> clearClients() async {
|
||||
await _ensureBoxIsOpen();
|
||||
await _clientBox.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Traiter les données des clients reçues de l'API
|
||||
Future<void> processClientsData(dynamic clientsData) async {
|
||||
try {
|
||||
debugPrint('Traitement des données des clients...');
|
||||
|
||||
// Vérifier que les données sont au bon format
|
||||
if (clientsData == null) {
|
||||
debugPrint('Aucune donnée de client à traiter');
|
||||
return;
|
||||
}
|
||||
|
||||
List<dynamic> clientsList;
|
||||
if (clientsData is List) {
|
||||
clientsList = clientsData;
|
||||
} else if (clientsData is Map && clientsData.containsKey('data')) {
|
||||
clientsList = clientsData['data'] as List<dynamic>;
|
||||
} else {
|
||||
debugPrint('Format de données de clients non reconnu');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vider la boîte avant d'ajouter les nouvelles données
|
||||
await _ensureBoxIsOpen();
|
||||
await _clientBox.clear();
|
||||
|
||||
// Traiter chaque client
|
||||
int count = 0;
|
||||
for (final clientData in clientsList) {
|
||||
try {
|
||||
final client = ClientModel.fromJson(clientData);
|
||||
await _clientBox.put(client.id, client);
|
||||
count++;
|
||||
debugPrint('Client traité: ${client.name} (ID: ${client.id})');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement d\'un client: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('$count clients traités et stockés');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement des clients: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les clients depuis l'API
|
||||
Future<List<ClientModel>> fetchClientsFromApi() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.get('/clients');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final clientsData = response.data;
|
||||
await processClientsData(clientsData);
|
||||
return getAllClients();
|
||||
} else {
|
||||
debugPrint(
|
||||
'Erreur lors de la récupération des clients: ${response.statusCode}');
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des clients: $e');
|
||||
return [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les clients par nom
|
||||
List<ClientModel> searchClientsByName(String query) {
|
||||
if (query.isEmpty) {
|
||||
return getAllClients();
|
||||
}
|
||||
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
return _clientBox.values
|
||||
.where((client) => client.name.toLowerCase().contains(lowercaseQuery))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les clients par type
|
||||
List<ClientModel> getClientsByType(int type) {
|
||||
return _clientBox.values.where((client) => client.fkType == type).toList();
|
||||
}
|
||||
|
||||
// Filtrer les clients par région
|
||||
List<ClientModel> getClientsByRegion(int regionId) {
|
||||
return _clientBox.values
|
||||
.where((client) => client.fkRegion == regionId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer les clients actifs
|
||||
List<ClientModel> getActiveClients() {
|
||||
return _clientBox.values
|
||||
.where((client) => client.chkActive == true)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
208
app/lib/core/repositories/membre_repository.dart
Normal file
208
app/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
app/lib/core/repositories/operation_repository.dart
Normal file
215
app/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();
|
||||
}
|
||||
}
|
||||
}
|
||||
502
app/lib/core/repositories/passage_repository.dart
Normal file
502
app/lib/core/repositories/passage_repository.dart
Normal file
@@ -0,0 +1,502 @@
|
||||
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>? _box;
|
||||
|
||||
Box<PassageModel> get _passageBox {
|
||||
if (_box != null && _box!.isOpen) {
|
||||
return _box!;
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
throw StateError(
|
||||
'La boîte ${AppKeys.passagesBoxName} n\'est pas ouverte. Appelez _ensureBoxIsOpen() avant d\'accéder à la boîte.');
|
||||
}
|
||||
|
||||
_box = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
return _box!;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Si nous avons déjà une référence à la boîte et qu'elle est ouverte, retourner
|
||||
if (_box != null && _box!.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la boîte est déjà ouverte, récupérer la référence
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
_box = Hive.box<PassageModel>(boxName);
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte $boxName déjà ouverte, référence récupérée');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon, ouvrir la boîte
|
||||
try {
|
||||
debugPrint('PassageRepository: Ouverture de la boîte $boxName...');
|
||||
_box = await Hive.openBox<PassageModel>(boxName);
|
||||
debugPrint('PassageRepository: Boîte $boxName ouverte avec succès');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'PassageRepository: ERREUR lors de l\'ouverture de la boîte $boxName: $e');
|
||||
rethrow; // Propager l'erreur pour permettre une gestion appropriée
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getAllPassages');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getAllPassages: $e');
|
||||
});
|
||||
|
||||
return _passageBox.values.toList();
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getAllPassages: $e');
|
||||
return []; // Retourner une liste vide en cas d'erreur
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer un passage par son ID
|
||||
PassageModel? getPassageById(int id) {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getPassageById');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassageById: $e');
|
||||
});
|
||||
|
||||
return _passageBox.get(id);
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getPassageById: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les passages par secteur
|
||||
List<PassageModel> getPassagesBySector(int sectorId) {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getPassagesBySector');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesBySector: $e');
|
||||
});
|
||||
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkSector == sectorId)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getPassagesBySector: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les passages par opération
|
||||
List<PassageModel> getPassagesByOperation(int operationId) {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getPassagesByOperation');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByOperation: $e');
|
||||
});
|
||||
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkOperation == operationId)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getPassagesByOperation: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les passages par type
|
||||
List<PassageModel> getPassagesByType(int typeId) {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getPassagesByType');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByType: $e');
|
||||
});
|
||||
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkType == typeId)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getPassagesByType: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les passages par type de règlement
|
||||
List<PassageModel> getPassagesByPaymentType(int paymentTypeId) {
|
||||
try {
|
||||
// S'assurer que la boîte est ouverte avant d'y accéder
|
||||
_ensureBoxIsOpen().then((_) {
|
||||
debugPrint(
|
||||
'PassageRepository: Boîte ouverte avec succès pour getPassagesByPaymentType');
|
||||
}).catchError((e) {
|
||||
debugPrint(
|
||||
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByPaymentType: $e');
|
||||
});
|
||||
|
||||
return _passageBox.values
|
||||
.where((passage) => passage.fkTypeReglement == paymentTypeId)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('PassageRepository: Erreur dans getPassagesByPaymentType: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
85
app/lib/core/repositories/region_repository.dart
Normal file
85
app/lib/core/repositories/region_repository.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/region_model.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
class RegionRepository extends ChangeNotifier {
|
||||
late Box<RegionModel> _regionBox;
|
||||
List<RegionModel> _regions = [];
|
||||
bool _isLoaded = false;
|
||||
|
||||
// Getter pour les régions
|
||||
List<RegionModel> get regions => _regions;
|
||||
bool get isLoaded => _isLoaded;
|
||||
|
||||
// Initialisation du repository
|
||||
Future<void> init() async {
|
||||
if (!Hive.isBoxOpen(AppKeys.regionsBoxName)) {
|
||||
_regionBox = await Hive.openBox<RegionModel>(AppKeys.regionsBoxName);
|
||||
} else {
|
||||
_regionBox = Hive.box<RegionModel>(AppKeys.regionsBoxName);
|
||||
}
|
||||
_loadRegions();
|
||||
}
|
||||
|
||||
// Chargement des régions depuis la boîte Hive
|
||||
void _loadRegions() {
|
||||
_regions = _regionBox.values.toList();
|
||||
_isLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Mise à jour des régions depuis l'API
|
||||
Future<void> updateRegionsFromApi(List<dynamic> regionsData) async {
|
||||
await _regionBox.clear();
|
||||
|
||||
for (var regionData in regionsData) {
|
||||
final region = RegionModel.fromJson(regionData);
|
||||
await _regionBox.put(region.id, region);
|
||||
}
|
||||
|
||||
_loadRegions();
|
||||
}
|
||||
|
||||
// Récupérer une région par son ID
|
||||
RegionModel? getRegionById(int id) {
|
||||
return _regionBox.get(id);
|
||||
}
|
||||
|
||||
// Récupérer une région par son code postal (2 premiers chiffres)
|
||||
RegionModel? getRegionByPostalCode(String postalCode) {
|
||||
if (postalCode.length < 2) return null;
|
||||
|
||||
final departement = postalCode.substring(0, 2);
|
||||
|
||||
for (var region in _regions) {
|
||||
if (region.departements != null &&
|
||||
region.departements!.split(',').contains(departement)) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Récupérer toutes les régions actives
|
||||
List<RegionModel> getActiveRegions() {
|
||||
return _regions.where((region) => region.chkActive).toList();
|
||||
}
|
||||
|
||||
// Convertir les régions en format pour le dropdown
|
||||
List<Map<String, dynamic>> getRegionsForDropdown() {
|
||||
return _regions
|
||||
.where((region) => region.chkActive)
|
||||
.map((region) => {
|
||||
'id': region.id,
|
||||
'name': region.libelle,
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Fermeture de la boîte Hive
|
||||
Future<void> close() async {
|
||||
await _regionBox.close();
|
||||
}
|
||||
}
|
||||
149
app/lib/core/repositories/sector_repository.dart
Normal file
149
app/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1949
app/lib/core/repositories/user_repository.dart
Normal file
1949
app/lib/core/repositories/user_repository.dart
Normal file
File diff suppressed because it is too large
Load Diff
269
app/lib/core/services/api_service.dart
Normal file
269
app/lib/core/services/api_service.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
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';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
|
||||
class ApiService {
|
||||
final Dio _dio = Dio();
|
||||
late final String _baseUrl;
|
||||
late final String _appIdentifier;
|
||||
String? _sessionId;
|
||||
|
||||
// Détermine l'environnement actuel (DEV, REC, PROD) en fonction de l'URL
|
||||
String _determineEnvironment() {
|
||||
if (!kIsWeb) {
|
||||
// En mode non-web, utiliser l'environnement de développement par défaut
|
||||
return 'DEV';
|
||||
}
|
||||
|
||||
final currentUrl = html.window.location.href.toLowerCase();
|
||||
|
||||
if (currentUrl.contains('dapp.geosector.fr')) {
|
||||
return 'DEV';
|
||||
} else if (currentUrl.contains('rapp.geosector.fr')) {
|
||||
return 'REC';
|
||||
} else {
|
||||
return 'PROD';
|
||||
}
|
||||
}
|
||||
|
||||
// Configure l'URL de base API et l'identifiant d'application selon l'environnement
|
||||
void _configureEnvironment() {
|
||||
final env = _determineEnvironment();
|
||||
|
||||
switch (env) {
|
||||
case 'DEV':
|
||||
_baseUrl = AppKeys.baseApiUrlDev;
|
||||
_appIdentifier = AppKeys.appIdentifierDev;
|
||||
break;
|
||||
case 'REC':
|
||||
_baseUrl = AppKeys.baseApiUrlRec;
|
||||
_appIdentifier = AppKeys.appIdentifierRec;
|
||||
break;
|
||||
default: // PROD
|
||||
_baseUrl = AppKeys.baseApiUrlProd;
|
||||
_appIdentifier = AppKeys.appIdentifierProd;
|
||||
}
|
||||
|
||||
debugPrint('GEOSECTOR 🔗 Environnement: $env, API: $_baseUrl');
|
||||
}
|
||||
|
||||
ApiService() {
|
||||
// Configurer l'environnement
|
||||
_configureEnvironment();
|
||||
|
||||
// Configurer Dio
|
||||
_dio.options.baseUrl = _baseUrl;
|
||||
_dio.options.connectTimeout = AppKeys.connectionTimeout;
|
||||
_dio.options.receiveTimeout = AppKeys.receiveTimeout;
|
||||
|
||||
// Ajouter les en-têtes par défaut avec l'identifiant d'application adapté à l'environnement
|
||||
final headers = Map<String, String>.from(AppKeys.defaultHeaders);
|
||||
headers['X-App-Identifier'] = _appIdentifier;
|
||||
|
||||
_dio.options.headers.addAll(headers);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Obtenir l'environnement actuel (utile pour le débogage)
|
||||
String getCurrentEnvironment() {
|
||||
return _determineEnvironment();
|
||||
}
|
||||
|
||||
// Obtenir l'URL API actuelle (utile pour le débogage)
|
||||
String getCurrentApiUrl() {
|
||||
return _baseUrl;
|
||||
}
|
||||
|
||||
// Obtenir l'identifiant d'application actuel (utile pour le débogage)
|
||||
String getCurrentAppIdentifier() {
|
||||
return _appIdentifier;
|
||||
}
|
||||
|
||||
// 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, {required String type}) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/lib/core/services/auth_service.dart
Normal file
51
app/lib/core/services/auth_service.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
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/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,
|
||||
{required String type}) 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
|
||||
/// et redirection vers la page de démarrage
|
||||
Future<bool> logout(BuildContext context) async {
|
||||
final bool result = await LoadingOverlay.show(
|
||||
context: context,
|
||||
spinnerSize: 80.0, // Spinner plus grand
|
||||
strokeWidth: 6.0, // Trait plus épais
|
||||
future: _userRepository.logout(),
|
||||
);
|
||||
|
||||
// Si la déconnexion a réussi, rediriger vers la page de démarrage
|
||||
if (result && context.mounted) {
|
||||
// Utiliser GoRouter pour naviguer vers la page de démarrage
|
||||
GoRouter.of(context).go('/');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Vérifie si un utilisateur est connecté
|
||||
bool isLoggedIn() {
|
||||
return _userRepository.isLoggedIn;
|
||||
}
|
||||
|
||||
/// Récupère le rôle de l'utilisateur connecté
|
||||
int getUserRole() {
|
||||
return _userRepository.getUserRole();
|
||||
}
|
||||
}
|
||||
157
app/lib/core/services/connectivity_service.dart
Normal file
157
app/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();
|
||||
}
|
||||
}
|
||||
149
app/lib/core/services/hive_reset_service.dart
Normal file
149
app/lib/core/services/hive_reset_service.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
// Importations conditionnelles pour le web vs non-web
|
||||
import 'js_interface.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/hive_web_fix.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/client_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/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/region_model.dart';
|
||||
import 'package:geosector_app/chat/models/chat_adapters.dart';
|
||||
|
||||
/// Service pour réinitialiser et recréer les Hive Boxes
|
||||
/// Utilisé pour résoudre les problèmes d'incompatibilité après mise à jour des modèles
|
||||
class HiveResetService {
|
||||
/// Réinitialise complètement Hive et recrée les boîtes nécessaires
|
||||
static Future<bool> resetAndRecreateHiveBoxes() async {
|
||||
try {
|
||||
debugPrint(
|
||||
'HiveResetService: Début de la réinitialisation complète de Hive');
|
||||
|
||||
// Approche plus radicale pour le web : supprimer directement IndexedDB
|
||||
if (kIsWeb) {
|
||||
// Utiliser JavaScript pour supprimer complètement la base de données IndexedDB
|
||||
evalJs('''
|
||||
(function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
// Fermer toutes les connexions IndexedDB
|
||||
if (window.indexedDB) {
|
||||
console.log("Suppression complète d'IndexedDB...");
|
||||
var request = indexedDB.deleteDatabase("geosector_app");
|
||||
request.onsuccess = function() {
|
||||
console.log("IndexedDB supprimé avec succès");
|
||||
resolve(true);
|
||||
};
|
||||
request.onerror = function(event) {
|
||||
console.log("Erreur lors de la suppression d'IndexedDB", event);
|
||||
reject(event);
|
||||
};
|
||||
} else {
|
||||
console.log("IndexedDB n'est pas disponible");
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
''');
|
||||
|
||||
// Attendre un peu pour s'assurer que la suppression est terminée
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
|
||||
// Réinitialiser Hive
|
||||
await Hive.initFlutter();
|
||||
} else {
|
||||
// Pour les plateformes mobiles, on utilise une approche différente
|
||||
await Hive.deleteFromDisk();
|
||||
await Hive.initFlutter();
|
||||
}
|
||||
|
||||
// Réenregistrer tous les adaptateurs
|
||||
_registerAdapters();
|
||||
|
||||
// Rouvrir les boîtes essentielles
|
||||
await _reopenEssentialBoxes();
|
||||
|
||||
debugPrint(
|
||||
'HiveResetService: Réinitialisation complète terminée avec succès');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('HiveResetService: Erreur lors de la réinitialisation: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ferme toutes les boîtes Hive ouvertes
|
||||
static Future<void> _closeAllBoxes() async {
|
||||
final boxNames = [
|
||||
AppKeys.usersBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.clientsBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
AppKeys.regionsBoxName,
|
||||
];
|
||||
|
||||
for (final boxName in boxNames) {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('HiveResetService: Fermeture de la boîte $boxName');
|
||||
await Hive.box(boxName).close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre tous les adaptateurs Hive
|
||||
static void _registerAdapters() {
|
||||
debugPrint('HiveResetService: Enregistrement des adaptateurs Hive');
|
||||
|
||||
// Enregistrer les adaptateurs pour les modèles principaux
|
||||
Hive.registerAdapter(UserModelAdapter());
|
||||
Hive.registerAdapter(AmicaleModelAdapter());
|
||||
Hive.registerAdapter(ClientModelAdapter());
|
||||
Hive.registerAdapter(OperationModelAdapter());
|
||||
Hive.registerAdapter(SectorModelAdapter());
|
||||
Hive.registerAdapter(PassageModelAdapter());
|
||||
Hive.registerAdapter(MembreModelAdapter());
|
||||
Hive.registerAdapter(UserSectorModelAdapter());
|
||||
|
||||
// Enregistrer les adaptateurs pour le chat
|
||||
Hive.registerAdapter(ConversationModelAdapter());
|
||||
Hive.registerAdapter(MessageModelAdapter());
|
||||
Hive.registerAdapter(ParticipantModelAdapter());
|
||||
Hive.registerAdapter(AnonymousUserModelAdapter());
|
||||
Hive.registerAdapter(AudienceTargetModelAdapter());
|
||||
Hive.registerAdapter(NotificationSettingsAdapter());
|
||||
|
||||
// Vérifier si RegionModelAdapter est disponible
|
||||
try {
|
||||
Hive.registerAdapter(RegionModelAdapter());
|
||||
} catch (e) {
|
||||
debugPrint('HiveResetService: RegionModelAdapter non disponible: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Rouvre les boîtes essentielles
|
||||
static Future<void> _reopenEssentialBoxes() async {
|
||||
debugPrint('HiveResetService: Réouverture des boîtes essentielles');
|
||||
|
||||
// Ouvrir les boîtes essentielles au démarrage
|
||||
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
|
||||
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
|
||||
await Hive.openBox(AppKeys.settingsBoxName);
|
||||
|
||||
// Ouvrir les boîtes de chat
|
||||
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
}
|
||||
}
|
||||
40
app/lib/core/services/hive_reset_state_service.dart
Normal file
40
app/lib/core/services/hive_reset_state_service.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service pour gérer l'état de réinitialisation de Hive
|
||||
/// Permet de stocker l'information indiquant si Hive a été réinitialisé
|
||||
/// et de notifier les widgets intéressés
|
||||
class HiveResetStateService extends ChangeNotifier {
|
||||
/// Indique si Hive a été réinitialisé
|
||||
bool _wasReset = false;
|
||||
|
||||
/// Indique si le dialogue de réinitialisation a déjà été affiché
|
||||
bool _dialogShown = false;
|
||||
|
||||
/// Getter pour savoir si Hive a été réinitialisé
|
||||
bool get wasReset => _wasReset;
|
||||
|
||||
/// Getter pour savoir si le dialogue a déjà été affiché
|
||||
bool get dialogShown => _dialogShown;
|
||||
|
||||
/// Marque Hive comme ayant été réinitialisé
|
||||
void markAsReset() {
|
||||
_wasReset = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Marque le dialogue comme ayant été affiché
|
||||
void markDialogAsShown() {
|
||||
_dialogShown = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Réinitialise l'état (à utiliser après une déconnexion par exemple)
|
||||
void reset() {
|
||||
_wasReset = false;
|
||||
_dialogShown = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance globale du service
|
||||
final hiveResetStateService = HiveResetStateService();
|
||||
182
app/lib/core/services/hive_web_fix.dart
Normal file
182
app/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');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/lib/core/services/js_interface.dart
Normal file
20
app/lib/core/services/js_interface.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
/// Interface pour les fonctionnalités JavaScript
|
||||
/// Importe conditionnellement dart:js pour le web ou un stub pour les autres plateformes
|
||||
library js_interface;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// Importation conditionnelle basée sur la plateforme
|
||||
import 'js_stub.dart' if (dart.library.js) 'dart:js' as js;
|
||||
|
||||
/// Exporte le contexte JavaScript pour être utilisé dans d'autres fichiers
|
||||
final context = js.context;
|
||||
|
||||
/// Fonction utilitaire pour évaluer du code JavaScript sur le web
|
||||
/// Ne fait rien sur les plateformes non-web
|
||||
dynamic evalJs(String code) {
|
||||
if (kIsWeb) {
|
||||
return js.context.callMethod('eval', [code]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
11
app/lib/core/services/js_stub.dart
Normal file
11
app/lib/core/services/js_stub.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Stub pour dart:js pour les plateformes non-web
|
||||
/// Fournit une implémentation vide des fonctionnalités de dart:js
|
||||
class JsContext {
|
||||
dynamic callMethod(String method, [List<dynamic>? args]) {
|
||||
// Ne fait rien sur les plateformes non-web
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Contexte JavaScript stub
|
||||
final JsContext context = JsContext();
|
||||
164
app/lib/core/services/location_service.dart
Normal file
164
app/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
app/lib/core/services/passage_data_service.dart
Normal file
194
app/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
app/lib/core/services/sync_service.dart
Normal file
96
app/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();
|
||||
}
|
||||
}
|
||||
190
app/lib/core/theme/app_theme.dart
Normal file
190
app/lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Couleurs du thème basées sur la maquette Figma
|
||||
static const Color primaryColor = Color(0xFF20335E); // Bleu foncé
|
||||
static const Color secondaryColor = Color(0xFF9DC7C8); // Bleu clair
|
||||
static const Color accentColor = Color(0xFF00E09D); // Vert
|
||||
static const Color errorColor = Color(0xFFE41B13); // Rouge
|
||||
static const Color warningColor = Color(0xFFF7A278); // Orange
|
||||
static const Color backgroundLightColor =
|
||||
Color(0xFFF4F5F6); // Gris très clair
|
||||
static const Color backgroundDarkColor = Color(0xFF111827);
|
||||
static const Color textLightColor = Color(0xFF000000); // Noir
|
||||
static const Color textDarkColor = Color(0xFFF9FAFB);
|
||||
|
||||
// Thème clair
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
fontFamily: 'Figtree', // Utilisation directe de la police locale
|
||||
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: const TextTheme().copyWith(
|
||||
displayLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
displayMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
displaySmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodySmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
),
|
||||
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: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: backgroundLightColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: textLightColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: textLightColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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,
|
||||
fontFamily: 'Figtree', // Utilisation directe de la police locale
|
||||
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: const TextTheme().copyWith(
|
||||
displayLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
displayMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
displaySmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
titleSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
bodySmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelLarge: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelMedium: const TextStyle(fontFamily: 'Figtree'),
|
||||
labelSmall: const TextStyle(fontFamily: 'Figtree'),
|
||||
),
|
||||
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: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF374151),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: textDarkColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: textDarkColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
app/lib/main.dart
Normal file
100
app/lib/main.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
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/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/client_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/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/region_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_state_service.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 avec gestion des erreurs
|
||||
bool hiveInitialized = false;
|
||||
|
||||
try {
|
||||
// Initialiser Hive
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Enregistrer les adaptateurs Hive pour les modèles principaux
|
||||
Hive.registerAdapter(UserModelAdapter());
|
||||
Hive.registerAdapter(AmicaleModelAdapter());
|
||||
Hive.registerAdapter(ClientModelAdapter());
|
||||
Hive.registerAdapter(OperationModelAdapter());
|
||||
Hive.registerAdapter(SectorModelAdapter());
|
||||
Hive.registerAdapter(PassageModelAdapter());
|
||||
Hive.registerAdapter(MembreModelAdapter());
|
||||
Hive.registerAdapter(UserSectorModelAdapter());
|
||||
// TODO: Décommenter après avoir généré le fichier region_model.g.dart
|
||||
// Hive.registerAdapter(RegionModelAdapter());
|
||||
|
||||
// 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
|
||||
try {
|
||||
// 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 amicales
|
||||
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
// Boîte pour les clients
|
||||
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
|
||||
// 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);
|
||||
|
||||
hiveInitialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'ouverture des boîtes Hive: $e');
|
||||
// Une erreur s'est produite lors de l'ouverture des boîtes, probablement due à une incompatibilité
|
||||
// Nous allons réinitialiser Hive
|
||||
hiveInitialized = false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation de Hive: $e');
|
||||
hiveInitialized = false;
|
||||
}
|
||||
|
||||
// Si Hive n'a pas été initialisé correctement, marquer l'état pour afficher le dialogue
|
||||
if (!hiveInitialized) {
|
||||
debugPrint(
|
||||
'Incompatibilité détectée dans les données Hive. Marquage pour affichage du dialogue...');
|
||||
// Marquer Hive comme ayant été réinitialisé pour afficher le dialogue plus tard
|
||||
hiveResetStateService.markAsReset();
|
||||
}
|
||||
|
||||
// Les autres boîtes (operations, sectors, passages, user_sector) 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
app/lib/presentation/MIGRATION.md
Normal file
118
app/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
app/lib/presentation/README.md
Normal file
26
app/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
app/lib/presentation/admin/admin_communication_page.dart
Normal file
557
app/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 '';
|
||||
}
|
||||
}
|
||||
1113
app/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
1113
app/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
279
app/lib/presentation/admin/admin_dashboard_page.dart
Normal file
279
app/lib/presentation/admin/admin_dashboard_page.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
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 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
|
||||
import 'package:geosector_app/core/models/loading_state.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
// 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 pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class AdminDashboardPage extends StatefulWidget {
|
||||
const AdminDashboardPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
with WidgetsBindingObserver {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Index de la page Amicale et membres
|
||||
static const int entitePageIndex = 5;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
// Overlay pour afficher la progression du chargement
|
||||
OverlayEntry? _progressOverlay;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
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})',
|
||||
);
|
||||
}
|
||||
|
||||
// Écouter les changements d'état du UserRepository
|
||||
userRepository.addListener(_handleUserRepositoryChanges);
|
||||
}
|
||||
|
||||
_pages = [
|
||||
const AdminDashboardHomePage(),
|
||||
const AdminStatisticsPage(),
|
||||
const AdminHistoryPage(),
|
||||
const AdminCommunicationPage(),
|
||||
const AdminMapPage(),
|
||||
const AdminEntitePage(),
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
|
||||
// Vérifier si des données sont en cours de chargement
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkLoadingState();
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
if (userRepository != null) {
|
||||
userRepository.removeListener(_handleUserRepositoryChanges);
|
||||
}
|
||||
_removeProgressOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour gérer les changements d'état du UserRepository
|
||||
void _handleUserRepositoryChanges() {
|
||||
_checkLoadingState();
|
||||
}
|
||||
|
||||
// Méthode pour vérifier l'état de chargement (barre de progression désactivée)
|
||||
void _checkLoadingState() {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
// Méthodes pour gérer l'overlay de progression (désactivées)
|
||||
void _showProgressOverlay(LoadingState state) {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
void _updateProgressOverlay(LoadingState state) {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
void _removeProgressOverlay() {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
// 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 Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
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',
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter la destination "Amicale et membres"
|
||||
destinations.add(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.business_outlined),
|
||||
selectedIcon: Icon(Icons.business),
|
||||
label: 'Amicale',
|
||||
),
|
||||
);
|
||||
|
||||
return destinations;
|
||||
}
|
||||
}
|
||||
46
app/lib/presentation/admin/admin_debug_info_widget.dart
Normal file
46
app/lib/presentation/admin/admin_debug_info_widget.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/environment_info_widget.dart';
|
||||
|
||||
/// Widget d'information de débogage pour l'administrateur
|
||||
/// À intégrer où nécessaire dans l'interface administrateur
|
||||
class AdminDebugInfoWidget extends StatelessWidget {
|
||||
const AdminDebugInfoWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bug_report, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Informations de débogage',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Environnement'),
|
||||
subtitle: const Text('Afficher les informations sur l\'environnement actuel'),
|
||||
onTap: () => EnvironmentInfoWidget.show(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
tileColor: Colors.grey.withOpacity(0.1),
|
||||
),
|
||||
// Autres options de débogage peuvent être ajoutées ici
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
361
app/lib/presentation/admin/admin_entite.dart
Normal file
361
app/lib/presentation/admin/admin_entite.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Page d'administration de l'amicale et des membres
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
class AdminEntitePage extends StatefulWidget {
|
||||
const AdminEntitePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminEntitePage> createState() => _AdminEntitePageState();
|
||||
}
|
||||
|
||||
class _AdminEntitePageState extends State<AdminEntitePage> {
|
||||
bool _isLoading = true;
|
||||
AmicaleModel? _amicale;
|
||||
List<MembreModel> _membres = [];
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer l'utilisateur connecté en utilisant l'instance globale
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
if (currentUser == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Utilisateur non connecté';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si fkEntite est null
|
||||
if (currentUser.fkEntite == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Utilisateur non associé à une amicale';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer l'amicale de l'utilisateur en utilisant l'instance globale
|
||||
final amicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
|
||||
if (amicale == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Amicale non trouvée';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les membres
|
||||
// Note: Dans un cas réel, nous devrions filtrer les membres par amicale,
|
||||
// mais le modèle MembreModel n'a pas de champ fkEntite pour le moment
|
||||
final membres = membreRepository.getAllMembres();
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_membres = membres;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors du chargement des données: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEditAmicale(AmicaleModel amicale) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Modifier l\'amicale'),
|
||||
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Naviguer vers la page de modification
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => EditAmicalePage(amicale: amicale),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEditMembre(MembreModel membre) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Modifier le membre'),
|
||||
content: Text(
|
||||
'Voulez-vous modifier le membre ${membre.firstName} ${membre.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Naviguer vers la page de modification
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => EditMembrePage(membre: membre),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
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),
|
||||
|
||||
// Message d'erreur si présent
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
if (_isLoading)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_amicale == null)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.business_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune amicale associée',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Vous n\'êtes pas associé à une amicale.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Amicale
|
||||
Text(
|
||||
'Informations de l\'amicale',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tableau Amicale
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AmicaleTableWidget(
|
||||
amicales: [_amicale!],
|
||||
// Pas de bouton de suppression pour sa propre amicale
|
||||
onDelete: null,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section Membres
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Membres de l\'amicale',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page d'ajout de membre
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => AddMembrePage(amicaleId: _amicale!.id),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un membre'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tableau Membres
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: MembreTableWidget(
|
||||
membres: _membres,
|
||||
onEdit: _handleEditMembre,
|
||||
// Pas de bouton de suppression pour les membres de sa propre amicale
|
||||
// sauf si l'utilisateur a un rôle élevé
|
||||
onDelete: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
945
app/lib/presentation/admin/admin_history_page.dart
Normal file
945
app/lib/presentation/admin/admin_history_page.dart
Normal file
@@ -0,0 +1,945 @@
|
||||
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';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
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 Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return _buildErrorWidget(_errorMessage);
|
||||
}
|
||||
|
||||
// Retourner le widget principal avec les données chargées
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
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 Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
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
app/lib/presentation/admin/admin_map_page.dart
Normal file
898
app/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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
582
app/lib/presentation/admin/admin_statistics_page.dart
Normal file
582
app/lib/presentation/admin/admin_statistics_page.dart
Normal file
@@ -0,0 +1,582 @@
|
||||
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';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
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 Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
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),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
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),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
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),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
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),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
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;
|
||||
}
|
||||
}
|
||||
904
app/lib/presentation/auth/login_page.dart
Normal file
904
app/lib/presentation/auth/login_page.dart
Normal file
@@ -0,0 +1,904 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'dart:js' as js;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:go_router/src/state.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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/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 pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
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)
|
||||
late 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();
|
||||
|
||||
// Vérification du type de connexion
|
||||
if (widget.loginType == null) {
|
||||
// Si aucun type n'est spécifié, naviguer vers la splash page
|
||||
print(
|
||||
'LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
GoRouter.of(context).go('/');
|
||||
});
|
||||
_loginType = '';
|
||||
} else {
|
||||
_loginType = widget.loginType!;
|
||||
print('LoginPage: Type de connexion utilisé: $_loginType');
|
||||
}
|
||||
|
||||
// En mode web, essayer de détecter le paramètre dans l'URL directement
|
||||
// UNIQUEMENT si le loginType n'est pas déjà 'user'
|
||||
if (kIsWeb && _loginType != 'user') {
|
||||
try {
|
||||
final uri = Uri.parse(Uri.base.toString());
|
||||
|
||||
// 1. Vérifier d'abord si nous avons déjà le paramètre 'type=user'
|
||||
final typeParam = uri.queryParameters['type'];
|
||||
if (typeParam != null && typeParam.trim().toLowerCase() == 'user') {
|
||||
setState(() {
|
||||
_loginType = 'user';
|
||||
});
|
||||
}
|
||||
// 2. Sinon, vérifier le fragment d'URL (hash)
|
||||
else if (uri.fragment.trim().toLowerCase() == 'user') {
|
||||
setState(() {
|
||||
_loginType = 'user';
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Enfin, si toujours pas de type 'user', vérifier le sessionStorage
|
||||
if (_loginType != 'user') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
// Utiliser une approche plus robuste pour accéder au sessionStorage
|
||||
// Éviter d'utiliser hasProperty qui peut causer des erreurs
|
||||
final result = js.context.callMethod('eval', [
|
||||
'''
|
||||
(function() {
|
||||
try {
|
||||
if (window.sessionStorage) {
|
||||
var value = sessionStorage.getItem('loginType');
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('Error accessing sessionStorage:', e);
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
'''
|
||||
]);
|
||||
|
||||
if (result != null &&
|
||||
result is String &&
|
||||
result.toLowerCase() == 'user') {
|
||||
setState(() {
|
||||
_loginType = 'user';
|
||||
print(
|
||||
'LoginPage: Type détecté depuis sessionStorage: $_loginType');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des paramètres d\'URL: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 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é
|
||||
// seulement si le rôle correspond au type de login
|
||||
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;
|
||||
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (lastUser.role is String) {
|
||||
roleValue = int.tryParse(lastUser.role as String) ?? 0;
|
||||
} else {
|
||||
roleValue = lastUser.role as int;
|
||||
}
|
||||
|
||||
// Vérifier si le rôle correspond au type de login
|
||||
bool roleMatches = false;
|
||||
if (_loginType == 'user' && roleValue == 1) {
|
||||
roleMatches = true;
|
||||
debugPrint('Rôle utilisateur (1) correspond au type de login (user)');
|
||||
} else if (_loginType == 'admin' && roleValue > 1) {
|
||||
roleMatches = true;
|
||||
debugPrint(
|
||||
'Rôle administrateur (${roleValue}) correspond au type de login (admin)');
|
||||
}
|
||||
|
||||
// Pré-remplir le champ username seulement si le rôle correspond
|
||||
if (roleMatches) {
|
||||
// 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();
|
||||
debugPrint('Champ username pré-rempli avec: ${lastUser.username}');
|
||||
} else if (lastUser.email.isNotEmpty) {
|
||||
_usernameController.text = lastUser.email;
|
||||
_usernameFocusNode.unfocus();
|
||||
debugPrint(
|
||||
'Champ username pré-rempli avec email: ${lastUser.email}');
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Le rôle (${roleValue}) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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: [
|
||||
// Logo simlifié
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 160,
|
||||
),
|
||||
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: Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _loginType == 'user'
|
||||
? [Colors.white, Colors.green.shade300]
|
||||
: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: _loginType == 'user'
|
||||
? Colors.green.withOpacity(0.5)
|
||||
: Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo simplifié
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 160,
|
||||
),
|
||||
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: Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _loginType == 'user'
|
||||
? [Colors.white, Colors.green.shade300]
|
||||
: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: _loginType == 'user'
|
||||
? Colors.green.withOpacity(0.5)
|
||||
: Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo simplifié avec chemin direct
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 140,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_loginType == 'user'
|
||||
? 'Connexion Utilisateur'
|
||||
: 'Connexion Administrateur',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _loginType == 'user'
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Ajouter un texte de débogage uniquement en mode développement
|
||||
if (kDebugMode)
|
||||
Text(
|
||||
'Type de connexion: $_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()) {
|
||||
// Vérifier que le type de connexion est spécifié
|
||||
if (_loginType.isEmpty) {
|
||||
print(
|
||||
'Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
print(
|
||||
'Login: Tentative avec type: $_loginType');
|
||||
|
||||
final success =
|
||||
await userRepository.login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
type: _loginType,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
// Récupérer directement le rôle de l'utilisateur
|
||||
final user =
|
||||
userRepository.getCurrentUser();
|
||||
if (user == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(
|
||||
user.role as String) ??
|
||||
1;
|
||||
} else {
|
||||
roleValue = user.role as int;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Role de l\'utilisateur: $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
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;
|
||||
}
|
||||
|
||||
// Vérifier que le type de connexion est spécifié
|
||||
if (_loginType.isEmpty) {
|
||||
print(
|
||||
'Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
print(
|
||||
'Login: Tentative avec type: $_loginType');
|
||||
|
||||
// Utiliser directement userRepository avec l'overlay de chargement
|
||||
final success = await userRepository
|
||||
.loginWithUI(
|
||||
context,
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
type: _loginType,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
debugPrint(
|
||||
'Connexion réussie, tentative de redirection...');
|
||||
|
||||
// Récupérer directement le rôle de l'utilisateur
|
||||
final user = userRepository
|
||||
.getCurrentUser();
|
||||
if (user == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(
|
||||
user.role as String) ??
|
||||
1;
|
||||
} else {
|
||||
roleValue = user.role as int;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Role de l\'utilisateur: $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
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 d'accueil
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Retour à l\'accueil',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
316
app/lib/presentation/auth/register_page.dart
Normal file
316
app/lib/presentation/auth/register_page.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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
|
||||
SvgPicture.asset(
|
||||
'assets/images/icon-geosector.svg',
|
||||
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 d'accueil
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Revenir à l\'accueil',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
513
app/lib/presentation/auth/splash_page.dart
Normal file
513
app/lib/presentation/auth/splash_page.dart
Normal file
@@ -0,0 +1,513 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/client_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/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/clear_cache_dialog.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isInitializing = true;
|
||||
String _statusMessage = "Initialisation...";
|
||||
double _progress = 0.0;
|
||||
bool _showButtons = false;
|
||||
|
||||
final List<String> _initializationSteps = [
|
||||
"Initialisation des services...",
|
||||
"Vérification de l'authentification...",
|
||||
"Chargement des données..."
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Animation controller sur 5 secondes (augmenté de 3 à 5 secondes)
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
// Animation de 4x la taille à 1x la taille (augmenté de 3x à 4x)
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 4.0, // Commencer à 4x la taille
|
||||
end: 1.0, // Terminer à la taille normale
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack, // Curve pour un effet de rebond
|
||||
),
|
||||
);
|
||||
|
||||
// Démarrer l'animation immédiatement
|
||||
_animationController.forward();
|
||||
|
||||
// Vérifier si Hive a été réinitialisé
|
||||
_checkHiveReset();
|
||||
|
||||
// Simuler le processus d'initialisation
|
||||
_startInitialization();
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si Hive a été réinitialisé et afficher le dialogue si nécessaire
|
||||
void _checkHiveReset() {
|
||||
// Vérifier si Hive a été réinitialisé et si le dialogue n'a pas encore été affiché
|
||||
if (hiveResetStateService.wasReset && !hiveResetStateService.dialogShown) {
|
||||
// Attendre que le widget soit complètement construit
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
// Afficher le dialogue de nettoyage du cache
|
||||
ClearCacheDialog.show(
|
||||
context,
|
||||
onClose: () {
|
||||
// Marquer le dialogue comme ayant été affiché
|
||||
hiveResetStateService.markDialogAsShown();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startInitialization() async {
|
||||
// Étape 1: Initialisation des services
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[0];
|
||||
_progress = 0.2;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiser toutes les boîtes Hive
|
||||
await _initializeAllHiveBoxes();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Étape 2: Vérification de l'authentification
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[1];
|
||||
_progress = 0.4;
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Étape 3: Chargement des données
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[2];
|
||||
_progress = 1.0; // Directement à 100% après la 3ème étape
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
});
|
||||
|
||||
// Attendre quelques secondes avant de rediriger automatiquement
|
||||
// si l'utilisateur est déjà connecté
|
||||
if (userRepository.isLoggedIn) {
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
_redirectToAppropriateScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour initialiser toutes les boîtes Hive
|
||||
Future<void> _initializeAllHiveBoxes() async {
|
||||
try {
|
||||
debugPrint('Initialisation de toutes les boîtes Hive...');
|
||||
|
||||
// Structure pour les boîtes à ouvrir avec leurs noms d'affichage
|
||||
final boxesToOpen = [
|
||||
{'name': AppKeys.usersBoxName, 'display': 'Préparation utilisateurs'},
|
||||
{'name': AppKeys.amicaleBoxName, 'display': 'Préparation amicale'},
|
||||
{'name': AppKeys.clientsBoxName, 'display': 'Préparation clients'},
|
||||
{'name': AppKeys.regionsBoxName, 'display': 'Préparation régions'},
|
||||
{
|
||||
'name': AppKeys.operationsBoxName,
|
||||
'display': 'Préparation opérations'
|
||||
},
|
||||
{'name': AppKeys.sectorsBoxName, 'display': 'Préparation secteurs'},
|
||||
{'name': AppKeys.passagesBoxName, 'display': 'Préparation passages'},
|
||||
{'name': AppKeys.membresBoxName, 'display': 'Préparation membres'},
|
||||
{
|
||||
'name': AppKeys.userSectorBoxName,
|
||||
'display': 'Préparation secteurs utilisateurs'
|
||||
},
|
||||
{'name': AppKeys.settingsBoxName, 'display': 'Préparation paramètres'},
|
||||
{
|
||||
'name': AppKeys.chatConversationsBoxName,
|
||||
'display': 'Préparation conversations'
|
||||
},
|
||||
{
|
||||
'name': AppKeys.chatMessagesBoxName,
|
||||
'display': 'Préparation messages'
|
||||
},
|
||||
];
|
||||
|
||||
// Calculer l'incrément de progression pour chaque boîte
|
||||
final progressIncrement = 0.2 / boxesToOpen.length;
|
||||
double currentProgress = 0.0;
|
||||
|
||||
// Ouvrir chaque boîte si elle n'est pas déjà ouverte
|
||||
for (int i = 0; i < boxesToOpen.length; i++) {
|
||||
final boxName = boxesToOpen[i]['name'] as String;
|
||||
final displayName = boxesToOpen[i]['display'] as String;
|
||||
|
||||
// Mettre à jour la barre de progression et le message
|
||||
currentProgress += progressIncrement;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = displayName;
|
||||
_progress =
|
||||
0.2 * (currentProgress / 0.2); // Normaliser entre 0 et 0.2
|
||||
});
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('Ouverture de la boîte $boxName ($displayName)...');
|
||||
|
||||
// Ouvrir la boîte avec le type approprié
|
||||
if (boxName == AppKeys.usersBoxName) {
|
||||
await Hive.openBox<UserModel>(boxName);
|
||||
} else if (boxName == AppKeys.amicaleBoxName) {
|
||||
await Hive.openBox<AmicaleModel>(boxName);
|
||||
} else if (boxName == AppKeys.clientsBoxName) {
|
||||
await Hive.openBox<ClientModel>(boxName);
|
||||
} else if (boxName == AppKeys.regionsBoxName) {
|
||||
// Ouvrir la boîte des régions sans type spécifique pour l'instant
|
||||
// car RegionModelAdapter n'est pas encore enregistré
|
||||
await Hive.openBox(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.passagesBoxName) {
|
||||
await Hive.openBox<PassageModel>(boxName);
|
||||
} else if (boxName == AppKeys.membresBoxName) {
|
||||
await Hive.openBox<MembreModel>(boxName);
|
||||
} else if (boxName == AppKeys.userSectorBoxName) {
|
||||
await Hive.openBox<UserSectorModel>(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);
|
||||
}
|
||||
|
||||
debugPrint('Boîte $boxName ouverte avec succès');
|
||||
} else {
|
||||
debugPrint('Boîte $boxName déjà ouverte');
|
||||
}
|
||||
|
||||
// Ajouter une temporisation entre chaque ouverture
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// Mettre à jour la barre de progression à 0.2 (20%) à la fin
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = 'Toutes les boîtes sont prêtes';
|
||||
_progress = 0.2;
|
||||
});
|
||||
}
|
||||
|
||||
debugPrint('Toutes les boîtes Hive sont maintenant ouvertes');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
|
||||
|
||||
// En cas d'erreur, mettre à jour le message
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = 'Erreur lors de l\'initialisation des données';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectToAppropriateScreen() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
if (userRepository.isLoggedIn) {
|
||||
debugPrint('SplashPage: Redirection d\'utilisateur connecté');
|
||||
|
||||
// Récupérer directement le rôle utilisateur
|
||||
final roleValue = userRepository.getUserRole();
|
||||
debugPrint('SplashPage: Rôle utilisateur = $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint('SplashPage: Redirection vers /admin (rôle $roleValue > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint('SplashPage: Redirection vers /user (rôle $roleValue = 1)');
|
||||
context.go('/user');
|
||||
}
|
||||
}
|
||||
// Ne redirige plus vers la landing page
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.blue.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Logo avec animation de réduction
|
||||
AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 180, // Augmenté de 140 à 180
|
||||
),
|
||||
),
|
||||
|
||||
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: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Sous-titre avec nouveau slogan
|
||||
AnimatedOpacity(
|
||||
opacity: _isInitializing ? 0.8 : 1.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Text(
|
||||
'Une application puissante et intuitive de gestion de vos distributions de calendriers',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Indicateur de chargement
|
||||
if (_isInitializing) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: Colors.grey.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
minHeight: 10, // Augmenté de 6 à 10
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_statusMessage,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Boutons après l'initialisation
|
||||
if (_showButtons) ...[
|
||||
// Bouton Connexion Utilisateur
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/login', extra: {'type': 'user'});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: const Text(
|
||||
'Connexion Utilisateur',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton Connexion Administrateur
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/login', extra: {'type': 'admin'});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: const Text(
|
||||
'Connexion Administrateur',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Lien d'inscription
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
context.go('/register');
|
||||
},
|
||||
child: Text(
|
||||
'Pas encore inscrit ?',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(flex: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
app/lib/presentation/public/landing_page.dart
Normal file
1
app/lib/presentation/public/landing_page.dart
Normal file
@@ -0,0 +1 @@
|
||||
// Ce fichier sera supprimé, remplacé par la fonctionnalité directe dans splash_page.dart
|
||||
262
app/lib/presentation/user/user_communication_page.dart
Normal file
262
app/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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
965
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file
965
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file
@@ -0,0 +1,965 @@
|
||||
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: [
|
||||
Builder(builder: (context) {
|
||||
// Récupérer l'opération actuelle
|
||||
final operation = userRepository.getCurrentOperation();
|
||||
if (operation != null) {
|
||||
return Text(
|
||||
'${operation.name} (${_formatDate(operation.dateDebut)}-${_formatDate(operation.dateFin)})',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
'Tableau de bord',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour charger les données de règlements de manière asynchrone
|
||||
Future<Map<String, dynamic>> _loadPaymentData() async {
|
||||
// Utiliser un délai plus long pour s'assurer que les données sont chargées
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Vérifier si les données sont complètement chargées
|
||||
final int totalPassages = passages.length;
|
||||
debugPrint(
|
||||
'Nombre total de passages chargés pour règlements: $totalPassages');
|
||||
|
||||
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
|
||||
if (totalPassages < 100 && !_dataFullyLoaded) {
|
||||
// Attendre un peu plus et réessayer
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
// Récupérer à nouveau les passages
|
||||
final newPassages = passageRepository.getAllPassages();
|
||||
final newTotalPassages = newPassages.length;
|
||||
debugPrint(
|
||||
'Nouveau nombre total de passages chargés pour règlements: $newTotalPassages');
|
||||
|
||||
// Si le nombre a augmenté, utiliser les nouvelles données
|
||||
if (newTotalPassages > totalPassages) {
|
||||
passages.clear();
|
||||
passages.addAll(newPassages);
|
||||
debugPrint(
|
||||
'Utilisation des nouvelles données de passages pour règlements');
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher les montants par type de règlement pour le débogage
|
||||
debugPrint('=== MONTANTS PAR TYPE DE RÈGLEMENT ===');
|
||||
paymentAmounts.forEach((typeId, amount) {
|
||||
final typeTitle = AppKeys.typesReglements[typeId]?['titre'] ?? 'Inconnu';
|
||||
debugPrint('Type $typeId ($typeTitle): ${amount.toStringAsFixed(2)} €');
|
||||
});
|
||||
debugPrint('=====================================');
|
||||
|
||||
// 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);
|
||||
|
||||
// Vérifier si des types de règlement ont un montant de 0
|
||||
// Si c'est le cas, ajouter un petit montant pour qu'ils apparaissent dans le graphique
|
||||
for (var payment in paymentDataList) {
|
||||
if (payment.amount == 0 && payment.typeId != 0) {
|
||||
// Ignorer le type 0 (Pas de règlement)
|
||||
debugPrint(
|
||||
'Type ${payment.typeId} (${payment.title}) a un montant de 0, ajout d\'un petit montant pour l\'affichage');
|
||||
// Trouver l'index dans la liste
|
||||
final index = paymentDataList.indexOf(payment);
|
||||
// Remplacer par un nouvel objet avec un petit montant
|
||||
paymentDataList[index] = PaymentData(
|
||||
typeId: payment.typeId,
|
||||
amount: 0.01, // Petit montant pour qu'il apparaisse dans le graphique
|
||||
color: payment.color,
|
||||
icon: payment.icon,
|
||||
title: payment.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Retourner les données calculées
|
||||
return {
|
||||
'paymentAmounts': paymentAmounts,
|
||||
'totalPayments': totalPayments,
|
||||
'passagesWithPaymentCount': passagesWithPaymentCount,
|
||||
'paymentDataList': paymentDataList,
|
||||
};
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les règlements (liste + graphique)
|
||||
Widget _buildCombinedPaymentsCard(bool isDesktop) {
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser un Future pour s'assurer que les données sont chargées
|
||||
future: _loadPaymentData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données de règlements...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles
|
||||
if (snapshot.hasData) {
|
||||
final data = snapshot.data!;
|
||||
final paymentAmounts =
|
||||
Map<int, double>.from(data['paymentAmounts'] as Map);
|
||||
final totalPayments = data['totalPayments'] as double;
|
||||
final passagesWithPaymentCount =
|
||||
data['passagesWithPaymentCount'] as int;
|
||||
final paymentDataList = data['paymentDataList'] as List<PaymentData>;
|
||||
|
||||
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(
|
||||
'Mes 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: Container(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
// Réduire légèrement la taille pour éviter la troncature
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width:
|
||||
200, // Taille réduite pour éviter la troncature
|
||||
height: 200,
|
||||
child: PaymentPieChart(
|
||||
payments: paymentDataList,
|
||||
size:
|
||||
200, // Taille fixe au lieu de double.infinity
|
||||
labelSize:
|
||||
10, // Réduire davantage la taille des étiquettes
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius:
|
||||
'55%', // Augmenter légèrement le rayon interne
|
||||
enable3DEffect:
|
||||
false, // Désactiver l'effet 3D pour préserver les couleurs originales
|
||||
effect3DIntensity:
|
||||
0.0, // Pas d'intensité 3D
|
||||
enableEnhancedExplode:
|
||||
false, // Désactiver l'effet d'explosion amélioré
|
||||
useGradient:
|
||||
false, // Ne pas utiliser de dégradés
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Par défaut, retourner un widget vide
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Variable pour suivre si les données sont complètement chargées
|
||||
bool _dataFullyLoaded = false;
|
||||
|
||||
// Méthode pour charger les données de passages de manière asynchrone
|
||||
Future<Map<String, dynamic>> _loadPassageData() async {
|
||||
// Utiliser un délai plus long pour s'assurer que les données sont chargées
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = currentUser?.id;
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passages = passageRepository.getAllPassages();
|
||||
|
||||
// Vérifier si les données sont complètement chargées
|
||||
final int totalPassages = passages.length;
|
||||
debugPrint('Nombre total de passages chargés: $totalPassages');
|
||||
|
||||
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
|
||||
if (totalPassages < 100 && !_dataFullyLoaded) {
|
||||
// Attendre un peu plus et réessayer
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
// Récupérer à nouveau les passages
|
||||
final newPassages = passageRepository.getAllPassages();
|
||||
final newTotalPassages = newPassages.length;
|
||||
debugPrint('Nouveau nombre total de passages chargés: $newTotalPassages');
|
||||
|
||||
// Si le nombre a augmenté, utiliser les nouvelles données
|
||||
if (newTotalPassages > totalPassages) {
|
||||
passages.clear();
|
||||
passages.addAll(newPassages);
|
||||
debugPrint('Utilisation des nouvelles données de passages');
|
||||
}
|
||||
}
|
||||
|
||||
// Marquer les données comme complètement chargées pour éviter de refaire cette vérification
|
||||
_dataFullyLoaded = true;
|
||||
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le total des passages pour l'utilisateur (somme des valeurs dans userTypesCount)
|
||||
final int totalUserPassages =
|
||||
userTypesCount.values.fold(0, (sum, count) => sum + count);
|
||||
|
||||
// Retourner les données calculées
|
||||
return {
|
||||
'passagesCounts': passagesCounts,
|
||||
'totalUserPassages': totalUserPassages,
|
||||
};
|
||||
}
|
||||
|
||||
// Construction d'une carte combinée pour les passages (liste + graphique)
|
||||
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser un Future pour s'assurer que les données sont chargées
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données de passages...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles
|
||||
if (snapshot.hasData) {
|
||||
final data = snapshot.data!;
|
||||
final passagesCounts =
|
||||
Map<int, int>.from(data['passagesCounts'] as Map);
|
||||
final totalUserPassages = data['totalUserPassages'] as int;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
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) {
|
||||
return Text(
|
||||
'Mes passages',
|
||||
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);
|
||||
final IconData iconData =
|
||||
typeData['icon_data'] as IconData;
|
||||
|
||||
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,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Par défaut, retourner un widget vide
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
// Utiliser le même mécanisme de chargement asynchrone que pour les autres graphiques
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser le même Future que pour le graphique des passages
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des données d\'activité...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles, afficher le graphique
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
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 le même mécanisme de chargement asynchrone que pour les autres widgets
|
||||
return FutureBuilder<Map<String, dynamic>>(
|
||||
// Utiliser le même Future que pour les autres widgets
|
||||
future: _loadPassageData(),
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un spinner pendant le chargement
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des derniers passages...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// En cas d'erreur
|
||||
if (snapshot.hasError) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Erreur lors du chargement des données: ${snapshot.error}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Si les données sont disponibles, afficher la liste des passages récents
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
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
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
378
app/lib/presentation/user/user_dashboard_page.dart
Normal file
378
app/lib/presentation/user/user_dashboard_page.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
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/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 {
|
||||
// Utiliser directement userRepository pour la déconnexion
|
||||
await userRepository.logoutWithUI(context);
|
||||
// La redirection est gérée dans logoutWithUI
|
||||
},
|
||||
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: 'Tableau de bord',
|
||||
),
|
||||
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
app/lib/presentation/user/user_history_page.dart
Normal file
572
app/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
app/lib/presentation/user/user_map_page.dart
Normal file
1084
app/lib/presentation/user/user_map_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
581
app/lib/presentation/user/user_statistics_page.dart
Normal file
581
app/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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
167
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file
167
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
|
||||
/// Widget pour afficher une ligne du tableau d'amicales
|
||||
/// Affiche les colonnes id, name, codePostal, libRegion et une colonne Actions
|
||||
/// La colonne Actions contient un bouton Delete pour les utilisateurs avec rôle > 2
|
||||
/// La ligne entière est cliquable pour afficher les détails de l'amicale
|
||||
class AmicaleRowWidget extends StatelessWidget {
|
||||
final AmicaleModel amicale;
|
||||
final Function(AmicaleModel)? onTap;
|
||||
final Function(AmicaleModel)? onDelete;
|
||||
final bool isHeader;
|
||||
final bool isAlternate;
|
||||
|
||||
const AmicaleRowWidget({
|
||||
Key? key,
|
||||
required this.amicale,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.isHeader = false,
|
||||
this.isAlternate = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final userRole = userRepository.getUserRole();
|
||||
|
||||
// Définir les styles en fonction du type de ligne (en-tête ou données)
|
||||
final textStyle = isHeader
|
||||
? theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: theme.textTheme.bodyMedium;
|
||||
|
||||
// Couleur de fond en fonction du type de ligne
|
||||
final backgroundColor = isHeader
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
: (isAlternate
|
||||
? theme.colorScheme.surface
|
||||
: theme.colorScheme.background);
|
||||
|
||||
return InkWell(
|
||||
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Colonne ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'ID' : amicale.id.toString(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Nom
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Nom' : amicale.name,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Code Postal
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Code Postal' : amicale.codePostal,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Ville
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Ville' : (amicale.ville ?? ''),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Région
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
isHeader ? 'Région' : (amicale.libRegion ?? ''),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Colonne Actions - seulement si l'utilisateur a le rôle > 2 et onDelete n'est pas null
|
||||
if (isHeader || (userRole > 2 && onDelete != null))
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: isHeader
|
||||
? Text(
|
||||
'Actions',
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Bouton Delete
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => onDelete!(amicale),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file
184
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Widget de tableau pour afficher une liste d'amicales
|
||||
///
|
||||
/// Ce widget affiche un tableau avec les colonnes :
|
||||
/// - ID
|
||||
/// - Nom
|
||||
/// - Code Postal
|
||||
/// - Région
|
||||
/// - Actions (boutons selon les droits de l'utilisateur)
|
||||
///
|
||||
/// Lorsqu'on clique sur une ligne, une modale s'affiche avec le formulaire EntiteForm
|
||||
class AmicaleTableWidget extends StatelessWidget {
|
||||
final List<AmicaleModel> amicales;
|
||||
final Function(AmicaleModel)? onDelete;
|
||||
final bool isLoading;
|
||||
final String? emptyMessage;
|
||||
final bool readOnly;
|
||||
|
||||
const AmicaleTableWidget({
|
||||
Key? key,
|
||||
required this.amicales,
|
||||
this.onDelete,
|
||||
this.isLoading = false,
|
||||
this.emptyMessage,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// En-tête du tableau - utiliser AmicaleRowWidget pour l'en-tête
|
||||
AmicaleRowWidget(
|
||||
amicale: AmicaleModel(
|
||||
id: 0,
|
||||
name: '',
|
||||
codePostal: '',
|
||||
ville: '',
|
||||
libRegion: '',
|
||||
),
|
||||
isHeader: true,
|
||||
onTap: null,
|
||||
onDelete: null,
|
||||
),
|
||||
|
||||
// Corps du tableau
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildTableContent(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableContent(BuildContext context) {
|
||||
// Afficher un indicateur de chargement si isLoading est true
|
||||
if (isLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher un message si la liste est vide
|
||||
if (amicales.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
emptyMessage ?? 'Aucune amicale trouvée',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher la liste des amicales
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: amicales.length,
|
||||
itemBuilder: (context, index) {
|
||||
final amicale = amicales[index];
|
||||
return AmicaleRowWidget(
|
||||
amicale: amicale,
|
||||
isAlternate: index % 2 == 1, // Alterner les couleurs
|
||||
onTap: (selectedAmicale) =>
|
||||
_showAmicaleDetails(context, selectedAmicale),
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher une modale avec le formulaire EntiteForm
|
||||
void _showAmicaleDetails(BuildContext context, AmicaleModel amicale) {
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
final userRepo = userRepository;
|
||||
// Créer une instance de RegionRepository
|
||||
final regionRepo = RegionRepository();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => MultiProvider(
|
||||
providers: [
|
||||
// Fournir les repositories nécessaires au formulaire
|
||||
Provider<UserRepository>.value(value: userRepo),
|
||||
Provider<RegionRepository>.value(value: regionRepo),
|
||||
],
|
||||
child: Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: MediaQuery.of(dialogContext).size.width * 0.6,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Détails de l\'amicale',
|
||||
style: Theme.of(dialogContext)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
Theme.of(dialogContext).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Formulaire EntiteForm en mode lecture seule
|
||||
EntiteForm(
|
||||
amicale: amicale,
|
||||
readOnly: readOnly,
|
||||
onSubmit: (updatedAmicale) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
// Ici, vous pourriez ajouter une logique pour mettre à jour l'amicale
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user