feat: Release version 3.1.4 - Mode terrain et génération PDF
✨ Nouvelles fonctionnalités: - Ajout du mode terrain pour utilisation mobile hors connexion - Génération automatique de reçus PDF avec template personnalisé - Révision complète du système de cartes avec amélioration des performances 🔧 Améliorations techniques: - Refactoring du module chat avec architecture simplifiée - Optimisation du système de sécurité NIST SP 800-63B - Amélioration de la gestion des secteurs géographiques - Support UTF-8 étendu pour les noms d'utilisateurs 📱 Application mobile: - Nouveau mode terrain dans user_field_mode_page - Interface utilisateur adaptative pour conditions difficiles - Synchronisation offline améliorée 🗺️ Cartographie: - Optimisation des performances MapBox - Meilleure gestion des tuiles hors ligne - Amélioration de l'affichage des secteurs 📄 Documentation: - Ajout guide Android (ANDROID-GUIDE.md) - Documentation sécurité API (API-SECURITY.md) - Guide module chat (CHAT_MODULE.md) 🐛 Corrections: - Résolution des erreurs 400 lors de la création d'utilisateurs - Correction de la validation des noms d'utilisateurs - Fix des problèmes de synchronisation chat 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,82 +1,198 @@
|
||||
# Module Chat GEOSECTOR
|
||||
# Module Chat Autonome pour GEOSECTOR
|
||||
|
||||
## Structure du module
|
||||
## Description
|
||||
Module de chat **totalement autonome et portable** pour l'application GEOSECTOR.
|
||||
- **100% indépendant** : Peut être réutilisé dans n'importe quelle application Flutter
|
||||
- **API REST uniquement** : Plus de dépendance MQTT
|
||||
- **Architecture simple** : Code maintenable et facilement compréhensible
|
||||
- **Cache local Hive** : Fonctionnement offline avec synchronisation automatique
|
||||
- **Gestion des rôles** : Permissions configurables via YAML
|
||||
- **Autocomplete intelligent** : Sélection des destinataires selon les permissions
|
||||
|
||||
Le module chat est organisé selon une architecture modulaire respectant la séparation des préoccupations :
|
||||
## Architecture simplifiée
|
||||
|
||||
### Structure minimale
|
||||
```
|
||||
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
|
||||
├── models/ # 2 modèles simples
|
||||
│ ├── room.dart
|
||||
│ └── message.dart
|
||||
├── services/ # Services de gestion
|
||||
│ ├── chat_service.dart # Service principal
|
||||
│ └── chat_config_loader.dart # Chargeur de configuration
|
||||
├── widgets/ # Composants réutilisables
|
||||
│ └── recipient_selector.dart # Sélecteur de destinataires
|
||||
├── pages/ # 2 pages essentielles
|
||||
│ ├── rooms_page.dart
|
||||
│ └── chat_page.dart
|
||||
├── chat.dart # Point d'entrée avec exports
|
||||
└── README.md # Documentation du module
|
||||
├── chat_config.yaml # Configuration des permissions
|
||||
└── chat_module.dart # Point d'entrée
|
||||
```
|
||||
|
||||
## Fonctionnalités principales
|
||||
## Intégration dans votre application
|
||||
|
||||
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';
|
||||
### 1. Installation
|
||||
```yaml
|
||||
# pubspec.yaml
|
||||
dependencies:
|
||||
dio: ^5.4.0
|
||||
hive: ^2.2.3
|
||||
hive_flutter: ^1.1.0
|
||||
yaml: ^3.1.2
|
||||
```
|
||||
|
||||
### Affichage de la page chat
|
||||
|
||||
### 2. Initialisation
|
||||
```dart
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatPage()),
|
||||
await ChatModule.init(
|
||||
apiUrl: 'https://api.geosector.fr',
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
userRole: currentUser.fkRole, // 1, 2 ou 9
|
||||
userEntite: currentUser.entiteId, // ID de l'amicale
|
||||
authToken: authToken, // Optionnel
|
||||
);
|
||||
```
|
||||
|
||||
### Création d'une conversation
|
||||
|
||||
### 3. Utilisation
|
||||
```dart
|
||||
final chatRepository = ChatRepository();
|
||||
final conversation = await chatRepository.createConversation({
|
||||
'type': 'one_to_one',
|
||||
'participants': [userId1, userId2],
|
||||
});
|
||||
// Ouvrir le chat
|
||||
ChatModule.openChat(context);
|
||||
|
||||
// Ou obtenir directement une page
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatModule.getRoomsPage(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## États d'implémentation
|
||||
## Gestion des permissions par rôle
|
||||
|
||||
- [x] Structure de base
|
||||
- [ ] Modèles de données complets
|
||||
- [ ] Intégration avec Hive
|
||||
- [ ] Services API
|
||||
- [ ] Gestion hors ligne
|
||||
- [ ] Widgets visuels
|
||||
- [ ] Tests unitaires
|
||||
### Configuration (chat_config.yaml)
|
||||
Les permissions sont définies dans un fichier YAML facilement modifiable :
|
||||
|
||||
## À faire
|
||||
#### Rôle 1 (Membre)
|
||||
- Peut discuter avec les membres de son amicale (rôle 1)
|
||||
- Peut discuter avec l'admin de son amicale (rôle 2)
|
||||
- Restriction : même entité uniquement
|
||||
|
||||
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
|
||||
#### Rôle 2 (Admin Amicale)
|
||||
- Peut discuter avec tous les membres de son amicale
|
||||
- Peut discuter avec les superadmins (rôle 9)
|
||||
- Peut créer des groupes de discussion
|
||||
|
||||
#### Rôle 9 (Super Admin)
|
||||
- Peut envoyer des messages à tous les admins d'amicale
|
||||
- Peut sélectionner des destinataires spécifiques
|
||||
- Peut faire du broadcast à tous les admins
|
||||
|
||||
## API Backend requise
|
||||
|
||||
### Endpoints REST
|
||||
- `GET /api/chat/rooms` - Liste des conversations filtrées par rôle
|
||||
- `POST /api/chat/rooms` - Créer une conversation avec vérification des permissions
|
||||
- `GET /api/chat/rooms/{id}/messages` - Messages d'une conversation
|
||||
- `POST /api/chat/rooms/{id}/messages` - Envoyer un message
|
||||
- `POST /api/chat/rooms/{id}/read` - Marquer comme lu
|
||||
- `GET /api/chat/recipients` - Liste des destinataires possibles selon le rôle
|
||||
|
||||
### Structure base de données
|
||||
```sql
|
||||
-- Tables préfixées "chat_"
|
||||
CREATE TABLE chat_rooms (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
title VARCHAR(255),
|
||||
type ENUM('private', 'group', 'broadcast'),
|
||||
created_at TIMESTAMP,
|
||||
created_by INT
|
||||
);
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
room_id VARCHAR(36),
|
||||
content TEXT,
|
||||
sender_id INT,
|
||||
sent_at TIMESTAMP,
|
||||
FOREIGN KEY (room_id) REFERENCES chat_rooms(id)
|
||||
);
|
||||
|
||||
CREATE TABLE chat_participants (
|
||||
room_id VARCHAR(36),
|
||||
user_id INT,
|
||||
role INT,
|
||||
entite_id INT,
|
||||
joined_at TIMESTAMP,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE chat_read_receipts (
|
||||
message_id VARCHAR(36),
|
||||
user_id INT,
|
||||
read_at TIMESTAMP,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Stockage local Hive
|
||||
|
||||
### TypeIds réservés
|
||||
- TypeId 50: Room
|
||||
- TypeId 51: Message
|
||||
- TypeIds 52-60: Réservés pour extensions futures
|
||||
|
||||
### Boxes Hive
|
||||
- `chat_rooms`: Conversations en cache
|
||||
- `chat_messages`: Messages en cache
|
||||
|
||||
## Interface utilisateur
|
||||
|
||||
### Design minimaliste et professionnel
|
||||
- Palette de couleurs sobre (blanc, gris, bleu accent)
|
||||
- Pas d'animations superflues
|
||||
- Focus sur la lisibilité et l'efficacité
|
||||
- Interface responsive pour toutes les plateformes
|
||||
|
||||
### Composants principaux
|
||||
1. **Liste des conversations** : Vue simple avec badges non-lus
|
||||
2. **Page de chat** : Messages avec bulles, input simple
|
||||
3. **Sélecteur de destinataires** : Autocomplete avec filtrage par rôle
|
||||
|
||||
## Planning de développement (2 sessions)
|
||||
|
||||
### Session 1 : Architecture de base ✅
|
||||
- [x] Nettoyage et suppression du MQTT
|
||||
- [x] Création des modèles simples (Room, Message)
|
||||
- [x] Service unique ChatService
|
||||
- [x] Pages minimales (RoomsPage, ChatPage)
|
||||
- [x] Module d'entrée ChatModule
|
||||
- [x] Gestion des rôles et permissions via YAML
|
||||
- [x] Widget autocomplete pour sélection des destinataires
|
||||
- [x] Adaptation de l'UI selon les permissions
|
||||
|
||||
### Session 2 : Finalisation
|
||||
- [ ] Tests d'intégration avec l'API
|
||||
- [ ] Documentation API complète
|
||||
- [ ] Optimisations performances
|
||||
- [ ] Gestion erreurs robuste
|
||||
|
||||
## Points d'attention
|
||||
|
||||
### Sécurité
|
||||
- Authentification via token JWT
|
||||
- Vérification des permissions côté serveur
|
||||
- Validation des inputs
|
||||
|
||||
### Performance
|
||||
- Cache Hive pour mode offline
|
||||
- Pagination des messages
|
||||
- Lazy loading des conversations
|
||||
|
||||
### Maintenance
|
||||
- Code simple et documenté
|
||||
- Architecture modulaire
|
||||
- Configuration externalisée (YAML)
|
||||
|
||||
## Support
|
||||
|
||||
Pour toute question ou problème, consulter la documentation principale de l'application GEOSECTOR.
|
||||
170
app/lib/chat/TODO_CHAT.md
Normal file
170
app/lib/chat/TODO_CHAT.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 📋 TODO List - Module Chat v1.0.0
|
||||
|
||||
## 🎯 Objectif
|
||||
Aligner le module chat avec l'API backend et optimiser l'expérience utilisateur.
|
||||
|
||||
## 📊 État d'avancement global : 8/13 (62%)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRIORITÉ HAUTE - Fonctionnalités essentielles
|
||||
|
||||
### 1. ✅ Pagination des messages
|
||||
- **Description** : Implémenter la pagination avec limit=50 et paramètre `before`
|
||||
- **Endpoint** : `GET /api/chat/rooms/{id}/messages?limit=50&before={message_id}`
|
||||
- **Fichiers** : `chat_service.dart`, `chat_page.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
### 2. ✅ Chargement progressif
|
||||
- **Description** : Ajouter bouton "Charger plus" en haut de la conversation
|
||||
- **UI** : Bouton ou indicateur en haut de la liste des messages
|
||||
- **Fichiers** : `chat_page.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
### 3. ✅ Gestion du flag has_more
|
||||
- **Description** : Gérer la réponse API pour savoir s'il reste des messages
|
||||
- **Logique** : Désactiver "Charger plus" si `has_more: false`
|
||||
- **Fichiers** : `message.dart`, `chat_service.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
---
|
||||
|
||||
## 🟠 PRIORITÉ MOYENNE - Amélioration UX
|
||||
|
||||
### 4. ✅ Message initial à la création
|
||||
- **Description** : Ajouter `initial_message` lors de la création d'une room
|
||||
- **Endpoint** : `POST /api/chat/rooms` avec body `{..., "initial_message": "..."}`
|
||||
- **Fichiers** : `chat_service.dart`, `recipient_selector.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
### 5. ⬜ Extraction infos chat du login
|
||||
- **Description** : Récupérer `total_rooms`, `unread_messages` depuis la réponse login
|
||||
- **Intégration** : Stocker dans Hive ou service global
|
||||
- **Fichiers** : `user_repository.dart`, `chat_service.dart`
|
||||
- **Temps estimé** : 20 min
|
||||
|
||||
### 6. ⬜ Badge messages non lus
|
||||
- **Description** : Afficher un badge rouge avec le nombre sur l'icône chat
|
||||
- **UI** : Badge sur NavigationDestination et dans la sidebar
|
||||
- **Fichiers** : `responsive_navigation.dart`, `admin_dashboard_page.dart`
|
||||
- **Temps estimé** : 15 min
|
||||
|
||||
### 7. ✅ Pull-to-refresh RoomsPage
|
||||
- **Description** : Ajouter RefreshIndicator pour rafraîchir la liste des rooms
|
||||
- **Geste** : Tirer vers le bas pour recharger
|
||||
- **Fichiers** : `rooms_page.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ (déjà implémenté)
|
||||
|
||||
### 8. ✅ Pull-to-refresh ChatPage
|
||||
- **Description** : Ajouter RefreshIndicator pour rafraîchir les messages
|
||||
- **Geste** : Tirer vers le bas pour recharger les derniers messages
|
||||
- **Fichiers** : `chat_page.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
---
|
||||
|
||||
## 🟡 PRIORITÉ BASSE - Optimisations
|
||||
|
||||
### 9. ✅ Optimiser cache Hive
|
||||
- **Description** : Limiter à 100 messages max par room dans le cache
|
||||
- **Logique** : Supprimer les plus anciens si > 100
|
||||
- **Fichiers** : `chat_service.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
### 10. ⬜ Optimiser le polling
|
||||
- **Description** : Polling actif seulement si la page chat est visible
|
||||
- **Optimisation** : Pause/reprise selon WidgetsBindingObserver
|
||||
- **Fichiers** : `chat_service.dart`, `rooms_page.dart`
|
||||
- **Temps estimé** : 15 min
|
||||
|
||||
### 11. ✅ Indicateurs de chargement
|
||||
- **Description** : Ajouter spinners lors du chargement de la pagination
|
||||
- **UI** : CircularProgressIndicator pendant les requêtes
|
||||
- **Fichiers** : `chat_page.dart`
|
||||
- **Statut** : ✅ COMPLÉTÉ
|
||||
|
||||
### 12. ⬜ Auto-refresh au retour
|
||||
- **Description** : Rafraîchir quand l'app revient au premier plan
|
||||
- **Implémentation** : WidgetsBindingObserver avec AppLifecycleState
|
||||
- **Fichiers** : `chat_module.dart`, `rooms_page.dart`
|
||||
- **Temps estimé** : 10 min
|
||||
|
||||
---
|
||||
|
||||
## 📚 DOCUMENTATION
|
||||
|
||||
### 13. ⬜ Mettre à jour README.md
|
||||
- **Description** : Documenter toutes les nouvelles fonctionnalités
|
||||
- **Sections** : Pagination, pull-to-refresh, badges, optimisations
|
||||
- **Fichiers** : `README.md`
|
||||
- **Temps estimé** : 15 min
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Temps total restant : ~1h
|
||||
|
||||
## 🚀 Ordre de développement recommandé
|
||||
|
||||
### Phase 1 : Core ✅ COMPLÉTÉ
|
||||
1. Pagination des messages ✅
|
||||
2. Chargement progressif ✅
|
||||
3. Gestion has_more ✅
|
||||
|
||||
### Phase 2 : Intégration (En cours)
|
||||
4. Message initial ✅
|
||||
5. Infos chat login ⬜
|
||||
6. Badge non lus ⬜
|
||||
7. Pull-to-refresh rooms ✅
|
||||
8. Pull-to-refresh chat ✅
|
||||
|
||||
### Phase 3 : Polish (Partiellement complété)
|
||||
9. Optimiser cache ✅
|
||||
10. Optimiser polling ⬜
|
||||
11. Indicateurs chargement ✅
|
||||
12. Auto-refresh ⬜
|
||||
|
||||
### Phase 4 : Documentation
|
||||
13. README.md ⬜
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- **Version actuelle** : 1.0.0
|
||||
- **Date de création** : 2025-01-17
|
||||
- **Dernière mise à jour** : 2025-01-17
|
||||
- **Développeur** : Module Chat Team
|
||||
|
||||
## 🔗 Références
|
||||
|
||||
- [API Documentation](../../../docs/API_CHAT.md)
|
||||
- [Architecture Flutter](../README.md)
|
||||
- [Permissions YAML](../chat_config.yaml)
|
||||
|
||||
## ✨ Améliorations implémentées
|
||||
|
||||
### Pagination
|
||||
- Limite de 50 messages par requête
|
||||
- Paramètre `before` pour charger les messages plus anciens
|
||||
- Gestion du flag `has_more` pour indiquer s'il reste des messages
|
||||
- Bouton "Charger plus" en haut de la conversation
|
||||
|
||||
### Optimisations cache
|
||||
- Limitation à 100 messages maximum par room dans Hive
|
||||
- Suppression automatique des messages les plus anciens
|
||||
- Méthode `_saveMessagesToCache` dédiée
|
||||
|
||||
### Message initial
|
||||
- Champ de texte ajouté dans le dialog de sélection des destinataires
|
||||
- Paramètre `initial_message` dans les méthodes de création de room
|
||||
- Support dans l'API pour envoyer le message initial
|
||||
|
||||
### Pull-to-refresh
|
||||
- RefreshIndicator dans RoomsPage (déjà existant)
|
||||
- RefreshIndicator dans ChatPage pour rafraîchir les messages
|
||||
- Rechargement complet des messages récents
|
||||
|
||||
### Indicateurs de chargement
|
||||
- Spinner pendant le chargement de la pagination
|
||||
- État `_isLoadingMore` pour gérer l'affichage
|
||||
- CircularProgressIndicator de taille réduite
|
||||
@@ -1,36 +0,0 @@
|
||||
/// Exportation principale du module chat
|
||||
///
|
||||
/// Ce fichier centralise les exportations du module chat
|
||||
/// pour faciliter l'importation dans d'autres parties de l'application
|
||||
library;
|
||||
|
||||
// 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';
|
||||
84
app/lib/chat/chat_config.yaml
Normal file
84
app/lib/chat/chat_config.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
# Configuration du module Chat
|
||||
# Regles de permissions par role
|
||||
|
||||
# Version du module
|
||||
module_info:
|
||||
version: "1.0.0"
|
||||
name: "Chat Module Light"
|
||||
description: "Module de chat autonome et portable pour GEOSECTOR"
|
||||
|
||||
chat_permissions:
|
||||
# Role 1: Membre standard
|
||||
role_1:
|
||||
name: "Membre"
|
||||
description: "Membre de l'amicale"
|
||||
can_message_with:
|
||||
- role: 1
|
||||
condition: "same_entite" # Meme amicale seulement
|
||||
description: "Collegues membres"
|
||||
- role: 2
|
||||
condition: "same_entite" # Admin de sa propre amicale
|
||||
description: "Administrateur de votre amicale"
|
||||
can_create_group: false
|
||||
can_broadcast: false
|
||||
help_text: "Vous pouvez discuter avec les membres de votre amicale"
|
||||
|
||||
# Role 2: Administrateur d'amicale
|
||||
role_2:
|
||||
name: "Admin Amicale"
|
||||
description: "Administrateur d'une amicale"
|
||||
can_message_with:
|
||||
- role: 1
|
||||
condition: "same_entite" # Membres de son amicale
|
||||
description: "Membres de votre amicale"
|
||||
- role: 2
|
||||
condition: "same_entite" # Autres admins de son amicale
|
||||
description: "Co-administrateurs"
|
||||
- role: 9
|
||||
condition: "all" # Tous les superadmins
|
||||
description: "Super administrateurs"
|
||||
can_create_group: true
|
||||
can_broadcast: false
|
||||
help_text: "Vous pouvez discuter avec les membres de votre amicale et les super admins"
|
||||
|
||||
# Role 9: Super administrateur
|
||||
role_9:
|
||||
name: "Super Admin"
|
||||
description: "Administrateur systeme"
|
||||
can_message_with:
|
||||
- role: 2
|
||||
condition: "all" # Tous les admins d'amicale
|
||||
description: "Administrateurs d'amicale"
|
||||
allow_selection: true # Permet selection multiple
|
||||
allow_broadcast: true # Permet envoi groupe
|
||||
can_create_group: true
|
||||
can_broadcast: true
|
||||
help_text: "Vous pouvez envoyer des messages a tous les administrateurs d'amicale ou selectionner des destinataires specifiques"
|
||||
|
||||
# Configuration de l'interface
|
||||
ui_config:
|
||||
show_role_badge: true
|
||||
show_entite_info: true
|
||||
enable_autocomplete: true
|
||||
min_search_length: 2
|
||||
|
||||
# Messages par defaut
|
||||
messages:
|
||||
no_permission: "Vous n'avez pas la permission de creer cette conversation"
|
||||
no_recipients: "Aucun destinataire disponible"
|
||||
search_placeholder: "Rechercher un destinataire..."
|
||||
new_conversation: "Nouvelle conversation"
|
||||
select_recipients: "Selectionner les destinataires"
|
||||
|
||||
# Couleurs par role (optionnel)
|
||||
role_colors:
|
||||
1: "#64748B" # Gris pour membre
|
||||
2: "#2563EB" # Bleu pour admin
|
||||
9: "#DC2626" # Rouge pour superadmin
|
||||
|
||||
# Configuration API
|
||||
api_config:
|
||||
recipients_endpoint: "/chat/recipients"
|
||||
create_room_endpoint: "/chat/rooms"
|
||||
require_entite_for_role_1: true
|
||||
require_entite_for_role_2: true
|
||||
59
app/lib/chat/chat_module.dart
Executable file
59
app/lib/chat/chat_module.dart
Executable file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'services/chat_service.dart';
|
||||
import 'pages/rooms_page.dart';
|
||||
import 'pages/chat_page.dart';
|
||||
|
||||
/// Module de chat autonome avec gestion des rôles
|
||||
///
|
||||
/// Les permissions sont gérées via le fichier chat_config.yaml
|
||||
class ChatModule {
|
||||
|
||||
/// Initialiser le module chat avec support des rôles
|
||||
///
|
||||
/// @param apiUrl URL de base de l'API
|
||||
/// @param userId ID de l'utilisateur connecté
|
||||
/// @param userName Nom de l'utilisateur
|
||||
/// @param userRole Rôle de l'utilisateur (1: membre, 2: admin amicale, 9: superadmin)
|
||||
/// @param userEntite ID de l'entité/amicale de l'utilisateur (optionnel)
|
||||
/// @param authToken Token JWT d'authentification (optionnel)
|
||||
static Future<void> init({
|
||||
required String apiUrl,
|
||||
required int userId,
|
||||
required String userName,
|
||||
required int userRole,
|
||||
int? userEntite,
|
||||
String? authToken,
|
||||
}) async {
|
||||
await ChatService.init(
|
||||
apiUrl: apiUrl,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
userRole: userRole,
|
||||
userEntite: userEntite,
|
||||
authToken: authToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir la page de liste des conversations
|
||||
static Widget getRoomsPage() => const RoomsPage();
|
||||
|
||||
/// Obtenir la page de chat pour une room spécifique
|
||||
static Widget getChatPage(String roomId, String roomTitle) => ChatPage(
|
||||
roomId: roomId,
|
||||
roomTitle: roomTitle,
|
||||
);
|
||||
|
||||
/// Naviguer vers le chat depuis n'importe où
|
||||
static void openChat(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RoomsPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
static void dispose() {
|
||||
ChatService.instance.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,51 +0,0 @@
|
||||
/// Constantes spécifiques au module chat
|
||||
library;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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 const MaterialApp(
|
||||
home: MqttIntegrationExample(),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
app/lib/chat/example_usage.dart
Normal file
118
app/lib/chat/example_usage.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
/// Exemple d'utilisation du module chat
|
||||
///
|
||||
/// Ce fichier montre comment intégrer le module chat dans votre application
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'chat_module.dart';
|
||||
|
||||
class ExampleApp extends StatelessWidget {
|
||||
const ExampleApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Chat Module Example',
|
||||
home: const HomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initChat();
|
||||
}
|
||||
|
||||
Future<void> _initChat() async {
|
||||
// Initialiser le module chat avec vos paramètres et rôle
|
||||
await ChatModule.init(
|
||||
apiUrl: 'https://api.votre-domaine.com',
|
||||
userId: 123, // ID de l'utilisateur connecté
|
||||
userName: 'Jean Dupont', // Nom de l'utilisateur
|
||||
userRole: 2, // Rôle: 1=membre, 2=admin amicale, 9=superadmin
|
||||
userEntite: 5, // ID de l'amicale/entité (optionnel)
|
||||
authToken: 'votre-token-jwt', // Token d'authentification (optionnel)
|
||||
);
|
||||
|
||||
setState(() => _isInitialized = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Application Example'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Bienvenue dans votre application'),
|
||||
const SizedBox(height: 20),
|
||||
if (_isInitialized)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Ouvrir le chat
|
||||
ChatModule.openChat(context);
|
||||
},
|
||||
child: const Text('Ouvrir le Chat'),
|
||||
)
|
||||
else
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Nettoyer les ressources du chat
|
||||
ChatModule.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Comment l'utiliser dans votre application :
|
||||
///
|
||||
/// 1. Copier le dossier lib/chat dans votre projet
|
||||
///
|
||||
/// 2. Ajouter les dépendances dans pubspec.yaml :
|
||||
/// dependencies:
|
||||
/// dio: ^5.4.0
|
||||
/// hive: ^2.2.3
|
||||
/// hive_flutter: ^1.1.0
|
||||
/// dev_dependencies:
|
||||
/// hive_generator: ^2.0.1
|
||||
/// build_runner: ^2.4.8
|
||||
///
|
||||
/// 3. Initialiser le module au démarrage :
|
||||
/// await ChatModule.init(
|
||||
/// apiUrl: 'https://votre-api.com',
|
||||
/// userId: currentUserId,
|
||||
/// userName: currentUserName,
|
||||
/// userRole: currentUserRole, // 1, 2 ou 9
|
||||
/// userEntite: currentUserEntite, // ID amicale
|
||||
/// authToken: authToken,
|
||||
/// );
|
||||
///
|
||||
/// 4. Ouvrir le chat depuis n'importe où :
|
||||
/// ChatModule.openChat(context);
|
||||
///
|
||||
/// 5. Ou naviguer directement vers une conversation :
|
||||
/// Navigator.push(
|
||||
/// context,
|
||||
/// MaterialPageRoute(
|
||||
/// builder: (_) => ChatModule.getChatPage(roomId, roomTitle),
|
||||
/// ),
|
||||
/// );
|
||||
@@ -1,104 +0,0 @@
|
||||
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: 23)
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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: 24)
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
63
app/lib/chat/models/message.dart
Normal file
63
app/lib/chat/models/message.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'message.g.dart';
|
||||
|
||||
/// Modèle simple de message
|
||||
@HiveType(typeId: 51)
|
||||
class Message extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String roomId;
|
||||
|
||||
@HiveField(2)
|
||||
final String content;
|
||||
|
||||
@HiveField(3)
|
||||
final int senderId;
|
||||
|
||||
@HiveField(4)
|
||||
final String senderName;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime sentAt;
|
||||
|
||||
@HiveField(6)
|
||||
final bool isMe;
|
||||
|
||||
@HiveField(7)
|
||||
final bool isRead;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.roomId,
|
||||
required this.content,
|
||||
required this.senderId,
|
||||
required this.senderName,
|
||||
required this.sentAt,
|
||||
this.isMe = false,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
// Simple factory depuis JSON
|
||||
factory Message.fromJson(Map<String, dynamic> json, int currentUserId) {
|
||||
return Message(
|
||||
id: json['id'],
|
||||
roomId: json['fk_room'],
|
||||
content: json['content'] ?? '',
|
||||
senderId: json['fk_user'] ?? 0,
|
||||
senderName: json['sender_name'] ?? 'Anonyme',
|
||||
sentAt: DateTime.parse(json['date_sent']),
|
||||
isMe: json['fk_user'] == currentUserId,
|
||||
isRead: json['statut'] == 'lu',
|
||||
);
|
||||
}
|
||||
|
||||
// Simple conversion en JSON pour envoi
|
||||
Map<String, dynamic> toJson() => {
|
||||
'fk_room': roomId,
|
||||
'content': content,
|
||||
'fk_user': senderId,
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,53 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'audience_target_model.dart';
|
||||
part of 'message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
|
||||
class MessageAdapter extends TypeAdapter<Message> {
|
||||
@override
|
||||
final int typeId = 24;
|
||||
final int typeId = 51;
|
||||
|
||||
@override
|
||||
AudienceTargetModel read(BinaryReader reader) {
|
||||
Message read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AudienceTargetModel(
|
||||
return Message(
|
||||
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?,
|
||||
roomId: fields[1] as String,
|
||||
content: fields[2] as String,
|
||||
senderId: fields[3] as int,
|
||||
senderName: fields[4] as String,
|
||||
sentAt: fields[5] as DateTime,
|
||||
isMe: fields[6] as bool,
|
||||
isRead: fields[7] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AudienceTargetModel obj) {
|
||||
void write(BinaryWriter writer, Message obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.conversationId)
|
||||
..write(obj.roomId)
|
||||
..writeByte(2)
|
||||
..write(obj.targetType)
|
||||
..write(obj.content)
|
||||
..writeByte(3)
|
||||
..write(obj.targetId)
|
||||
..write(obj.senderId)
|
||||
..writeByte(4)
|
||||
..write(obj.createdAt)
|
||||
..write(obj.senderName)
|
||||
..writeByte(5)
|
||||
..write(obj.roleFilter)
|
||||
..write(obj.sentAt)
|
||||
..writeByte(6)
|
||||
..write(obj.entityFilter);
|
||||
..write(obj.isMe)
|
||||
..writeByte(7)
|
||||
..write(obj.isRead);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,7 +56,7 @@ class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AudienceTargetModelAdapter &&
|
||||
other is MessageAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
61
app/lib/chat/models/room.dart
Normal file
61
app/lib/chat/models/room.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'room.g.dart';
|
||||
|
||||
/// Modèle simple de conversation/room
|
||||
@HiveType(typeId: 50)
|
||||
class Room extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String title;
|
||||
|
||||
@HiveField(2)
|
||||
final String type; // 'private' ou 'group'
|
||||
|
||||
@HiveField(3)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(4)
|
||||
final String? lastMessage;
|
||||
|
||||
@HiveField(5)
|
||||
final DateTime? lastMessageAt;
|
||||
|
||||
@HiveField(6)
|
||||
final int unreadCount;
|
||||
|
||||
Room({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
this.lastMessage,
|
||||
this.lastMessageAt,
|
||||
this.unreadCount = 0,
|
||||
});
|
||||
|
||||
// Simple factory depuis JSON
|
||||
factory Room.fromJson(Map<String, dynamic> json) {
|
||||
return Room(
|
||||
id: json['id'],
|
||||
title: json['title'] ?? 'Sans titre',
|
||||
type: json['type'] ?? 'private',
|
||||
createdAt: DateTime.parse(json['date_creation']),
|
||||
lastMessage: json['last_message'],
|
||||
lastMessageAt: json['last_message_at'] != null
|
||||
? DateTime.parse(json['last_message_at'])
|
||||
: null,
|
||||
unreadCount: json['unread_count'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Simple conversion en JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'type': type,
|
||||
'date_creation': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'anonymous_user_model.dart';
|
||||
part of 'room.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AnonymousUserModelAdapter extends TypeAdapter<AnonymousUserModel> {
|
||||
class RoomAdapter extends TypeAdapter<Room> {
|
||||
@override
|
||||
final int typeId = 23;
|
||||
final int typeId = 50;
|
||||
|
||||
@override
|
||||
AnonymousUserModel read(BinaryReader reader) {
|
||||
Room read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AnonymousUserModel(
|
||||
return Room(
|
||||
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>(),
|
||||
title: fields[1] as String,
|
||||
type: fields[2] as String,
|
||||
createdAt: fields[3] as DateTime,
|
||||
lastMessage: fields[4] as String?,
|
||||
lastMessageAt: fields[5] as DateTime?,
|
||||
unreadCount: fields[6] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AnonymousUserModel obj) {
|
||||
void write(BinaryWriter writer, Room obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.deviceId)
|
||||
..write(obj.title)
|
||||
..writeByte(2)
|
||||
..write(obj.name)
|
||||
..write(obj.type)
|
||||
..writeByte(3)
|
||||
..write(obj.email)
|
||||
..writeByte(4)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(4)
|
||||
..write(obj.lastMessage)
|
||||
..writeByte(5)
|
||||
..write(obj.convertedToUserId)
|
||||
..write(obj.lastMessageAt)
|
||||
..writeByte(6)
|
||||
..write(obj.metadata);
|
||||
..write(obj.unreadCount);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,7 +53,7 @@ class AnonymousUserModelAdapter extends TypeAdapter<AnonymousUserModel> {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AnonymousUserModelAdapter &&
|
||||
other is RoomAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,81 +1,359 @@
|
||||
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
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/message.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/chat_config_loader.dart';
|
||||
|
||||
/// Page simple de chat
|
||||
class ChatPage extends StatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
final String roomId;
|
||||
final String roomTitle;
|
||||
|
||||
const ChatPage({
|
||||
super.key,
|
||||
required this.roomId,
|
||||
required this.roomTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
String? _selectedConversationId;
|
||||
|
||||
final _service = ChatService.instance;
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMore = true;
|
||||
List<Message> _messages = [];
|
||||
String? _oldestMessageId;
|
||||
|
||||
@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) => const ChatScreen(
|
||||
conversationId:
|
||||
'conversation-id', // TODO: obtenir l'ID de la conversation
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialMessages();
|
||||
_service.markAsRead(widget.roomId);
|
||||
}
|
||||
|
||||
Future<void> _loadInitialMessages() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final result = await _service.getMessages(widget.roomId);
|
||||
final messages = result['messages'] as List<Message>;
|
||||
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
_hasMore = result['has_more'] as bool;
|
||||
if (messages.isNotEmpty) {
|
||||
_oldestMessageId = messages.first.id;
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Attendre un peu avant de scroller pour laisser le temps au ListView de se construire
|
||||
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
||||
}
|
||||
|
||||
Future<void> _loadMoreMessages() async {
|
||||
if (_isLoadingMore || !_hasMore || _oldestMessageId == null) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
final result = await _service.getMessages(widget.roomId, beforeMessageId: _oldestMessageId);
|
||||
final newMessages = result['messages'] as List<Message>;
|
||||
|
||||
setState(() {
|
||||
// Insérer les messages plus anciens au début
|
||||
_messages = [...newMessages, ..._messages];
|
||||
_hasMore = result['has_more'] as bool;
|
||||
|
||||
if (newMessages.isNotEmpty) {
|
||||
_oldestMessageId = newMessages.first.id;
|
||||
}
|
||||
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
_messageController.clear();
|
||||
await _service.sendMessage(widget.roomId, text);
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Obtenir le rôle de l'utilisateur pour la colorisation
|
||||
final userRole = _service.getUserRole();
|
||||
|
||||
// Déterminer la couleur du badge selon le rôle
|
||||
Color badgeColor;
|
||||
switch (userRole) {
|
||||
case 1:
|
||||
badgeColor = Colors.green; // Vert pour les membres
|
||||
break;
|
||||
case 2:
|
||||
badgeColor = Colors.blue; // Bleu pour les admins amicale
|
||||
break;
|
||||
case 9:
|
||||
badgeColor = Colors.red; // Rouge pour les super admins
|
||||
break;
|
||||
default:
|
||||
badgeColor = Colors.grey; // Gris par défaut
|
||||
}
|
||||
|
||||
// Obtenir la version du module
|
||||
final moduleVersion = ChatConfigLoader.instance.getModuleVersion();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFAFAFA),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.roomTitle),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1E293B),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Messages
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ValueListenableBuilder<Box<Message>>(
|
||||
valueListenable: _service.messagesBox.listenable(),
|
||||
builder: (context, box, _) {
|
||||
// Mettre à jour la liste avec les nouveaux messages envoyés
|
||||
final recentMessages = box.values
|
||||
.where((m) => m.roomId == widget.roomId &&
|
||||
!_messages.any((msg) => msg.id == m.id))
|
||||
.toList();
|
||||
|
||||
// Combiner les messages chargés et les nouveaux
|
||||
final allMessages = [..._messages, ...recentMessages]
|
||||
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
|
||||
|
||||
if (allMessages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Aucun message',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Bouton "Charger plus" en haut
|
||||
if (_hasMore)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: _isLoadingMore
|
||||
? const SizedBox(
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: _loadMoreMessages,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Charger plus de messages'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des messages avec pull-to-refresh
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadInitialMessages,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: allMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = allMessages[index];
|
||||
return _MessageBubble(message: message);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Input
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400]),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: const Color(0xFF2563EB),
|
||||
onPressed: _sendMessage,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Badge de version en bas à droite
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: badgeColor.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 14,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'v$moduleVersion',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget simple pour une bulle de message
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const _MessageBubble({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMe = message.isMe;
|
||||
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: !isMe ? Border.all(color: Colors.grey[300]!) : null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMe)
|
||||
Text(
|
||||
message.senderName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatTime(message.sentAt),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime date) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
340
app/lib/chat/pages/rooms_page.dart
Normal file
340
app/lib/chat/pages/rooms_page.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/room.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/chat_config_loader.dart';
|
||||
import '../widgets/recipient_selector.dart';
|
||||
import 'chat_page.dart';
|
||||
|
||||
/// Page de liste des conversations avec gestion des permissions
|
||||
class RoomsPage extends StatefulWidget {
|
||||
const RoomsPage({super.key});
|
||||
|
||||
@override
|
||||
State<RoomsPage> createState() => _RoomsPageState();
|
||||
}
|
||||
|
||||
class _RoomsPageState extends State<RoomsPage> {
|
||||
final _service = ChatService.instance;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRooms();
|
||||
}
|
||||
|
||||
Future<void> _loadRooms() async {
|
||||
setState(() => _isLoading = true);
|
||||
await _service.getRooms();
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roleLabel = ChatConfigLoader.instance.getRoleName(_service.currentUserRole);
|
||||
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFAFAFA),
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Messages'),
|
||||
Text(
|
||||
roleLabel,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1E293B),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _createNewConversation,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadRooms,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ValueListenableBuilder<Box<Room>>(
|
||||
valueListenable: _service.roomsBox.listenable(),
|
||||
builder: (context, box, _) {
|
||||
final rooms = box.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
|
||||
if (rooms.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune conversation',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _createNewConversation,
|
||||
child: const Text('Démarrer une conversation'),
|
||||
),
|
||||
if (helpText.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
helpText,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadRooms,
|
||||
child: ListView.builder(
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final room = rooms[index];
|
||||
return _RoomTile(room: room);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createNewConversation() async {
|
||||
final currentRole = _service.currentUserRole;
|
||||
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
|
||||
|
||||
// Déterminer si on permet la sélection multiple
|
||||
// Pour role 1 (membre), permettre la sélection multiple pour contacter plusieurs membres/admins
|
||||
// Pour role 2 (admin amicale), permettre la sélection multiple pour GEOSECTOR ou Amicale
|
||||
// Pour role 9 (super admin), permettre la sélection multiple selon config
|
||||
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
|
||||
(currentRole == 9 && config.any((c) => c['allow_selection'] == true));
|
||||
|
||||
// Ouvrir le dialog de sélection
|
||||
final result = await RecipientSelectorDialog.show(
|
||||
context,
|
||||
allowMultiple: allowMultiple,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
final recipients = result['recipients'] as List<Map<String, dynamic>>?;
|
||||
final initialMessage = result['initial_message'] as String?;
|
||||
|
||||
if (recipients != null && recipients.isNotEmpty) {
|
||||
try {
|
||||
Room? newRoom;
|
||||
|
||||
if (recipients.length == 1) {
|
||||
// Conversation privée
|
||||
final recipient = recipients.first;
|
||||
newRoom = await _service.createPrivateRoom(
|
||||
recipientId: recipient['id'],
|
||||
recipientName: recipient['name'],
|
||||
recipientRole: recipient['role'],
|
||||
recipientEntite: recipient['entite_id'],
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
} else {
|
||||
// Conversation de groupe
|
||||
final participantIds = recipients.map((r) => r['id'] as int).toList();
|
||||
|
||||
// Déterminer le titre en fonction du type de groupe
|
||||
String title;
|
||||
if (currentRole == 1) {
|
||||
// Pour un membre
|
||||
final hasAdmins = recipients.any((r) => r['role'] == 2);
|
||||
final hasMembers = recipients.any((r) => r['role'] == 1);
|
||||
|
||||
if (hasAdmins && !hasMembers) {
|
||||
title = 'Administrateurs Amicale';
|
||||
} else if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
} else if (currentRole == 2) {
|
||||
// Pour un admin d'amicale
|
||||
final hasSuperAdmins = recipients.any((r) => r['role'] == 9);
|
||||
final hasMembers = recipients.any((r) => r['role'] == 1);
|
||||
|
||||
if (hasSuperAdmins && !hasMembers) {
|
||||
title = 'GEOSECTOR Support';
|
||||
} else if (!hasSuperAdmins && hasMembers && recipients.length > 5) {
|
||||
title = 'Amicale - Tous les membres';
|
||||
} else if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
} else {
|
||||
// Pour un super admin
|
||||
if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// Créer la room avec le bon type
|
||||
newRoom = await _service.createRoom(
|
||||
title: title,
|
||||
participantIds: participantIds,
|
||||
type: (currentRole == 1 || currentRole == 2) ? 'group' : 'broadcast',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
if (newRoom != null && mounted) {
|
||||
// Naviguer vers la nouvelle conversation
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
roomId: newRoom!.id,
|
||||
roomTitle: newRoom.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget simple pour une tuile de room
|
||||
class _RoomTile extends StatelessWidget {
|
||||
final Room room;
|
||||
|
||||
const _RoomTile({required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
room.title[0].toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
room.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: room.lastMessage != null
|
||||
? Text(
|
||||
room.lastMessage!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
)
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (room.lastMessageAt != null)
|
||||
Text(
|
||||
_formatTime(room.lastMessageAt!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
if (room.unreadCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
room.unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
roomId: room.id,
|
||||
roomTitle: room.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${diff.inDays}j';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}h';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}m';
|
||||
} else {
|
||||
return 'Maintenant';
|
||||
}
|
||||
}
|
||||
}
|
||||
326
app/lib/chat/pages/rooms_page_embedded.dart
Normal file
326
app/lib/chat/pages/rooms_page_embedded.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/room.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/chat_config_loader.dart';
|
||||
import '../widgets/recipient_selector.dart';
|
||||
import 'chat_page.dart';
|
||||
|
||||
/// Version embarquée de RoomsPage sans AppBar pour intégration
|
||||
class RoomsPageEmbedded extends StatefulWidget {
|
||||
final VoidCallback? onAddPressed;
|
||||
final VoidCallback? onRefreshPressed;
|
||||
|
||||
const RoomsPageEmbedded({
|
||||
super.key,
|
||||
this.onAddPressed,
|
||||
this.onRefreshPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RoomsPageEmbedded> createState() => RoomsPageEmbeddedState();
|
||||
}
|
||||
|
||||
class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
|
||||
final _service = ChatService.instance;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRooms();
|
||||
}
|
||||
|
||||
Future<void> _loadRooms() async {
|
||||
setState(() => _isLoading = true);
|
||||
await _service.getRooms();
|
||||
setState(() => _isLoading = false);
|
||||
widget.onRefreshPressed?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<Box<Room>>(
|
||||
valueListenable: _service.roomsBox.listenable(),
|
||||
builder: (context, box, _) {
|
||||
final rooms = box.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
|
||||
if (rooms.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune conversation',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: widget.onAddPressed ?? createNewConversation,
|
||||
child: const Text('Démarrer une conversation'),
|
||||
),
|
||||
if (helpText.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
helpText,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadRooms,
|
||||
child: ListView.builder(
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final room = rooms[index];
|
||||
return _RoomTile(room: room);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createNewConversation() async {
|
||||
final currentRole = _service.currentUserRole;
|
||||
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
|
||||
|
||||
// Déterminer si on permet la sélection multiple
|
||||
// Pour role 1 (membre), permettre la sélection multiple pour contacter plusieurs membres/admins
|
||||
// Pour role 2 (admin amicale), permettre la sélection multiple pour GEOSECTOR ou Amicale
|
||||
// Pour role 9 (super admin), permettre la sélection multiple selon config
|
||||
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
|
||||
(currentRole == 9 && config.any((c) => c['allow_selection'] == true));
|
||||
|
||||
// Ouvrir le dialog de sélection
|
||||
final result = await RecipientSelectorDialog.show(
|
||||
context,
|
||||
allowMultiple: allowMultiple,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
final recipients = result['recipients'] as List<Map<String, dynamic>>?;
|
||||
final initialMessage = result['initial_message'] as String?;
|
||||
|
||||
if (recipients != null && recipients.isNotEmpty) {
|
||||
try {
|
||||
Room? newRoom;
|
||||
|
||||
if (recipients.length == 1) {
|
||||
// Conversation privée
|
||||
final recipient = recipients.first;
|
||||
newRoom = await _service.createPrivateRoom(
|
||||
recipientId: recipient['id'],
|
||||
recipientName: recipient['name'],
|
||||
recipientRole: recipient['role'],
|
||||
recipientEntite: recipient['entite_id'],
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
} else {
|
||||
// Conversation de groupe
|
||||
final participantIds = recipients.map((r) => r['id'] as int).toList();
|
||||
|
||||
// Déterminer le titre en fonction du type de groupe
|
||||
String title;
|
||||
if (currentRole == 1) {
|
||||
// Pour un membre
|
||||
final hasAdmins = recipients.any((r) => r['role'] == 2);
|
||||
final hasMembers = recipients.any((r) => r['role'] == 1);
|
||||
|
||||
if (hasAdmins && !hasMembers) {
|
||||
title = 'Administrateurs Amicale';
|
||||
} else if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
} else if (currentRole == 2) {
|
||||
// Pour un admin d'amicale
|
||||
final hasSuperAdmins = recipients.any((r) => r['role'] == 9);
|
||||
final hasMembers = recipients.any((r) => r['role'] == 1);
|
||||
|
||||
if (hasSuperAdmins && !hasMembers) {
|
||||
title = 'GEOSECTOR Support';
|
||||
} else if (!hasSuperAdmins && hasMembers && recipients.length > 5) {
|
||||
title = 'Amicale - Tous les membres';
|
||||
} else if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
} else {
|
||||
// Pour un super admin
|
||||
if (recipients.length > 3) {
|
||||
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
|
||||
} else {
|
||||
title = recipients.map((r) => r['name']).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// Créer la room avec le bon type
|
||||
newRoom = await _service.createRoom(
|
||||
title: title,
|
||||
participantIds: participantIds,
|
||||
type: (currentRole == 1 || currentRole == 2) ? 'group' : 'broadcast',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
if (newRoom != null && mounted) {
|
||||
// Naviguer vers la nouvelle conversation
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
roomId: newRoom!.id,
|
||||
roomTitle: newRoom.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode publique pour rafraîchir
|
||||
void refresh() {
|
||||
_loadRooms();
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget simple pour une tuile de room
|
||||
class _RoomTile extends StatelessWidget {
|
||||
final Room room;
|
||||
|
||||
const _RoomTile({required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
room.title[0].toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
room.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: room.lastMessage != null
|
||||
? Text(
|
||||
room.lastMessage!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
)
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (room.lastMessageAt != null)
|
||||
Text(
|
||||
_formatTime(room.lastMessageAt!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
if (room.unreadCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
room.unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
roomId: room.id,
|
||||
roomTitle: room.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${diff.inDays}j';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}h';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}m';
|
||||
} else {
|
||||
return 'Maintenant';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,323 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/// Service API pour la communication avec le backend du chat
|
||||
///
|
||||
/// Ce service gère toutes les requêtes HTTP vers l'API chat
|
||||
library;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
238
app/lib/chat/services/chat_config_loader.dart
Normal file
238
app/lib/chat/services/chat_config_loader.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// Classe pour charger et gérer la configuration du chat depuis chat_config.yaml
|
||||
class ChatConfigLoader {
|
||||
static ChatConfigLoader? _instance;
|
||||
static ChatConfigLoader get instance => _instance ??= ChatConfigLoader._();
|
||||
|
||||
Map<String, dynamic>? _config;
|
||||
|
||||
ChatConfigLoader._();
|
||||
|
||||
/// Charger la configuration depuis le fichier YAML
|
||||
Future<void> loadConfig() async {
|
||||
try {
|
||||
// Charger le fichier YAML
|
||||
final yamlString = await rootBundle.loadString('lib/chat/chat_config.yaml');
|
||||
|
||||
// Vérifier que le contenu n'est pas vide
|
||||
if (yamlString.isEmpty) {
|
||||
print('Fichier de configuration chat vide, utilisation de la configuration par défaut');
|
||||
_config = _getDefaultConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parser le YAML
|
||||
dynamic yamlMap;
|
||||
try {
|
||||
yamlMap = loadYaml(yamlString);
|
||||
} catch (parseError) {
|
||||
print('Erreur de parsing YAML (utilisation de la config par défaut): $parseError');
|
||||
print('Contenu YAML problématique (premiers 500 caractères): ${yamlString.substring(0, yamlString.length > 500 ? 500 : yamlString.length)}');
|
||||
_config = _getDefaultConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir en Map<String, dynamic>
|
||||
_config = _convertYamlToMap(yamlMap);
|
||||
print('Configuration chat chargée avec succès');
|
||||
} catch (e) {
|
||||
print('Erreur lors du chargement de la configuration chat: $e');
|
||||
// Utiliser une configuration par défaut en cas d'erreur
|
||||
_config = _getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertir YamlMap en Map standard
|
||||
dynamic _convertYamlToMap(dynamic yamlData) {
|
||||
if (yamlData is YamlMap) {
|
||||
final map = <String, dynamic>{};
|
||||
yamlData.forEach((key, value) {
|
||||
map[key.toString()] = _convertYamlToMap(value);
|
||||
});
|
||||
return map;
|
||||
} else if (yamlData is YamlList) {
|
||||
return yamlData.map((item) => _convertYamlToMap(item)).toList();
|
||||
} else {
|
||||
return yamlData;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir les permissions pour un rôle
|
||||
Map<String, dynamic> getPermissionsForRole(int role) {
|
||||
if (_config == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final permissions = _config!['chat_permissions'] as Map<String, dynamic>?;
|
||||
if (permissions == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return permissions['role_$role'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Vérifier si un utilisateur peut envoyer un message à un autre
|
||||
bool canSendMessageTo({
|
||||
required int senderRole,
|
||||
required int recipientRole,
|
||||
int? senderEntite,
|
||||
int? recipientEntite,
|
||||
}) {
|
||||
final permissions = getPermissionsForRole(senderRole);
|
||||
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
|
||||
|
||||
if (canMessageWith == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final rule in canMessageWith) {
|
||||
if (rule['role'] == recipientRole) {
|
||||
final condition = rule['condition'] as String?;
|
||||
|
||||
switch (condition) {
|
||||
case 'same_entite':
|
||||
return senderEntite != null &&
|
||||
recipientEntite != null &&
|
||||
senderEntite == recipientEntite;
|
||||
case 'all':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Obtenir les destinataires possibles pour un rôle
|
||||
List<Map<String, dynamic>> getPossibleRecipientsConfig(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
|
||||
|
||||
if (canMessageWith == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return canMessageWith.map((rule) {
|
||||
return {
|
||||
'role': rule['role'],
|
||||
'condition': rule['condition'],
|
||||
'description': rule['description'],
|
||||
'allow_selection': rule['allow_selection'] ?? false,
|
||||
'allow_broadcast': rule['allow_broadcast'] ?? false,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Obtenir le nom d'un rôle
|
||||
String getRoleName(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['name'] as String? ?? 'Utilisateur';
|
||||
}
|
||||
|
||||
/// Obtenir la description d'un rôle
|
||||
String getRoleDescription(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['description'] as String? ?? '';
|
||||
}
|
||||
|
||||
/// Obtenir le texte d'aide pour un rôle
|
||||
String getHelpText(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['help_text'] as String? ?? '';
|
||||
}
|
||||
|
||||
/// Vérifier si un rôle peut créer des groupes
|
||||
bool canCreateGroup(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['can_create_group'] as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Vérifier si un rôle peut faire du broadcast
|
||||
bool canBroadcast(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['can_broadcast'] as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Obtenir la configuration UI
|
||||
Map<String, dynamic> getUIConfig() {
|
||||
return _config?['ui_config'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Obtenir les messages de l'interface
|
||||
Map<String, dynamic> getUIMessages() {
|
||||
final uiConfig = getUIConfig();
|
||||
return uiConfig['messages'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Obtenir la couleur d'un rôle
|
||||
String getRoleColor(int role) {
|
||||
final uiConfig = getUIConfig();
|
||||
final roleColors = uiConfig['role_colors'] as Map<String, dynamic>?;
|
||||
return roleColors?[role.toString()] as String? ?? '#64748B';
|
||||
}
|
||||
|
||||
/// Obtenir les informations du module
|
||||
Map<String, dynamic> getModuleInfo() {
|
||||
return _config?['module_info'] as Map<String, dynamic>? ?? {
|
||||
'version': '1.0.0',
|
||||
'name': 'Chat Module Light',
|
||||
'description': 'Module de chat autonome et portable pour GEOSECTOR'
|
||||
};
|
||||
}
|
||||
|
||||
/// Obtenir la version du module
|
||||
String getModuleVersion() {
|
||||
final moduleInfo = getModuleInfo();
|
||||
return moduleInfo['version'] as String? ?? '1.0.0';
|
||||
}
|
||||
|
||||
/// Configuration par défaut si le fichier YAML n'est pas trouvé
|
||||
Map<String, dynamic> _getDefaultConfig() {
|
||||
return {
|
||||
'chat_permissions': {
|
||||
'role_1': {
|
||||
'name': 'Membre',
|
||||
'can_message_with': [
|
||||
{'role': 1, 'condition': 'same_entite'},
|
||||
{'role': 2, 'condition': 'same_entite'},
|
||||
],
|
||||
'can_create_group': false,
|
||||
'can_broadcast': false,
|
||||
'help_text': 'Vous pouvez discuter avec les membres de votre amicale',
|
||||
},
|
||||
'role_2': {
|
||||
'name': 'Admin Amicale',
|
||||
'can_message_with': [
|
||||
{'role': 1, 'condition': 'same_entite'},
|
||||
{'role': 2, 'condition': 'same_entite'},
|
||||
{'role': 9, 'condition': 'all'},
|
||||
],
|
||||
'can_create_group': true,
|
||||
'can_broadcast': false,
|
||||
'help_text': 'Vous pouvez discuter avec les membres et les super admins',
|
||||
},
|
||||
'role_9': {
|
||||
'name': 'Super Admin',
|
||||
'can_message_with': [
|
||||
{'role': 2, 'condition': 'all', 'allow_selection': true, 'allow_broadcast': true},
|
||||
],
|
||||
'can_create_group': true,
|
||||
'can_broadcast': true,
|
||||
'help_text': 'Vous pouvez envoyer des messages aux administrateurs d\'amicale',
|
||||
},
|
||||
},
|
||||
'ui_config': {
|
||||
'show_role_badge': true,
|
||||
'enable_autocomplete': true,
|
||||
'messages': {
|
||||
'no_permission': 'Vous n\'avez pas la permission',
|
||||
'search_placeholder': 'Rechercher...',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
112
app/lib/chat/services/chat_info_service.dart
Normal file
112
app/lib/chat/services/chat_info_service.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service pour gérer les informations globales du chat (badges, stats)
|
||||
/// Récupère les infos depuis l'API au login et les maintient à jour
|
||||
class ChatInfoService extends ChangeNotifier {
|
||||
static ChatInfoService? _instance;
|
||||
static ChatInfoService get instance => _instance ??= ChatInfoService._();
|
||||
|
||||
ChatInfoService._();
|
||||
|
||||
// Stats du chat
|
||||
int _totalRooms = 0;
|
||||
int _unreadMessages = 0;
|
||||
DateTime? _lastUpdate;
|
||||
|
||||
// Getters
|
||||
int get totalRooms => _totalRooms;
|
||||
int get unreadMessages => _unreadMessages;
|
||||
DateTime? get lastUpdate => _lastUpdate;
|
||||
bool get hasUnread => _unreadMessages > 0;
|
||||
|
||||
/// Met à jour les infos depuis la réponse de login
|
||||
/// Attend une structure : { "chat": { "total_rooms": 5, "unread_messages": 12 } }
|
||||
void updateFromLogin(Map<String, dynamic> loginData) {
|
||||
debugPrint('📊 ChatInfoService: Mise à jour depuis login');
|
||||
|
||||
final chatData = loginData['chat'];
|
||||
if (chatData != null && chatData is Map<String, dynamic>) {
|
||||
_totalRooms = chatData['total_rooms'] ?? 0;
|
||||
_unreadMessages = chatData['unread_messages'] ?? 0;
|
||||
_lastUpdate = DateTime.now();
|
||||
|
||||
debugPrint('💬 Chat stats - Rooms: $_totalRooms, Non lus: $_unreadMessages');
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint('⚠️ Pas de données chat dans la réponse login');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour directement les stats
|
||||
void updateStats({int? totalRooms, int? unreadMessages}) {
|
||||
bool hasChanged = false;
|
||||
|
||||
if (totalRooms != null && totalRooms != _totalRooms) {
|
||||
_totalRooms = totalRooms;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if (unreadMessages != null && unreadMessages != _unreadMessages) {
|
||||
_unreadMessages = unreadMessages;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Décrémente le nombre de messages non lus
|
||||
void decrementUnread(int count) {
|
||||
if (count > 0) {
|
||||
_unreadMessages = (_unreadMessages - count).clamp(0, 999);
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque tous les messages d'une room comme lus
|
||||
void markRoomAsRead(int messagesCount) {
|
||||
if (messagesCount > 0) {
|
||||
decrementUnread(messagesCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Incrémente le nombre de messages non lus (nouveau message reçu)
|
||||
void incrementUnread(int count) {
|
||||
if (count > 0) {
|
||||
_unreadMessages = (_unreadMessages + count).clamp(0, 999);
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialise les stats (au logout)
|
||||
void reset() {
|
||||
_totalRooms = 0;
|
||||
_unreadMessages = 0;
|
||||
_lastUpdate = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force un refresh des stats depuis l'API
|
||||
Future<void> refreshFromApi() async {
|
||||
// Cette méthode pourrait appeler directement l'API
|
||||
// Pour l'instant on la laisse vide, elle sera utile plus tard
|
||||
debugPrint('📊 ChatInfoService: Refresh depuis API demandé');
|
||||
}
|
||||
|
||||
/// Retourne un label formaté pour le badge
|
||||
String get badgeLabel {
|
||||
if (_unreadMessages == 0) return '';
|
||||
if (_unreadMessages > 99) return '99+';
|
||||
return _unreadMessages.toString();
|
||||
}
|
||||
|
||||
/// Debug info
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatInfoService(rooms: $_totalRooms, unread: $_unreadMessages, lastUpdate: $_lastUpdate)';
|
||||
}
|
||||
}
|
||||
468
app/lib/chat/services/chat_service.dart
Normal file
468
app/lib/chat/services/chat_service.dart
Normal file
@@ -0,0 +1,468 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/room.dart';
|
||||
import '../models/message.dart';
|
||||
import 'chat_config_loader.dart';
|
||||
import 'chat_info_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Service de chat avec règles métier configurables via YAML
|
||||
/// Les permissions sont définies dans chat_config.yaml
|
||||
class ChatService {
|
||||
static ChatService? _instance;
|
||||
static ChatService get instance => _instance!;
|
||||
|
||||
late final Dio _dio;
|
||||
late final Box<Room> _roomsBox;
|
||||
late final Box<Message> _messagesBox;
|
||||
|
||||
late int _currentUserId;
|
||||
late String _currentUserName;
|
||||
late int _currentUserRole;
|
||||
late int? _currentUserEntite;
|
||||
String? _authToken;
|
||||
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Initialisation avec gestion des rôles et configuration YAML
|
||||
static Future<void> init({
|
||||
required String apiUrl,
|
||||
required int userId,
|
||||
required String userName,
|
||||
required int userRole,
|
||||
int? userEntite,
|
||||
String? authToken,
|
||||
}) async {
|
||||
_instance = ChatService._();
|
||||
|
||||
// Charger la configuration depuis le YAML
|
||||
await ChatConfigLoader.instance.loadConfig();
|
||||
|
||||
// Initialiser Hive
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(RoomAdapter());
|
||||
Hive.registerAdapter(MessageAdapter());
|
||||
|
||||
// Ouvrir les boxes en utilisant les constantes centralisées
|
||||
_instance!._roomsBox = await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
|
||||
_instance!._messagesBox = await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
|
||||
|
||||
// Configurer l'utilisateur
|
||||
_instance!._currentUserId = userId;
|
||||
_instance!._currentUserName = userName;
|
||||
_instance!._currentUserRole = userRole;
|
||||
_instance!._currentUserEntite = userEntite;
|
||||
_instance!._authToken = authToken;
|
||||
|
||||
// Configurer Dio
|
||||
_instance!._dio = Dio(BaseOptions(
|
||||
baseUrl: apiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {},
|
||||
));
|
||||
|
||||
// Démarrer la synchronisation
|
||||
_instance!._startSync();
|
||||
}
|
||||
|
||||
ChatService._();
|
||||
|
||||
/// Obtenir les destinataires possibles selon le rôle
|
||||
Future<List<Map<String, dynamic>>> getPossibleRecipients({String? search}) async {
|
||||
try {
|
||||
// L'API utilise le token pour identifier l'utilisateur et son rôle
|
||||
String endpoint = '/chat/recipients';
|
||||
final params = <String, dynamic>{};
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
params['search'] = search;
|
||||
}
|
||||
|
||||
final response = await _dio.get(endpoint, queryParameters: params);
|
||||
|
||||
// Gérer différents formats de réponse
|
||||
if (response.data is List) {
|
||||
return List<Map<String, dynamic>>.from(response.data);
|
||||
} else if (response.data is Map && response.data['recipients'] != null) {
|
||||
return List<Map<String, dynamic>>.from(response.data['recipients']);
|
||||
} else if (response.data is Map && response.data['data'] != null) {
|
||||
return List<Map<String, dynamic>>.from(response.data['data']);
|
||||
} else {
|
||||
print('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}');
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur getPossibleRecipients: $e');
|
||||
// Fallback sur logique locale selon le rôle
|
||||
return _getLocalRecipients();
|
||||
}
|
||||
}
|
||||
|
||||
/// Logique locale de récupération des destinataires
|
||||
List<Map<String, dynamic>> _getLocalRecipients() {
|
||||
// Cette méthode devrait accéder à la box membres de l'app principale
|
||||
// Pour l'instant on retourne une liste vide
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Vérifier si l'utilisateur peut créer une conversation avec un destinataire
|
||||
bool canCreateConversationWith(int recipientRole, {int? recipientEntite}) {
|
||||
return ChatConfigLoader.instance.canSendMessageTo(
|
||||
senderRole: _currentUserRole,
|
||||
recipientRole: recipientRole,
|
||||
senderEntite: _currentUserEntite,
|
||||
recipientEntite: recipientEntite,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les rooms filtrées selon les permissions
|
||||
Future<List<Room>> getRooms() async {
|
||||
try {
|
||||
// L'API filtre automatiquement selon le token Bearer
|
||||
final response = await _dio.get('/chat/rooms');
|
||||
|
||||
// Debug : afficher le type et le contenu de la réponse
|
||||
print('📊 Type de réponse /chat/rooms: ${response.data.runtimeType}');
|
||||
if (response.data is Map) {
|
||||
print('📊 Clés de la réponse: ${(response.data as Map).keys.toList()}');
|
||||
}
|
||||
|
||||
// Gérer différents formats de réponse API
|
||||
List<dynamic> roomsData;
|
||||
if (response.data is Map) {
|
||||
// La plupart du temps, l'API retourne un objet avec status et rooms
|
||||
if (response.data['rooms'] != null) {
|
||||
roomsData = response.data['rooms'] as List;
|
||||
print('✅ Réponse API: status=${response.data['status']}, ${roomsData.length} rooms');
|
||||
} else if (response.data['data'] != null) {
|
||||
roomsData = response.data['data'] as List;
|
||||
print('✅ Réponse avec propriété "data" (${roomsData.length} rooms)');
|
||||
} else {
|
||||
// Pas de propriété rooms ou data, liste vide
|
||||
print('⚠️ Réponse sans rooms ni data: ${response.data}');
|
||||
roomsData = [];
|
||||
}
|
||||
} else if (response.data is List) {
|
||||
// Si c'est directement une liste (moins courant)
|
||||
roomsData = response.data as List;
|
||||
print('✅ Réponse est directement une liste avec ${roomsData.length} rooms');
|
||||
} else {
|
||||
// Format complètement inattendu
|
||||
print('⚠️ Format de réponse inattendu pour /chat/rooms: ${response.data.runtimeType}');
|
||||
roomsData = [];
|
||||
}
|
||||
|
||||
final rooms = roomsData
|
||||
.map((json) => Room.fromJson(json))
|
||||
.toList();
|
||||
|
||||
// Sauvegarder dans Hive
|
||||
await _roomsBox.clear();
|
||||
for (final room in rooms) {
|
||||
await _roomsBox.put(room.id, room);
|
||||
}
|
||||
|
||||
// Mettre à jour les stats globales
|
||||
final totalUnread = rooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
|
||||
ChatInfoService.instance.updateStats(
|
||||
totalRooms: rooms.length,
|
||||
unreadMessages: totalUnread,
|
||||
);
|
||||
|
||||
return rooms;
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des rooms (utilisation du cache): $e');
|
||||
// Fallback sur le cache local en cas d'erreur API (404, etc.)
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une room avec vérification des permissions
|
||||
Future<Room?> createRoom({
|
||||
required String title,
|
||||
required List<int> participantIds,
|
||||
String? type,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier les permissions localement d'abord
|
||||
// L'API fera aussi une vérification
|
||||
|
||||
final data = {
|
||||
'title': title,
|
||||
'type': type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'),
|
||||
'participants': participantIds,
|
||||
// L'API récupère le rôle et l'entité depuis le token
|
||||
};
|
||||
|
||||
// Ajouter le message initial s'il est fourni
|
||||
if (initialMessage != null && initialMessage.isNotEmpty) {
|
||||
data['initial_message'] = initialMessage;
|
||||
}
|
||||
|
||||
final response = await _dio.post('/chat/rooms', data: data);
|
||||
|
||||
final room = Room.fromJson(response.data);
|
||||
await _roomsBox.put(room.id, room);
|
||||
|
||||
return room;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une conversation one-to-one
|
||||
Future<Room?> createPrivateRoom({
|
||||
required int recipientId,
|
||||
required String recipientName,
|
||||
required int recipientRole,
|
||||
int? recipientEntite,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
// Vérifier les permissions via la configuration
|
||||
if (!canCreateConversationWith(recipientRole, recipientEntite: recipientEntite)) {
|
||||
final messages = ChatConfigLoader.instance.getUIMessages();
|
||||
throw Exception(messages['no_permission'] ?? 'Permission refusée');
|
||||
}
|
||||
|
||||
return createRoom(
|
||||
title: recipientName,
|
||||
participantIds: [recipientId],
|
||||
type: 'private',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer une conversation de groupe (pour superadmin)
|
||||
Future<Room?> createGroupRoom({
|
||||
required String title,
|
||||
required List<int> adminIds,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
if (_currentUserRole != 9) {
|
||||
throw Exception('Seuls les superadmins peuvent créer des groupes');
|
||||
}
|
||||
|
||||
return createRoom(
|
||||
title: title,
|
||||
participantIds: adminIds,
|
||||
type: 'broadcast',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les messages d'une room avec pagination
|
||||
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': 50,
|
||||
};
|
||||
|
||||
if (beforeMessageId != null) {
|
||||
params['before'] = beforeMessageId;
|
||||
}
|
||||
|
||||
final response = await _dio.get('/chat/rooms/$roomId/messages', queryParameters: params);
|
||||
|
||||
// Gérer différents formats de réponse
|
||||
List<dynamic> messagesData;
|
||||
bool hasMore = false;
|
||||
|
||||
if (response.data is List) {
|
||||
// Si c'est directement une liste de messages
|
||||
messagesData = response.data as List;
|
||||
} else if (response.data is Map) {
|
||||
// Si c'est un objet avec messages et has_more
|
||||
messagesData = response.data['messages'] ?? response.data['data'] ?? [];
|
||||
hasMore = response.data['has_more'] ?? false;
|
||||
} else {
|
||||
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
|
||||
messagesData = [];
|
||||
}
|
||||
|
||||
final messages = messagesData
|
||||
.map((json) => Message.fromJson(json, _currentUserId))
|
||||
.toList();
|
||||
|
||||
// Sauvegarder dans Hive (en limitant à 100 messages par room)
|
||||
await _saveMessagesToCache(roomId, messages);
|
||||
|
||||
return {
|
||||
'messages': messages,
|
||||
'has_more': hasMore,
|
||||
};
|
||||
} catch (e) {
|
||||
print('Erreur getMessages: $e');
|
||||
// Fallback sur le cache local
|
||||
final cachedMessages = _messagesBox.values
|
||||
.where((m) => m.roomId == roomId)
|
||||
.toList()
|
||||
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
|
||||
|
||||
return {
|
||||
'messages': cachedMessages,
|
||||
'has_more': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarder les messages dans le cache en limitant à 100 par room
|
||||
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages) async {
|
||||
// Obtenir tous les messages existants pour cette room
|
||||
final existingMessages = _messagesBox.values
|
||||
.where((m) => m.roomId == roomId)
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); // Plus récent en premier
|
||||
|
||||
// Ajouter les nouveaux messages
|
||||
for (final message in newMessages) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
}
|
||||
|
||||
// Si on dépasse 100 messages, supprimer les plus anciens
|
||||
final allMessages = [...existingMessages, ...newMessages]
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
|
||||
if (allMessages.length > 100) {
|
||||
final messagesToDelete = allMessages.skip(100).toList();
|
||||
for (final message in messagesToDelete) {
|
||||
await _messagesBox.delete(message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer un message
|
||||
Future<Message?> sendMessage(String roomId, String content) async {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final tempMessage = Message(
|
||||
id: tempId,
|
||||
roomId: roomId,
|
||||
content: content,
|
||||
senderId: _currentUserId,
|
||||
senderName: _currentUserName,
|
||||
sentAt: DateTime.now(),
|
||||
isMe: true,
|
||||
);
|
||||
|
||||
await _messagesBox.put(tempId, tempMessage);
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/chat/rooms/$roomId/messages',
|
||||
data: {
|
||||
'content': content,
|
||||
// L'API récupère le sender depuis le token
|
||||
},
|
||||
);
|
||||
|
||||
final finalMessage = Message.fromJson(response.data, _currentUserId);
|
||||
|
||||
await _messagesBox.delete(tempId);
|
||||
await _messagesBox.put(finalMessage.id, finalMessage);
|
||||
|
||||
// Mettre à jour la room
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: content,
|
||||
lastMessageAt: DateTime.now(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
|
||||
return finalMessage;
|
||||
} catch (e) {
|
||||
return tempMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer comme lu
|
||||
Future<void> markAsRead(String roomId) async {
|
||||
try {
|
||||
await _dio.post('/chat/rooms/$roomId/read');
|
||||
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
// Décrémenter les messages non lus dans ChatInfoService
|
||||
if (room.unreadCount > 0) {
|
||||
ChatInfoService.instance.decrementUnread(room.unreadCount);
|
||||
}
|
||||
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronisation périodique
|
||||
void _startSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
getRooms();
|
||||
});
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
void dispose() {
|
||||
_syncTimer?.cancel();
|
||||
_dio.close();
|
||||
}
|
||||
|
||||
// Getters
|
||||
Box<Room> get roomsBox => _roomsBox;
|
||||
Box<Message> get messagesBox => _messagesBox;
|
||||
int get currentUserId => _currentUserId;
|
||||
|
||||
/// Obtenir le rôle de l'utilisateur actuel
|
||||
int getUserRole() => _currentUserRole;
|
||||
int get currentUserRole => _currentUserRole;
|
||||
String get currentUserName => _currentUserName;
|
||||
|
||||
/// Obtenir le label du rôle
|
||||
String getRoleLabel(int role) {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return 'Membre';
|
||||
case 2:
|
||||
return 'Admin Amicale';
|
||||
case 9:
|
||||
return 'Super Admin';
|
||||
default:
|
||||
return 'Utilisateur';
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir la description des permissions
|
||||
String getPermissionsDescription() {
|
||||
switch (_currentUserRole) {
|
||||
case 1:
|
||||
return 'Vous pouvez discuter avec les membres de votre amicale';
|
||||
case 2:
|
||||
return 'Vous pouvez discuter avec les membres et les super admins';
|
||||
case 9:
|
||||
return 'Vous pouvez envoyer des messages aux administrateurs d\'amicale';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
# 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
|
||||
@@ -1,205 +0,0 @@
|
||||
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();
|
||||
// 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}');
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/// Configuration pour le broker MQTT
|
||||
///
|
||||
/// Centralise les paramètres de connexion au broker MQTT
|
||||
library;
|
||||
|
||||
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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/// 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
|
||||
library;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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) const 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)
|
||||
const Text(
|
||||
'Expéditeur',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Text('Contenu du message...'),
|
||||
if (showTimestamp || showStatus)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (showTimestamp)
|
||||
const Text('12:34', style: TextStyle(fontSize: 12)),
|
||||
if (showStatus) const SizedBox(width: 4),
|
||||
if (showStatus) const Icon(Icons.check, size: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
599
app/lib/chat/widgets/recipient_selector.dart
Normal file
599
app/lib/chat/widgets/recipient_selector.dart
Normal file
@@ -0,0 +1,599 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/chat_config_loader.dart';
|
||||
|
||||
/// Widget pour sélectionner les destinataires avec autocomplete
|
||||
/// Respecte les règles de permissions définies dans chat_config.yaml
|
||||
class RecipientSelector extends StatefulWidget {
|
||||
final Function(List<Map<String, dynamic>>) onRecipientsSelected;
|
||||
final bool allowMultiple;
|
||||
|
||||
const RecipientSelector({
|
||||
super.key,
|
||||
required this.onRecipientsSelected,
|
||||
this.allowMultiple = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecipientSelector> createState() => _RecipientSelectorState();
|
||||
}
|
||||
|
||||
class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
final _service = ChatService.instance;
|
||||
final _searchController = TextEditingController();
|
||||
final _selectedRecipients = <Map<String, dynamic>>[];
|
||||
|
||||
List<Map<String, dynamic>> _suggestions = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialRecipients();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialRecipients() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final recipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_suggestions = recipients;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchRecipients(String query) async {
|
||||
if (query.length < 2) {
|
||||
_loadInitialRecipients();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final recipients = await _service.getPossibleRecipients(search: query);
|
||||
setState(() {
|
||||
_suggestions = recipients;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleRecipient(Map<String, dynamic> recipient) {
|
||||
setState(() {
|
||||
if (widget.allowMultiple) {
|
||||
final exists = _selectedRecipients.any((r) => r['id'] == recipient['id']);
|
||||
if (exists) {
|
||||
_selectedRecipients.removeWhere((r) => r['id'] == recipient['id']);
|
||||
} else {
|
||||
_selectedRecipients.add(recipient);
|
||||
}
|
||||
} else {
|
||||
_selectedRecipients.clear();
|
||||
_selectedRecipients.add(recipient);
|
||||
}
|
||||
});
|
||||
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
}
|
||||
|
||||
Widget _buildRoleBadge(int role) {
|
||||
final color = ChatConfigLoader.instance.getRoleColor(role);
|
||||
final name = ChatConfigLoader.instance.getRoleName(role);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _hexToColor(color).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _hexToColor(color).withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _hexToColor(color),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _hexToColor(String hex) {
|
||||
final buffer = StringBuffer();
|
||||
if (hex.length == 6 || hex.length == 7) buffer.write('ff');
|
||||
buffer.write(hex.replaceFirst('#', ''));
|
||||
return Color(int.parse(buffer.toString(), radix: 16));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentRole = _service.currentUserRole;
|
||||
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
|
||||
final canBroadcast = config.any((c) => c['allow_broadcast'] == true);
|
||||
final canSelect = config.any((c) => c['allow_selection'] == true);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête avec options pour membre role 1
|
||||
if (currentRole == 1) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sélectionner les destinataires',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bouton Admin pour contacter tous les admins de l'amicale
|
||||
ActionChip(
|
||||
label: const Text('Administrateurs'),
|
||||
avatar: const Icon(Icons.admin_panel_settings, size: 18),
|
||||
backgroundColor: Colors.red.shade50,
|
||||
onPressed: () async {
|
||||
final allRecipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
// Sélectionner tous les admins de l'amicale (role 2)
|
||||
_selectedRecipients.addAll(
|
||||
allRecipients.where((r) => r['role'] == 2)
|
||||
);
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
if (_selectedRecipients.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text('${_selectedRecipients.length} sélectionné(s)'),
|
||||
avatar: const Icon(Icons.check_circle, size: 18),
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
onPressed: () {
|
||||
setState(() => _selectedRecipients.clear());
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ou sélectionnez des membres individuellement :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
// En-tête avec options pour admin role 2
|
||||
if (currentRole == 2) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sélectionner les destinataires',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bouton GEOSECTOR pour contacter tous les super-admins
|
||||
ActionChip(
|
||||
label: const Text('GEOSECTOR'),
|
||||
avatar: const Icon(Icons.business, size: 18),
|
||||
backgroundColor: Colors.blue.shade50,
|
||||
onPressed: () async {
|
||||
final allRecipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
// Sélectionner tous les super-admins (role 9)
|
||||
_selectedRecipients.addAll(
|
||||
allRecipients.where((r) => r['role'] == 9)
|
||||
);
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
// Bouton Amicale pour contacter tous les membres de son amicale
|
||||
ActionChip(
|
||||
label: const Text('Toute l\'Amicale'),
|
||||
avatar: const Icon(Icons.group, size: 18),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
onPressed: () async {
|
||||
final allRecipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
// Sélectionner tous les membres de l'amicale (role 1)
|
||||
_selectedRecipients.addAll(
|
||||
allRecipients.where((r) => r['role'] == 1)
|
||||
);
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
if (_selectedRecipients.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text('${_selectedRecipients.length} sélectionné(s)'),
|
||||
avatar: const Icon(Icons.check_circle, size: 18),
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
onPressed: () {
|
||||
setState(() => _selectedRecipients.clear());
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ou sélectionnez des membres individuellement :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
// En-tête avec options pour super-admin
|
||||
if (currentRole == 9) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sélectionner les destinataires',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (canBroadcast)
|
||||
ActionChip(
|
||||
label: const Text('Tous les admins'),
|
||||
avatar: const Icon(Icons.groups, size: 18),
|
||||
onPressed: () async {
|
||||
final allAdmins = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
_selectedRecipients.addAll(
|
||||
allAdmins.where((r) => r['role'] == 2)
|
||||
);
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
if (_selectedRecipients.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text('${_selectedRecipients.length} sélectionné(s)'),
|
||||
avatar: const Icon(Icons.check_circle, size: 18),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
onPressed: () {
|
||||
setState(() => _selectedRecipients.clear());
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
|
||||
// Barre de recherche
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: ChatConfigLoader.instance.getUIMessages()['search_placeholder']
|
||||
?? 'Rechercher...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
onChanged: _searchRecipients,
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des suggestions
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _suggestions.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
ChatConfigLoader.instance.getUIMessages()['no_recipients']
|
||||
?? 'Aucun destinataire disponible',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipient = _suggestions[index];
|
||||
final isSelected = _selectedRecipients.any(
|
||||
(r) => r['id'] == recipient['id']
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey.shade200,
|
||||
child: Text(
|
||||
recipient['name']?.substring(0, 1).toUpperCase() ?? '?',
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
recipient['name'] ?? 'Sans nom',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: recipient['entite_name'] != null
|
||||
? Text(
|
||||
recipient['entite_name'],
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (recipient['role'] != null)
|
||||
_buildRoleBadge(recipient['role']),
|
||||
if (widget.allowMultiple || canSelect)
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => _toggleRecipient(recipient),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _toggleRecipient(recipient),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton de validation
|
||||
if (_selectedRecipients.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(_selectedRecipients),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
widget.allowMultiple
|
||||
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
|
||||
: 'Créer conversation',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog pour sélectionner les destinataires
|
||||
class RecipientSelectorDialog extends StatelessWidget {
|
||||
final bool allowMultiple;
|
||||
|
||||
const RecipientSelectorDialog({
|
||||
super.key,
|
||||
this.allowMultiple = false,
|
||||
});
|
||||
|
||||
static Future<Map<String, dynamic>?> show(
|
||||
BuildContext context, {
|
||||
bool allowMultiple = false,
|
||||
}) async {
|
||||
return showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (context) => RecipientSelectorDialog(
|
||||
allowMultiple: allowMultiple,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
maxWidth: 500,
|
||||
),
|
||||
child: _RecipientSelectorWithMessage(
|
||||
allowMultiple: allowMultiple,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget interne pour gérer la sélection et le message initial
|
||||
class _RecipientSelectorWithMessage extends StatefulWidget {
|
||||
final bool allowMultiple;
|
||||
|
||||
const _RecipientSelectorWithMessage({
|
||||
required this.allowMultiple,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RecipientSelectorWithMessage> createState() => _RecipientSelectorWithMessageState();
|
||||
}
|
||||
|
||||
class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMessage> {
|
||||
List<Map<String, dynamic>> _selectedRecipients = [];
|
||||
final _messageController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Sélecteur de destinataires
|
||||
Expanded(
|
||||
child: RecipientSelector(
|
||||
allowMultiple: widget.allowMultiple,
|
||||
onRecipientsSelected: (recipients) {
|
||||
setState(() {
|
||||
_selectedRecipients = recipients;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Champ de message initial si des destinataires sont sélectionnés
|
||||
if (_selectedRecipients.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Message initial (optionnel)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Écrivez votre premier message...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400]),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton de validation
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop({
|
||||
'recipients': _selectedRecipients,
|
||||
'initial_message': _messageController.text.trim(),
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
widget.allowMultiple
|
||||
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
|
||||
: 'Créer conversation',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class AppKeys {
|
||||
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 chatRoomsBoxName = 'chat_rooms';
|
||||
static const String chatMessagesBoxName = 'chat_messages';
|
||||
static const String regionsBoxName = 'regions';
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/core/models/loading_state.dart';
|
||||
import 'package:geosector_app/chat/services/chat_info_service.dart';
|
||||
|
||||
class UserRepository extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
@@ -276,7 +277,17 @@ class UserRepository extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Étape 5: Traitement de toutes les autres données via DataLoadingService
|
||||
// Étape 5: Traitement des infos chat
|
||||
if (apiResult['chat'] != null) {
|
||||
try {
|
||||
ChatInfoService.instance.updateFromLogin(apiResult);
|
||||
debugPrint('💬 Infos chat mises à jour');
|
||||
} catch (chatError) {
|
||||
debugPrint('⚠️ Erreur traitement infos chat: $chatError');
|
||||
}
|
||||
}
|
||||
|
||||
// Étape 6: Traitement de toutes les autres données via DataLoadingService
|
||||
try {
|
||||
await DataLoadingService.instance.processLoginData(apiResult);
|
||||
} catch (processingError) {
|
||||
@@ -316,6 +327,9 @@ class UserRepository extends ChangeNotifier {
|
||||
// Effacer les données via les services singleton
|
||||
await CurrentUserService.instance.clearUser();
|
||||
await CurrentAmicaleService.instance.clearAmicale();
|
||||
|
||||
// Réinitialiser les infos chat
|
||||
ChatInfoService.instance.reset();
|
||||
|
||||
// Nettoyage des données via HiveService (préserve les utilisateurs)
|
||||
await HiveService.instance.cleanDataOnLogout();
|
||||
|
||||
@@ -65,6 +65,14 @@ class ConnectivityService extends ChangeNotifier {
|
||||
|
||||
/// Constructeur du service de connectivité
|
||||
ConnectivityService() {
|
||||
// Initialiser avec une connexion par défaut (supposée disponible)
|
||||
// Ceci sera mis à jour rapidement par _initConnectivity()
|
||||
if (kIsWeb) {
|
||||
_connectionStatus = [ConnectivityResult.wifi];
|
||||
} else {
|
||||
// Sur mobile, on suppose qu'il y a au moins une connexion jusqu'à vérification
|
||||
_connectionStatus = [ConnectivityResult.wifi];
|
||||
}
|
||||
_initConnectivity();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ 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/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/client_repository.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
// Chat imports removed - using new simplified chat module
|
||||
import 'package:geosector_app/core/models/loading_state.dart';
|
||||
|
||||
/// Service singleton pour gérer le chargement et la gestion des données au login
|
||||
@@ -54,10 +53,7 @@ class DataLoadingService extends ChangeNotifier {
|
||||
Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
Box<AmicaleModel> get _amicaleBox =>
|
||||
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
Box<ConversationModel> get _chatConversationBox =>
|
||||
Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
Box<MessageModel> get _chatMessageBox =>
|
||||
Hive.box<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
// Chat boxes removed - handled by new chat module
|
||||
Box get _settingsBox => Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
/// Traite toutes les données reçues de l'API lors du login
|
||||
@@ -569,7 +565,7 @@ class DataLoadingService extends ChangeNotifier {
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatRoomsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ 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';
|
||||
// Chat adapters removed - handled by new chat module
|
||||
|
||||
class HiveAdapters {
|
||||
/// Enregistre tous les TypeAdapters nécessaires
|
||||
@@ -42,24 +42,7 @@ class HiveAdapters {
|
||||
Hive.registerAdapter(AmicaleModelAdapter());
|
||||
}
|
||||
|
||||
// Chat adapters
|
||||
if (!Hive.isAdapterRegistered(20)) {
|
||||
Hive.registerAdapter(ConversationModelAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(21)) {
|
||||
Hive.registerAdapter(MessageModelAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(22)) {
|
||||
Hive.registerAdapter(ParticipantModelAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(23)) {
|
||||
Hive.registerAdapter(AnonymousUserModelAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(24)) {
|
||||
Hive.registerAdapter(AudienceTargetModelAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(25)) {
|
||||
Hive.registerAdapter(NotificationSettingsAdapter());
|
||||
}
|
||||
// Chat adapters are now handled by the chat module itself
|
||||
// TypeIds 50-60 are reserved for chat module
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ 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';
|
||||
import 'package:geosector_app/chat/models/room.dart';
|
||||
import 'package:geosector_app/chat/models/message.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
|
||||
@@ -91,12 +92,8 @@ class HiveResetService {
|
||||
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());
|
||||
Hive.registerAdapter(RoomAdapter());
|
||||
Hive.registerAdapter(MessageAdapter());
|
||||
|
||||
// Vérifier si RegionModelAdapter est disponible
|
||||
try {
|
||||
@@ -117,7 +114,7 @@ class HiveResetService {
|
||||
await Hive.openBox(AppKeys.settingsBoxName);
|
||||
|
||||
// Ouvrir les boîtes de chat
|
||||
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
|
||||
await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
|
||||
await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ 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';
|
||||
// Chat imports removed - using new simplified chat module
|
||||
|
||||
/// Service singleton centralisé pour la gestion complète des Box Hive
|
||||
/// Utilisé par main.dart pour l'initialisation et par logout pour le nettoyage
|
||||
@@ -35,8 +34,7 @@ class HiveService {
|
||||
HiveBoxConfig<PassageModel>(AppKeys.passagesBoxName, 'PassageModel'),
|
||||
HiveBoxConfig<MembreModel>(AppKeys.membresBoxName, 'MembreModel'),
|
||||
HiveBoxConfig<UserSectorModel>(AppKeys.userSectorBoxName, 'UserSectorModel'),
|
||||
HiveBoxConfig<ConversationModel>(AppKeys.chatConversationsBoxName, 'ConversationModel'),
|
||||
HiveBoxConfig<MessageModel>(AppKeys.chatMessagesBoxName, 'MessageModel'),
|
||||
// Chat boxes removed - handled by new chat module
|
||||
HiveBoxConfig<dynamic>(AppKeys.settingsBoxName, 'Settings'),
|
||||
HiveBoxConfig<dynamic>(AppKeys.regionsBoxName, 'Regions'),
|
||||
];
|
||||
@@ -403,12 +401,7 @@ class HiveService {
|
||||
case 'UserSectorModel':
|
||||
await Hive.openBox<UserSectorModel>(config.name);
|
||||
break;
|
||||
case 'ConversationModel':
|
||||
await Hive.openBox<ConversationModel>(config.name);
|
||||
break;
|
||||
case 'MessageModel':
|
||||
await Hive.openBox<MessageModel>(config.name);
|
||||
break;
|
||||
// Chat boxes removed - handled by new chat module
|
||||
default:
|
||||
// Pour Settings, Regions, etc.
|
||||
await Hive.openBox(config.name);
|
||||
@@ -455,6 +448,37 @@ class HiveService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Vérification rapide que les boxes critiques sont initialisées
|
||||
/// Utilisé par LoginPage pour détecter si une redirection vers SplashPage est nécessaire
|
||||
bool areBoxesInitialized() {
|
||||
try {
|
||||
// Vérifier seulement les boxes critiques pour le login
|
||||
final criticalBoxes = [
|
||||
AppKeys.userBoxName, // Nécessaire pour getCurrentUser
|
||||
AppKeys.membresBoxName, // Nécessaire pour le pré-remplissage
|
||||
AppKeys.settingsBoxName, // Nécessaire pour les préférences
|
||||
];
|
||||
|
||||
for (final boxName in criticalBoxes) {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint('⚠️ Box critique non ouverte: $boxName');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier aussi le flag d'initialisation
|
||||
if (!_isInitialized) {
|
||||
debugPrint('⚠️ HiveService non initialisé');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur vérification boxes: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupération sécurisée d'une Box typée
|
||||
Box<T> getTypedBox<T>(String boxName) {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/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';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/chat/pages/rooms_page_embedded.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
|
||||
class AdminCommunicationPage extends StatefulWidget {
|
||||
const AdminCommunicationPage({super.key});
|
||||
@@ -12,546 +14,238 @@ class AdminCommunicationPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AdminCommunicationPageState extends State<AdminCommunicationPage> {
|
||||
int selectedContactId = 0;
|
||||
String selectedContactName = '';
|
||||
bool isTeamChat = true;
|
||||
String messageText = '';
|
||||
bool isReplying = false;
|
||||
Map<String, dynamic>? replyingTo;
|
||||
bool _isChatInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
String? _initError;
|
||||
GlobalKey<RoomsPageEmbeddedState>? _roomsPageKey;
|
||||
|
||||
// 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',
|
||||
},
|
||||
];
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeChat();
|
||||
}
|
||||
|
||||
// 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',
|
||||
},
|
||||
];
|
||||
Future<void> _initializeChat() async {
|
||||
if (_isInitializing) return;
|
||||
|
||||
setState(() {
|
||||
_isInitializing = true;
|
||||
_initError = null;
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
try {
|
||||
// Récupérer les informations utilisateur
|
||||
final currentUser = CurrentUserService.instance;
|
||||
final apiService = ApiService.instance;
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
throw Exception('Administrateur non connecté');
|
||||
}
|
||||
|
||||
// Initialiser le module chat avec les informations de l'administrateur
|
||||
await ChatModule.init(
|
||||
apiUrl: apiService.baseUrl,
|
||||
userId: currentUser.currentUser!.id,
|
||||
userName: currentUser.userName ?? currentUser.userEmail ?? 'Administrateur',
|
||||
userRole: currentUser.currentUser!.role,
|
||||
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
|
||||
authToken: currentUser.sessionId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isChatInitialized = true;
|
||||
_isInitializing = false;
|
||||
_roomsPageKey = GlobalKey<RoomsPageEmbeddedState>();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_initError = e.toString();
|
||||
_isInitializing = false;
|
||||
});
|
||||
debugPrint('Erreur initialisation chat admin: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshRooms() {
|
||||
_roomsPageKey?.currentState?.refresh();
|
||||
}
|
||||
|
||||
@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 = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return 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),
|
||||
),
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: _buildContent(theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ImageProvider? _getAvatarForContact(int contactId) {
|
||||
String? avatarPath;
|
||||
|
||||
if (isTeamChat) {
|
||||
final contact = teamContacts.firstWhere(
|
||||
(c) => c['id'] == contactId,
|
||||
orElse: () => {'avatar': null},
|
||||
Widget _buildContent(ThemeData theme) {
|
||||
if (_isInitializing) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Initialisation du chat administrateur...',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
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': ''},
|
||||
if (_initError != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur d\'initialisation chat',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_initError!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return contact['email'] ?? '';
|
||||
}
|
||||
return '';
|
||||
|
||||
if (_isChatInitialized) {
|
||||
// Afficher le module chat avec un header simplifié
|
||||
return Column(
|
||||
children: [
|
||||
// En-tête simplifié avec boutons intégrés
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.admin_panel_settings,
|
||||
color: Colors.red.shade600,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Messages Administration',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Boutons d'action
|
||||
IconButton(
|
||||
icon: Icon(Icons.add, color: Colors.red.shade600),
|
||||
onPressed: () {
|
||||
// Déclencher la création d'une nouvelle conversation
|
||||
// Cela sera géré par RoomsPageEmbedded
|
||||
_roomsPageKey?.currentState?.createNewConversation();
|
||||
},
|
||||
tooltip: 'Nouvelle conversation',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: Colors.red.shade600),
|
||||
onPressed: _refreshRooms,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Module chat sans AppBar
|
||||
Expanded(
|
||||
child: RoomsPageEmbedded(
|
||||
key: _roomsPageKey,
|
||||
onRefreshPressed: () {
|
||||
// Callback optionnel après refresh
|
||||
debugPrint('Conversations actualisées');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// État initial
|
||||
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(
|
||||
'Chat administrateur non initialisé',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: const Text('Initialiser le chat'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@@ -146,13 +147,25 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
|
||||
// Ajouter les éléments de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
// Utiliser createBadgedNavigationDestination pour les messages
|
||||
if (item.label == 'Messages') {
|
||||
destinations.add(
|
||||
createBadgedNavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
showBadge: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter les éléments admin si l'utilisateur a le rôle requis
|
||||
@@ -165,13 +178,25 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
}
|
||||
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
// Utiliser createBadgedNavigationDestination pour les messages
|
||||
if (item.label == 'Messages') {
|
||||
destinations.add(
|
||||
createBadgedNavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
showBadge: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3981,6 +3981,8 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
initialZoom: _currentZoom,
|
||||
mapController: _mapController,
|
||||
disableDrag: _isDraggingPoint,
|
||||
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue
|
||||
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web
|
||||
labelMarkers: _buildSectorLabels(),
|
||||
markers: [
|
||||
..._buildMarkers(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/services/hive_service.dart'; // Pour vérifier l'initialisation
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
final String? loginType;
|
||||
@@ -58,7 +59,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
|
||||
// État de la connexion Internet
|
||||
bool _isConnected = false;
|
||||
bool _isConnected = true; // Par défaut, on suppose qu'il y a une connexion
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
@@ -95,7 +96,28 @@ class _LoginPageState extends State<LoginPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Vérification du type de connexion
|
||||
// VÉRIFICATION CRITIQUE : S'assurer que Hive est initialisé
|
||||
// Cette vérification DOIT se faire avant tout accès aux repositories
|
||||
if (!HiveService.instance.areBoxesInitialized()) {
|
||||
debugPrint('⚠️ LoginPage: Boxes Hive non initialisées, redirection vers SplashPage');
|
||||
|
||||
// Construire les paramètres pour la redirection après initialisation
|
||||
final loginType = widget.loginType ?? 'admin';
|
||||
|
||||
// Rediriger immédiatement vers SplashPage avec les bons paramètres
|
||||
// pour que SplashPage puisse rediriger automatiquement après l'initialisation
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialiser avec des valeurs par défaut pour éviter les erreurs
|
||||
_loginType = '';
|
||||
return; // IMPORTANT : Arrêter l'exécution du reste de initState
|
||||
}
|
||||
|
||||
// Vérification du type de connexion (seulement si Hive est initialisé)
|
||||
if (widget.loginType == null) {
|
||||
// Si aucun type n'est spécifié, naviguer vers la splash page
|
||||
print(
|
||||
@@ -191,7 +213,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// 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();
|
||||
// Vérifier à nouveau que les boxes sont disponibles
|
||||
if (!HiveService.instance.areBoxesInitialized()) {
|
||||
debugPrint('⚠️ Boxes non disponibles pour pré-remplir le username');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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)
|
||||
@@ -236,6 +265,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
'Le rôle ($roleValue) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du pré-remplissage: $e');
|
||||
// Continuer sans pré-remplir en cas d'erreur
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,12 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _nameFocusNode = FocusNode();
|
||||
final _searchController = TextEditingController();
|
||||
Color _selectedColor = Colors.blue;
|
||||
final List<int> _selectedMemberIds = [];
|
||||
bool _isLoading = false;
|
||||
bool _membersLoaded = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -108,6 +110,7 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_nameFocusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -162,69 +165,111 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
}
|
||||
|
||||
void _showColorPicker() {
|
||||
// Liste de couleurs prédéfinies
|
||||
final List<Color> colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.lime,
|
||||
Colors.yellow,
|
||||
Colors.amber,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
Colors.grey,
|
||||
Colors.blueGrey,
|
||||
const Color(0xFF1E88E5), // Bleu personnalisé
|
||||
const Color(0xFF43A047), // Vert personnalisé
|
||||
const Color(0xFFE53935), // Rouge personnalisé
|
||||
const Color(0xFFFFB300), // Ambre personnalisé
|
||||
const Color(0xFF8E24AA), // Violet personnalisé
|
||||
];
|
||||
// Grille 6x6 de couleurs suivant le spectre
|
||||
// 6 colonnes: Rouge, Orange, Jaune, Vert, Bleu, Violet
|
||||
// 6 lignes: variations de luminosité/saturation
|
||||
final List<Color> colors = _generateSpectralColorGrid();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Choisir une couleur'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
content: Container(
|
||||
width: double.maxFinite,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
border: Border.all(
|
||||
color: _selectedColor == color ? Colors.black : Colors.grey,
|
||||
width: _selectedColor == color ? 3 : 1,
|
||||
width: 280, // Largeur fixe pour contrôler la taille
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
final isSelected = _selectedColor.value == color.value;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
width: 35,
|
||||
height: 35,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.black87 : Colors.grey.shade400,
|
||||
width: isSelected ? 2.5 : 0.5,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Affichage de la couleur sélectionnée
|
||||
Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Couleur sélectionnée',
|
||||
style: TextStyle(
|
||||
color: _selectedColor.computeLuminance() > 0.5
|
||||
? Colors.black87
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -237,19 +282,107 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
// Mettre en surbrillance les termes recherchés dans le texte
|
||||
List<TextSpan> _highlightSearchTerms(String text) {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return [TextSpan(text: text)];
|
||||
}
|
||||
|
||||
final List<TextSpan> spans = [];
|
||||
final lowerText = text.toLowerCase();
|
||||
int start = 0;
|
||||
int index = lowerText.indexOf(_searchQuery, start);
|
||||
|
||||
while (index != -1) {
|
||||
// Ajouter le texte avant le terme trouvé
|
||||
if (index > start) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(start, index),
|
||||
));
|
||||
}
|
||||
|
||||
// Ajouter le terme trouvé en surbrillance
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(index, index + _searchQuery.length),
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.yellow,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
));
|
||||
|
||||
start = index + _searchQuery.length;
|
||||
index = lowerText.indexOf(_searchQuery, start);
|
||||
}
|
||||
|
||||
// Ajouter le reste du texte
|
||||
if (start < text.length) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(start),
|
||||
));
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
// Générer une grille 6x6 de couleurs spectrales
|
||||
List<Color> _generateSpectralColorGrid() {
|
||||
final List<Color> colors = [];
|
||||
|
||||
// 6 teintes de base (colonnes)
|
||||
final List<double> hues = [
|
||||
0, // Rouge
|
||||
30, // Orange
|
||||
60, // Jaune
|
||||
120, // Vert
|
||||
210, // Bleu
|
||||
270, // Violet
|
||||
];
|
||||
|
||||
// 6 variations de luminosité/saturation (lignes)
|
||||
// Du plus clair au plus foncé
|
||||
final List<Map<String, double>> variations = [
|
||||
{'saturation': 0.3, 'lightness': 0.85}, // Très clair
|
||||
{'saturation': 0.5, 'lightness': 0.70}, // Clair
|
||||
{'saturation': 0.7, 'lightness': 0.55}, // Moyen clair
|
||||
{'saturation': 0.85, 'lightness': 0.45}, // Moyen foncé
|
||||
{'saturation': 0.95, 'lightness': 0.35}, // Foncé
|
||||
{'saturation': 1.0, 'lightness': 0.25}, // Très foncé
|
||||
];
|
||||
|
||||
// Générer la grille ligne par ligne
|
||||
for (final variation in variations) {
|
||||
for (final hue in hues) {
|
||||
colors.add(
|
||||
HSLColor.fromAHSL(
|
||||
1.0,
|
||||
hue,
|
||||
variation['saturation']!,
|
||||
variation['lightness']!,
|
||||
).toColor(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final dialogHeight = (screenHeight * 0.8).clamp(0.0, 800.0); // 80% de l'écran avec max 800px
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.existingSector == null ? 'Nouveau secteur' : 'Modifier le secteur'),
|
||||
content: SingleChildScrollView(
|
||||
content: Container(
|
||||
width: 450, // Largeur fixe pour la dialog
|
||||
height: dialogHeight, // Hauteur avec maximum de 800px
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section scrollable pour nom et couleur
|
||||
// Nom du secteur
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -329,6 +462,37 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Champ de recherche pour filtrer les membres
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par prénom, nom ou nom de tournée...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (_selectedMemberIds.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -341,62 +505,111 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des membres avec scrolling et filtre
|
||||
if (currentAmicale != null)
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
final membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.toList();
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucun membre disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
// Filtrer les membres de l'amicale
|
||||
var membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.toList();
|
||||
|
||||
// Appliquer le filtre de recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
membres = membres.where((membre) {
|
||||
final firstName = membre.firstName?.toLowerCase() ?? '';
|
||||
final lastName = membre.name?.toLowerCase() ?? '';
|
||||
final sectName = membre.sectName?.toLowerCase() ?? '';
|
||||
|
||||
// Log pour debug
|
||||
if (index < 3) { // Limiter les logs aux 3 premiers membres
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
|
||||
}
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
title: Text(
|
||||
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
return firstName.contains(_searchQuery) ||
|
||||
lastName.contains(_searchQuery) ||
|
||||
sectName.contains(_searchQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Aucun membre trouvé pour "$_searchQuery"'
|
||||
: 'Aucun membre disponible',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher le nombre de résultats
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
'${membres.length} membre${membres.length > 1 ? 's' : ''} ${_searchQuery.isNotEmpty ? 'trouvé${membres.length > 1 ? 's' : ''}' : 'disponible${membres.length > 1 ? 's' : ''}'}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
|
||||
// Log pour debug
|
||||
if (index < 3) { // Limiter les logs aux 3 premiers membres
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
|
||||
}
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
title: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: _highlightSearchTerms(
|
||||
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
|
||||
),
|
||||
),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
|
||||
class UserCommunicationPage extends StatefulWidget {
|
||||
const UserCommunicationPage({super.key});
|
||||
@@ -14,24 +13,54 @@ class UserCommunicationPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
String? _selectedConversationId;
|
||||
late Box<ConversationModel> _conversationsBox;
|
||||
bool _hasConversations = false;
|
||||
bool _isChatInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
String? _initError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkConversations();
|
||||
_initializeChat();
|
||||
}
|
||||
|
||||
Future<void> _checkConversations() async {
|
||||
Future<void> _initializeChat() async {
|
||||
if (_isInitializing) return;
|
||||
|
||||
setState(() {
|
||||
_isInitializing = true;
|
||||
_initError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
_conversationsBox = Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
|
||||
// Récupérer les informations utilisateur
|
||||
final currentUser = CurrentUserService.instance;
|
||||
final apiService = ApiService.instance;
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
throw Exception('Utilisateur non connecté');
|
||||
}
|
||||
|
||||
// Initialiser le module chat avec les informations de l'utilisateur
|
||||
await ChatModule.init(
|
||||
apiUrl: apiService.baseUrl,
|
||||
userId: currentUser.currentUser!.id,
|
||||
userName: currentUser.userName ?? currentUser.userEmail ?? 'Utilisateur',
|
||||
userRole: currentUser.currentUser!.role,
|
||||
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
|
||||
authToken: currentUser.sessionId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_hasConversations = _conversationsBox.values.isNotEmpty;
|
||||
_isChatInitialized = true;
|
||||
_isInitializing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des conversations: $e');
|
||||
setState(() {
|
||||
_initError = e.toString();
|
||||
_isInitializing = false;
|
||||
});
|
||||
debugPrint('Erreur initialisation chat: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,131 +86,69 @@ class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildContent(theme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ThemeData theme) {
|
||||
Widget _buildContent(ThemeData theme) {
|
||||
if (_isInitializing) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Initialisation du chat...',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_initError != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur d\'initialisation',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_initError!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isChatInitialized) {
|
||||
// Afficher directement le module chat
|
||||
return ChatModule.getRoomsPage();
|
||||
}
|
||||
|
||||
// État initial
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -193,70 +160,24 @@ class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Sélectionnez une conversation',
|
||||
'Chat non initialisé',
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _initializeChat,
|
||||
child: const Text('Initialiser le chat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@override
|
||||
void dispose() {
|
||||
// Ne pas disposer le chat ici car il est partagé
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passage_form.dart';
|
||||
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
|
||||
|
||||
// Import des pages utilisateur
|
||||
import 'user_dashboard_home_page.dart';
|
||||
@@ -11,6 +12,7 @@ import 'user_statistics_page.dart';
|
||||
import 'user_history_page.dart';
|
||||
import 'user_communication_page.dart';
|
||||
import 'user_map_page.dart';
|
||||
import 'user_field_mode_page.dart';
|
||||
|
||||
class UserDashboardPage extends StatefulWidget {
|
||||
const UserDashboardPage({super.key});
|
||||
@@ -37,6 +39,7 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
const UserHistoryPage(),
|
||||
const UserCommunicationPage(),
|
||||
const UserMapPage(),
|
||||
const UserFieldModePage(),
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
@@ -140,32 +143,38 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
NavigationDestination(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
createBadgedNavigationDestination(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
selectedIcon: const Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
showBadge: true,
|
||||
),
|
||||
NavigationDestination(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: 'Terrain',
|
||||
),
|
||||
],
|
||||
onNewPassagePressed: () => _showPassageForm(context),
|
||||
body: _pages[_selectedIndex],
|
||||
|
||||
1003
app/lib/presentation/user/user_field_mode_page.dart
Normal file
1003
app/lib/presentation/user/user_field_mode_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
@@ -600,6 +601,8 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
initialPosition: _currentPosition,
|
||||
initialZoom: _currentZoom,
|
||||
mapController: _mapController,
|
||||
// Utiliser OpenStreetMap sur mobile, Mapbox sur web
|
||||
useOpenStreetMap: !kIsWeb,
|
||||
markers: _buildPassageMarkers(),
|
||||
polygons: _buildSectorPolygons(),
|
||||
showControls: true,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/chat/services/chat_info_service.dart';
|
||||
|
||||
/// Fonction helper pour créer une NavigationDestination avec badge
|
||||
NavigationDestination createBadgedNavigationDestination({
|
||||
required Icon icon,
|
||||
required Icon selectedIcon,
|
||||
required String label,
|
||||
bool showBadge = false,
|
||||
}) {
|
||||
if (!showBadge) {
|
||||
return NavigationDestination(
|
||||
icon: icon,
|
||||
selectedIcon: selectedIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
// Créer les icônes avec badge
|
||||
final badgedIcon = BadgedIcon(
|
||||
icon: icon.icon!,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
final badgedSelectedIcon = BadgedIcon(
|
||||
icon: selectedIcon.icon!,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
return NavigationDestination(
|
||||
icon: badgedIcon,
|
||||
selectedIcon: badgedSelectedIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget pour afficher un badge sur une icône
|
||||
class BadgedIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool showBadge;
|
||||
final Color? color;
|
||||
final double? size;
|
||||
|
||||
const BadgedIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.showBadge = false,
|
||||
this.color,
|
||||
this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconWidget = Icon(icon, color: color, size: size);
|
||||
|
||||
if (!showBadge) {
|
||||
return iconWidget;
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: ChatInfoService.instance,
|
||||
builder: (context, _) {
|
||||
final unreadCount = ChatInfoService.instance.unreadMessages;
|
||||
final badgeLabel = ChatInfoService.instance.badgeLabel;
|
||||
|
||||
if (unreadCount == 0) {
|
||||
return iconWidget;
|
||||
}
|
||||
|
||||
return Badge(
|
||||
label: Text(
|
||||
badgeLabel,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
textColor: Colors.white,
|
||||
child: iconWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:http_cache_file_store/http_cache_file_store.dart';
|
||||
@@ -46,6 +47,9 @@ class MapboxMap extends StatefulWidget {
|
||||
|
||||
/// Désactive le drag de la carte
|
||||
final bool disableDrag;
|
||||
|
||||
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
|
||||
final bool useOpenStreetMap;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
@@ -60,6 +64,7 @@ class MapboxMap extends StatefulWidget {
|
||||
this.showControls = true,
|
||||
this.mapStyle,
|
||||
this.disableDrag = false,
|
||||
this.useOpenStreetMap = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -70,7 +75,8 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Contrôleur de carte interne
|
||||
late final MapController _mapController;
|
||||
|
||||
/// Niveau de zoom actuel
|
||||
/// Niveau de zoom actuel (utilisé pour l'affichage futur)
|
||||
// ignore: unused_field
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
/// Provider de cache pour les tuiles
|
||||
@@ -91,7 +97,9 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
Future<void> _initializeCache() async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}MapboxTileCache');
|
||||
// Utiliser un nom de cache différent selon le provider
|
||||
final cacheName = widget.useOpenStreetMap ? 'OSMTileCache' : 'MapboxTileCache';
|
||||
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}$cacheName');
|
||||
|
||||
_tileProvider = CachedTileProvider(
|
||||
store: cacheStore,
|
||||
@@ -105,12 +113,14 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation du cache: $e');
|
||||
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
|
||||
// En cas d'erreur, on continue sans cache
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
_tileProvider = null; // Utiliser NetworkTileProvider en fallback
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,7 +148,7 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
@@ -155,19 +165,41 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
// Utiliser l'environnement actuel pour obtenir la bonne clé API
|
||||
final String environment = ApiService.instance.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
|
||||
final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
String urlTemplate;
|
||||
|
||||
if (widget.useOpenStreetMap) {
|
||||
// Utiliser OpenStreetMap comme alternative
|
||||
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
|
||||
} else {
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
// Utiliser l'environnement actuel pour obtenir la bonne clé API
|
||||
final String environment = ApiService.instance.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
|
||||
// Essayer différentes API Mapbox selon la plateforme
|
||||
if (kIsWeb) {
|
||||
// Sur web, on peut utiliser l'API styles
|
||||
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
} else {
|
||||
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards
|
||||
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
|
||||
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
|
||||
}
|
||||
|
||||
// Debug pour vérifier la configuration
|
||||
debugPrint('MapboxMap: Plateforme: ${kIsWeb ? "Web" : "Mobile"}');
|
||||
debugPrint('MapboxMap: Environnement: $environment');
|
||||
debugPrint('MapboxMap: Token: ${mapboxToken.substring(0, 10)}...'); // Afficher seulement le début du token
|
||||
debugPrint('MapboxMap: URL Template: ${urlTemplate.substring(0, 50)}...');
|
||||
}
|
||||
|
||||
// Afficher un indicateur pendant l'initialisation du cache
|
||||
if (!_cacheInitialized) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte sans cache en attendant
|
||||
_buildMapContent(urlTemplate, mapboxToken),
|
||||
_buildMapContent(urlTemplate),
|
||||
// Indicateur discret
|
||||
const Positioned(
|
||||
top: 8,
|
||||
@@ -194,10 +226,10 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
);
|
||||
}
|
||||
|
||||
return _buildMapContent(urlTemplate, mapboxToken);
|
||||
return _buildMapContent(urlTemplate);
|
||||
}
|
||||
|
||||
Widget _buildMapContent(String urlTemplate, String mapboxToken) {
|
||||
Widget _buildMapContent(String urlTemplate) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte principale
|
||||
@@ -232,13 +264,24 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
maxNativeZoom: 19,
|
||||
additionalOptions: {
|
||||
'accessToken': mapboxToken,
|
||||
},
|
||||
// Utilise le cache si disponible
|
||||
maxZoom: 20,
|
||||
minZoom: 1,
|
||||
// Retirer tileSize pour utiliser la valeur par défaut
|
||||
// Les additionalOptions ne sont pas nécessaires car le token est dans l'URL
|
||||
// Utilise le cache si disponible sur web, NetworkTileProvider sur mobile
|
||||
tileProvider: _cacheInitialized && _tileProvider != null
|
||||
? _tileProvider!
|
||||
: NetworkTileProvider(),
|
||||
: NetworkTileProvider(
|
||||
headers: {
|
||||
'User-Agent': 'geosector_app/3.1.3',
|
||||
'Accept': '*/*',
|
||||
},
|
||||
),
|
||||
errorTileCallback: (tile, error, stackTrace) {
|
||||
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
|
||||
debugPrint('MapboxMap: Coordonnées de la tuile: ${tile.coordinates}');
|
||||
debugPrint('MapboxMap: Stack trace: $stackTrace');
|
||||
},
|
||||
),
|
||||
|
||||
// Polygones
|
||||
|
||||
Reference in New Issue
Block a user