commit b5aafc424b1f05be6df424ae0310f0d8eb66fff4 Author: d6soft <7829284+d6soft@users.noreply.github.com> Date: Thu May 1 18:59:27 2025 +0200 Initialisation du projet geosector complet (web + flutter) diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..cd0ae6af --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Flutter +flutt/build/ +flutt/.dart_tool/ +flutt/.packages +flutt/pubspec.lock +flutt/.flutter-plugins +flutt/.flutter-plugins-dependencies +flutt/*.iml +flutt/.idea/ +flutt/.vscode/ +flutt/ios/Flutter/flutter_framework.ipa +flutt/flutter_*.log + +# Web (PHP/JavaScript) +web/vendor/ +web/node_modules/ +web/.env +web/*.log +web/storage/*.key +web/public/storage +web/public/hot + +# Général +*.DS_Store +*.log +*.tmp +*.temp +.env +.env.* diff --git a/docs/CDC.md b/docs/CDC.md new file mode 100644 index 00000000..ef6b8009 --- /dev/null +++ b/docs/CDC.md @@ -0,0 +1,214 @@ +# CAHIER DES CHARGES GEOSECTOR + +Geosector est entièrement développé en Flutter +Il utilise les dépendances suivantes + +- Hive pour le stockage des données sur mobile en cas déconnexion du réseau +- Stripe pour le paiement en ligne +- MapBox pour l’affichage des cartes et la gestion des secteurs (polygones) et des passages (markers) +- Fl_chart pour les graphiques + +L’application sera accessible via l’url https://app.geosector.fr + +Elle utilisera une API FulRest modulaire développée en Full PHP8.3 et connectée à une base de données centrale MariaDB10.11 +L’API sera accessible via l’url https://app.geosector.fr/api/geo/… +Les requêtes authentifiées passeront par le PHP session_id. + +L’application Flutter utilise des adresses tirées de la base nationale des adresses Open Data via l’API. +Cette base adresses est importée chaque semaine par un script bash dans une base MariaDB + +La base de données centrale geosector_app devra être initialisée et des scripts sql devront être créés pour importer les données d'une autre base avec une autre structure. + +L’application Flutter GEOSECTOR se compose de trois parties : partie publique, partie admin et partie utilisateur. + +# PARTIE PUBLIQUE + +2 pages. + +## PAGE PUBLIQUE + +- Cette page présente la solution +- Elle donne un lien vers l'App Store et Play Store pour télécharger l'application mobile +- Elle présente des captures d'écran de l'application mobile et de l'interface administrateur. +- Elle donne un lien pour se connecter et suivant son e-mail et son mot de passe, on ira dans l’interface admin ou utilisateur. +- Elle donne un lien pour s'inscrire et créer son amicale en tant que administrateur. +- Elle donne des statistiques de connexion sur l'admin et le mobile sur les 2 derniers mois et sur les 2 dernières années. +- Elle donne le nombre de clients inscrits et autres infos sur 3 cards + Au niveau du footer on a l’adresse de Geosector avec le lien Facebook, les liens de cette page et le formulaire de contacts +- ne pas oublier les pages mentions légales et conditions d'utilisation. + Mettre aussi la modale pour la gestion des cookies + +## LOGIN / REGISTER + +- La connexion se fait par un username et un userpswd (mot de passe). +- Un lien "mot de passe oublié" permet de saisir son email pour récupérer un nouveau mot de passe. +- Un lien "s'inscrire" permet de saisir son e-mail, son nom et prénom, le nom de l'amicale, son code postal et sa commune pour recevoir un userame et un mot de passe par email. + +# PARTIE UTILISATEUR + +je voudrais commencer à voir l'interface UI de @user_dashboard_page.dart avec 5 pages qu'il faudra nommer user_xxx_page et qui contiendront des widgets communs à toute l'application. + +Le design doit être soigné, clair, espacé, très lisible avec un jeu d'élévations et d'ombres et en tenant compte de @app_theme.dart + +En version Web le menu se trouvera dans une SideBar à gauche avec des icones et le contenu à droite +En version mobile, le menu se trouvera en bas des pages avec les mêmes icones + +## PAGE PRINCIPALE - Dashboard + +- synthèse des passages de l’utilisateur et le montant collecté par type de règlement (espèce, chèque, carte bancaire). +- graphique des passages réalisés par jour depuis deux semaines +- Bouton de création d’un nouveau passage + +## PAGE STATISTIQUES + +- Graphiques au choix par jour, semaine, mois du nombre de passages et des sommes collectées + +## PAGE HISTORIQUE + +- Liste des passages par type, par date avec la possibilité de faire des recherches et de consulter le reçu au format PDF et les éventuelles erreurs détectées par retour d'email + +## PAGE COMMUNICATION + +- Permet de communiquer au sein de l’équipe par chat +- Permet de répondre à un mail d’un client + +## PAGE CARTE + +- Visualiser ses secteurs d’activité via MapBox +- Visualiser ces passages +- Sélectionner des passages près de sa position +- Cliquer sur un passage pour ouvrir le formulaire passage +- Cliquer sur la carte pour créer un passage à la position du clic + +## FORMULAIRE PASSAGE + +- Saisir les informations de passage et permettre de régler en ligne par carte bancaire via Stripe, et d’envoyer par mail ou SMS, le reçu au format PDF. + +Il faudrait créer des widgets communs : + +1. pour la carte MapBox qui aura certaines fonctionnalités suivant le role du user +2. pour le formulaire de passage qui sera utilisé à plusieurs endroits dans l'appli +3. historique des passages : liste des passages avec des critères de filtres et de tri paramétrable suivant le rôle du user +4. Statistiques : 3 types de graphiques avec critères de sélection paramétrables suivant le rôle du user + +# PARTIE ADMIN + +En version Web, le menu se trouvera dans une Sidebar à gauche. +En version mobile, le menu se trouvera en bas des pages. +La partie admin est différente suivant le rôle de l’utilisateur : super admin ou admin d’une amicale. + +## ADMIN D’UNE AMICALE + +8 pages + +### PAGE PRINCIPALE + +- Synthèse des passages par secteur et par utilisateur et le montant collecté sur l’opération en cours +- Graphique des passages réalisés par jour depuis deux semaines + +### PAGE AMICALE + +- Saisie des informations et options de l’amicale +- Upload du logo +- Gestion des abonnements avec paiement en ligne STRIPE en fonction du nombre de calendriers distribués. +- Gestion des SMS avec paiement en ligne STRIPE de pack de 200 à 2000 SMS. + +### PAGE MEMBRES + +- Gérer les membres de l’amicale +- Upload des badges des membres +- Demande de réinitialisation de mot de passe +- Importer/Exporter une liste de membres + +### PAGE COMMUNICATION + +- Communiquer par chat au sein de l’équipe et auprès de Geosector + +### PAGE CONNEXIONS + +- Consulter les dernières connexions de l’équipe des 15 derniers jours +- Graphique des connexions sur 5 mois glissants + +### PAGE CARTE + +- Voir les secteurs d’activité et les passages avec des filtres sur des secteurs ou des utilisateurs + +### PAGE OPERATIONS + +- Gérer ses opérations et opération active présélectionnée +- Gérer les secteurs de l’opération : couleur, titre, membres +- Tracer les secteurs sur une carte +- Affichage des passages en fonction de l’historique et des adresses récupérées de la base adresses +- Affichage de la liste des membres actifs avec leurs données de stats par type de passage +- Exporter les données de l’opération au format Excel +- Exporter les données d’un membre + +### PAGE STATISTIQUES + +- Afficher des graphiques d’activité par secteur, par membre, et sur des périodes sélectionnées + +## ADMIN GEOSECTOR + +L’admin des super administrateurs GEOSECTOR a 1 page en plus + +### PAGE CLIENTS + +- Affiche la liste des amicales créées actives ou en démo avec une recherche sur le nom de l'amicale, un code postal, une commune, un nom de membre +- gestion des amicales en mode démo (inscription en ligne) +- Création d’une amicale en récupérant le formulaire de la page AMICALE +- Suppression d’une amicale +- Consulter le nombre de membres, de passages réalisés sur la dernière opération de chaque amicale +- Gérer les secteurs de son opération avec possibilité de suppression ou de restauration +- Gérer les opérations avec possibilité de suppression, création +- Gérer les abonnements en fonction du nombre de passages réalisés et d’envoyer par mail une facture au format PDF + +# WIDGETS COMMUNS + +Des widgets communs doivent être développés pour être utilisés dans plusieurs pages et être appelés avec des paramètres. + +- Widget Formulaire de passage +- Widget Carte avec des actions sur les markers (ajout, modification, suppression) et sur les secteurs (ajout, modification, suppression) suivant le rôle de l'utilisateur +- Widget Graphiques de connexions suivant le rôle Admin ou Super Admin +- Widget Graphiques de statistiques de passages d'un membre +- Widget Graphiques de statistiques d'un secteur +- Widget Graphiques de statistiques d'une opération +- Widget Formulaire d'une amicale +- Widget Formulaire d'un membre +- Widget Formulaire d'une opération +- Widget Formulaire d'un secteur + +# CHIFFREMENT + +Chaque mot de passe doit être chiffré avec l'algorithme Argon2 +Des données sensibles seront à chiffer avec l'algorithme AES-256 dans la base de données. Ces champs seront nommées avec le préfixe `encrypted_` +Le chiffrement sera utilisé avec IV identique pour les emails, les adresses car elles sont utilisées dans les recherches. + +# EMAILS + +Les emails seront envoyés via l'API vers le serveur SMTP de geosector.fr avec les adresses noreply et contact@geosector.fr +Une gestion de queue des emails sera développée par l'API pour éviter d'envoyer trop d'emails par heure (limite 1500 emails/heure). +Un script externe en PHP sera développé pour nettoyer la queue et envoyer les emails stockés en base de données. +Un script externe en PHP sera développé pour contrôler les éventuels retours d'emails non réceptionnés (erreur email) et qui renverra l'information d'erreur dans la table ope_pass des passages pour que l'utilisateur soit alerté et qu'il puisse mettre à jour l'email et effectuer un renvoi du reçu au format PDF. + +# SMS + +Un SMS peut être envoyé sur un passage à l'habitant pour lui confirmer le passage et la somme collectée. +Seules les amicales qui ont accepté cette fonctionnalité dans l'interface admin pourront envoyer des SMS. +Les SMS seront envoyés via l'API et via le serveur SMS OVH. +Une gestion de règlement sera développée pour que l'amicale puisse régler le paiement des SMS envoyés, par pack de 200, 500, 1000, 2000 SMS. + +# BASES DE DONNEES EXTERNES OPEN DATA + +Des scripts PHP au niveau de l'API seront développés pour importer hebdomadairement la base ADRESSES Open Data. +Ces données seront stockées dans une base de données centrale MariaDB. + +Des scripts PHP au niveau de l'API seront développés pour importer hebdomadairement la base SIRENE Open Data. +Ces données seront stockées dans une base de données centrale MariaDB. + +Des scripts PHP au niveau de l'API seront développés pour importer hebdomadairement la base BATIMENTS Open Data. +Ces données seront stockées dans une base de données centrale MariaDB. + +Des scripts PHP au niveau de l'API seront développés pour importer hebdomadairement la base OpenStreetMap Open Data. +Ces données seront stockées dans une base de données centrale MariaDB. + +Des points d'entrées de l'API seront développés pour récupérer les données des bases externes à destination de GEOSECTOR. diff --git a/docs/DB-diagram.md b/docs/DB-diagram.md new file mode 100644 index 00000000..c7865eb6 --- /dev/null +++ b/docs/DB-diagram.md @@ -0,0 +1,292 @@ +erDiagram + users ||--o{ ope_pass : "fk_user" + users ||--o{ ope_users : "fk_user" + users ||--o{ ope_users_sectors : "fk_user" + users ||--o{ ope_users_suivis : "fk_user" + users ||--o{ ope_pass_histo : "fk_user" + users ||--o{ medias : "fk_user_creat/fk_user_modif" + users }o--|| users_entites : "fk_entite" + users }o--|| x_users_roles : "fk_role" + users }o--|| x_users_categories : "fk_categorie" + users }o--|| x_users_sous_categories : "fk_sous_categorie" + users }o--|| x_users_grades : "fk_grade" + + operations ||--o{ ope_pass : "fk_operation" + operations ||--o{ ope_users : "fk_operation" + operations ||--o{ ope_sectors : "fk_operation" + operations ||--o{ ope_users_sectors : "fk_operation" + operations ||--o{ ope_users_suivis : "fk_operation" + operations }o--|| users_entites : "fk_entite" + + sectors ||--o{ ope_users_sectors : "fk_sector" + sectors ||--o{ sectors_adresses : "fk_sector" + sectors ||--o{ sectors_streets : "fk_sector" + + ope_sectors ||--o{ ope_users_sectors : "fk_sector" + ope_sectors ||--o{ ope_pass : "fk_sector" + + ope_pass ||--o{ ope_pass_histo : "fk_pass" + ope_pass ||--o{ ope_pass_recus : "fk_pass" + ope_pass ||--o{ email_queue : "rowid" + ope_pass }o--|| x_types_reglements : "fk_type_reglement" + + x_users_categories ||--o{ x_users_sous_categories : "fk_user_categorie" + + x_pays ||--o{ x_regions : "fk_pays" + x_regions ||--o{ x_departements : "fk_region" + x_departements ||--o{ x_villes : "fk_departement" + x_pays }o--|| x_devises : "fk_devise" + + users_entites }o--|| x_regions : "fk_region" + users_entites }o--|| x_entites_types : "fk_type" + + email_counter { + int id PK + timestamp hour_start + int count + } + + email_queue { + int id PK + int rowid "ope_pass.rowid" + varchar to_email + varchar subject + text body + enum status + } + + medias { + int rowid PK + varchar support + int support_rowid + varchar fichier + varchar type_fichier + varchar description + datetime date_creat + int fk_user_creat FK + datetime date_modif + int fk_user_modif FK + } + + ope_pass { + int rowid PK + int fk_operation FK + int fk_sector FK + int fk_user FK + varchar fk_adresse + datetime date_eve + int fk_type + varchar numero + varchar rue + varchar ville + int fk_habitat + decimal montant + int fk_type_reglement FK + } + + ope_pass_histo { + int rowid PK + int fk_pass FK + int fk_user FK + datetime date_histo + varchar sujet + varchar remarque + } + + ope_pass_recus { + int rowid PK + int fk_pass FK + varchar chemin + varchar nom_recu + datetime date_recu + } + + ope_sectors { + int rowid PK + int fk_operation FK + varchar libelle + text sector + varchar color + } + + ope_users { + int rowid PK + int fk_operation FK + int fk_user FK + tinyint active + } + + ope_users_sectors { + int rowid PK + int fk_operation FK + int fk_user FK + int fk_sector FK + tinyint active + } + + ope_users_suivis { + int rowid PK + int fk_operation FK + int fk_user FK + datetime date_suivi + varchar latitude + varchar longitude + } + + operations { + int rowid PK + int fk_entite FK + varchar libelle + date date_deb + date date_fin + tinyint active + } + + sectors { + int rowid PK + varchar libelle + text sector + varchar color + tinyint active + } + + sectors_adresses { + int rowid PK + varchar fk_adresse + int fk_sector FK + varchar numero + varchar rue + varchar cp + varchar ville + varchar gps_lat + varchar gps_lng + } + + sectors_streets { + int rowid PK + int fk_sector FK + varchar fk_adresse + varchar osm_lat + varchar osm_lng + varchar osm_name + varchar osm_street + varchar osm_city + } + + users { + int rowid PK + int fk_entite FK + int fk_titre + varchar libelle + varchar prenom + varchar username + varchar userpass + varchar email + int fk_role FK + int fk_categorie FK + int fk_sous_categorie FK + int fk_grade FK + tinyint active + } + + users_entites { + int rowid PK + varchar libelle + varchar adresse1 + varchar cp + varchar ville + int fk_region FK + int fk_type FK + varchar email + tinyint active + } + + x_departements { + int rowid PK + varchar code + int fk_region FK + varchar libelle + } + + x_devises { + int rowid PK + varchar code + varchar symbole + varchar libelle + } + + x_entites_types { + int rowid PK + varchar libelle + tinyint active + } + + x_pays { + int rowid PK + varchar code + int fk_continent + int fk_devise FK + varchar libelle + } + + x_regions { + int rowid PK + int fk_pays FK + varchar libelle + varchar libelle_long + } + + x_types_passages { + int rowid PK + varchar libelle + varchar color_button + varchar color_mark + } + + x_types_reglements { + int rowid PK + varchar libelle + tinyint active + } + + x_users_categories { + int rowid PK + varchar libelle + tinyint active + } + + x_users_grades { + int rowid PK + varchar libelle + tinyint active + } + + x_users_roles { + int rowid PK + varchar libelle + tinyint active + } + + x_users_sous_categories { + int rowid PK + int fk_user_categorie FK + varchar libelle + tinyint active + } + + x_villes { + int rowid PK + int fk_departement FK + varchar libelle + varchar cp + varchar code_insee + } + + z_sessions { + text sid + int fk_user FK + varchar role + timestamp date_modified + varchar ip + varchar browser + } \ No newline at end of file diff --git a/docs/api_endpoints.md b/docs/api_endpoints.md new file mode 100644 index 00000000..4cf16466 --- /dev/null +++ b/docs/api_endpoints.md @@ -0,0 +1,678 @@ +# API Endpoints de GEOSECTOR + +## Description générale + +GEOSECTOR utilise une API REST modulaire développée en PHP 8.3 pour communiquer avec le backend. Cette API permet la gestion des utilisateurs, des opérations, des secteurs et des passages. Ce document décrit tous les endpoints disponibles, leurs paramètres et leurs réponses. + +## Configuration de base + +- **URL de base**: `https://app.geosector.fr/api/geo` +- **Authentification**: Session PHP avec token Bearer +- **Format des réponses**: JSON +- **Timeouts**: + - Connexion: 5 secondes + - Réception: 30 secondes + +## Headers par défaut + +``` +Content-Type: application/json +X-App-Identifier: app.geosector.fr +X-Client-Type: web/mobile (détecté automatiquement) +Accept: application/json +``` + +## Authentification + +### Login + +Permet à un utilisateur de se connecter et d'obtenir un ID de session. + +- **URL**: `/login` +- **Méthode**: `POST` +- **Authentification requise**: Non +- **Paramètres**: + +| Paramètre | Type | Description | +|-----------|--------|--------------------------------------------| +| username | string | Nom d'utilisateur | +| password | string | Mot de passe | +| type | string | Type de connexion ('admin' ou 'user') | + +- **Réponse réussie**: +```json +{ + "status": "success", + "message": "Connexion réussie", + "session_id": "session_token_here", + "user": { + "id": 123, + "email": "user@example.com", + "name": "Nom Utilisateur", + "username": "username", + "first_name": "Prénom", + "sect_name": "Nom section", + "fk_role": 1, + "interface": "user" + }, + "operations": [...], + "sectors": [...], + "passages": [...] +} +``` + +- **Réponse d'erreur**: +```json +{ + "status": "error", + "message": "Identifiants incorrects" +} +``` + +### Logout + +Déconnecte l'utilisateur en invalidant sa session. + +- **URL**: `/logout` +- **Méthode**: `POST` +- **Authentification requise**: Oui +- **Paramètres**: Aucun +- **Réponse réussie**: +```json +{ + "status": "success", + "message": "Déconnexion réussie" +} +``` + +### Register + +Enregistre un nouvel administrateur d'amicale. + +- **URL**: `/register` +- **Méthode**: `POST` +- **Authentification requise**: Non +- **Paramètres**: + +| Paramètre | Type | Description | +|---------------|--------|----------------------------| +| email | string | Email | +| name | string | Nom | +| amicale_name | string | Nom de l'amicale | +| postal_code | string | Code postal | +| city_name | string | Nom de la ville | + +- **Réponse réussie**: +```json +{ + "status": "success", + "message": "Inscription réussie", + "user_id": 123, + "session_id": "session_token_here", + "session_expiry": "2025-04-20T12:00:00Z" +} +``` + +## Gestion des utilisateurs + +### Récupérer tous les utilisateurs + +- **URL**: `/users` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: Aucun +- **Réponse**: +```json +[ + { + "id": 123, + "email": "user@example.com", + "name": "Nom Utilisateur", + "role": 1, + "isActive": true + }, + ... +] +``` + +### Récupérer un utilisateur par ID + +- **URL**: `/users/{id}` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: ID dans l'URL +- **Réponse**: +```json +{ + "id": 123, + "email": "user@example.com", + "name": "Nom Utilisateur", + "role": 1, + "isActive": true +} +``` + +### Créer un utilisateur + +- **URL**: `/users` +- **Méthode**: `POST` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: + +| Paramètre | Type | Description | +|-----------|---------|------------------------------| +| email | string | Email de l'utilisateur | +| name | string | Nom de l'utilisateur | +| role | integer | Rôle (1=user, 2/4/9=admin) | + +- **Réponse**: +```json +{ + "id": 123, + "email": "user@example.com", + "name": "Nom Utilisateur", + "role": 1, + "isActive": true +} +``` + +### Mettre à jour un utilisateur + +- **URL**: `/users/{id}` +- **Méthode**: `PUT` +- **Authentification requise**: Oui (Admin ou propriétaire du compte) +- **Paramètres**: ID dans l'URL + corps de la requête + +| Paramètre | Type | Description | +|-----------|---------|----------------------------------| +| email | string | Email de l'utilisateur | +| name | string | Nom de l'utilisateur | +| role | integer | Rôle (1=user, 2/4/9=admin) | +| isActive | boolean | Statut de l'utilisateur | + +- **Réponse**: +```json +{ + "id": 123, + "email": "user@example.com", + "name": "Nom Utilisateur", + "role": 1, + "isActive": true +} +``` + +### Supprimer un utilisateur + +- **URL**: `/users/{id}` +- **Méthode**: `DELETE` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: ID dans l'URL +- **Réponse**: +```json +{ + "status": "success", + "message": "Utilisateur supprimé avec succès" +} +``` + +## Gestion des opérations + +### Récupérer toutes les opérations + +- **URL**: `/operations` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Filtres optionnels**: + +| Paramètre | Type | Description | +|-----------|---------|----------------------------------------| +| active | boolean | Filtrer par statut actif/inactif | + +- **Réponse**: +```json +[ + { + "id": 456, + "libelle": "Opération 2025", + "date_deb": "2025-01-01", + "date_fin": "2025-12-31", + "active": true + }, + ... +] +``` + +### Récupérer une opération par ID + +- **URL**: `/operations/{id}` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: ID dans l'URL +- **Réponse**: +```json +{ + "id": 456, + "libelle": "Opération 2025", + "date_deb": "2025-01-01", + "date_fin": "2025-12-31", + "active": true, + "sectors": [ + { + "id": 789, + "libelle": "Secteur Nord", + "color": "#FF5733", + "sector": "POLYGON((...))" + }, + ... + ] +} +``` + +### Créer une opération + +- **URL**: `/operations` +- **Méthode**: `POST` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: + +| Paramètre | Type | Description | +|-----------|---------|----------------------------------| +| libelle | string | Nom de l'opération | +| date_deb | string | Date de début (YYYY-MM-DD) | +| date_fin | string | Date de fin (YYYY-MM-DD) | +| active | boolean | Statut de l'opération | + +- **Réponse**: +```json +{ + "id": 456, + "libelle": "Opération 2025", + "date_deb": "2025-01-01", + "date_fin": "2025-12-31", + "active": true +} +``` + +### Mettre à jour une opération + +- **URL**: `/operations/{id}` +- **Méthode**: `PUT` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: ID dans l'URL + corps de la requête + +| Paramètre | Type | Description | +|-----------|---------|----------------------------------| +| libelle | string | Nom de l'opération | +| date_deb | string | Date de début (YYYY-MM-DD) | +| date_fin | string | Date de fin (YYYY-MM-DD) | +| active | boolean | Statut de l'opération | + +- **Réponse**: +```json +{ + "id": 456, + "libelle": "Opération 2025", + "date_deb": "2025-01-01", + "date_fin": "2025-12-31", + "active": true +} +``` + +## Gestion des secteurs + +### Récupérer tous les secteurs + +- **URL**: `/sectors` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Filtres optionnels**: + +| Paramètre | Type | Description | +|--------------|---------|----------------------------------------| +| operation_id | integer | Filtrer par ID d'opération | + +- **Réponse**: +```json +[ + { + "id": 789, + "libelle": "Secteur Nord", + "color": "#FF5733", + "sector": "POLYGON((...))" + }, + ... +] +``` + +### Récupérer un secteur par ID + +- **URL**: `/sectors/{id}` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: ID dans l'URL +- **Réponse**: +```json +{ + "id": 789, + "libelle": "Secteur Nord", + "color": "#FF5733", + "sector": "POLYGON((...))", + "adresses": [ + { + "id": 101, + "numero": "12", + "rue": "Rue des Lilas", + "cp": "75001", + "ville": "Paris", + "gps_lat": "48.8566", + "gps_lng": "2.3522" + }, + ... + ] +} +``` + +### Créer un secteur + +- **URL**: `/sectors` +- **Méthode**: `POST` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: + +| Paramètre | Type | Description | +|--------------|---------|----------------------------------| +| libelle | string | Nom du secteur | +| color | string | Couleur au format hexadécimal | +| sector | string | Coordonnées WKT du polygone | +| operation_id | integer | ID de l'opération associée | + +- **Réponse**: +```json +{ + "id": 789, + "libelle": "Secteur Nord", + "color": "#FF5733", + "sector": "POLYGON((...))" +} +``` + +### Mettre à jour un secteur + +- **URL**: `/sectors/{id}` +- **Méthode**: `PUT` +- **Authentification requise**: Oui (Admin) +- **Paramètres**: ID dans l'URL + corps de la requête + +| Paramètre | Type | Description | +|--------------|---------|----------------------------------| +| libelle | string | Nom du secteur | +| color | string | Couleur au format hexadécimal | +| sector | string | Coordonnées WKT du polygone | + +- **Réponse**: +```json +{ + "id": 789, + "libelle": "Secteur Nord", + "color": "#FF5733", + "sector": "POLYGON((...))" +} +``` + +## Gestion des passages + +### Récupérer tous les passages + +- **URL**: `/passages` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Filtres optionnels**: + +| Paramètre | Type | Description | +|--------------|---------|----------------------------------------| +| operation_id | integer | Filtrer par ID d'opération | +| sector_id | integer | Filtrer par ID de secteur | +| user_id | integer | Filtrer par ID d'utilisateur | +| date_start | string | Date de début (YYYY-MM-DD) | +| date_end | string | Date de fin (YYYY-MM-DD) | + +- **Réponse**: +```json +[ + { + "id": 1001, + "fk_operation": 456, + "fk_sector": 789, + "fk_user": 123, + "date_eve": "2025-01-15T14:30:00Z", + "montant": 25.50, + "fk_type": 1, + "fk_type_reglement": 2, + "numero": "12", + "rue": "Rue des Lilas", + "ville": "Paris" + }, + ... +] +``` + +### Récupérer un passage par ID + +- **URL**: `/passages/{id}` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: ID dans l'URL +- **Réponse**: +```json +{ + "id": 1001, + "fk_operation": 456, + "fk_sector": 789, + "fk_user": 123, + "date_eve": "2025-01-15T14:30:00Z", + "montant": 25.50, + "fk_type": 1, + "fk_type_reglement": 2, + "numero": "12", + "rue": "Rue des Lilas", + "ville": "Paris", + "historique": [ + { + "date_histo": "2025-01-15T14:30:00Z", + "sujet": "Création", + "remarque": "Passage créé" + }, + ... + ], + "recus": [ + { + "chemin": "/recus/2025/...", + "nom_recu": "recu_20250115_1001.pdf", + "date_recu": "2025-01-15T14:35:00Z" + }, + ... + ] +} +``` + +### Créer un passage + +- **URL**: `/passages` +- **Méthode**: `POST` +- **Authentification requise**: Oui +- **Paramètres**: + +| Paramètre | Type | Description | +|-------------------|---------|----------------------------------| +| fk_operation | integer | ID de l'opération | +| fk_sector | integer | ID du secteur | +| date_eve | string | Date du passage (ISO 8601) | +| montant | number | Montant collecté | +| fk_type | integer | Type de passage (1-6) | +| fk_type_reglement | integer | Type de règlement (0-3) | +| numero | string | Numéro de rue | +| rue | string | Nom de la rue | +| ville | string | Ville | +| send_email | boolean | Envoyer un reçu par email | +| email | string | Email pour l'envoi du reçu | +| send_sms | boolean | Envoyer un SMS de confirmation | +| telephone | string | Numéro pour l'envoi du SMS | + +- **Réponse**: +```json +{ + "id": 1001, + "fk_operation": 456, + "fk_sector": 789, + "fk_user": 123, + "date_eve": "2025-01-15T14:30:00Z", + "montant": 25.50, + "fk_type": 1, + "fk_type_reglement": 2, + "numero": "12", + "rue": "Rue des Lilas", + "ville": "Paris", + "recu_url": "/api/geo/recus/2025/recu_20250115_1001.pdf" +} +``` + +### Mettre à jour un passage + +- **URL**: `/passages/{id}` +- **Méthode**: `PUT` +- **Authentification requise**: Oui +- **Paramètres**: ID dans l'URL + corps de la requête + +| Paramètre | Type | Description | +|-------------------|---------|----------------------------------| +| fk_type | integer | Type de passage (1-6) | +| fk_type_reglement | integer | Type de règlement (0-3) | +| montant | number | Montant collecté | +| remarque | string | Remarque sur la modification | + +- **Réponse**: +```json +{ + "id": 1001, + "fk_operation": 456, + "fk_sector": 789, + "fk_user": 123, + "date_eve": "2025-01-15T14:30:00Z", + "montant": 30.00, + "fk_type": 1, + "fk_type_reglement": 2, + "numero": "12", + "rue": "Rue des Lilas", + "ville": "Paris" +} +``` + +## Synchronisation des données + +### Synchronisation générale + +Permet de synchroniser plusieurs types de données en une seule requête. + +- **URL**: `/data/sync` +- **Méthode**: `POST` +- **Authentification requise**: Oui +- **Paramètres**: + +| Paramètre | Type | Description | +|------------|-------|------------------------------------------| +| users | array | Liste des utilisateurs à synchroniser | +| operations | array | Liste des opérations à synchroniser | +| sectors | array | Liste des secteurs à synchroniser | +| passages | array | Liste des passages à synchroniser | + +- **Réponse**: +```json +{ + "status": "success", + "synced": { + "users": 5, + "operations": 2, + "sectors": 3, + "passages": 10 + }, + "errors": [] +} +``` + +## Services géographiques + +### Recherche d'adresses + +Recherche des adresses à partir de la base nationale des adresses. + +- **URL**: `/geo/addresses/search` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: + +| Paramètre | Type | Description | +|-----------|--------|--------------------------------------------| +| q | string | Texte de recherche (rue, code postal...) | +| limit | integer| Nombre maximum de résultats (défaut: 10) | + +- **Réponse**: +```json +[ + { + "id": "ADDRESS-ID", + "numero": "12", + "rue": "Rue des Lilas", + "cp": "75001", + "ville": "Paris", + "latitude": 48.8566, + "longitude": 2.3522 + }, + ... +] +``` + +### Récupérer les rues dans un secteur + +- **URL**: `/geo/sectors/{id}/streets` +- **Méthode**: `GET` +- **Authentification requise**: Oui +- **Paramètres**: ID du secteur dans l'URL +- **Réponse**: +```json +[ + { + "id": "STREET-ID", + "name": "Rue des Lilas", + "city": "Paris", + "osm_lat": 48.8566, + "osm_lng": 2.3522 + }, + ... +] +``` + +## Codes d'erreur + +| Code | Description | +|------|--------------------------------------------| +| 400 | Requête invalide (paramètres manquants) | +| 401 | Non authentifié | +| 403 | Non autorisé (droits insuffisants) | +| 404 | Ressource introuvable | +| 409 | Conflit (ex: email déjà utilisé) | +| 422 | Erreur de validation des données | +| 500 | Erreur serveur | + +## Notes d'utilisation + +1. **Authentification**: Après connexion, l'ID de session doit être fourni dans l'en-tête `Authorization` sous forme de `Bearer {session_id}` pour toutes les requêtes authentifiées. + +2. **Filtrage**: La plupart des endpoints GET supportent des paramètres de filtrage additionnels. + +3. **Pagination**: Les endpoints retournant des listes importantes supportent la pagination via les paramètres `page` et `limit`. + +4. **Synchronisation**: La synchronisation des données permet de travailler hors ligne et de synchroniser lors du retour en ligne. + +5. **Reçus PDF**: Les passages créés peuvent générer automatiquement des reçus PDF qui peuvent être envoyés par email ou SMS. + +6. **Données géographiques**: Les secteurs sont stockés au format WKT (Well-Known Text) pour les polygones. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..3b7c90cc --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,262 @@ +# Architecture de GEOSECTOR + +## Description générale + +GEOSECTOR est une application Flutter conçue pour la gestion de secteurs géographiques et de passages. L'application suit une architecture modulaire qui sépare clairement les préoccupations et permet une maintenance facilitée. Elle utilise un pattern de conception proche du MVVM (Model-View-ViewModel) avec des instances globales pour la gestion d'état et l'accès aux services. + +## Structure du projet + +L'application est organisée selon une architecture en couches avec une séparation claire des responsabilités : + +``` +lib/ +├── app.dart # Point d'entrée de l'application +├── main.dart # Configuration initiale +├── core/ # Fonctionnalités communes et services de base +│ ├── constants/ # Constantes de l'application +│ ├── data/ # Modèles de données +│ │ └── models/ # Définitions des modèles pour Hive +│ ├── providers/ # Providers pour l'injection de dépendances +│ ├── repositories/ # Gestion des données et logique métier +│ ├── routes/ # Configuration du routage +│ ├── services/ # Services d'infrastructure +│ ├── theme/ # Thème de l'application +│ └── widgets/ # Widgets communs réutilisables +├── presentation/ # Interface utilisateur par module +│ ├── admin/ # Écrans d'administration +│ ├── auth/ # Écrans d'authentification +│ ├── public/ # Écrans publics +│ ├── user/ # Écrans utilisateur +│ └── widgets/ # Widgets partagés pour l'UI +└── shared/ # Ressources partagées + ├── app_theme.dart # Configuration du thème + └── widgets/ # Widgets partagés entre modules +``` + +## Couches d'architecture + +### 1. Couche de présentation (Presentation Layer) + +La couche de présentation est responsable de l'interface utilisateur et est divisée en modules fonctionnels : + +- **admin/** : Pages du tableau de bord administrateur +- **auth/** : Pages d'authentification (login, register) +- **public/** : Pages accessibles sans authentification +- **user/** : Pages du tableau de bord utilisateur +- **widgets/** : Composants UI partagés entre les modules + +Chaque module de présentation suit une structure cohérente avec des pages distinctes pour chaque fonctionnalité principale : + +``` +user/ +├── user_dashboard_page.dart # Page principale avec navigation +├── user_dashboard_home_page.dart # Accueil du tableau de bord +├── user_map_page.dart # Carte et secteurs +├── user_history_page.dart # Historique des passages +├── user_statistics_page.dart # Statistiques et graphiques +└── user_communication_page.dart # Communication interne +``` + +De même, le module admin suit une structure similaire avec des pages spécifiques : + +``` +admin/ +├── admin_dashboard_page.dart # Page principale avec navigation +├── admin_dashboard_home_page.dart # Accueil du tableau de bord admin +├── admin_map_page.dart # Gestion des cartes et secteurs +├── admin_history_page.dart # Historique global des passages +├── admin_statistics_page.dart # Statistiques avancées +├── admin_communication_page.dart # Gestion des communications +└── admin_entite.dart # Gestion des amicales et membres +``` + +### 2. Couche de données (Data Layer) + +La couche de données est responsable de la gestion des données et comprend : + +- **models/** : Modèles de données avec adaptateurs Hive pour la persistance locale + + - `user_model.dart` : Données utilisateur et authentification + - `operation_model.dart` : Opérations et campagnes + - `sector_model.dart` : Secteurs géographiques + - `passage_model.dart` : Données des passages + - `membre_model.dart` : Données des membres pour l'interface admin + +- **repositories/** : Gestionnaires de données pour chaque domaine + - `user_repository.dart` : Gestion des utilisateurs et authentification + - `operation_repository.dart` : Gestion des opérations + - `passage_repository.dart` : Gestion des passages + - `sector_repository.dart` : Gestion des secteurs + - `membre_repository.dart` : Gestion des membres pour l'interface admin + +### 3. Couche de services (Services Layer) + +La couche de services fournit des fonctionnalités d'infrastructure et d'intégration : + +- `api_service.dart` : Communication avec l'API backend +- `auth_service.dart` : Gestion de l'authentification +- `connectivity_service.dart` : Surveillance de la connectivité réseau +- `location_service.dart` : Services de géolocalisation +- `passage_data_service.dart` : Traitement des données de passage +- `sync_service.dart` : Synchronisation des données locales/serveur + +## Gestion d'état et injection de dépendances + +L'application utilise des instances globales pour la gestion d'état et l'accès aux services. Ces instances sont définies dans le fichier `app.dart` : + +```dart +// Instances globales des services et repositories +final apiService = ApiService(); +final operationRepository = OperationRepository(apiService); +final passageRepository = PassageRepository(apiService); +final userRepository = UserRepository(apiService); +final sectorRepository = SectorRepository(apiService); +final membreRepository = MembreRepository(apiService); +final syncService = SyncService(userRepository: userRepository); +final connectivityService = ConnectivityService(); +``` + +Le widget `AppProviders` a été maintenu pour la compatibilité, mais il retourne simplement l'enfant sans utiliser Provider : + +```dart +class AppProviders extends StatelessWidget { + final Widget child; + + const AppProviders({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Les instances globales sont maintenant définies dans app.dart + // Cette classe est maintenue pour la compatibilité + return child; + } +} +``` + +Cette approche permet : + +1. De simplifier l'architecture en éliminant la dépendance à Provider +2. D'accéder directement aux services et repositories depuis n'importe quel widget +3. De réduire la complexité du code et d'améliorer les performances +4. De maintenir une séparation claire des responsabilités + +## Routage et navigation + +L'application utilise `go_router` pour la gestion des routes et de la navigation : + +- **AppRouter** : Configuration centralisée des routes de l'application +- **Redirections conditionnelles** : Contrôle d'accès basé sur l'état d'authentification +- **Persistance de parcours** : Sauvegarde du dernier chemin de l'utilisateur + +Le système de routage implémente : + +- Redirection basée sur les rôles (admin vs utilisateur) +- Gestion des sessions persistantes +- Navigation entre les différentes sections de l'application + +## Persistance des données + +L'application utilise Hive pour la persistance locale des données : + +- **Initialisation sélective** : Ouverture des boîtes Hive à la demande +- **Gestion efficace de la mémoire** : Optimisation de l'utilisation des ressources +- **Mode hors ligne** : Fonctionnalité complète même sans connexion Internet + +La gestion des boîtes Hive est optimisée selon ces principes : + +- Ouverture des boîtes essentielles au démarrage (`users`, `settings`) +- Ouverture des autres boîtes (`operations`, `sectors`, `passages`, `membres`) après connexion +- Nettoyage et recréation des boîtes sans les fermer lors de la déconnexion + +## Intégrations externes + +L'application intègre plusieurs services externes : + +- **Mapbox** : Affichage des cartes et gestion des secteurs géographiques +- **Stripe** : Traitement des paiements en ligne +- **API FulRest PHP** : Communication avec le backend +- **Base de données MariaDB** : Stockage centralisé des données + +## Sécurité + +L'application implémente plusieurs mesures de sécurité : + +- **Chiffrement Argon2** pour les mots de passe +- **Chiffrement AES-256** pour les données sensibles +- **Session PHP** pour l'authentification API +- **Mécanismes de contrôle d'accès** basés sur les rôles + +## Flux d'authentification + +Le flux d'authentification suit ce processus : + +1. Vérification initiale de session persistante (Hive) +2. Redirection vers Login/Register si nécessaire +3. Authentification via API Service +4. Stockage sécurisé des informations de session +5. Redirection vers l'interface appropriée (admin vs utilisateur) + +## Widgets communs + +L'application utilise plusieurs widgets communs pour maintenir une cohérence d'interface : + +1. **Widget Carte MapBox** : Affichage/édition de secteurs et marqueurs +2. **Widget Formulaire de passage** : Saisie des données de passage +3. **Widget Historique** : Liste des passages avec filtres et tri + - Filtrage par type de passage (avec possibilité d'exclure certains types) + - Filtrage par utilisateur (pour les administrateurs) + - Filtrage par secteur (pour les administrateurs) + - Filtrage par période (derniers 15 jours, dernière semaine, dernier mois, personnalisé) + - Recherche textuelle (adresse, nom, notes) + - Interface adaptative (compacte pour desktop, étendue pour mobile) +4. **Widget Statistiques** : Visualisation graphique des données + +### Widget PassagesListWidget + +Le widget `PassagesListWidget` est un composant réutilisable qui permet d'afficher une liste de passages avec des fonctionnalités avancées de filtrage : + +```dart +PassagesListWidget( + passages: formattedPassages, + showFilters: true, + showSearch: true, + showActions: true, + initialSearchQuery: searchQuery, + initialTypeFilter: selectedType, + initialPaymentFilter: selectedPaymentMethod, + // Filtres avancés + excludePassageTypes: [2], // Exclure les passages "À finaliser" + filterByUserId: selectedUserId, // Filtrer par utilisateur + filterBySectorId: selectedSectorId, // Filtrer par secteur + periodFilter: 'lastMonth', // Période par défaut + dateRange: selectedDateRange, // Plage de dates personnalisée + // Callbacks + onPassageSelected: (passage) => _showDetailsDialog(context, passage), + onReceiptView: (passage) => _showReceiptDialog(context, passage), + onDetailsView: (passage) => _showDetailsDialog(context, passage), + onPassageEdit: (passage) => _editPassage(passage), +) +``` + +Ce widget est utilisé dans plusieurs pages de l'application : + +- `user_dashboard_home_page.dart` : Affiche les derniers passages de l'utilisateur courant +- `user_history_page.dart` : Affiche l'historique complet des passages de l'utilisateur courant +- `admin_history_page.dart` : Affiche l'historique global de tous les passages avec des filtres avancés + +## Relations avec d'autres composants + +- **Backend API** : Communication via ApiService pour toutes les opérations CRUD +- **Stockage local** : Utilisation de Hive pour la persistance des données +- **Services externes** : Intégration avec Mapbox, Stripe et autres services tiers + +## Bonnes pratiques implémentées + +1. **Séparation des préoccupations** : Découpage clair entre UI, logique métier et données +2. **Accès simplifié aux services** : Utilisation d'instances globales pour un accès direct aux services +3. **Mode hors ligne** : Fonctionnalité complète même sans connexion Internet +4. **Thème cohérent** : Application d'un thème unifié via AppTheme +5. **Routage centralisé** : Gestion des redirections et autorisations d'accès diff --git a/docs/chat.md b/docs/chat.md new file mode 100644 index 00000000..5771c48b --- /dev/null +++ b/docs/chat.md @@ -0,0 +1,622 @@ +# 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 trois 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 pour la gestion des messages et la synchronisation + +### 2. 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 + +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 +``` + +#### Adaptations pour Hive + +Ces modèles seront adaptés pour Hive avec leurs adaptateurs respectifs : + +```dart +@HiveType(typeId: 20) +class ConversationModel extends HiveObject { + @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 participants; + + @HiveField(6) + final bool isSynced; + + @HiveField(7) + final String replyPermission; + + @HiveField(8) + final bool isPinned; + + @HiveField(9) + final DateTime? expiryDate; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 21) +class MessageModel extends HiveObject { + @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; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 22) +class ParticipantModel extends HiveObject { + @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; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 23) +class AudienceTargetModel extends HiveObject { + @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; // 'all' ou ID de rôle + + @HiveField(6) + final String? entityFilter; // 'all' ou ID d'entité + + // ... autres propriétés et méthodes +} +``` + +### 3. 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) + POST - Envoyer un message + +/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 +``` + +#### Synchronisation + +Le système supportera : + +- Synchronisation en temps réel via WebSockets (optionnel) +- Synchronisation par polling avec gestion des messages non lus +- Enregistrement local des messages avec Hive pour le fonctionnement hors ligne + +### 4. 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? 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) onSend, + List>? availableTargets, + String? initialTitle, + String? initialMessage, + bool allowAttachments = true, + bool allowPinning = true, + List replyPermissionOptions = const ['all', 'admins_only', 'sender_only', 'none'], + String defaultReplyPermission = 'none', + DateTime? expiryDate, + bool isGeosector = true, // Active la sélection des destinataires + }) + ``` + +7. **AnnouncementTargetSelector** : Sélecteur de destinataires pour annonces (Geosector uniquement) + + ```dart + AnnouncementTargetSelector({ + required Function(AudienceTargetModel) onTargetSelected, + required List availableEntities, + bool showRoleFilter = true, + bool showEntityFilter = true, + String defaultRole = 'all', + String defaultEntity = 'all', + }) + ``` + +8. **AnnouncementBanner** : Bannière pour afficher une annonce importante + ```dart + AnnouncementBanner({ + required MessageModel announcement, + required Function() onView, + Function()? onDismiss, + bool isDismissible = true, + Duration? autoDismissAfter, + Color? backgroundColor, + Widget? icon, + }) + ``` + +#### Fonctionnalités des widgets + +- Design adaptatif (mobile/web) +- Support des thèmes clairs/sombres +- Gestion des messages non lus +- Indicateurs de frappe +- Accusés de réception et de lecture +- Support des pièces jointes (fichiers, images) +- Recherche dans les conversations +- Conversion d'utilisateurs anonymes en clients (Resalice) + +### 5. Gestion des données locales (Hive) + +#### Organisation des boîtes Hive + +```dart +// 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'; +``` + +#### Stratégie de synchronisation + +1. **Ouverture sélective** : Ouverture des boîtes à la demande +2. **Gestion de conflit** : Stratégie pour résoudre les conflits entre données locales et serveur +3. **Nettoyage intelligent** : Suppression des messages anciens selon des règles configurables +4. **Marqueurs de synchronisation** : Tracking des messages synchronisés/non-synchronisés + +## Implémentation technique + +### 1. Structure des repositories + +```dart +class ChatRepository { + // Gestion des conversations + Future> getConversations({bool forceRefresh = false}); + Future getConversation(String id); + Future createConversation(Map data); + Future deleteConversation(String id); + Future pinConversation(String id, bool isPinned); + Future updateReplyPermission(String id, String replyPermission); + + // Gestion des messages + Future> getMessages(String conversationId, {int page = 1, int limit = 50}); + Future sendMessage(String conversationId, Map messageData); + Future markMessageAsRead(String messageId); + + // Gestion des participants + Future addParticipant(String conversationId, Map participantData); + Future removeParticipant(String conversationId, String participantId); + + // Gestion des utilisateurs anonymes (Resalice) + Future createAnonymousUser({String? name, String? email}); + Future convertAnonymousToUser(String anonymousId, String userId); + + // Gestion des annonces + Future> getAnnouncements({bool forceRefresh = false}); + Future createAnnouncement(Map data); + Future> getAnnouncementStats(String conversationId); + + // Gestion des cibles d'audience + Future>> getAvailableAudienceTargets(); + Future addAudienceTarget(String conversationId, Map targetData); + Future removeAudienceTarget(String conversationId, String targetId); +} +``` + +### 2. Intégration avec l'API + +```dart +class ChatApiService { + final String baseUrl; + final String? authToken; + + // Constructeur avec paramètres pour l'URL et l'authentification + ChatApiService({ + required this.baseUrl, + this.authToken, + }); + + // Méthodes HTTP pour communiquer avec l'API + Future> fetchConversations(); + Future> fetchMessages(String conversationId, {int page = 1, int limit = 50}); + Future> createConversation(Map data); + Future> sendMessage(String conversationId, Map messageData); + // ...autres méthodes +} +``` + +### 3. Gestion hors ligne + +```dart +class OfflineQueueService { + // Ajouter des opérations en attente + Future addPendingOperation(String operationType, Map data); + + // Traiter les opérations en attente + Future processPendingOperations(); + + // Écouter les changements de connectivité + void listenToConnectivityChanges(); +} +``` + +### 4. 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 +- **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 +- **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 +- **Annonces non pertinentes** pour ce cas d'usage (pas de widget `AnnouncementTargetSelector`) + +### Adaptations par projet + +La solution de chat doit être adaptable selon le contexte : + +1. **Configuration globale** : Un système de configuration permet de définir quelles fonctionnalités sont activées + + ```dart + // Configuration pour Geosector + const chatConfig = ChatConfig( + enableAnnouncements: true, + enableTargetSelection: true, + showAnnouncementStats: true, + defaultReplyPermission: 'none', + ); + + // Configuration pour Resalice + const chatConfig = ChatConfig( + enableAnnouncements: false, + enableTargetSelection: false, + showAnnouncementStats: false, + defaultReplyPermission: 'all', + ); + ``` + +2. **Interfaces conditionnelles** : Les widgets adaptent leur affichage selon la configuration + + ```dart + // Dans AnnouncementComposer + if (config.enableTargetSelection) { + children.add(AnnouncementTargetSelector(...)); + } + ``` + +3. **Types de conversation limités** : La création de certains types de conversation est restreinte + ```dart + // Dans Resalice, seuls les types one_to_one et anonymous sont autorisés + if (!config.enableAnnouncements && type == 'announcement') { + throw UnsupportedConversationType(); + } + ``` + +## 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. + +### 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. + +## Étapes d'implémentation suggérées + +1. **Phase 1 : Base du système** (3-4 semaines) + + - Modèles de données et adaptateurs Hive + - Configuration de l'API backend + - Widgets de base pour affichage/envoi de messages + - Structure de base pour les annonces et broadcasts + +2. **Phase 2 : Fonctionnalités avancées** (2-3 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 + +3. **Phase 3 : Cas spécifiques** (2-3 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é à 6-9 semaines pour un développeur expérimenté en Flutter et PHP. L'adaptation ultérieure à Resalice devrait prendre environ 2-3 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. Cette fonctionnalité est particulièrement adaptée aux cas d'usage mentionnés pour Geosector, où l'admin général souhaite communiquer avec tous les admins d'entités, ou un admin d'entité avec tous les utilisateurs de son entité. + +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. diff --git a/docs/geosector-db-diagram.md b/docs/geosector-db-diagram.md new file mode 100644 index 00000000..97d1f680 --- /dev/null +++ b/docs/geosector-db-diagram.md @@ -0,0 +1,200 @@ +# Diagramme Relationnel de la Base de Données Geosector + +```mermaid +erDiagram + %% Tables de référence (x_*) + x_devises ||--o{ x_pays : "fk_devise" + x_pays ||--o{ x_regions : "fk_pays" + x_regions ||--o{ x_departements : "fk_region" + x_regions ||--o{ entites : "fk_region" + x_entites_types ||--o{ entites : "fk_type" + x_departements ||--o{ x_villes : "fk_departement" + + %% Utilisateurs et entités + entites ||--o{ users : "fk_entite" + entites ||--o{ operations : "fk_entite" + x_users_roles ||--o{ users : "fk_role" + x_users_titres ||--o{ users : "fk_titre" + + %% Opérations et secteurs + operations ||--o{ ope_sectors : "fk_operation" + operations ||--o{ ope_users : "fk_operation" + operations ||--o{ ope_users_sectors : "fk_operation" + operations ||--o{ ope_pass : "fk_operation" + + users ||--o{ ope_users : "fk_user" + users ||--o{ ope_users_sectors : "fk_user" + users ||--o{ ope_pass : "fk_user" + users ||--o{ ope_pass_histo : "fk_user" + + ope_sectors ||--o{ ope_users_sectors : "fk_sector" + ope_sectors ||--o{ sectors_adresses : "fk_sector" + ope_sectors ||--o{ ope_pass : "fk_sector" + + ope_pass ||--o{ ope_pass_histo : "fk_pass" + x_types_reglements ||--o{ ope_pass : "fk_type_reglement" + + %% Système de chat + chat_rooms ||--o{ chat_participants : "id_room" + chat_rooms ||--o{ chat_messages : "fk_room" + chat_rooms ||--o{ chat_listes_diffusion : "fk_room" + chat_rooms ||--o{ chat_notifications : "fk_room" + + users ||--o{ chat_rooms : "fk_user" + users ||--o{ chat_participants : "id_user" + users ||--o{ chat_messages : "fk_user" + users ||--o{ chat_listes_diffusion : "fk_user" + users ||--o{ chat_read_messages : "fk_user" + users ||--o{ chat_notifications : "fk_user" + + chat_messages ||--o{ chat_read_messages : "fk_message" + chat_messages ||--o{ chat_notifications : "fk_message" + + %% Définition des entités avec leurs attributs principaux + x_devises { + int_unsigned id PK + string code + string symbole + string libelle + tinyint_unsigned chk_active + } + + x_pays { + int_unsigned id PK + int_unsigned fk_continent FK + int_unsigned fk_devise FK + string libelle + tinyint_unsigned chk_active + } + + x_regions { + int_unsigned id PK + int_unsigned fk_pays FK + string libelle + tinyint_unsigned chk_active + } + + x_departements { + int_unsigned id PK + string code + int_unsigned fk_region FK + string libelle + tinyint_unsigned chk_active + } + + x_villes { + int_unsigned id PK + int_unsigned fk_departement FK + string libelle + string cp + tinyint_unsigned chk_active + } + + entites { + int_unsigned id PK + string libelle + int_unsigned fk_region FK + int_unsigned fk_type FK + tinyint_unsigned chk_demo + tinyint_unsigned chk_active + } + + users { + int_unsigned id PK + int_unsigned fk_entite FK + int_unsigned fk_role FK + int_unsigned fk_titre FK + string encrypted_name + string encrypt_user_name + string encrypt_password + tinyint_unsigned chk_active + } + + operations { + int_unsigned id PK + int_unsigned fk_entite FK + string libelle + date date_deb + date date_fin + tinyint_unsigned chk_active + } + + ope_sectors { + int_unsigned id PK + int_unsigned fk_operation FK + string libelle + tinyint_unsigned chk_active + } + + ope_users { + int_unsigned id PK + int_unsigned fk_operation FK + int_unsigned fk_user FK + tinyint_unsigned chk_active + } + + ope_users_sectors { + int_unsigned id PK + int_unsigned fk_operation FK + int_unsigned fk_user FK + int_unsigned fk_sector FK + tinyint_unsigned chk_active + } + + sectors_adresses { + int_unsigned id PK + string fk_adresse + int_unsigned osm_id + int_unsigned fk_sector FK + string rue + string cp + string ville + } + + ope_pass { + int_unsigned id PK + int_unsigned fk_operation FK + int_unsigned fk_sector FK + int_unsigned fk_user FK + int_unsigned fk_type_reglement FK + timestamp passed_at + tinyint_unsigned chk_active + } + + ope_pass_histo { + int_unsigned id PK + int_unsigned fk_pass FK + int_unsigned fk_user FK + } + + chat_rooms { + int_unsigned id PK + string name + enum type + int_unsigned fk_user FK + int_unsigned fk_entite FK + } + + chat_messages { + int_unsigned id PK + int_unsigned fk_room FK + int_unsigned fk_user FK + text content + enum statut + } + + chat_read_messages { + bigint_unsigned id PK + int_unsigned fk_message FK + int_unsigned fk_user FK + timestamp date_read + } + + chat_notifications { + bigint_unsigned id PK + int_unsigned fk_user FK + int_unsigned fk_message FK + int_unsigned fk_room FK + string type + } +``` diff --git a/docs/geosector-db.sql b/docs/geosector-db.sql new file mode 100644 index 00000000..4aa2c3d2 --- /dev/null +++ b/docs/geosector-db.sql @@ -0,0 +1,623 @@ +-- Création de la base de données geo_app si elle n'existe pas +DROP DATABASE IF EXISTS `geo_app`; +CREATE DATABASE IF NOT EXISTS `geo_app` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Création de l'utilisateur et attribution des droits +CREATE USER IF NOT EXISTS 'geo_app_user'@'localhost' IDENTIFIED BY 'QO:96df*?k{4W6m'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `geo_app`.* TO 'geo_app_user'@'localhost'; +FLUSH PRIVILEGES; + +USE geo_app; + +-- +-- Table structure for table `email_counter` +-- + +DROP TABLE IF EXISTS `email_counter`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `email_counter` ( + `id` int unsigned NOT NULL DEFAULT '1', + `hour_start` timestamp NULL DEFAULT NULL, + `count` int unsigned DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `x_devises`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_devises` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `code` varchar(3) DEFAULT NULL, + `symbole` varchar(6) DEFAULT NULL, + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `x_entites_types` +-- + +DROP TABLE IF EXISTS `x_entites_types`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_entites_types` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `x_types_passages` +-- + +DROP TABLE IF EXISTS `x_types_passages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_types_passages` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `color_button` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `color_mark` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `color_table` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `chk_active` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `x_types_reglements` +-- + +DROP TABLE IF EXISTS `x_types_reglements`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_types_reglements` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `x_users_roles` +-- + +DROP TABLE IF EXISTS `x_users_roles`; + +CREATE TABLE `x_users_roles` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Les différents rôles des utilisateurs'; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `x_users_titres`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_users_titres` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Les différents titres des utilisateurs'; + +DROP TABLE IF EXISTS `x_pays`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_pays` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `code` varchar(3) DEFAULT NULL, + `fk_continent` int unsigned DEFAULT NULL, + `fk_devise` int unsigned DEFAULT '1', + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`), + CONSTRAINT `x_pays_ibfk_1` FOREIGN KEY (`fk_devise`) REFERENCES `x_devises` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Table des pays avec leurs codes'; +/*!40101 SET character_set_client = @saved_cs_client */; + + +DROP TABLE IF EXISTS `x_regions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_regions` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_pays` int unsigned DEFAULT '1', + `libelle` varchar(45) DEFAULT NULL, + `libelle_long` varchar(45) DEFAULT NULL, + `table_osm` varchar(45) DEFAULT NULL, + `departements` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`), + CONSTRAINT `x_regions_ibfk_1` FOREIGN KEY (`fk_pays`) REFERENCES `x_pays` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `x_departements`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_departements` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `code` varchar(3) DEFAULT NULL, + `fk_region` int unsigned DEFAULT '1', + `libelle` varchar(45) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`), + CONSTRAINT `x_departements_ibfk_1` FOREIGN KEY (`fk_region`) REFERENCES `x_regions` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `entites`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `entites` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `encrypted_name` varchar(255) DEFAULT NULL, + `adresse1` varchar(45) DEFAULT '', + `adresse2` varchar(45) DEFAULT '', + `cp` varchar(5) DEFAULT '', + `ville` varchar(45) DEFAULT '', + `fk_region` int unsigned DEFAULT NULL, + `fk_type` int unsigned DEFAULT '1', + `encrypted_phone` varchar(128) DEFAULT '', + `encrypted_mobile` varchar(128) DEFAULT '', + `encrypted_email` varchar(255) DEFAULT '', + `gps_lat` varchar(20) NOT NULL DEFAULT '', + `gps_lng` varchar(20) NOT NULL DEFAULT '', + `encrypted_stripe_id` varchar(255) DEFAULT '', + `iban` varchar(30) DEFAULT '', + `bic` varchar(15) DEFAULT '', + `chk_demo` tinyint(1) unsigned DEFAULT '1', + `chk_mdp_manuel` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT 'Gestion des mots de passe manuelle O/N', + `chk_copie_mail_recu` tinyint(1) unsigned NOT NULL DEFAULT '0', + `chk_accept_sms` tinyint(1) unsigned NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + CONSTRAINT `entites_ibfk_1` FOREIGN KEY (`fk_region`) REFERENCES `x_regions` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `entites_ibfk_2` FOREIGN KEY (`fk_type`) REFERENCES `x_entites_types` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `x_villes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `x_villes` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_departement` int unsigned DEFAULT '1', + `libelle` varchar(65) DEFAULT NULL, + `cp` varchar(5) DEFAULT NULL, + `code_insee` varchar(5) DEFAULT NULL, + `departement` varchar(65) DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`), + CONSTRAINT `x_villes_ibfk_1` FOREIGN KEY (`fk_departement`) REFERENCES `x_departements` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=38950 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_entite` int unsigned DEFAULT '1', + `fk_role` int unsigned DEFAULT '1', + `fk_titre` int unsigned DEFAULT '1', + `num_adherent` int unsigned NOT NULL DEFAULT '0', + `encrypted_name` varchar(255) DEFAULT NULL, + `first_name` varchar(45) DEFAULT NULL, + `sect_name` varchar(60) DEFAULT '', + `encrypt_user_name` varchar(128) DEFAULT '', + `user_pswd` varchar(60) DEFAULT NULL, + `encrypt_phone` varchar(128) DEFAULT NULL, + `encrypt_mobile` varchar(128) DEFAULT NULL, + `encrypt_email` varchar(255) DEFAULT '', + `infos` varchar(200) NOT NULL DEFAULT '', + `chk_alert_email` tinyint(1) unsigned DEFAULT '1', + `chk_suivi` tinyint(1) unsigned DEFAULT '0', + `date_naissance` date DEFAULT NULL, + `date_embauche` date DEFAULT NULL, + `anciennete` varchar(20) DEFAULT '-', + `matricule` varchar(10) NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + KEY `fk_entite` (`fk_entite`), + KEY `username` (`encrypt_user_name`), + CONSTRAINT `users_ibfk_1` FOREIGN KEY (`fk_entite`) REFERENCES `entites` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `users_ibfk_2` FOREIGN KEY (`fk_role`) REFERENCES `x_users_roles` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `users_ibfk_3` FOREIGN KEY (`fk_titre`) REFERENCES `x_users_titres` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `operations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `operations` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_entite` int unsigned NOT NULL DEFAULT '1', + `libelle` varchar(75) NOT NULL DEFAULT '', + `date_deb` date NOT NULL DEFAULT '0000-00-00', + `date_fin` date NOT NULL DEFAULT '0000-00-00', + `chk_distinct_sectors` tinyint(1) unsigned NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned NOT NULL DEFAULT '0', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned NOT NULL DEFAULT '0', + `chk_active` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + KEY `fk_entite` (`fk_entite`), + KEY `date_deb` (`date_deb`), + CONSTRAINT `operations_ibfk_1` FOREIGN KEY (`fk_entite`) REFERENCES `entites` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + + +DROP TABLE IF EXISTS `ope_sectors`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_sectors` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_operation` int unsigned NOT NULL DEFAULT '0', + `libelle` varchar(75) NOT NULL DEFAULT '', + `sector` text NOT NULL DEFAULT '', + `color` varchar(7) NOT NULL DEFAULT '#4B77BE', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned NOT NULL DEFAULT '0', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned NOT NULL DEFAULT '0', + `chk_active` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id` (`id`), + KEY `fk_operation` (`fk_operation`), + CONSTRAINT `ope_sectors_ibfk_1` FOREIGN KEY (`fk_operation`) REFERENCES `operations` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +DROP TABLE IF EXISTS `ope_users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_users` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_operation` int unsigned NOT NULL DEFAULT '0', + `fk_user` int unsigned NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned DEFAULT NULL, + `chk_active` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`), + CONSTRAINT `ope_users_ibfk_1` FOREIGN KEY (`fk_operation`) REFERENCES `operations` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_users_ibfk_2` FOREIGN KEY (`fk_user`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `email_queue`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `email_queue` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_pass` int unsigned NOT NULL DEFAULT '0', + `to_email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `subject` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `body` text COLLATE utf8mb4_unicode_ci, + `headers` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `status` enum('pending','sent','failed') COLLATE utf8mb4_unicode_ci DEFAULT 'pending', + `attempts` int unsigned DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `ope_users_sectors`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_users_sectors` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_operation` int unsigned NOT NULL DEFAULT '0', + `fk_user` int unsigned NOT NULL DEFAULT '0', + `fk_sector` int unsigned NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned NOT NULL DEFAULT '0', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned DEFAULT NULL, + `chk_active` tinyint(1) unsigned DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `id` (`id`), + KEY `fk_operation` (`fk_operation`), + KEY `fk_user` (`fk_user`), + KEY `fk_sector` (`fk_sector`), + CONSTRAINT `ope_users_sectors_ibfk_1` FOREIGN KEY (`fk_operation`) REFERENCES `operations` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_users_sectors_ibfk_2` FOREIGN KEY (`fk_user`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_users_sectors_ibfk_3` FOREIGN KEY (`fk_sector`) REFERENCES `ope_sectors` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `ope_users_suivis`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_users_suivis` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_operation` int unsigned NOT NULL DEFAULT '0', + `fk_user` int unsigned NOT NULL DEFAULT '0', + `date_suivi` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date du suivi', + `gps_lat` varchar(20) NOT NULL DEFAULT '', + `gps_lng` varchar(20) NOT NULL DEFAULT '', + `vitesse` varchar(20) NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned NOT NULL DEFAULT '0', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `sectors_adresses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sectors_adresses` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_adresse` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'adresses.cp??.id', + `osm_id` int unsigned NOT NULL DEFAULT '0', + `fk_sector` int unsigned NOT NULL DEFAULT '0', + `osm_name` varchar(50) NOT NULL DEFAULT '', + `numero` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `rue_bis` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `rue` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `cp` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ville` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `gps_lat` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `gps_lng` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `osm_date_creat` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + PRIMARY KEY (`id`), + KEY `sectors_adresses_fk_sector_index` (`fk_sector`), + KEY `sectors_adresses_numero_index` (`numero`), + KEY `sectors_adresses_rue_index` (`rue`), + KEY `sectors_adresses_ville_index` (`ville`), + CONSTRAINT `sectors_adresses_ibfk_1` FOREIGN KEY (`fk_sector`) REFERENCES `ope_sectors` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `ope_pass`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_pass` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_operation` int unsigned NOT NULL DEFAULT '0', + `fk_sector` int unsigned DEFAULT '0', + `fk_user` int unsigned NOT NULL DEFAULT '0', + `fk_adresse` varchar(25) DEFAULT '' COMMENT 'adresses.cp??.id', + `passed_at` timestamp NULL DEFAULT NULL COMMENT 'Date du passage', + `fk_type` int unsigned DEFAULT '0', + `numero` varchar(10) NOT NULL DEFAULT '', + `rue` varchar(75) NOT NULL DEFAULT '', + `rue_bis` varchar(1) NOT NULL DEFAULT '', + `ville` varchar(75) NOT NULL DEFAULT '', + `fk_habitat` int unsigned DEFAULT '1', + `appt` varchar(5) DEFAULT '', + `niveau` varchar(5) DEFAULT '', + `residence` varchar(75) DEFAULT '', + `gps_lat` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `gps_lng` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `libelle` varchar(45) NOT NULL DEFAULT '', + `montant` decimal(7,2) NOT NULL DEFAULT '0.00', + `fk_type_reglement` int unsigned DEFAULT '1', + `remarque` text DEFAULT '', + `email` varchar(75) DEFAULT '', + `nom_recu` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `date_recu` timestamp NULL DEFAULT NULL COMMENT 'Date de réception', + `date_creat_recu` timestamp NULL DEFAULT NULL COMMENT 'Date de création du reçu', + `date_sent_recu` timestamp NULL DEFAULT NULL COMMENT 'Date envoi du reçu', + `email_erreur` varchar(30) DEFAULT '', + `chk_email_sent` tinyint(1) unsigned NOT NULL DEFAULT '0', + `phone` varchar(15) NOT NULL DEFAULT '', + `docremis` tinyint(1) unsigned DEFAULT '0', + `date_repasser` timestamp NULL DEFAULT NULL COMMENT 'Date prévue pour repasser', + `nb_passages` int DEFAULT '1' COMMENT 'Nb passages pour les a repasser', + `chk_gps_maj` tinyint(1) unsigned DEFAULT '0', + `chk_map_create` tinyint(1) unsigned DEFAULT '0', + `chk_mobile` tinyint(1) unsigned DEFAULT '0', + `chk_synchro` tinyint(1) unsigned DEFAULT '1' COMMENT 'chk synchro entre web et appli', + `chk_api_adresse` tinyint(1) unsigned DEFAULT '0', + `chk_maj_adresse` tinyint(1) unsigned DEFAULT '0', + `anomalie` tinyint(1) unsigned DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + `fk_user_creat` int unsigned DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Date de modification', + `fk_user_modif` int unsigned DEFAULT NULL, + `chk_active` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + KEY `fk_operation` (`fk_operation`), + KEY `fk_sector` (`fk_sector`), + KEY `fk_user` (`fk_user`), + KEY `fk_type` (`fk_type`), + KEY `fk_type_reglement` (`fk_type_reglement`), + KEY `email` (`email`), + CONSTRAINT `ope_pass_ibfk_1` FOREIGN KEY (`fk_operation`) REFERENCES `operations` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_pass_ibfk_2` FOREIGN KEY (`fk_sector`) REFERENCES `ope_sectors` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_pass_ibfk_3` FOREIGN KEY (`fk_user`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ope_pass_ibfk_4` FOREIGN KEY (`fk_type_reglement`) REFERENCES `x_types_reglements` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `ope_pass_histo`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ope_pass_histo` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_pass` int unsigned NOT NULL DEFAULT '0', + `fk_user` int unsigned NOT NULL DEFAULT '0', + `date_histo` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date historique', + `sujet` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remarque` varchar(250) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `ope_pass_histo_fk_pass_IDX` (`fk_pass`) USING BTREE, + KEY `ope_pass_histo_date_histo_IDX` (`date_histo`) USING BTREE, + CONSTRAINT `ope_pass_histo_ibfk_1` FOREIGN KEY (`fk_pass`) REFERENCES `ope_pass` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `ope_pass_histo_ibfk_2` FOREIGN KEY (`fk_user`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +DROP TABLE IF EXISTS `medias`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `medias` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `support` varchar(45) NOT NULL DEFAULT '', + `support_id` int unsigned NOT NULL DEFAULT '0', + `fichier` varchar(250) NOT NULL DEFAULT '', + `description` varchar(100) NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `fk_user_creat` int unsigned NOT NULL DEFAULT '0', + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `fk_user_modif` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `id_UNIQUE` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- Création des tables pour le système de chat +DROP TABLE IF EXISTS `chat_rooms`; +-- Table des salles de discussion +CREATE TABLE chat_rooms ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + type ENUM('privee', 'groupe', 'liste_diffusion') NOT NULL, + date_creation timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + fk_user INT UNSIGNED NOT NULL, + fk_entite INT UNSIGNED, + statut ENUM('active', 'archive') NOT NULL DEFAULT 'active', + description TEXT, + INDEX idx_user (fk_user), + INDEX idx_entite (fk_entite) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +DROP TABLE IF EXISTS `chat_participants`; +-- Table des participants aux salles de discussion +CREATE TABLE chat_participants ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + id_room INT UNSIGNED NOT NULL, + id_user INT UNSIGNED NOT NULL, + role ENUM('administrateur', 'participant', 'en_lecture_seule') NOT NULL DEFAULT 'participant', + date_ajout timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date ajout', + notification_activee BOOLEAN NOT NULL DEFAULT TRUE, + INDEX idx_room (id_room), + INDEX idx_user (id_user), + CONSTRAINT uc_room_user UNIQUE (id_room, id_user), + FOREIGN KEY (id_room) REFERENCES chat_rooms(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `chat_messages`; +-- Table des messages +CREATE TABLE chat_messages ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + fk_room INT UNSIGNED NOT NULL, + fk_user INT UNSIGNED NOT NULL, + content TEXT, + date_sent timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date envoi', + type ENUM('texte', 'media', 'systeme') NOT NULL DEFAULT 'texte', + statut ENUM('envoye', 'livre', 'lu') NOT NULL DEFAULT 'envoye', + INDEX idx_room (fk_room), + INDEX idx_user (fk_user), + INDEX idx_date (date_sent), + FOREIGN KEY (fk_room) REFERENCES chat_rooms(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `chat_listes_diffusion`; +-- Table des listes de diffusion +CREATE TABLE chat_listes_diffusion ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + fk_room INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + fk_user INT UNSIGNED NOT NULL, + date_creation timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + INDEX idx_room (fk_room), + INDEX idx_user (fk_user), + FOREIGN KEY (fk_room) REFERENCES chat_rooms(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `chat_read_messages`; +-- Table pour suivre la lecture des messages +CREATE TABLE chat_read_messages ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + fk_message INT UNSIGNED NOT NULL, + fk_user INT UNSIGNED NOT NULL, + date_read timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de lecture', + INDEX idx_message (fk_message), + INDEX idx_user (fk_user), + CONSTRAINT uc_message_user UNIQUE (fk_message, fk_user), + FOREIGN KEY (fk_message) REFERENCES chat_messages(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `chat_notifications`; +-- Table des notifications +CREATE TABLE chat_notifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + fk_user INT UNSIGNED NOT NULL, + fk_message INT UNSIGNED, + fk_room INT UNSIGNED, + type VARCHAR(50) NOT NULL, + contenu TEXT, + date_creation timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création', + date_lecture timestamp NULL DEFAULT NULL COMMENT 'Date de lecture', + statut ENUM('non_lue', 'lue') NOT NULL DEFAULT 'non_lue', + INDEX idx_user (fk_user), + INDEX idx_message (fk_message), + INDEX idx_room (fk_room), + FOREIGN KEY (fk_message) REFERENCES chat_messages(id) ON DELETE SET NULL, + FOREIGN KEY (fk_room) REFERENCES chat_rooms(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `z_params`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `params` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libelle` varchar(35) NOT NULL DEFAULT '', + `valeur` varchar(255) NOT NULL DEFAULT '', + `aide` varchar(150) NOT NULL DEFAULT '', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + + +DROP TABLE IF EXISTS `z_sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `z_sessions` ( + `sid` text NOT NULL, + `fk_user` int NOT NULL, + `role` varchar(10) DEFAULT NULL, + `date_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `ip` varchar(50) NOT NULL, + `browser` varchar(150) NOT NULL, + `data` mediumtext +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/docs/gestion_hive_boxes.md b/docs/gestion_hive_boxes.md new file mode 100644 index 00000000..e7232eb7 --- /dev/null +++ b/docs/gestion_hive_boxes.md @@ -0,0 +1,280 @@ +# Guide de Gestion des Boîtes Hive dans GeoSector + +Ce document explique comment les boîtes Hive sont gérées dans l'application GeoSector, particulièrement pendant les processus de connexion et déconnexion. + +## Table des matières + +1. [Introduction](#introduction) +2. [Boîtes Hive utilisées](#boîtes-hive-utilisées) +3. [Initialisation des boîtes Hive](#initialisation-des-boîtes-hive) +4. [Services et repositories impliqués](#services-et-repositories-impliqués) +5. [Processus de connexion (login)](#processus-de-connexion-login) +6. [Processus de déconnexion (logout)](#processus-de-déconnexion-logout) +7. [Problèmes connus et solutions](#problèmes-connus-et-solutions) +8. [Bonnes pratiques](#bonnes-pratiques) + +## Introduction + +Hive est une base de données NoSQL légère et rapide utilisée dans GeoSector pour stocker les données localement. Les données sont organisées en "boîtes" (boxes) qui peuvent être typées pour stocker des modèles spécifiques. + +Dans cette application, Hive est utilisé pour : +- Stocker les données utilisateur et maintenir les sessions +- Conserver les données des opérations, secteurs et passages +- Permettre l'utilisation de l'application en mode hors ligne + +## Boîtes Hive utilisées + +Les boîtes Hive sont définies dans `lib/core/constants/app_keys.dart` : + +```dart +// Noms des boîtes Hive +static const String usersBoxName = 'users'; +static const String operationsBoxName = 'operations'; +static const String sectorsBoxName = 'sectors'; +static const String passagesBoxName = 'passages'; +static const String settingsBoxName = 'settings'; +``` + +Chaque boîte stocke un type spécifique de données : +- **users** : Stocke les informations des utilisateurs (`UserModel`) +- **operations** : Stocke les opérations (`OperationModel`) +- **sectors** : Stocke les secteurs (`SectorModel`) +- **passages** : Stocke les passages (`PassageModel`) +- **settings** : Stocke les préférences utilisateur (non typée) + +## Initialisation des boîtes Hive + +Actuellement, les boîtes Hive sont initialisées dès le démarrage de l'application dans `main.dart` : + +```dart +// Initialiser Hive +await Hive.initFlutter(); + +// Enregistrer les adaptateurs Hive +Hive.registerAdapter(UserModelAdapter()); +Hive.registerAdapter(OperationModelAdapter()); +Hive.registerAdapter(SectorModelAdapter()); +Hive.registerAdapter(PassageModelAdapter()); + +// Ouvrir les boîtes Hive +await Hive.openBox(AppKeys.usersBoxName); +await Hive.openBox(AppKeys.operationsBoxName); +await Hive.openBox(AppKeys.sectorsBoxName); +await Hive.openBox(AppKeys.passagesBoxName); +await Hive.openBox(AppKeys.settingsBoxName); +``` + +### Problème d'initialisation précoce + +Cette approche ouvre toutes les boîtes Hive dès le démarrage de l'application, même sur les pages publiques comme `LandingPage` où elles ne sont pas nécessaires. Cela explique pourquoi vous voyez les messages suivants dans la console : + +``` +Got object store box in database users. +Got object store box in database operations. +Got object store box in database sectors. +Got object store box in database passages. +Got object store box in database settings. +``` + +### Solution recommandée + +Pour optimiser l'initialisation des boîtes Hive, il est recommandé de : + +1. N'initialiser que la boîte `users` au démarrage (pour vérifier si un utilisateur est déjà connecté) +2. Initialiser les autres boîtes uniquement après une connexion réussie + +Modification suggérée pour `main.dart` : + +```dart +// Initialiser Hive +await Hive.initFlutter(); + +// Enregistrer les adaptateurs Hive +Hive.registerAdapter(UserModelAdapter()); +Hive.registerAdapter(OperationModelAdapter()); +Hive.registerAdapter(SectorModelAdapter()); +Hive.registerAdapter(PassageModelAdapter()); + +// N'ouvrir que la boîte des utilisateurs au démarrage +await Hive.openBox(AppKeys.usersBoxName); +await Hive.openBox(AppKeys.settingsBoxName); // Préférences générales + +// Les autres boîtes seront ouvertes après connexion dans UserRepository.login() +``` + +## Services et repositories impliqués + +### UserRepository + +Le `UserRepository` est le principal gestionnaire des boîtes Hive. Il est responsable de : +- L'initialisation des boîtes au démarrage de l'application +- La gestion des boîtes pendant les processus de connexion et déconnexion +- Le nettoyage et la recréation des boîtes lorsque nécessaire + +### Autres repositories spécialisés + +- **OperationRepository** : Gère la boîte `operations` +- **SectorRepository** : Gère la boîte `sectors` +- **PassageRepository** : Gère la boîte `passages` + +Ces repositories sont injectés dans le `UserRepository` pour traiter les données spécifiques à chaque modèle. + +## Processus de connexion (login) + +Le processus de connexion dans `UserRepository.login()` suit ces étapes : + +1. **Nettoyage initial** : + - Suppression des boîtes non référencées (`auth`, `locations`, `messages`) + - Nettoyage adapté à la plateforme (Web, iOS, Android) + +2. **Préparation des boîtes** : + - Appel à `_clearAndRecreateBoxes()` pour vider et recréer les boîtes sans les fermer + - Utilisation de `_ensureBoxIsOpen()` pour garantir que les boîtes sont ouvertes + +3. **Appel API et traitement des données** : + - Connexion via l'API + - Vérification que toutes les boîtes sont ouvertes avant le traitement + - Traitement des données reçues (opérations, secteurs, passages) + +4. **Gestion des erreurs** : + - Tentatives de récupération en cas d'erreur + - Réouverture des boîtes si nécessaire + +### Code clé pour la connexion + +```dart +// S'assurer que les boîtes sont ouvertes +await _ensureBoxIsOpen(AppKeys.operationsBoxName); +await _ensureBoxIsOpen(AppKeys.sectorsBoxName); +await _ensureBoxIsOpen(AppKeys.passagesBoxName); + +// Traiter les données +await _processOperations(operationsData); +await _processSectors(sectorsData); +await _processPassages(passagesData); +``` + +## Processus de déconnexion (logout) + +Le processus de déconnexion dans `UserRepository.logout()` suit ces étapes : + +1. **Préparation** : + - S'assurer que la boîte des utilisateurs est ouverte + - Suppression des boîtes non référencées + +2. **Gestion de l'utilisateur** : + - Récupération de l'utilisateur actuel avant nettoyage + - Déconnexion de la session API + - Mise à jour de l'utilisateur pour effacer les données de session + +3. **Nettoyage des données** : + - Nettoyage adapté à la plateforme (Web, iOS, Android) + - Appel à `_clearAndRecreateBoxes()` pour vider les boîtes sans les fermer + +### Code clé pour la déconnexion + +```dart +// S'assurer que la boîte des utilisateurs est ouverte +await _ensureBoxIsOpen(AppKeys.usersBoxName); + +// Récupérer l'utilisateur et effacer sa session +final updatedUser = currentUser.copyWith( + sessionId: null, + sessionExpiry: null, + lastPath: null +); +await saveUser(updatedUser); + +// Vider les boîtes sans les fermer +await _clearAndRecreateBoxes(); +``` + +## Problèmes connus et solutions + +### 1. Initialisation précoce des boîtes Hive + +**Problème** : Toutes les boîtes Hive sont ouvertes dès le démarrage de l'application, même sur les pages publiques où elles ne sont pas nécessaires. + +**Solution** : Modifier `main.dart` pour n'ouvrir que les boîtes essentielles au démarrage (users et settings) et initialiser les autres boîtes uniquement après connexion. + +### 2. Erreur "Box has already been closed" + +**Problème** : Des erreurs se produisent lorsqu'on tente d'accéder à une boîte qui a été fermée prématurément. + +**Solution** : +- Utiliser la méthode `_ensureBoxIsOpen()` avant d'accéder à une boîte +- Éviter de fermer les boîtes qui pourraient être utilisées plus tard +- Préférer `box.clear()` à `box.close()` pour vider les données sans fermer la boîte + +### 3. Persistance indésirable des données entre sessions + +**Problème** : Les données peuvent persister entre les sessions utilisateur, créant des conflits ou des fuites de données. + +**Solution** : Utiliser `_clearAndRecreateBoxes()` lors de la déconnexion pour vider correctement toutes les boîtes sauf `users`. + +## Bonnes pratiques + +### Initialisation à la demande + +1. **Initialiser les boîtes uniquement lorsqu'elles sont nécessaires** : + - N'ouvrir que les boîtes `users` et `settings` au démarrage + - Initialiser les autres boîtes après connexion réussie + - Utiliser `_ensureBoxIsOpen()` avant chaque accès à une boîte + +2. **Centraliser la gestion des boîtes** : + - Créer un service dédié à la gestion des boîtes Hive + - Utiliser des méthodes comme `openRequiredBoxes()` et `clearAllBoxes()` + +3. **Optimiser pour les différentes plateformes** : + - Adapter le nettoyage selon la plateforme (Web, iOS, Android) + - Utiliser des méthodes spécifiques comme `_clearIndexedDB()` pour le web + +### Éviter l'erreur "Box has already been closed" + +1. **Ne jamais fermer une boîte qui pourrait être utilisée plus tard** : + - Utiliser `_ensureBoxIsOpen()` au lieu de fermer et rouvrir les boîtes + - Vider les boîtes avec `box.clear()` au lieu de les fermer + +2. **Vérifier qu'une boîte est ouverte avant de l'utiliser** : + ```dart + if (!Hive.isBoxOpen(boxName)) { + await Hive.openBox(boxName); + } + ``` + +3. **Gestion des erreurs robuste** : + - Toujours entourer les opérations Hive de blocs try/catch + - Prévoir des mécanismes de récupération en cas d'erreur + +### Méthode utilitaire _ensureBoxIsOpen + +Cette méthode est cruciale pour garantir qu'une boîte est ouverte avant de l'utiliser : + +```dart +Future _ensureBoxIsOpen(String boxName) async { + try { + if (!Hive.isBoxOpen(boxName)) { + debugPrint('Ouverture de la boîte $boxName...'); + if (boxName == AppKeys.passagesBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.operationsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.sectorsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.usersBoxName) { + await Hive.openBox(boxName); + } else { + await Hive.openBox(boxName); + } + debugPrint('Boîte $boxName ouverte avec succès'); + } + } catch (e) { + debugPrint('Erreur lors de l\'ouverture de la boîte $boxName: $e'); + throw Exception('Impossible d\'ouvrir la boîte $boxName: $e'); + } +} +``` + +--- + +Ce guide devrait aider à comprendre et maintenir la gestion des boîtes Hive dans l'application GeoSector. Pour toute question ou problème, consultez la documentation de Hive ou contactez l'équipe de développement. diff --git a/docs/model_definitions.md b/docs/model_definitions.md new file mode 100644 index 00000000..3bd8b4dc --- /dev/null +++ b/docs/model_definitions.md @@ -0,0 +1,356 @@ +# Modèles de données GEOSECTOR + +## Description générale + +GEOSECTOR utilise plusieurs modèles de données pour représenter les entités principales de l'application. Ces modèles sont utilisés à la fois pour la persistance locale via Hive et pour la communication avec l'API backend. Ce document décrit en détail chaque modèle, ses propriétés et ses méthodes. + +## Intégration avec Hive + +Tous les modèles sont annotés pour être utilisés avec Hive, une base de données NoSQL légère: + +- Chaque modèle a un `typeId` unique pour Hive +- Les propriétés sont annotées avec `@HiveField` et un index unique +- Les modèles étendent `HiveObject` pour accéder aux fonctionnalités de persistance + +## Modèle Utilisateur (UserModel) + +**Description**: Représente un utilisateur de l'application, avec ses informations personnelles et de session. + +### Propriétés + +| Propriété | Type | Description | Hive Index | +| ------------- | --------- | ----------------------------------------- | ---------- | +| id | int | Identifiant unique | 0 | +| email | String | Adresse email | 1 | +| name | String? | Nom de l'utilisateur | 2 | +| username | String? | Nom d'utilisateur pour connexion | 11 | +| firstName | String? | Prénom de l'utilisateur | 10 | +| role | int | Rôle/niveau d'accès (1=user, 2/4/9=admin) | 3 | +| createdAt | DateTime | Date de création du compte | 4 | +| lastSyncedAt | DateTime | Dernière synchronisation avec le serveur | 5 | +| isActive | bool | Statut actif/inactif | 6 | +| isSynced | bool | Indique si synchronisé avec le serveur | 7 | +| sessionId | String? | Identifiant de session pour l'API | 8 | +| sessionExpiry | DateTime? | Date d'expiration de la session | 9 | +| lastPath | String? | Dernier chemin visité dans l'app | 12 | +| sectName | String? | Nom de la section/département | 13 | +| interface | String? | Type d'interface ('admin' ou 'user') | 14 | + +### Méthodes + +- **fromJson**: Convertit un objet JSON en UserModel +- **toJson**: Convertit un UserModel en objet JSON +- **copyWith**: Crée une copie avec des valeurs modifiées +- **hasValidSession**: Vérifie si la session est valide +- **clearSession**: Efface les données de session + +### Exemple d'utilisation + +```dart +// Créer un utilisateur +final user = UserModel( + id: 123, + email: "user@example.com", + name: "John Doe", + username: "johndoe", + role: 1, + createdAt: DateTime.now(), + lastSyncedAt: DateTime.now(), + isActive: true, +); + +// Vérifier la validité de la session +if (user.hasValidSession) { + // Utiliser la session... +} + +// Créer une version modifiée +final updatedUser = user.copyWith( + name: "John Smith", + isActive: false, +); +``` + +## Modèle Opération (OperationModel) + +**Description**: Représente une opération de collecte avec ses dates et son statut. + +### Propriétés + +| Propriété | Type | Description | Hive Index | +| ------------ | -------- | ---------------------------------------- | ---------- | +| id | int | Identifiant unique | 0 | +| name | String | Nom de l'opération | 1 | +| dateDebut | DateTime | Date de début de l'opération | 2 | +| dateFin | DateTime | Date de fin de l'opération | 3 | +| lastSyncedAt | DateTime | Dernière synchronisation avec le serveur | 4 | +| isActive | bool | Statut actif/inactif | 5 | +| isSynced | bool | Indique si synchronisé avec le serveur | 6 | + +### Méthodes + +- **fromJson**: Convertit un objet JSON en OperationModel +- **toJson**: Convertit un OperationModel en objet JSON +- **copyWith**: Crée une copie avec des valeurs modifiées + +### Exemple d'utilisation + +```dart +// Créer une opération +final operation = OperationModel( + id: 456, + name: "Opération 2025", + dateDebut: DateTime(2025, 1, 1), + dateFin: DateTime(2025, 12, 31), + lastSyncedAt: DateTime.now(), + isActive: true, +); + +// Créer une version modifiée +final updatedOperation = operation.copyWith( + name: "Opération 2025 - Printemps", + dateDebut: DateTime(2025, 3, 1), + dateFin: DateTime(2025, 6, 30), +); +``` + +## Modèle Secteur (SectorModel) + +**Description**: Représente un secteur géographique avec ses coordonnées et son style. + +### Propriétés + +| Propriété | Type | Description | Hive Index | +| --------- | ------ | -------------------------------------------- | ---------- | +| id | int | Identifiant unique | 0 | +| libelle | String | Nom du secteur | 1 | +| color | String | Couleur au format hexadécimal (#RRGGBB) | 2 | +| sector | String | Coordonnées du polygone au format spécifique | 3 | + +### Méthodes + +- **fromJson**: Convertit un objet JSON en SectorModel +- **toJson**: Convertit un SectorModel en objet JSON +- **copyWith**: Crée une copie avec des valeurs modifiées +- **getCoordinates**: Extrait les coordonnées du polygone sous forme de liste + +### Format des coordonnées + +Le champ `sector` stocke les coordonnées du polygone dans un format spécifique: + +``` +lat1/lng1#lat2/lng2#lat3/lng3#... +``` + +Par exemple: + +``` +48.8566/2.3522#48.8567/2.3520#48.8565/2.3518#48.8564/2.3521#48.8566/2.3522 +``` + +### Exemple d'utilisation + +```dart +// Créer un secteur +final sector = SectorModel( + id: 789, + libelle: "Secteur Nord", + color: "#FF5733", + sector: "48.8566/2.3522#48.8567/2.3520#48.8565/2.3518#48.8564/2.3521#48.8566/2.3522", +); + +// Obtenir les coordonnées du secteur +final coordinates = sector.getCoordinates(); +// Résultat: [[48.8566, 2.3522], [48.8567, 2.3520], [48.8565, 2.3518], ...] +``` + +## Modèle Membre (MembreModel) + +**Description**: Représente un membre de l'organisation, utilisé uniquement dans l'interface admin. + +### Propriétés + +| Propriété | Type | Description | Hive Index | +| ------------- | --------- | -------------------------------- | ---------- | +| id | int | Identifiant unique | 0 | +| fkRole | int | ID du rôle du membre | 1 | +| fkTitre | int | ID du titre (civilité) du membre | 2 | +| firstName | String | Prénom du membre | 3 | +| sectName | String? | Nom de la section/département | 4 | +| dateNaissance | DateTime? | Date de naissance | 5 | +| dateEmbauche | DateTime? | Date d'embauche | 6 | +| chkActive | int | Statut actif (1) ou inactif (0) | 7 | +| name | String | Nom de famille du membre | 8 | +| username | String | Nom d'utilisateur pour connexion | 9 | +| email | String | Adresse email | 10 | + +### Méthodes + +- **fromJson**: Convertit un objet JSON en MembreModel +- **toJson**: Convertit un MembreModel en objet JSON +- **copyWith**: Crée une copie avec des valeurs modifiées + +### Exemple d'utilisation + +```dart +// Créer un membre +final membre = MembreModel( + id: 9999979, + fkRole: 1, + fkTitre: 1, + firstName: "Pierre", + sectName: "", + dateNaissance: DateTime.parse("1966-04-24"), + dateEmbauche: DateTime.parse("2017-12-01"), + chkActive: 0, + name: "VAISSAIRE", + username: "pv_mobile", + email: "pierre.vaissaire@d6soft.fr", +); + +// Créer une version modifiée +final updatedMembre = membre.copyWith( + chkActive: 1, + sectName: "Section A", +); +``` + +## Modèle Passage (PassageModel) + +**Description**: Représente un passage effectué chez un habitant avec toutes les informations associées. + +### Propriétés + +| Propriété | Type | Description | Hive Index | +| --------------- | -------- | --------------------------------------------- | ---------- | +| id | int | Identifiant unique | 0 | +| fkOperation | int | ID de l'opération associée | 1 | +| fkSector | int | ID du secteur | 2 | +| fkUser | int | ID de l'utilisateur qui a effectué le passage | 3 | +| fkType | int | Type de passage (1-6) | 4 | +| fkAdresse | String | Identifiant de l'adresse | 5 | +| passedAt | DateTime | Date et heure du passage | 6 | +| numero | String | Numéro de rue | 7 | +| rue | String | Nom de la rue | 8 | +| rueBis | String | Complément de rue | 9 | +| ville | String | Ville | 10 | +| residence | String | Nom de la résidence | 11 | +| fkHabitat | int | Type d'habitat | 12 | +| appt | String | Numéro d'appartement | 13 | +| niveau | String | Niveau/étage | 14 | +| gpsLat | String | Latitude GPS | 15 | +| gpsLng | String | Longitude GPS | 16 | +| nomRecu | String | Nom du fichier de reçu | 17 | +| remarque | String | Remarques/commentaires | 18 | +| montant | String | Montant collecté | 19 | +| fkTypeReglement | int | Type de règlement (0-3) | 20 | +| emailErreur | String | Message d'erreur pour l'envoi par email | 21 | +| nbPassages | int | Nombre de passages à cette adresse | 22 | +| name | String | Nom de l'habitant | 23 | +| email | String | Email de l'habitant | 24 | +| phone | String | Téléphone de l'habitant | 25 | +| lastSyncedAt | DateTime | Dernière synchronisation avec le serveur | 26 | +| isActive | bool | Statut actif/inactif | 27 | +| isSynced | bool | Indique si synchronisé avec le serveur | 28 | + +### Types de passages + +| ID | Description | Couleur | +| --- | ----------- | ---------- | +| 1 | Effectué | Vert | +| 2 | À finaliser | Orange | +| 3 | Refusé | Rouge | +| 4 | Don | Bleu ciel | +| 5 | Lot | Bleu foncé | +| 6 | Maison vide | Gris | + +### Types de règlements + +| ID | Description | Couleur | +| --- | ---------------- | ------- | +| 0 | Pas de règlement | Gris | +| 1 | Espèce | Jaune | +| 2 | Chèque | Vert | +| 3 | CB | Bleu | + +### Méthodes + +- **fromJson**: Convertit un objet JSON en PassageModel +- **toJson**: Convertit un PassageModel en objet JSON +- **copyWith**: Crée une copie avec des valeurs modifiées +- **toString**: Représentation textuelle pour débogage + +### Exemple d'utilisation + +```dart +// Créer un passage +final passage = PassageModel( + id: 1001, + fkOperation: 456, + fkSector: 789, + fkUser: 123, + fkType: 1, // Effectué + fkAdresse: "ADRESSE-123", + passedAt: DateTime.now(), + numero: "12", + rue: "Rue des Lilas", + ville: "Paris", + gpsLat: "48.8566", + gpsLng: "2.3522", + montant: "25.50", + fkTypeReglement: 2, // Chèque + nbPassages: 1, + name: "Dupont", + lastSyncedAt: DateTime.now(), +); + +// Créer une version modifiée +final updatedPassage = passage.copyWith( + montant: "30.00", + remarque: "Client fidèle", +); +``` + +## Relations entre les modèles + +Les modèles sont reliés entre eux par des clés étrangères: + +1. **PassageModel**: + + - `fkOperation` → `OperationModel.id` + - `fkSector` → `SectorModel.id` + - `fkUser` → `UserModel.id` + +2. **SectorModel**: + + - Lié aux opérations via les passages + +3. **OperationModel**: + + - Contient des secteurs et des passages + +4. **UserModel**: + - Possède des passages + +## Gestion de la synchronisation + +Tous les modèles principaux possèdent les propriétés de synchronisation: + +- `isSynced`: Indique si l'objet a été synchronisé avec le serveur +- `lastSyncedAt`: Date de dernière synchronisation + +Cette approche permet le travail hors ligne et la synchronisation ultérieure des modifications. + +## Stockage Hive + +Les modèles sont stockés dans des "boxes" Hive distinctes: + +| Modèle | Nom de la box | Type ID | +| -------------- | ------------- | ------- | +| UserModel | users | 0 | +| OperationModel | operations | 1 | +| SectorModel | sectors | 3 | +| PassageModel | passages | 4 | +| MembreModel | membres | 5 | + +Les boxes sont initialisées au démarrage de l'application ou lors de la connexion d'un utilisateur. diff --git a/docs/planning-client.csv b/docs/planning-client.csv new file mode 100644 index 00000000..9f608eb6 --- /dev/null +++ b/docs/planning-client.csv @@ -0,0 +1,10 @@ +Description GEOSECTOR 2025,Livraison prévue +1. Mise en place de l'infrastructure technique et configuration initiale,01/04/25 +2. Développement de la partie publique et système d'authentification,14/04/25 +3. Développement des fonctionnalités principales de l'interface utilisateur,28/04/25 +4. Intégration des paiements et gestion des documents dématérialisés,12/05/25 +5. Développement des fonctionnalités d'administration pour les amicales,26/05/25 +6. Développement des outils de cartographie et gestion des opérations,09/06/25 +7. Développement des fonctionnalités super admin et gestion commerciale,23/06/25 +"8. Tests, déploiement et mise en production de l'application",07/07/25 +"9. Documentation, formation et support au lancement",21/07/25 diff --git a/docs/planning-geo-2025-google-agenda.csv b/docs/planning-geo-2025-google-agenda.csv new file mode 100644 index 00000000..ebffc2f8 --- /dev/null +++ b/docs/planning-geo-2025-google-agenda.csv @@ -0,0 +1,32 @@ +Subject,Start Date,Start Time,End Date,End Time,All Day Event,Description,Location,Private +"GEO - Développement de la page Communication pour les utilisateurs",01/05/2025,08:30,01/05/2025,12:30,FALSE,"Implémentation du widget de chat pour la communication d'équipe. Création de l'interface pour répondre aux mails clients. Intégration avec les repositories pour la gestion des données.","Bureau D6SOFT",FALSE +"GEO - Finalisation Widget Carte MapBox (partie utilisateur)",03/05/2025,08:30,03/05/2025,12:30,FALSE,"Développement des fonctionnalités de visualisation des secteurs d'activité, visualisation des passages, sélection de passages près de la position et création de passage sur clic. Implémentation de la détection de la position utilisateur.","Bureau D6SOFT",FALSE +"GEO - Formulaire de passage avec intégration Stripe (partie 1)",05/05/2025,08:30,05/05/2025,12:30,FALSE,"Conception du widget Formulaire de passage. Implémentation des champs et validations. Mise en place de la logique de collecte des données. Préparation pour l'intégration de Stripe.","Bureau D6SOFT",FALSE +"GEO - Formulaire de passage avec intégration Stripe (partie 2)",07/05/2025,08:30,07/05/2025,12:30,FALSE,"Intégration de Stripe pour le paiement en ligne. Configuration du client Stripe dans Flutter. Gestion des paiements et des erreurs. Tests des transactions.","Bureau D6SOFT",FALSE +"GEO - Développement du système d'envoi de reçus PDF par email et SMS",09/05/2025,08:30,09/05/2025,12:30,FALSE,"Mise en place de la génération de reçu au format PDF. Intégration avec le service d'emails. Configuration de l'envoi de SMS via l'API OVH. Tests des envois.","Bureau D6SOFT",FALSE +"GEO - Implémentation des widgets statistiques communs",11/05/2025,08:30,11/05/2025,12:30,FALSE,"Développement des widgets de graphiques (passages, règlements). Implémentation du filtrage par période (jour/semaine/mois). Configuration de l'affichage responsive.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Principale",13/05/2025,08:30,13/05/2025,12:30,FALSE,"Création de la page principale d'administration avec synthèse des passages par secteur et utilisateur. Implémentation du graphique des passages des deux dernières semaines.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Amicale",15/05/2025,08:30,15/05/2025,12:30,FALSE,"Implémentation du formulaire d'informations de l'amicale. Upload de logo. Gestion des abonnements avec l'intégration STRIPE. Configuration des packs SMS.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Membres",17/05/2025,08:30,17/05/2025,12:30,FALSE,"Création de l'interface de gestion des membres. Upload des badges. Fonctionnalités d'import/export de listes. Gestion de réinitialisation des mots de passe.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Communication",19/05/2025,08:30,19/05/2025,12:30,FALSE,"Implémentation du système de chat pour l'équipe. Intégration avec l'API pour la communication avec Geosector. Tests de communication.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Connexions",21/05/2025,08:30,21/05/2025,12:30,FALSE,"Création de l'interface de consultation des connexions. Implémentation du graphique sur 5 mois. Filtrage par membre. Optimisation des requêtes API.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Opérations (partie 1)",23/05/2025,08:30,23/05/2025,12:30,FALSE,"Conception de l'interface de gestion des opérations. Implémentation de la sélection de l'opération active. Gestion des secteurs (couleurs, titres, membres).","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Opérations (partie 2)",25/05/2025,08:30,25/05/2025,12:30,FALSE,"Développement de l'interface pour tracer les secteurs sur la carte. Affichage des passages selon l'historique. Intégration avec les modules existants.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Opérations (partie 3)",27/05/2025,08:30,27/05/2025,12:30,FALSE,"Implémentation de l'affichage des membres actifs avec leurs statistiques. Exportation des données au format Excel. Tests d'intégration complets.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Statistiques",29/05/2025,08:30,29/05/2025,12:30,FALSE,"Création des graphiques d'activité par secteur, par membre et par période. Implémentation des filtres dynamiques. Tests et optimisation des performances.","Bureau D6SOFT",FALSE +"GEO - Développement de la page Admin - Clients (Super Admin)",31/05/2025,08:30,31/05/2025,12:30,FALSE,"Implémentation de la liste des amicales avec recherche. Gestion des amicales en démo. Interface de création/suppression d'amicales. Consultation des statistiques.","Bureau D6SOFT",FALSE +"GEO - Développement des scripts d'importation de données Open Data (partie 1)",02/06/2025,08:30,02/06/2025,12:30,FALSE,"Conception et développement des scripts PHP pour l'importation hebdomadaire de la base ADRESSES et SIRENE. Configuration des tâches planifiées. Tests d'importation.","Bureau D6SOFT",FALSE +"GEO - Développement des scripts d'importation de données Open Data (partie 2)",04/06/2025,08:30,04/06/2025,12:30,FALSE,"Conception et développement des scripts PHP pour l'importation hebdomadaire de la base BATIMENTS et OpenStreetMap. Optimisation des requêtes SQL. Tests d'intégration.","Bureau D6SOFT",FALSE +"GEO - Implémentation du système de chiffrement des données sensibles",06/06/2025,08:30,06/06/2025,12:30,FALSE,"Configuration du chiffrement des mots de passe avec Argon2. Mise en place du chiffrement AES-256 pour les données sensibles. Tests de sécurité.","Bureau D6SOFT",FALSE +"GEO - Développement du système de gestion des emails et file d'attente",08/06/2025,08:30,08/06/2025,12:30,FALSE,"Implémentation de la queue des emails en base de données. Création du script de traitement de la queue. Développement du système de gestion des retours d'emails.","Bureau D6SOFT",FALSE +"GEO - Développement du système de gestion des SMS via OVH",10/06/2025,08:30,10/06/2025,12:30,FALSE,"Configuration de l'intégration avec l'API SMS OVH. Implémentation de la gestion des packs SMS. Tests d'envoi et de réception de SMS.","Bureau D6SOFT",FALSE +"GEO - Création du site vitrine Svelte (partie 1)",12/06/2025,08:30,12/06/2025,12:30,FALSE,"Configuration initiale du projet Svelte. Développement de la page d'accueil avec présentation de la solution. Conception des composants de base.","Bureau D6SOFT",FALSE +"GEO - Création du site vitrine Svelte (partie 2)",14/06/2025,08:30,14/06/2025,12:30,FALSE,"Développement des sections de présentation des fonctionnalités. Implémentation du lien vers l'App Store et Play Store. Intégration des captures d'écran.","Bureau D6SOFT",FALSE +"GEO - Création du site vitrine Svelte (partie 3)",16/06/2025,08:30,16/06/2025,12:30,FALSE,"Implémentation du formulaire de contact. Développement des pages légales (mentions légales, conditions d'utilisation). Mise en place de la gestion des cookies.","Bureau D6SOFT",FALSE +"GEO - Intégration de l'authentification et des liens entre site vitrine et application",18/06/2025,08:30,18/06/2025,12:30,FALSE,"Développement des liens entre le site vitrine et l'application Flutter. Configuration du formulaire de connexion et d'inscription. Tests d'intégration.","Bureau D6SOFT",FALSE +"GEO - Tests utilisateurs et débogage global (partie 1)",20/06/2025,08:30,20/06/2025,12:30,FALSE,"Procédure de tests utilisateur sur les fonctionnalités principales. Identification et correction des bugs. Améliorations de l'interface utilisateur.","Bureau D6SOFT",FALSE +"GEO - Tests utilisateurs et débogage global (partie 2)",22/06/2025,08:30,22/06/2025,12:30,FALSE,"Tests des parcours utilisateurs complexes. Vérification de la synchronisation des données. Optimisation des performances. Corrections de bugs.","Bureau D6SOFT",FALSE +"GEO - Optimisation des performances et de la taille de l'application",24/06/2025,08:30,24/06/2025,12:30,FALSE,"Analyse des performances. Optimisation du code Flutter. Réduction de la taille de l'application. Amélioration des temps de chargement.","Bureau D6SOFT",FALSE +"GEO - Documentation technique et guide utilisateur",26/06/2025,08:30,26/06/2025,12:30,FALSE,"Rédaction de la documentation technique détaillée. Création du guide utilisateur avec captures d'écran. Préparation des ressources pour la formation.","Bureau D6SOFT",FALSE +"GEO - Préparation du déploiement et configuration des environnements",28/06/2025,08:30,28/06/2025,12:30,FALSE,"Configuration des environnements de production. Préparation du déploiement sur les stores (AppStore, PlayStore). Configuration du serveur web et de la base de données.","Bureau D6SOFT",FALSE +"GEO - Finalisation et préparation au lancement",30/06/2025,08:30,30/06/2025,12:30,FALSE,"Derniers tests d'intégration. Vérification des configurations de sécurité. Préparation des scripts de déploiement. Planification du lancement.","Bureau D6SOFT",FALSE \ No newline at end of file diff --git a/docs/planning-geo.csv b/docs/planning-geo.csv new file mode 100644 index 00000000..a4d6c634 --- /dev/null +++ b/docs/planning-geo.csv @@ -0,0 +1,21 @@ +Description;Date de début;Date échéance +Sprint 1 - Initialisation: Environnement de développement Flutter/PHP, Structure BDD, Configuration d'API;07/04/2025;11/04/2025 +Sprint 2 - Base de données: Initialisation de la BDD centrale et des scripts d'importation, Scripts SQL de migration;14/04/2025;18/04/2025 +Sprint 3 - API Core: Développement des modules principaux de l'API (authentification, utilisateurs, amicales);21/04/2025;25/04/2025 +Sprint 4 - Import Open Data: Scripts d'import pour bases Adresses, SIRENE, Bâtiments, OpenStreetMap;28/04/2025;02/05/2025 +Sprint 5 - Widgets communs I: Formulaires (passage, amicale, membre, opération, secteur);05/05/2025;09/05/2025 +Sprint 6 - Widgets communs II: Cartes MapBox avec gestion des markers et secteurs;12/05/2025;16/05/2025 +Sprint 7 - Widgets communs III: Graphiques statistiques (Fl_chart) pour connexions, passages, opérations;19/05/2025;23/05/2025 +Sprint 8 - Partie publique: Page d'accueil et système authentification/enregistrement;26/05/2025;30/05/2025 +Sprint 9 - Partie utilisateur I: Page principale et statistiques;02/06/2025;06/06/2025 +Sprint 10 - Partie utilisateur II: Historique et communication;09/06/2025;13/06/2025 +Sprint 11 - Partie utilisateur III: Carte et formulaire passage;16/06/2025;20/06/2025 +Sprint 12 - Partie admin I: Page principale et amicale;23/06/2025;27/06/2025 +Sprint 13 - Partie admin II: Membres et communication;30/06/2025;04/07/2025 +Sprint 14 - Partie admin III: Connexions et carte;07/07/2025;11/07/2025 +Sprint 15 - Partie admin IV: Opérations et statistiques;14/07/2025;18/07/2025 +Sprint 16 - Super admin: Page clients et fonctionnalités spécifiques;21/07/2025;25/07/2025 +Sprint 17 - Intégration paiement: Stripe pour abonnements et packs SMS;28/07/2025;01/08/2025 +Sprint 18 - Système communications: Emails (SMTP, queue, vérification) et SMS (OVH);04/08/2025;08/08/2025 +Sprint 19 - Tests et corrections: Tests fonctionnels, corrections de bugs, optimisation;11/08/2025;15/08/2025 +Sprint 20 - Finalisation: Documentation, déploiement final, préparation livraison;18/08/2025;22/08/2025 \ No newline at end of file diff --git a/docs/planning-geosector-2025.xls b/docs/planning-geosector-2025.xls new file mode 100644 index 00000000..cd7d3ffa Binary files /dev/null and b/docs/planning-geosector-2025.xls differ diff --git a/docs/planning-geosector.csv b/docs/planning-geosector.csv new file mode 100644 index 00000000..0978105c --- /dev/null +++ b/docs/planning-geosector.csv @@ -0,0 +1,33 @@ +Description,Start_Date,Due_Date +1. Configuration du projet Flutter et mise en place de l'architecture,2025-04-01,2025-04-04 +2. Configuration de l'API PHP8.3 et base de données MariaDB10.11,2025-04-01,2025-04-04 +3. Mise en place du script d'import de la base nationale des adresses,2025-04-01,2025-04-04 +4. Développement de la page publique de présentation,2025-04-07,2025-04-11 +5. Développement des pages de login/register et récupération de mot de passe,2025-04-07,2025-04-11 +6. Intégration de Hive pour le stockage local des données,2025-04-14,2025-04-18 +7. Développement de la page principale utilisateur,2025-04-14,2025-04-18 +8. Intégration de MapBox et configuration initiale des cartes,2025-04-21,2025-04-25 +9. Développement de la page statistiques utilisateur avec Fl_chart,2025-04-21,2025-04-25 +10. Développement de la page historique utilisateur,2025-04-28,2025-05-02 +11. Développement de la page communication utilisateur,2025-04-28,2025-05-02 +12. Développement de la page carte utilisateur et visualisation des secteurs,2025-05-05,2025-05-09 +13. Développement du formulaire de passage et gestion des coordonnées,2025-05-05,2025-05-09 +14. Intégration de Stripe pour les paiements en ligne,2025-05-12,2025-05-16 +15. Développement de la génération et envoi des reçus PDF par mail/SMS,2025-05-12,2025-05-16 +16. Développement de la page principale admin amicale,2025-05-19,2025-05-23 +17. Développement de la page amicale et gestion du logo,2025-05-19,2025-05-23 +18. Développement de la page membres avec import/export,2025-05-26,2025-05-30 +19. Développement de la page communication admin,2025-05-26,2025-05-30 +20. Développement de la page connexions et graphiques,2025-06-02,2025-06-06 +21. Développement de la page carte admin avec filtres,2025-06-02,2025-06-06 +22. Développement de la page opérations et gestion des secteurs,2025-06-09,2025-06-13 +23. Développement du traçage des secteurs sur la carte,2025-06-09,2025-06-13 +24. Développement des exports au format Excel,2025-06-16,2025-06-20 +25. Développement de la page statistiques admin,2025-06-16,2025-06-20 +26. Développement de la page clients pour super admin Geosector,2025-06-23,2025-06-27 +27. Développement de la gestion des abonnements et facturation PDF,2025-06-23,2025-06-27 +28. Tests et débogage de l'application mobile,2025-06-30,2025-07-04 +29. Tests et débogage de l'interface web,2025-06-30,2025-07-04 +30. Finalisation et déploiement de l'application,2025-07-07,2025-07-11 +31. Publication sur App Store et Play Store,2025-07-14,2025-07-18 +32. Documentation et formation,2025-07-21,2025-07-25 diff --git a/docs/widgets_common.md b/docs/widgets_common.md new file mode 100644 index 00000000..b38c5156 --- /dev/null +++ b/docs/widgets_common.md @@ -0,0 +1,346 @@ +# Widgets communs GEOSECTOR + +## Description générale + +GEOSECTOR utilise un ensemble de widgets communs réutilisables pour maintenir une cohérence visuelle et simplifier le développement. Ces widgets sont conçus pour s'adapter à la fois à l'interface web et mobile, et respectent les thèmes définis dans l'application. Ce document détaille les widgets principaux, leurs propriétés et leur utilisation. + +## Interface utilisateur de base + +### CustomButton + +Un bouton stylisé et réutilisable avec gestion de l'état de chargement. + +**Fichier**: `lib/presentation/widgets/custom_button.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|----------------|----------------|---------------------------------------------------|--------| +| onPressed | VoidCallback? | Fonction appelée lors du clic | Oui | +| text | String | Texte du bouton | Oui | +| icon | IconData? | Icône à afficher à gauche du texte | Non | +| isLoading | bool | Afficher un indicateur de chargement | Non | +| width | double? | Largeur personnalisée | Non | +| backgroundColor| Color? | Couleur de fond (utilise primary par défaut) | Non | +| textColor | Color? | Couleur du texte (utilise white par défaut) | Non | + +**Exemple d'utilisation**: + +```dart +CustomButton( + onPressed: () => saveData(), + text: 'Enregistrer', + icon: Icons.save, + isLoading: isSaving, + backgroundColor: Colors.green, +) +``` + +### CustomTextField + +Champ de texte stylisé et réutilisable avec gestion des validateurs. + +**Fichier**: `lib/presentation/widgets/custom_text_field.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|-----------------|------------------------|------------------------------------------------|--------| +| controller | TextEditingController | Contrôleur du champ | Oui | +| label | String | Libellé du champ | Oui | +| hintText | String? | Texte indicatif lorsque vide | Non | +| prefixIcon | IconData? | Icône au début du champ | Non | +| suffixIcon | Widget? | Widget à la fin du champ | Non | +| obscureText | bool | Masquer le texte (pour les mots de passe) | Non | +| keyboardType | TextInputType | Type de clavier | Non | +| validator | Function? | Fonction de validation | Non | +| maxLines | int? | Nombre maximum de lignes | Non | +| minLines | int? | Nombre minimum de lignes | Non | +| readOnly | bool | Lecture seule | Non | +| onChanged | Function? | Fonction appelée lors des modifications | Non | +| fillColor | Color? | Couleur de fond du champ | Non | + +**Exemple d'utilisation**: + +```dart +CustomTextField( + controller: _emailController, + label: 'Adresse email', + hintText: 'Saisissez votre email', + prefixIcon: Icons.email, + keyboardType: TextInputType.emailAddress, + validator: (value) => value.isEmpty ? 'Email requis' : null, +) +``` + +## Navigation et mise en page + +### DashboardAppBar + +Barre d'applications personnalisée pour les tableaux de bord. + +**Fichier**: `lib/presentation/widgets/dashboard_app_bar.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|----------------------|--------------|--------------------------------------------------|--------| +| title | String | Titre principal de l'AppBar | Oui | +| pageTitle | String? | Titre de la page actuelle | Non | +| additionalActions | List?| Actions supplémentaires | Non | +| showNewPassageButton | bool | Afficher le bouton "Nouveau passage" | Non | +| onNewPassagePressed | VoidCallback?| Fonction appelée pour créer un nouveau passage | Non | +| isAdmin | bool | Indique si l'utilisateur est un administrateur | Non | + +**Exemple d'utilisation**: + +```dart +DashboardAppBar( + title: 'GEOSECTOR', + pageTitle: 'Tableau de bord', + isAdmin: userRepository.isAdmin(), + showNewPassageButton: true, + onNewPassagePressed: () => Navigator.pushNamed(context, '/passage/new'), +) +``` + +## Visualisation cartographique + +### MapboxMap + +Widget de carte réutilisable utilisant Mapbox pour afficher des marqueurs et des polygones. + +**Fichier**: `lib/presentation/widgets/mapbox_map.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|------------------|----------------------------|----------------------------------------------|--------| +| initialPosition | LatLng | Position initiale de la carte | Non | +| initialZoom | double | Niveau de zoom initial | Non | +| markers | List? | Liste des marqueurs à afficher | Non | +| polygons | List? | Liste des polygones à afficher | Non | +| mapController | MapController? | Contrôleur de carte externe | Non | +| onMapEvent | Function(MapEvent)? | Callback lors des événements de carte | Non | +| showControls | bool | Afficher les boutons de contrôle | Non | +| mapStyle | String? | Style de la carte Mapbox | Non | + +**Exemple d'utilisation**: + +```dart +MapboxMap( + initialPosition: LatLng(48.1173, -1.6778), + initialZoom: 13.0, + markers: sectorMarkers, + polygons: [ + Polygon( + points: sector.getCoordinates().map((p) => LatLng(p[0], p[1])).toList(), + color: Color(int.parse(sector.color.replaceAll('#', '0xFF'))), + borderStrokeWidth: 2, + borderColor: Colors.black, + ), + ], + showControls: true, +) +``` + +## Affichage des données + +### PassagesListWidget + +Widget réutilisable pour afficher une liste de passages avec filtres et actions. + +**Fichier**: `lib/presentation/widgets/passages/passages_list_widget.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|--------------------|----------------------------------------|-----------------------------------------------|--------| +| passages | List> | Liste des passages à afficher | Oui | +| title | String? | Titre de la section | Non | +| maxPassages | int? | Nombre maximum de passages à afficher | Non | +| showFilters | bool | Afficher les filtres | Non | +| showSearch | bool | Afficher la barre de recherche | Non | +| showActions | bool | Afficher les boutons d'action | Non | +| onPassageSelected | Function(Map)? | Callback à la sélection d'un passage | Non | +| onPassageEdit | Function(Map)? | Callback pour éditer un passage | Non | +| onReceiptView | Function(Map)? | Callback pour voir un reçu | Non | +| initialTypeFilter | String? | Filtre de type initial | Non | +| initialPaymentFilter| String? | Filtre de paiement initial | Non | + +**Format attendu des passages**: + +```dart +[ + { + 'id': 1001, + 'address': '12 Rue des Lilas, Paris', + 'type': 1, // Correspond à AppKeys.typesPassages + 'payment': 2, // Correspond à AppKeys.typesReglements + 'date': DateTime(2025, 4, 15), + 'amount': 25.50, + 'name': 'Martin Dupont', + 'notes': 'Premier étage à droite', + 'hasError': false, + 'fkUser': 123 // ID de l'utilisateur qui a effectué le passage + }, + // ... +] +``` + +**Exemple d'utilisation**: + +```dart +PassagesListWidget( + passages: passagesList, + title: 'Historique des passages', + showFilters: true, + showSearch: true, + onPassageSelected: (passage) => showPassageDetails(passage), + onPassageEdit: (passage) => editPassage(passage), + onReceiptView: (passage) => viewReceipt(passage), +) +``` + +## Visualisation de données + +### ActivityChart + +Graphique d'activité affichant les passages par jour/semaine/mois. + +**Fichier**: `lib/presentation/widgets/charts/activity_chart.dart` + +**Propriétés principales**: + +| Propriété | Type | Description | Requis | +|----------------------|-----------------------------|-----------------------------------------------|--------| +| passageData | List>? | Données des passages | Non* | +| periodType | String | Type de période (Jour, Semaine, Mois) | Non | +| height | double | Hauteur du graphique | Non | +| daysToShow | int | Nombre de jours à afficher | Non | +| userId | int? | ID de l'utilisateur pour filtrer | Non | +| excludePassageTypes | List | Types de passages à exclure | Non | +| loadFromHive | bool | Charger depuis Hive au lieu des données fournies | Non | +| title | String | Titre du graphique | Non | +| showDataLabels | bool | Afficher les étiquettes de valeur | Non | +| columnWidth | double | Largeur des colonnes | Non | +| columnSpacing | double | Espacement entre les colonnes | Non | +| showAllPassages | bool | Afficher tous les passages sans filtrage | Non | + +\* Soit `passageData` doit être fourni, soit `loadFromHive` doit être `true`. + +**Format attendu de passageData**: + +```dart +[ + { + 'date': '2025-04-01', + 'type_passage': 1, + 'nb': 5 + }, + { + 'date': '2025-04-01', + 'type_passage': 3, + 'nb': 2 + }, + // ... +] +``` + +**Exemple d'utilisation**: + +```dart +ActivityChart( + loadFromHive: true, + periodType: 'Jour', + height: 350, + daysToShow: 15, + userId: userRepository.userId, + excludePassageTypes: [2, 6], + title: 'Activité des 15 derniers jours', +) +``` + +## Autres widgets + +### PassagePieChart et PaymentPieChart + +Graphiques circulaires affichant la répartition des passages et des paiements. + +**Fichiers**: +- `lib/presentation/widgets/charts/passage_pie_chart.dart` +- `lib/presentation/widgets/charts/payment_pie_chart.dart` + +Ces widgets utilisent les mêmes principes que ActivityChart mais avec une visualisation circulaire. + +**Exemple d'utilisation**: + +```dart +Row( + children: [ + Expanded( + child: PassagePieChart( + loadFromHive: true, + title: 'Répartition par type', + height: 300, + ), + ), + Expanded( + child: PaymentPieChart( + loadFromHive: true, + title: 'Répartition par règlement', + height: 300, + ), + ), + ], +) +``` + +## Adaptabilité et responsive design + +Tous les widgets communs sont conçus pour s'adapter aux différentes tailles d'écran: + +1. **Détection de la taille d'écran**: + ```dart + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + ``` + +2. **Layouts conditionnels**: + ```dart + isDesktop + ? _buildDesktopLayout() + : _buildMobileLayout() + ``` + +3. **Flexibilité des widgets**: + - Utilisation fréquente de `Expanded` et `Flexible` + - Définition de contraintes minimales et maximales + - Ajustement du nombre de colonnes selon l'espace disponible + +## Bonnes pratiques implémentées + +1. **Gestion des erreurs robuste** - Chaque widget inclut une gestion d'erreurs pour éviter les plantages lors des problèmes de données. + +2. **Optimisation des performances** - Évitement des rebuilds inutiles et utilisation d'animations fluides. + +3. **Séparation des préoccupations** - Les widgets se concentrent sur l'affichage, laissant la logique métier aux repositories. + +4. **Documentation complète** - Chaque widget comporte des commentaires de documentation détaillés. + +5. **Paramétrage flexible** - La plupart des widgets peuvent être personnalisés via leurs propriétés. + +## Utilisation avec les thèmes + +Les widgets communs respectent le thème de l'application défini dans `app_theme.dart`. Ils utilisent systématiquement: + +```dart +final theme = Theme.of(context); +// Utilisation des couleurs du thème +theme.colorScheme.primary +theme.colorScheme.onPrimary +// Utilisation des styles de texte du thème +theme.textTheme.bodyLarge +``` + +Cela garantit une apparence cohérente et le support des thèmes clairs et sombres. diff --git a/flutt/.cline b/flutt/.cline new file mode 100644 index 00000000..0ed2fe04 --- /dev/null +++ b/flutt/.cline @@ -0,0 +1,23 @@ +{ + "memoryBank": { + "enabled": true, + "path": "./docs", + "maxContextSize": 100000 + }, + "contextSettings": { + "maxTokens": 100000, + "cline.maxAutoApprovedRequests": 100, + "cline.enableMemoryBank": true, + "cline.includeSnippetsFromMemory": true, + "cline.contextLength": 10000, + "cline.autoFormat": true + }, + "mcpServers": { + "github.com/modelcontextprotocol/servers/tree/main/src/git": { + "command": "python", + "args": ["-m", "mcp_server_git"], + "disabled": false, + "autoApprove": [] + } + } +} diff --git a/flutt/.env-backup b/flutt/.env-backup new file mode 100644 index 00000000..bc4e9d61 --- /dev/null +++ b/flutt/.env-backup @@ -0,0 +1,16 @@ +# Configuration pour le serveur de backup 1 +BACKUP_SERVER1_IP="192.168.1.7" +BACKUP_SERVER1_PORT="22" +BACKUP_SERVER1_USER="root" +BACKUP_SERVER1_KEY="/Users/pierre/.ssh/id_rsa_mbpi" +BACKUP_SERVER1_PATH="/var/backups" + +# Configuration pour le serveur de backup 2 +BACKUP_SERVER2_IP="server2.example.com" +BACKUP_SERVER2_PORT="22" +BACKUP_SERVER2_USER="backup_user" +BACKUP_SERVER2_KEY="/path/to/private_key2" +BACKUP_SERVER2_PATH="/path/to/backups" + +# Configuration générale +BACKUP_RETENTION_DAYS=30 # Nombre de jours de conservation des backups diff --git a/flutt/.env-deploy-dev b/flutt/.env-deploy-dev new file mode 100644 index 00000000..65d89532 --- /dev/null +++ b/flutt/.env-deploy-dev @@ -0,0 +1,15 @@ +# Paramètres de connexion au host Debian 12 +HOST_SSH_USER=debian +HOST_SSH_HOST=145.239.9.105 +HOST_SSH_PORT=22 +HOST_SSH_KEY=/Users/pierre/.ssh/id_rsa_mbpi + +# Paramètres du container Incus +INCUS_PROJECT=DEV +INCUS_CONTAINER=d-apps +CONTAINER_USER=root +USE_SUDO=true + +# Paramètres de déploiement +DEPLOY_TARGET_DIR=/var/www/geosector +FLUTTER_BUILD_DIR=build/web diff --git a/flutt/.gitignore b/flutt/.gitignore new file mode 100644 index 00000000..51af7fb6 --- /dev/null +++ b/flutt/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +sync_config.jsonc \ No newline at end of file diff --git a/flutt/.metadata b/flutt/.metadata new file mode 100644 index 00000000..4212cc82 --- /dev/null +++ b/flutt/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutt/.windsurfrules b/flutt/.windsurfrules new file mode 100644 index 00000000..e69de29b diff --git a/flutt/README.md b/flutt/README.md new file mode 100644 index 00000000..4f19b2a3 --- /dev/null +++ b/flutt/README.md @@ -0,0 +1,16 @@ +# geosector_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/flutt/add_framework_paths.rb b/flutt/add_framework_paths.rb new file mode 100644 index 00000000..0374ff74 --- /dev/null +++ b/flutt/add_framework_paths.rb @@ -0,0 +1,57 @@ +#!/usr/bin/env ruby + +require 'xcodeproj' + +# Ouvrir le projet +project_path = 'ios/Runner.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Trouver la cible Runner +target = project.targets.find { |t| t.name == 'Runner' } + +# Parcourir toutes les configurations de build +target.build_configurations.each do |config| + # Obtenir les paramètres de build actuels + build_settings = config.build_settings + + # Définir les chemins de recherche de frameworks + framework_search_paths = [ + '$(inherited)', + '"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift"', + '"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus"', + '"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation"', + '"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios"', + '"${PODS_ROOT}/Flutter"', + '"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flutter"' + ] + + # Ajouter les chemins de recherche de frameworks + build_settings['FRAMEWORK_SEARCH_PATHS'] = framework_search_paths + + # Ajouter les chemins de recherche d'en-têtes + header_search_paths = [ + '$(inherited)', + '"${PODS_ROOT}/Flutter"', + '"${PODS_CONFIGURATION_BUILD_DIR}"' + ] + + build_settings['HEADER_SEARCH_PATHS'] = header_search_paths + + # S'assurer que les modules sont activés + build_settings['DEFINES_MODULE'] = 'YES' + + # Désactiver le bitcode + build_settings['ENABLE_BITCODE'] = 'NO' + + # Inclure tous les assets d'icônes + build_settings['ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS'] = 'YES' + + # Autres paramètres importants + build_settings['SWIFT_VERSION'] = '5.0' + build_settings['CLANG_ENABLE_MODULES'] = 'YES' +end + +# Enregistrer les modifications +project.save + +puts "✅ Chemins de recherche de frameworks ajoutés avec succès !" diff --git a/flutt/analysis_options.yaml b/flutt/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/flutt/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutt/android/.gitignore b/flutt/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/flutt/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/flutt/android/app/build.gradle.kts b/flutt/android/app/build.gradle.kts new file mode 100644 index 00000000..ccc98c9a --- /dev/null +++ b/flutt/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "fr.geosector.app.geosector_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "fr.geosector.app.geosector_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/flutt/android/app/src/debug/AndroidManifest.xml b/flutt/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/flutt/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutt/android/app/src/main/AndroidManifest.xml b/flutt/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..744d1026 --- /dev/null +++ b/flutt/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/android/app/src/main/kotlin/fr/geosector/app2/geosector_app/MainActivity.kt b/flutt/android/app/src/main/kotlin/fr/geosector/app2/geosector_app/MainActivity.kt new file mode 100644 index 00000000..cdd138b2 --- /dev/null +++ b/flutt/android/app/src/main/kotlin/fr/geosector/app2/geosector_app/MainActivity.kt @@ -0,0 +1,5 @@ +package fr.geosector.app.geosector_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/flutt/android/app/src/main/res/drawable-v21/launch_background.xml b/flutt/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/flutt/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutt/android/app/src/main/res/drawable/launch_background.xml b/flutt/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/flutt/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutt/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutt/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/flutt/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/flutt/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..29dc6a1c Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/flutt/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutt/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/flutt/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/flutt/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..042fde04 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutt/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/flutt/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..cfd57477 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutt/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/flutt/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..9be6e5fe Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutt/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/flutt/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/flutt/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..a9bca263 Binary files /dev/null and b/flutt/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/flutt/android/app/src/main/res/values-night/styles.xml b/flutt/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/flutt/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutt/android/app/src/main/res/values/styles.xml b/flutt/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/flutt/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutt/android/app/src/profile/AndroidManifest.xml b/flutt/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/flutt/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutt/android/build.gradle.kts b/flutt/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/flutt/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/flutt/android/gradle.properties b/flutt/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/flutt/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/flutt/android/gradle/wrapper/gradle-wrapper.properties b/flutt/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..afa1e8eb --- /dev/null +++ b/flutt/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/flutt/android/settings.gradle.kts b/flutt/android/settings.gradle.kts new file mode 100644 index 00000000..a439442c --- /dev/null +++ b/flutt/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/flutt/assets/animations/geo_main.json b/flutt/assets/animations/geo_main.json new file mode 100644 index 00000000..e997e0e4 --- /dev/null +++ b/flutt/assets/animations/geo_main.json @@ -0,0 +1,802 @@ +{ + "v": "5.7.5", + "fr": 30, + "ip": 0, + "op": 90, + "w": 400, + "h": 400, + "nm": "GeoSector Animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Earth", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 0, + "s": [0] + }, + { + "t": 90, + "s": [360] + } + ], + "ix": 10 + }, + "p": { + "a": 0, + "k": [200, 200, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0, 0], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 0, + "s": [0, 0, 100] + }, + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 15, + "s": [110, 110, 100] + }, + { + "t": 25, + "s": [100, 100, 100] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": { + "a": 0, + "k": [120, 120], + "ix": 2 + }, + "p": { + "a": 0, + "k": [0, 0], + "ix": 3 + }, + "nm": "Ellipse Path 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.125, 0.553, 0.965, 1], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 4, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.2, 0.6, 1, 1], + "ix": 4 + }, + "o": { + "a": 0, + "k": 70, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Earth Base", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [-60, 0], + [60, 0] + ], + "c": false + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [1, 1, 1, 1], + "ix": 3 + }, + "o": { + "a": 0, + "k": 30, + "ix": 4 + }, + "w": { + "a": 0, + "k": 2, + "ix": 5 + }, + "lc": 2, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Equator", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 2, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [0, -60], + [0, 60] + ], + "c": false + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [1, 1, 1, 1], + "ix": 3 + }, + "o": { + "a": 0, + "k": 30, + "ix": 4 + }, + "w": { + "a": 0, + "k": 2, + "ix": 5 + }, + "lc": 2, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Meridian", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 3, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": { + "a": 0, + "k": [20, 20], + "ix": 2 + }, + "p": { + "a": 0, + "k": [40, -30], + "ix": 3 + }, + "nm": "Ellipse Path 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.133, 0.624, 0.125, 1], + "ix": 4 + }, + "o": { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Continent 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 4, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": { + "a": 0, + "k": [25, 25], + "ix": 2 + }, + "p": { + "a": 0, + "k": [-35, 20], + "ix": 3 + }, + "nm": "Ellipse Path 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.133, 0.624, 0.125, 1], + "ix": 4 + }, + "o": { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Continent 2", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 5, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 90, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Marker Pin", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 30, + "s": [0] + }, + { + "t": 40, + "s": [5] + }, + { + "t": 50, + "s": [-5] + }, + { + "t": 60, + "s": [0] + } + ], + "ix": 10 + }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 25, + "s": [200, 80, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 30, + "s": [200, 120, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "t": 35, + "s": [200, 110, 0] + } + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0, 0], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 20, + "s": [0, 0, 100] + }, + { + "i": { "x": [0.667, 0.667, 0.667], "y": [1, 1, 1] }, + "o": { "x": [0.333, 0.333, 0.333], "y": [0, 0, 0] }, + "t": 30, + "s": [120, 120, 100] + }, + { + "t": 35, + "s": [100, 100, 100] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 0], + [5.523, 0], + [0, 5.523], + [-5.523, 0], + [0, -5.523], + [0, 0] + ], + "o": [ + [0, 5.523], + [-5.523, 0], + [0, -5.523], + [5.523, 0], + [0, 0], + [0, 0] + ], + "v": [ + [10, -5], + [0, 5], + [-10, -5], + [0, -15], + [10, -5], + [0, 25] + ], + "c": false + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.925, 0.267, 0.267, 1], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 5, + "ix": 5 + }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.925, 0.267, 0.267, 1], + "ix": 4 + }, + "o": { + "a": 0, + "k": 100, + "ix": 5 + }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [0, 0], + "ix": 2 + }, + "a": { + "a": 0, + "k": [0, 0], + "ix": 1 + }, + "s": { + "a": 0, + "k": [100, 100], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Pin", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 20, + "op": 90, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} \ No newline at end of file diff --git a/flutt/assets/images/app-screenshot1.svg b/flutt/assets/images/app-screenshot1.svg new file mode 100644 index 00000000..03802abb --- /dev/null +++ b/flutt/assets/images/app-screenshot1.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/assets/images/app-screenshot2.svg b/flutt/assets/images/app-screenshot2.svg new file mode 100644 index 00000000..5f52f70e --- /dev/null +++ b/flutt/assets/images/app-screenshot2.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/assets/images/app-store-badge.svg b/flutt/assets/images/app-store-badge.svg new file mode 100644 index 00000000..dd0021f6 --- /dev/null +++ b/flutt/assets/images/app-store-badge.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + Download on the + App Store + diff --git a/flutt/assets/images/city-map-bg-fixed.svg b/flutt/assets/images/city-map-bg-fixed.svg new file mode 100644 index 00000000..b29d45ea --- /dev/null +++ b/flutt/assets/images/city-map-bg-fixed.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/assets/images/city-map-bg.svg b/flutt/assets/images/city-map-bg.svg new file mode 100644 index 00000000..abb8649c --- /dev/null +++ b/flutt/assets/images/city-map-bg.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/assets/images/geosector-logo-200.png b/flutt/assets/images/geosector-logo-200.png new file mode 100644 index 00000000..b1b98abd Binary files /dev/null and b/flutt/assets/images/geosector-logo-200.png differ diff --git a/flutt/assets/images/geosector-logo-200.png~ b/flutt/assets/images/geosector-logo-200.png~ new file mode 100644 index 00000000..ecbeffdb Binary files /dev/null and b/flutt/assets/images/geosector-logo-200.png~ differ diff --git a/flutt/assets/images/geosector-logo-80.png b/flutt/assets/images/geosector-logo-80.png new file mode 100644 index 00000000..492e6349 Binary files /dev/null and b/flutt/assets/images/geosector-logo-80.png differ diff --git a/flutt/assets/images/geosector-logo-80.png~ b/flutt/assets/images/geosector-logo-80.png~ new file mode 100644 index 00000000..2fc853c0 Binary files /dev/null and b/flutt/assets/images/geosector-logo-80.png~ differ diff --git a/flutt/assets/images/geosector-logo.png b/flutt/assets/images/geosector-logo.png new file mode 100644 index 00000000..fdf990ca Binary files /dev/null and b/flutt/assets/images/geosector-logo.png differ diff --git a/flutt/assets/images/geosector-logo.png~ b/flutt/assets/images/geosector-logo.png~ new file mode 100644 index 00000000..fdf990ca Binary files /dev/null and b/flutt/assets/images/geosector-logo.png~ differ diff --git a/flutt/assets/images/play-store-badge.svg b/flutt/assets/images/play-store-badge.svg new file mode 100644 index 00000000..f3a2af2f --- /dev/null +++ b/flutt/assets/images/play-store-badge.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + GET IT ON + Google Play + diff --git a/flutt/backup.sh b/flutt/backup.sh new file mode 100755 index 00000000..fa542b3e --- /dev/null +++ b/flutt/backup.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Chemin du projet +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_NAME="$(basename "$PROJECT_DIR")" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP_FILENAME="${PROJECT_NAME}_${TIMESTAMP}.tar.gz" +TEMP_BACKUP_DIR="/tmp" +BACKUP_PATH="${TEMP_BACKUP_DIR}/${BACKUP_FILENAME}" + +# Charger les variables d'environnement +if [ ! -f "${PROJECT_DIR}/.env-backup" ]; then + echo "❌ Fichier .env-backup manquant" + exit 1 +fi +source "${PROJECT_DIR}/.env-backup" + +# Fonction pour gérer les erreurs +error_exit() { + echo "❌ $1" + exit 1 +} + +# Fonction pour nettoyer les anciens backups +cleanup_old_backups() { + local server_ip=$1 + local server_port=$2 + local server_user=$3 + local server_key=$4 + local server_path=$5 + + echo "🧹 Nettoyage des backups plus anciens que ${BACKUP_RETENTION_DAYS} jours sur $server_ip..." + ssh -i "$server_key" -p "$server_port" "$server_user@$server_ip" \ + "find $server_path -name '${PROJECT_NAME}_*.tar.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete" || \ + echo "⚠️ Avertissement: Nettoyage des anciens backups sur $server_ip échoué" +} + +# Création du backup +echo "📦 Création du backup de ${PROJECT_NAME}..." +cd "${PROJECT_DIR}/.." +tar -czf "$BACKUP_PATH" \ + --exclude="${PROJECT_NAME}/build" \ + --exclude="${PROJECT_NAME}/.dart_tool" \ + --exclude="${PROJECT_NAME}/.pub" \ + --exclude="${PROJECT_NAME}/.flutter-plugins-dependencies" \ + --exclude="${PROJECT_NAME}/ios/Pods" \ + --exclude="${PROJECT_NAME}/android/.gradle" \ + --exclude="${PROJECT_NAME}/.git" \ + --exclude="${PROJECT_NAME}/node_modules" \ + "${PROJECT_NAME}/" || error_exit "Création du backup échouée" + +echo "✅ Backup créé: $BACKUP_PATH ($(du -h "$BACKUP_PATH" | cut -f1))" + +# Transfert vers le serveur 1 +echo "📤 Envoi du backup vers ${BACKUP_SERVER1_IP}..." +scp -i "$BACKUP_SERVER1_KEY" -P "$BACKUP_SERVER1_PORT" "$BACKUP_PATH" \ + "${BACKUP_SERVER1_USER}@${BACKUP_SERVER1_IP}:${BACKUP_SERVER1_PATH}/" || \ + error_exit "Transfert vers ${BACKUP_SERVER1_IP} échoué" + +echo "✅ Backup envoyé vers ${BACKUP_SERVER1_IP}" +cleanup_old_backups "$BACKUP_SERVER1_IP" "$BACKUP_SERVER1_PORT" "$BACKUP_SERVER1_USER" \ + "$BACKUP_SERVER1_KEY" "$BACKUP_SERVER1_PATH" + +# Transfert vers le serveur 2 +echo "📤 Envoi du backup vers ${BACKUP_SERVER2_IP}..." +scp -i "$BACKUP_SERVER2_KEY" -P "$BACKUP_SERVER2_PORT" "$BACKUP_PATH" \ + "${BACKUP_SERVER2_USER}@${BACKUP_SERVER2_IP}:${BACKUP_SERVER2_PATH}/" || \ + error_exit "Transfert vers ${BACKUP_SERVER2_IP} échoué" + +echo "✅ Backup envoyé vers ${BACKUP_SERVER2_IP}" +cleanup_old_backups "$BACKUP_SERVER2_IP" "$BACKUP_SERVER2_PORT" "$BACKUP_SERVER2_USER" \ + "$BACKUP_SERVER2_KEY" "$BACKUP_SERVER2_PATH" + +# Nettoyage du fichier temporaire +echo "🧹 Suppression du fichier temporaire..." +rm "$BACKUP_PATH" || echo "⚠️ Avertissement: Suppression du fichier temporaire échouée" + +echo "✅ Backup terminé avec succès à $(date '+%H:%M:%S') !" +echo "📂 Fichiers sauvegardés sur:" +echo " - ${BACKUP_SERVER1_IP}:${BACKUP_SERVER1_PATH}/${BACKUP_FILENAME}" +echo " - ${BACKUP_SERVER2_IP}:${BACKUP_SERVER2_PATH}/${BACKUP_FILENAME}" diff --git a/flutt/clean_flutter.sh b/flutt/clean_flutter.sh new file mode 100755 index 00000000..ec160773 --- /dev/null +++ b/flutt/clean_flutter.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Afficher les commandes en cours d'exécution +set -x + +# Vérifier si nous sommes dans un projet Flutter +if [ ! -f "pubspec.yaml" ]; then + echo "Erreur: Assurez-vous d'être dans un projet Flutter (pubspec.yaml non trouvé)" + exit 1 +fi + +# Nettoyer Flutter et générer le code +flutter pub run build_runner clean +flutter pub run build_runner build --delete-conflicting-outputs +flutter clean + +# Nettoyer iOS +cd ios || exit +rm -rf Pods/ +rm -rf .symlinks/ +rm -f Podfile.lock +pod cache clean --all +rm -rf ~/Library/Developer/Xcode/DerivedData + +# Retour au dossier racine et mise à jour des dépendances +cd .. +flutter pub get + +# Réinstaller les pods iOS +cd ios || exit +pod deintegrate +pod cache clean --all +pod repo update +pod install + +# Retour au dossier racine +cd .. + +echo "Nettoyage terminé avec succès!" \ No newline at end of file diff --git a/flutt/deploy-dev.sh b/flutt/deploy-dev.sh new file mode 100755 index 00000000..04708433 --- /dev/null +++ b/flutt/deploy-dev.sh @@ -0,0 +1,58 @@ +#!/bin/bash +cd /Users/pierre/dev/geosector/flutt + +# Charger les variables d'environnement +if [ ! -f .env-deploy-dev ]; then + echo "❌ Fichier .env-deploy-dev manquant" + exit 1 +fi +source .env-deploy-dev + +# Fonction pour gérer les erreurs +error_exit() { + echo "❌ $1" + exit 1 +} + +# Construction de l'application Flutter +echo "🧹 Cleaning previous builds..." +flutter clean || error_exit "Flutter clean failed" + +echo "📦 Getting dependencies..." +flutter pub get || error_exit "Flutter pub get failed" + +# Nettoyage et génération du code +echo "🗑️ Cleaning generated files..." +dart pub run build_runner clean || error_exit "Build runner clean failed" + +echo "🔨 Generating code files..." +dart pub run build_runner build --delete-conflicting-outputs || error_exit "Code generation failed" + +# Construction de l'application Flutter +echo "🏗️ Building Flutter web application..." +flutter build web --release || error_exit "Flutter build failed" + +echo "✅ Build completed successfully!" + +# Préparation de la commande SSH pour le host +SSH_HOST_CMD="ssh -i $HOST_SSH_KEY -p $HOST_SSH_PORT $HOST_SSH_USER@$HOST_SSH_HOST" + +# Préparation du chemin temporaire sur le host +TEMP_DIR="/tmp/geosector-deploy-$(date +%s)" + +echo "📤 Copie des fichiers vers le host temporairement..." +rsync -rltz --delete \ + -e "ssh -i $HOST_SSH_KEY -p $HOST_SSH_PORT" \ + $FLUTTER_BUILD_DIR/ \ + $HOST_SSH_USER@$HOST_SSH_HOST:$TEMP_DIR/ || error_exit "Transfert vers le host échoué" + +echo "🔄 Transfert des fichiers du host vers le container..." +$SSH_HOST_CMD "sudo incus project switch $INCUS_PROJECT && sudo incus file push -r $TEMP_DIR/* $INCUS_CONTAINER$DEPLOY_TARGET_DIR/" || error_exit "Transfert vers le container échoué" + +echo "🧹 Nettoyage du répertoire temporaire sur le host..." +$SSH_HOST_CMD "rm -rf $TEMP_DIR" + +echo "🔒 Configuration des permissions dans le container..." +$SSH_HOST_CMD "sudo incus project switch $INCUS_PROJECT && sudo incus exec $INCUS_CONTAINER -- chown -R www-data:www-data $DEPLOY_TARGET_DIR" || error_exit "Configuration des permissions échouée" + +echo "✅ Déploiement terminé avec succès à $(date '+%H:%M:%S') !" diff --git a/flutt/docs/chat.md b/flutt/docs/chat.md new file mode 100644 index 00000000..5771c48b --- /dev/null +++ b/flutt/docs/chat.md @@ -0,0 +1,622 @@ +# 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 trois 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 pour la gestion des messages et la synchronisation + +### 2. 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 + +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 +``` + +#### Adaptations pour Hive + +Ces modèles seront adaptés pour Hive avec leurs adaptateurs respectifs : + +```dart +@HiveType(typeId: 20) +class ConversationModel extends HiveObject { + @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 participants; + + @HiveField(6) + final bool isSynced; + + @HiveField(7) + final String replyPermission; + + @HiveField(8) + final bool isPinned; + + @HiveField(9) + final DateTime? expiryDate; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 21) +class MessageModel extends HiveObject { + @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; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 22) +class ParticipantModel extends HiveObject { + @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; + + // ... autres propriétés et méthodes +} + +@HiveType(typeId: 23) +class AudienceTargetModel extends HiveObject { + @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; // 'all' ou ID de rôle + + @HiveField(6) + final String? entityFilter; // 'all' ou ID d'entité + + // ... autres propriétés et méthodes +} +``` + +### 3. 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) + POST - Envoyer un message + +/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 +``` + +#### Synchronisation + +Le système supportera : + +- Synchronisation en temps réel via WebSockets (optionnel) +- Synchronisation par polling avec gestion des messages non lus +- Enregistrement local des messages avec Hive pour le fonctionnement hors ligne + +### 4. 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? 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) onSend, + List>? availableTargets, + String? initialTitle, + String? initialMessage, + bool allowAttachments = true, + bool allowPinning = true, + List replyPermissionOptions = const ['all', 'admins_only', 'sender_only', 'none'], + String defaultReplyPermission = 'none', + DateTime? expiryDate, + bool isGeosector = true, // Active la sélection des destinataires + }) + ``` + +7. **AnnouncementTargetSelector** : Sélecteur de destinataires pour annonces (Geosector uniquement) + + ```dart + AnnouncementTargetSelector({ + required Function(AudienceTargetModel) onTargetSelected, + required List availableEntities, + bool showRoleFilter = true, + bool showEntityFilter = true, + String defaultRole = 'all', + String defaultEntity = 'all', + }) + ``` + +8. **AnnouncementBanner** : Bannière pour afficher une annonce importante + ```dart + AnnouncementBanner({ + required MessageModel announcement, + required Function() onView, + Function()? onDismiss, + bool isDismissible = true, + Duration? autoDismissAfter, + Color? backgroundColor, + Widget? icon, + }) + ``` + +#### Fonctionnalités des widgets + +- Design adaptatif (mobile/web) +- Support des thèmes clairs/sombres +- Gestion des messages non lus +- Indicateurs de frappe +- Accusés de réception et de lecture +- Support des pièces jointes (fichiers, images) +- Recherche dans les conversations +- Conversion d'utilisateurs anonymes en clients (Resalice) + +### 5. Gestion des données locales (Hive) + +#### Organisation des boîtes Hive + +```dart +// 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'; +``` + +#### Stratégie de synchronisation + +1. **Ouverture sélective** : Ouverture des boîtes à la demande +2. **Gestion de conflit** : Stratégie pour résoudre les conflits entre données locales et serveur +3. **Nettoyage intelligent** : Suppression des messages anciens selon des règles configurables +4. **Marqueurs de synchronisation** : Tracking des messages synchronisés/non-synchronisés + +## Implémentation technique + +### 1. Structure des repositories + +```dart +class ChatRepository { + // Gestion des conversations + Future> getConversations({bool forceRefresh = false}); + Future getConversation(String id); + Future createConversation(Map data); + Future deleteConversation(String id); + Future pinConversation(String id, bool isPinned); + Future updateReplyPermission(String id, String replyPermission); + + // Gestion des messages + Future> getMessages(String conversationId, {int page = 1, int limit = 50}); + Future sendMessage(String conversationId, Map messageData); + Future markMessageAsRead(String messageId); + + // Gestion des participants + Future addParticipant(String conversationId, Map participantData); + Future removeParticipant(String conversationId, String participantId); + + // Gestion des utilisateurs anonymes (Resalice) + Future createAnonymousUser({String? name, String? email}); + Future convertAnonymousToUser(String anonymousId, String userId); + + // Gestion des annonces + Future> getAnnouncements({bool forceRefresh = false}); + Future createAnnouncement(Map data); + Future> getAnnouncementStats(String conversationId); + + // Gestion des cibles d'audience + Future>> getAvailableAudienceTargets(); + Future addAudienceTarget(String conversationId, Map targetData); + Future removeAudienceTarget(String conversationId, String targetId); +} +``` + +### 2. Intégration avec l'API + +```dart +class ChatApiService { + final String baseUrl; + final String? authToken; + + // Constructeur avec paramètres pour l'URL et l'authentification + ChatApiService({ + required this.baseUrl, + this.authToken, + }); + + // Méthodes HTTP pour communiquer avec l'API + Future> fetchConversations(); + Future> fetchMessages(String conversationId, {int page = 1, int limit = 50}); + Future> createConversation(Map data); + Future> sendMessage(String conversationId, Map messageData); + // ...autres méthodes +} +``` + +### 3. Gestion hors ligne + +```dart +class OfflineQueueService { + // Ajouter des opérations en attente + Future addPendingOperation(String operationType, Map data); + + // Traiter les opérations en attente + Future processPendingOperations(); + + // Écouter les changements de connectivité + void listenToConnectivityChanges(); +} +``` + +### 4. 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 +- **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 +- **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 +- **Annonces non pertinentes** pour ce cas d'usage (pas de widget `AnnouncementTargetSelector`) + +### Adaptations par projet + +La solution de chat doit être adaptable selon le contexte : + +1. **Configuration globale** : Un système de configuration permet de définir quelles fonctionnalités sont activées + + ```dart + // Configuration pour Geosector + const chatConfig = ChatConfig( + enableAnnouncements: true, + enableTargetSelection: true, + showAnnouncementStats: true, + defaultReplyPermission: 'none', + ); + + // Configuration pour Resalice + const chatConfig = ChatConfig( + enableAnnouncements: false, + enableTargetSelection: false, + showAnnouncementStats: false, + defaultReplyPermission: 'all', + ); + ``` + +2. **Interfaces conditionnelles** : Les widgets adaptent leur affichage selon la configuration + + ```dart + // Dans AnnouncementComposer + if (config.enableTargetSelection) { + children.add(AnnouncementTargetSelector(...)); + } + ``` + +3. **Types de conversation limités** : La création de certains types de conversation est restreinte + ```dart + // Dans Resalice, seuls les types one_to_one et anonymous sont autorisés + if (!config.enableAnnouncements && type == 'announcement') { + throw UnsupportedConversationType(); + } + ``` + +## 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. + +### 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. + +## Étapes d'implémentation suggérées + +1. **Phase 1 : Base du système** (3-4 semaines) + + - Modèles de données et adaptateurs Hive + - Configuration de l'API backend + - Widgets de base pour affichage/envoi de messages + - Structure de base pour les annonces et broadcasts + +2. **Phase 2 : Fonctionnalités avancées** (2-3 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 + +3. **Phase 3 : Cas spécifiques** (2-3 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é à 6-9 semaines pour un développeur expérimenté en Flutter et PHP. L'adaptation ultérieure à Resalice devrait prendre environ 2-3 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. Cette fonctionnalité est particulièrement adaptée aux cas d'usage mentionnés pour Geosector, où l'admin général souhaite communiquer avec tous les admins d'entités, ou un admin d'entité avec tous les utilisateurs de son entité. + +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. diff --git a/flutt/fix-web-assets.sh b/flutt/fix-web-assets.sh new file mode 100644 index 00000000..48e83f57 --- /dev/null +++ b/flutt/fix-web-assets.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Script pour corriger le problème d'assets dans l'application web +echo "🔍 Fixing assets structure for web deployment..." + +# Création du dossier assets/assets/animations si inexistant +mkdir -p build/web/assets/assets/animations + +# Copie des animations depuis le répertoire source +cp -r assets/animations/* build/web/assets/assets/animations/ + +echo "✅ Assets structure fixed!" + +# Si besoin de redéployer sans reconstruire l'application +if [ "$1" == "--deploy" ]; then + # Définition des variables + REMOTE_USER="root" + REMOTE_HOST="87.98.163.161" + SSH_KEY="/Users/pierre/.ssh/id_rsa_mbpi" + REMOTE_PATH="/var/www/geosector" + + echo "📤 Deploying fixed assets to server..." + rsync -rltz \ + -e "ssh -i ${SSH_KEY}" \ + build/web/assets/ \ + ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/assets/ + + if [ $? -ne 0 ]; then + echo "❌ Deployment failed" + exit 1 + fi + + echo "✅ Assets deployed successfully!" +fi diff --git a/flutt/fix_ios_build.sh b/flutt/fix_ios_build.sh new file mode 100755 index 00000000..5f76dfee --- /dev/null +++ b/flutt/fix_ios_build.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# Script de réinitialisation complète pour résoudre les problèmes de compilation iOS +# Spécialement conçu pour résoudre l'erreur "No such module 'Flutter'" +# Version 2.0 - Avec ajout automatique des chemins de recherche de frameworks + +echo "🧹 Nettoyage complet de l'environnement iOS..." + +# Se placer dans le répertoire du projet +cd "$(dirname "$0")" + +# Supprimer les fichiers générés par Flutter +echo "📦 Nettoyage des fichiers Flutter..." +flutter clean + +# Supprimer le cache pub +echo "🗑️ Suppression du cache pub pour les plugins problématiques..." +rm -rf ~/.pub-cache/hosted/pub.dev/connectivity_plus-* + +# Supprimer les fichiers de CocoaPods +echo "🗂️ Nettoyage des fichiers CocoaPods..." +cd ios +rm -rf Pods +rm -rf .symlinks +rm -f Podfile.lock +rm -rf ~/Library/Developer/Xcode/DerivedData + +# Supprimer le workspace Xcode (il sera recréé) +echo "🔄 Suppression du workspace Xcode..." +rm -rf Runner.xcworkspace + +# Revenir au répertoire parent +cd .. + +# Récupérer les dépendances Flutter +echo "📥 Récupération des dépendances Flutter..." +flutter pub get + +# Régénérer les fichiers iOS +echo "🔨 Précaching des outils iOS..." +flutter precache --ios --force + +# Forcer la génération des plugins +echo "🔌 Régénération des plugins..." +flutter pub cache repair +flutter pub get + +# Réinstaller les pods avec des options supplémentaires +echo "📲 Réinstallation des pods..." +cd ios +pod deintegrate +pod cache clean --all +pod repo update +pod install --repo-update --verbose + +# Ajouter automatiquement les chemins de recherche de frameworks +echo "🔍 Ajout des chemins de recherche de frameworks..." + +# Créer un fichier temporaire pour stocker les chemins de recherche +cat > ios/add_framework_paths.rb << 'EOL' +#!/usr/bin/env ruby + +require 'xcodeproj' + +# Ouvrir le projet +project_path = 'Runner.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Trouver la cible Runner +target = project.targets.find { |t| t.name == 'Runner' } + +# Parcourir toutes les configurations de build +target.build_configurations.each do |config| + # Obtenir les paramètres de build actuels + build_settings = config.build_settings + + # Définir les chemins de recherche de frameworks + framework_search_paths = [ + '$(inherited)', + '"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift"', + '"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus"', + '"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation"', + '"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios"', + '"${PODS_ROOT}/Flutter"', + '"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flutter"' + ] + + # Ajouter les chemins de recherche de frameworks + build_settings['FRAMEWORK_SEARCH_PATHS'] = framework_search_paths + + # Ajouter les chemins de recherche d'en-têtes + header_search_paths = [ + '$(inherited)', + '"${PODS_ROOT}/Flutter"', + '"${PODS_CONFIGURATION_BUILD_DIR}"' + ] + + build_settings['HEADER_SEARCH_PATHS'] = header_search_paths + + # S'assurer que les modules sont activés + build_settings['DEFINES_MODULE'] = 'YES' + + # Désactiver le bitcode + build_settings['ENABLE_BITCODE'] = 'NO' + + # Inclure tous les assets d'icônes + build_settings['ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS'] = 'YES' + + # Autres paramètres importants + build_settings['SWIFT_VERSION'] = '5.0' + build_settings['CLANG_ENABLE_MODULES'] = 'YES' +end + +# Enregistrer les modifications +project.save + +puts "✅ Chemins de recherche de frameworks ajoutés avec succès !" +EOL + +# Exécuter le script Ruby pour ajouter les chemins de recherche +cd ios +ruby add_framework_paths.rb +cd .. + +echo "✅ Réinitialisation iOS terminée ! Ouvrez le projet avec 'open ios/Runner.xcworkspace'" diff --git a/flutt/git-create-branch.sh b/flutt/git-create-branch.sh new file mode 100755 index 00000000..09b24b87 --- /dev/null +++ b/flutt/git-create-branch.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Script to create a new branch from main/origin + +# Check if branch name is provided +if [ $# -eq 0 ]; then + echo "Error: Branch name is required" + echo "Usage: $0 " + exit 1 +fi + +# Store branch name from parameter +BRANCH_NAME=$1 + +# Ensure we have the latest from origin +echo "Fetching latest changes from origin..." +git fetch origin + +# Check if we're already on main, if not switch to it +CURRENT_BRANCH=$(git symbolic-ref --short HEAD) +if [ "$CURRENT_BRANCH" != "main" ]; then + echo "Switching to main branch..." + git checkout main +fi + +# Pull latest changes from main +echo "Pulling latest changes from main..." +git pull origin main + +# Create and checkout the new branch +echo "Creating and checking out new branch: $BRANCH_NAME" +git checkout -b "$BRANCH_NAME" + +# Stage all changes +echo "Staging all changes..." +git add . + +# Ask if user wants to make an initial commit +read -p "Do you want to make an initial commit? (Y/n): " COMMIT_CHOICE + +# Default to Yes if Enter is pressed without input +COMMIT_CHOICE=${COMMIT_CHOICE:-Y} + +if [[ $COMMIT_CHOICE =~ ^[Yy]$ ]]; then + # Ask for commit message + read -p "Enter commit message: " COMMIT_MESSAGE + + # Check if commit message is provided + if [ -n "$COMMIT_MESSAGE" ]; then + # Make the commit + echo "Creating commit with message: '$COMMIT_MESSAGE'" + git commit -m "$COMMIT_MESSAGE" + + # Push to remote with upstream tracking + echo "Pushing to origin and setting upstream tracking..." + git push -u origin "$BRANCH_NAME" + + echo "Branch '$BRANCH_NAME' has been pushed to origin with tracking." + else + echo "No commit message provided. Skipping commit." + fi +else + echo "Skipping initial commit. You can commit changes later." +fi + +echo "Success! You are now on branch: $BRANCH_NAME" +echo "Ready to start working!" \ No newline at end of file diff --git a/flutt/git-merge.sh b/flutt/git-merge.sh new file mode 100755 index 00000000..e1de7ec4 --- /dev/null +++ b/flutt/git-merge.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Check if a branch name was provided +if [ -z "$1" ]; then + echo "Error: Please provide the name of the branch to merge" + echo "Usage: ./git-merge.sh branch_name" + exit 1 +fi + +BRANCH_NAME=$1 + +# Check if the branch exists +if ! git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then + echo "Error: Branch '$BRANCH_NAME' does not exist" + exit 1 +fi + +# Display the steps that will be executed +echo "=== Starting merge process ===" +echo "1. Checkout to main" +echo "2. Pull latest changes" +echo "3. Merge branch $BRANCH_NAME" +echo "4. Push to origin" +echo "5. Delete local and remote branches" + +# Ask for confirmation +read -p "Do you want to continue? (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Operation cancelled" + exit 1 +fi + +# Execute commands +echo -e "\n=== Checking out to main ===" +git checkout main +if [ $? -ne 0 ]; then + echo "Error during checkout to main" + exit 1 +fi + +echo -e "\n=== Pulling latest changes ===" +git pull origin main +if [ $? -ne 0 ]; then + echo "Error during pull" + exit 1 +fi + +echo -e "\n=== Merging branch $BRANCH_NAME ===" +git merge $BRANCH_NAME +if [ $? -ne 0 ]; then + echo "Error during merge. Please resolve conflicts manually" + exit 1 +fi + +echo -e "\n=== Pushing to origin ===" +git push origin main +if [ $? -ne 0 ]; then + echo "Error during push" + exit 1 +fi + +echo -e "\n=== Deleting local branch ===" +git branch -d $BRANCH_NAME +if [ $? -ne 0 ]; then + echo "Warning: Unable to delete local branch" + echo "If you are sure everything is properly merged, use: git branch -D $BRANCH_NAME" +fi + +echo -e "\n=== Deleting remote branch ===" +git push origin --delete $BRANCH_NAME +if [ $? -ne 0 ]; then + echo "Warning: Unable to delete remote branch" +fi + +echo -e "\n=== Merge process completed successfully ===" diff --git a/flutt/ios/.gitignore b/flutt/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/flutt/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/flutt/ios/Flutter/AppFrameworkInfo.plist b/flutt/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..7c569640 --- /dev/null +++ b/flutt/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/flutt/ios/Flutter/Debug.xcconfig b/flutt/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..9803018c --- /dev/null +++ b/flutt/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Generated.xcconfig" +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" diff --git a/flutt/ios/Flutter/Release.xcconfig b/flutt/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..a4a8c604 --- /dev/null +++ b/flutt/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Generated.xcconfig" +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" diff --git a/flutt/ios/Podfile b/flutt/ios/Podfile new file mode 100644 index 00000000..d185dd26 --- /dev/null +++ b/flutt/ios/Podfile @@ -0,0 +1,81 @@ +# Uncomment this line to define a global platform for your project +# Spécifier la version minimale d'iOS pour Stripe Tap to Pay +platform :ios, '15.4' + +# Ignorer les avertissements des pods +install! 'cocoapods', :warn_for_unused_master_specs_repo => false + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + # Utiliser les frameworks dynamiques + use_frameworks! + + # Désactiver les en-têtes modulaires pour éviter les conflits + # use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + # Configuration post-installation + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + # Maintenir la version minimale iOS 15.4 pour Stripe Tap to Pay + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.4' + + # Désactiver Bitcode (recommandé par Flutter) + config.build_settings['ENABLE_BITCODE'] = 'NO' + + # Paramètres pour la compatibilité avec Xcode récent + config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' + config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' + + # Paramètres pour éviter les erreurs de module + config.build_settings['DEFINES_MODULE'] = 'YES' + config.build_settings['SWIFT_VERSION'] = '5.0' + + # Désactiver le support Mac Catalyst + config.build_settings['SUPPORTS_MACCATALYST'] = 'NO' + + # Paramètres de signature de code + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + + # Ajout des permissions de géolocalisation + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_LOCATION=1', + ] + end + end + + # Flutter post install + flutter_post_install(installer) if defined?(flutter_post_install) +end diff --git a/flutt/ios/Podfile.lock b/flutt/ios/Podfile.lock new file mode 100644 index 00000000..4655a7ab --- /dev/null +++ b/flutt/ios/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - ReachabilitySwift (5.2.4) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - ReachabilitySwift + +EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: f0d28569a754ac33c3d750271af244edf72e3a3c + +COCOAPODS: 1.16.2 diff --git a/flutt/ios/Runner.xcodeproj/project.pbxproj b/flutt/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..2238bb67 --- /dev/null +++ b/flutt/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,814 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B29AD2EBEFD19B161772B50D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54642C142BC98D17D428B51D /* Pods_Runner.framework */; }; + C89DA9B9EA30824E0E881287 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EE1328CE0A8907C5568E72D /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 132FE5584808793FE93F08F5 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 21F13E510138E0DBAAA0667E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3DE8EBA41425E2A096EE70CF /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 3EE1328CE0A8907C5568E72D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 54642C142BC98D17D428B51D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8814F9792D6E6BA0874B431C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CE8A95C455B844D61A15C99F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + FE152E3AEF93B264AE11B5E9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9491A6C7DD10151C405FF968 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C89DA9B9EA30824E0E881287 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B29AD2EBEFD19B161772B50D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 5301A26C8AA9F29008B3D808 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 54642C142BC98D17D428B51D /* Pods_Runner.framework */, + 3EE1328CE0A8907C5568E72D /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + C67A4583967DEDA799C193DE /* Pods */, + 5301A26C8AA9F29008B3D808 /* Frameworks */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + C67A4583967DEDA799C193DE /* Pods */ = { + isa = PBXGroup; + children = ( + 8814F9792D6E6BA0874B431C /* Pods-Runner.debug.xcconfig */, + CE8A95C455B844D61A15C99F /* Pods-Runner.release.xcconfig */, + FE152E3AEF93B264AE11B5E9 /* Pods-Runner.profile.xcconfig */, + 3DE8EBA41425E2A096EE70CF /* Pods-RunnerTests.debug.xcconfig */, + 21F13E510138E0DBAAA0667E /* Pods-RunnerTests.release.xcconfig */, + 132FE5584808793FE93F08F5 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 04F44EDE70DDFF90BCE6241F /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 9491A6C7DD10151C405FF968 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9413D06037CA6133CC0A13EB /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 625AB1A439E96A5838DD474D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1630; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + preferredProjectObjectVersion = 77; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 04F44EDE70DDFF90BCE6241F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 625AB1A439E96A5838DD474D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9413D06037CA6133CC0A13EB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 6WT84NWCTC; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios\"", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flutter\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}\"", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GEOSECTOR; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.2.1; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3DE8EBA41425E2A096EE70CF /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app.geosectorApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 21F13E510138E0DBAAA0667E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app.geosectorApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 132FE5584808793FE93F08F5 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app.geosectorApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 6WT84NWCTC; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios\"", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flutter\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}\"", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GEOSECTOR; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.2.1; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 6WT84NWCTC; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios\"", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flutter\"", + ); + "FRAMEWORK_SEARCH_PATHS[arch=*]" = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/connectivity_plus\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/path_provider_foundation\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/url_launcher_ios\"", + ); + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Flutter\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}\"", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GEOSECTOR; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.2.1; + PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/flutt/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutt/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/flutt/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/flutt/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutt/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutt/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..0c57d3a4 --- /dev/null +++ b/flutt/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutt/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/flutt/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/flutt/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutt/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/flutt/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/flutt/ios/Runner/AppDelegate.swift b/flutt/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/flutt/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/flutt/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutt/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutt/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/flutt/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/ios/Runner/Base.lproj/Main.storyboard b/flutt/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/flutt/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutt/ios/Runner/Info.plist b/flutt/ios/Runner/Info.plist new file mode 100644 index 00000000..3a1c53c2 --- /dev/null +++ b/flutt/ios/Runner/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Geosector App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + geosector_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSLocationWhenInUseUsageDescription + Cette application nécessite l'accès à votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. + NSLocationAlwaysAndWhenInUseUsageDescription + Cette application nécessite l'accès à votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. + NSLocationAlwaysUsageDescription + Cette application nécessite l'accès à votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/flutt/ios/Runner/Runner-Bridging-Header.h b/flutt/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/flutt/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/flutt/ios/RunnerTests/RunnerTests.swift b/flutt/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/flutt/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutt/ios_reset.sh b/flutt/ios_reset.sh new file mode 100755 index 00000000..fb6cb71d --- /dev/null +++ b/flutt/ios_reset.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Se placer dans le répertoire du projet +cd "$(dirname "$0")" + +# Supprimer les fichiers générés par Flutter +flutter clean + +# Supprimer les fichiers de CocoaPods +cd ios +rm -rf Pods +rm -rf .symlinks +rm -f Podfile.lock +rm -rf ~/Library/Developer/Xcode/DerivedData + +# Supprimer le workspace Xcode (il sera recréé) +rm -rf Runner.xcworkspace + +# Revenir au répertoire parent +cd .. + +# Récupérer les dépendances Flutter +flutter pub get + +# Régénérer les fichiers iOS +flutter precache --ios + +# Réinstaller les pods +cd ios +pod install --repo-update + +echo "Réinitialisation iOS terminée !" diff --git a/flutt/lib/app.dart b/flutt/lib/app.dart new file mode 100644 index 00000000..93bbf293 --- /dev/null +++ b/flutt/lib/app.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:go_router/go_router.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/repositories/operation_repository.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/sector_repository.dart'; +import 'package:geosector_app/core/repositories/membre_repository.dart'; +import 'package:geosector_app/core/services/sync_service.dart'; +import 'package:geosector_app/core/services/connectivity_service.dart'; +import 'package:geosector_app/presentation/auth/splash_page.dart'; +import 'package:geosector_app/presentation/public/landing_page.dart'; +import 'package:geosector_app/presentation/auth/login_page.dart'; +import 'package:geosector_app/presentation/auth/register_page.dart'; +import 'package:geosector_app/presentation/admin/admin_dashboard_page.dart'; +import 'package:geosector_app/presentation/user/user_dashboard_page.dart'; + +// Instances globales des services et repositories +final apiService = ApiService(); +final operationRepository = OperationRepository(apiService); +final passageRepository = PassageRepository(apiService); +final userRepository = UserRepository(apiService); +final sectorRepository = SectorRepository(apiService); +final membreRepository = MembreRepository(apiService); +final syncService = SyncService(userRepository: userRepository); +final connectivityService = ConnectivityService(); + +class GeoSectorApp extends StatelessWidget { + const GeoSectorApp({super.key}); + + @override + Widget build(BuildContext context) { + // Utiliser directement le router sans provider + final router = GoRouter( + initialLocation: '/', + debugLogDiagnostics: true, + refreshListenable: + userRepository, // Écouter les changements d'état d'authentification + redirect: (context, state) { + // Sauvegarder le chemin actuel pour l'utilisateur connecté, sauf pour la page de splash + if (state.uri.toString() != '/' && userRepository.isLoggedIn) { + // Ne pas sauvegarder les chemins de login/register + if (!state.uri.toString().startsWith('/login') && + !state.uri.toString().startsWith('/register') && + !state.uri.toString().startsWith('/public')) { + userRepository.updateLastPath(state.uri.toString()); + } + } + + // Vérifier si l'utilisateur est sur la page de splash + if (state.uri.toString() == '/') { + // Vérifier si l'utilisateur a une session valide + final currentUser = userRepository.getCurrentUser(); + if (currentUser == null || currentUser.sessionId == null) { + // Si pas de session valide, rediriger vers la landing page + return '/public'; + } + + // Si l'utilisateur a une session valide et un chemin précédent, y retourner + final lastPath = userRepository.getLastPath(); + if (lastPath != null && lastPath.isNotEmpty) { + return lastPath; + } + + // Sinon, rediriger vers le tableau de bord approprié + if (userRepository.isAdmin()) { + return '/admin'; + } else { + return '/user'; + } + } + + // Vérifier si l'utilisateur est sur une page d'authentification + final isLoggedIn = userRepository.isLoggedIn; + final isOnLoginPage = state.uri.toString() == '/login'; + final isOnRegisterPage = state.uri.toString() == '/register'; + final isOnAdminRegisterPage = state.uri.toString() == '/admin-register'; + final isOnPublicPage = state.uri.toString() == '/public'; + + // Vérifier si l'utilisateur vient de la landing page et va vers la page de connexion + // Cette information est stockée dans les paramètres de la route + final isFromLandingPage = + state.uri.queryParameters['from'] == 'landing'; + + // Permettre l'accès aux pages publiques sans authentification + if (isOnPublicPage) { + return null; + } + + // Si l'utilisateur vient de la landing page et va vers la page de connexion ou d'inscription, + // ne pas rediriger, même s'il est déjà connecté + if ((isOnLoginPage || isOnRegisterPage) && isFromLandingPage) { + return null; + } + + // Si l'utilisateur n'est pas connecté et n'est pas sur une page d'authentification, rediriger vers la page de connexion + if (!isLoggedIn && + !isOnLoginPage && + !isOnRegisterPage && + !isOnAdminRegisterPage) { + return '/login'; + } + + // Si l'utilisateur est connecté et se trouve sur une page d'authentification, rediriger vers le tableau de bord approprié + if (isLoggedIn && + (isOnLoginPage || isOnRegisterPage || isOnAdminRegisterPage)) { + if (userRepository.isAdmin()) { + return '/admin'; + } else { + return '/user'; + } + } + + // Si l'utilisateur est connecté en tant qu'administrateur mais essaie d'accéder à une page utilisateur, rediriger vers le tableau de bord admin + if (isLoggedIn && + userRepository.isAdmin() && + state.uri.toString().startsWith('/user')) { + return '/admin'; + } + + // Si l'utilisateur est connecté en tant qu'utilisateur mais essaie d'accéder à une page admin, rediriger vers le tableau de bord utilisateur + if (isLoggedIn && + !userRepository.isAdmin() && + state.uri.toString().startsWith('/admin')) { + return '/user'; + } + + return null; + }, + routes: [ + // Splash screen et page de démarrage + GoRoute( + path: '/', + builder: (context, state) => const SplashPage(), + ), + + // Pages publiques + GoRoute( + path: '/public', + builder: (context, state) => const LandingPage(), + ), + + // Pages d'authentification + GoRoute( + path: '/login', + builder: (context, state) { + // Extraire le type de connexion depuis les extras + Map? extras; + if (state.extra != null && state.extra is Map) { + extras = state.extra as Map; + } + + String? loginType = extras?['type']; + print('DEBUG ROUTER: Type dans les extras: $loginType'); + + // Nettoyer le paramètre type si présent + if (loginType != null) { + loginType = loginType.trim().toLowerCase(); + print('DEBUG ROUTER: Type nettoyé: $loginType'); + } else { + // Fallback: essayer de récupérer depuis les paramètres d'URL + final queryParams = state.uri.queryParameters; + loginType = queryParams['type']; + if (loginType != null) { + loginType = loginType.trim().toLowerCase(); + print('DEBUG ROUTER: Type récupéré des params URL: $loginType'); + } else { + loginType = 'admin'; // Valeur par défaut + print('DEBUG ROUTER: Type par défaut: admin'); + } + } + + return LoginPage( + key: Key('login_page_${loginType}'), + loginType: loginType, + ); + }, + ), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterPage(), + ), + + // Pages administrateur + GoRoute( + path: '/admin', + builder: (context, state) => const AdminDashboardPage(), + routes: [ + // Ajouter d'autres routes admin ici + ], + ), + + // Pages utilisateur + GoRoute( + path: '/user', + builder: (context, state) => const UserDashboardPage(), + routes: [ + // Ajouter d'autres routes utilisateur ici + ], + ), + ], + ); + + return MaterialApp.router( + debugShowCheckedModeBanner: false, + title: 'GEOSECTOR', + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + routerConfig: router, + ); + } +} diff --git a/flutt/lib/chat/README.md b/flutt/lib/chat/README.md new file mode 100644 index 00000000..4464c749 --- /dev/null +++ b/flutt/lib/chat/README.md @@ -0,0 +1,82 @@ +# Module Chat GEOSECTOR + +## Structure du module + +Le module chat est organisé selon une architecture modulaire respectant la séparation des préoccupations : + +``` +lib/chat/ +├── models/ # Modèles de données +│ ├── conversation_model.dart +│ ├── message_model.dart +│ ├── participant_model.dart +│ └── audience_target_model.dart +├── repositories/ # Gestion des données +│ └── chat_repository.dart +├── services/ # Services techniques +│ ├── chat_api_service.dart +│ └── offline_queue_service.dart +├── widgets/ # Composants UI +│ ├── chat_screen.dart +│ ├── conversations_list.dart +│ ├── message_bubble.dart +│ └── chat_input.dart +├── pages/ # Pages de l'application +│ └── chat_page.dart +├── chat.dart # Point d'entrée avec exports +└── README.md # Documentation du module +``` + +## Fonctionnalités principales + +1. **Conversations** : Support des conversations one-to-one, groupes et annonces +2. **Messages** : Envoi/réception de messages texte et pièces jointes +3. **Participants** : Gestion des participants aux conversations +4. **Annonces** : Diffusion de messages à des groupes spécifiques +5. **Mode hors ligne** : File d'attente pour la synchronisation des données + +## Utilisation + +### Importation + +```dart +import 'package:geosector/chat/chat.dart'; +``` + +### Affichage de la page chat + +```dart +Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ChatPage()), +); +``` + +### Création d'une conversation + +```dart +final chatRepository = ChatRepository(); +final conversation = await chatRepository.createConversation({ + 'type': 'one_to_one', + 'participants': [userId1, userId2], +}); +``` + +## États d'implémentation + +- [x] Structure de base +- [ ] Modèles de données complets +- [ ] Intégration avec Hive +- [ ] Services API +- [ ] Gestion hors ligne +- [ ] Widgets visuels +- [ ] Tests unitaires + +## À faire + +1. Compléter l'implémentation des modèles avec les adaptateurs Hive +2. Implémenter les méthodes dans les services et repositories +3. Créer les widgets visuels avec le design approprié +4. Ajouter les adaptateurs Hive pour le stockage local +5. Implémenter la gestion des pièces jointes +6. Ajouter les tests unitaires diff --git a/flutt/lib/chat/chat.dart b/flutt/lib/chat/chat.dart new file mode 100644 index 00000000..55811aed --- /dev/null +++ b/flutt/lib/chat/chat.dart @@ -0,0 +1,35 @@ +/// Exportation principale du module chat +/// +/// Ce fichier centralise les exportations du module chat +/// pour faciliter l'importation dans d'autres parties de l'application + +// Models +export 'models/conversation_model.dart'; +export 'models/message_model.dart'; +export 'models/participant_model.dart'; +export 'models/audience_target_model.dart'; +export 'models/anonymous_user_model.dart'; +export 'models/chat_config.dart'; +export 'models/notification_settings.dart'; + +// Repositories +export 'repositories/chat_repository.dart'; + +// Services +export 'services/chat_api_service.dart'; +export 'services/offline_queue_service.dart'; +export 'services/notifications/mqtt_notification_service.dart'; +export 'services/notifications/mqtt_config.dart'; + +// Widgets +export 'widgets/chat_screen.dart'; +export 'widgets/conversations_list.dart'; +export 'widgets/message_bubble.dart'; +export 'widgets/chat_input.dart'; +export 'widgets/notification_settings_widget.dart'; + +// Pages +export 'pages/chat_page.dart'; + +// Constants +export 'constants/chat_constants.dart'; diff --git a/flutt/lib/chat/chat_updated.md b/flutt/lib/chat/chat_updated.md new file mode 100644 index 00000000..537f9c16 --- /dev/null +++ b/flutt/lib/chat/chat_updated.md @@ -0,0 +1,510 @@ +# Solution de Chat pour Applications Flutter + +## Présentation générale + +Cette solution propose un système de chat personnalisé et autonome pour des applications Flutter, avec possibilité d'intégration web. Elle est conçue pour fonctionner dans deux contextes différents : + +1. **Chat entre utilisateurs authentifiés** (cas Geosector) : communications one-to-one ou en groupe entre utilisateurs déjà enregistrés dans la base de données. +2. **Chat entre professionnels et visiteurs anonymes** (cas Resalice) : communications initiées par des visiteurs anonymes qui peuvent ensuite être convertis en clients référencés. + +## Architecture technique + +### 1. Structure générale + +La solution s'articule autour de quatre composants principaux : + +- **Module Flutter** : Widgets et logique pour l'interface utilisateur mobile +- **Module Web** : Composants pour l'intégration web (compatible avec Flutter Web ou sites traditionnels) +- **API Backend** : Endpoints REST uniquement pour la récupération de l'historique des conversations +- **Module Go Chat Service** : Service de gestion des messages MQTT, modération et synchronisation avec la base de données + +### 2. Infrastructure de notifications + +#### Broker MQTT +Le système utilise MQTT pour les notifications en temps réel : +- Broker Mosquitto hébergé dans un container Incus +- Connexion sécurisée via SSL/TLS (port 8883) +- Authentification par username/password +- QoS 1 (at least once) pour garantir la livraison + +#### Module Go Chat Service +Un service externe en Go qui : +- Écoute les événements MQTT +- Enregistre les messages dans la base de données +- Applique des règles de modération configurables +- Synchronise les notifications avec le stockage + +```go +type ChatService struct { + mqttClient mqtt.Client + db *sql.DB + moderator *Moderator + config *ChatConfig +} + +type ChatConfig struct { + ApplicationID string + ModeratorEnabled bool + BadWords []string + FloodLimits int + SpamRules map[string]interface{} + Webhooks []string +} +``` + +### 3. Modèle de données + +#### Entités principales + +``` +Conversation + ├── id : Identifiant unique + ├── type : Type de conversation (one_to_one, group, anonymous, broadcast, announcement) + ├── title : Titre facultatif pour les groupes et obligatoire pour les annonces + ├── reply_permission : Niveau de permission pour répondre (all, admins_only, sender_only, none) + ├── created_at : Date de création + ├── updated_at : Dernière mise à jour + ├── is_pinned : Indique si la conversation est épinglée (pour annonces importantes) + ├── expiry_date : Date d'expiration optionnelle (pour annonces temporaires) + └── participants : Liste des participants + +Message + ├── id : Identifiant unique + ├── conversation_id : ID de la conversation + ├── sender_id : ID de l'expéditeur (null pour anonyme) + ├── sender_type : Type d'expéditeur (user, anonymous, system) + ├── content : Contenu du message + ├── content_type : Type de contenu (text, image, file) + ├── created_at : Date d'envoi + ├── delivered_at : Date de réception + ├── read_at : Date de lecture + ├── status : Statut du message (sent, delivered, read, error) + ├── is_announcement : Indique s'il s'agit d'une annonce officielle + ├── is_moderated : Indique si le message a été modéré + └── moderation_status : Statut de la modération (pending, approved, rejected) + +Participant + ├── id : Identifiant unique + ├── conversation_id : ID de la conversation + ├── user_id : ID de l'utilisateur (si authentifié) + ├── anonymous_id : ID anonyme (pour Resalice) + ├── role : Rôle (admin, member, read_only) + ├── joined_at : Date d'ajout à la conversation + ├── via_target : Indique si l'utilisateur est inclus via un AudienceTarget + ├── can_reply : Possibilité explicite de répondre (override de reply_permission) + └── last_read_message_id : ID du dernier message lu + +AudienceTarget + ├── id : Identifiant unique + ├── conversation_id : ID de la conversation + ├── target_type : Type de cible (role, entity, all, combined) + ├── target_id : ID du rôle ou de l'entité ciblée (pour compatibility) + ├── role_filter : Filtre de rôle pour le ciblage combiné ('all', '1', '2', etc.) + ├── entity_filter : Filtre d'entité pour le ciblage combiné ('all', 'id_entité') + └── created_at : Date de création + +AnonymousUser (pour Resalice) + ├── id : Identifiant unique + ├── device_id : Identifiant du dispositif + ├── name : Nom temporaire (si fourni) + ├── email : Email (si fourni) + ├── created_at : Date de création + ├── converted_to_user_id : ID utilisateur après conversion + └── metadata : Informations supplémentaires + +ChatNotification + ├── id : Identifiant unique + ├── user_id : ID de l'utilisateur destinataire + ├── message_id : ID du message + ├── conversation_id : ID de la conversation + ├── type : Type de notification + ├── status : Statut (sent, delivered, read) + ├── sent_at : Date d'envoi + └── read_at : Date de lecture +``` + +### 4. Backend et API + +#### Structure de l'API + +L'API sera développée en PHP 8.3 pour s'intégrer avec vos systèmes existants : + +``` +/api/chat/conversations + GET - Liste des conversations de l'utilisateur + POST - Créer une nouvelle conversation + +/api/chat/conversations/{id} + GET - Détails d'une conversation + PUT - Mettre à jour une conversation + DELETE - Supprimer une conversation + +/api/chat/conversations/{id}/messages + GET - Messages d'une conversation (pagination) - uniquement pour l'historique + +/api/chat/conversations/{id}/participants + GET - Liste des participants + POST - Ajouter un participant + DELETE - Retirer un participant + +/api/chat/messages/{id} + PUT - Mettre à jour un message (ex: marquer comme lu) + DELETE - Supprimer un message + +/api/chat/anonymous + POST - Démarrer une conversation anonyme + +# Nouveaux endpoints pour les annonces +/api/chat/announcements + GET - Liste des annonces pour l'utilisateur + POST - Créer une nouvelle annonce + +/api/chat/announcements/{id}/stats + GET - Obtenir les statistiques de lecture (qui a lu/non lu) + +/api/chat/audience-targets + GET - Obtenir les cibles disponibles pour l'utilisateur actuel + +/api/chat/conversations/{id}/pin + PUT - Épingler/désépingler une conversation + +/api/chat/conversations/{id}/reply-permission + PUT - Modifier les permissions de réponse + +/api/chat/moderation/rules + GET - Obtenir les règles de modération + PUT - Mettre à jour les règles de modération +``` + +#### Synchronisation + +Le système supporte deux flux de données distincts : + +1. **Temps réel via MQTT** : + - Envoi de messages en temps réel + - Notifications instantanées + - Gestion via le module Go + +2. **Récupération historique via REST** : + - Chargement de l'historique des conversations + - Synchronisation des anciens messages + - Accès direct à la base de données + +- Enregistrement local des messages avec Hive pour le fonctionnement hors ligne + +### 5. Widgets Flutter + +#### Widgets principaux + +1. **ChatScreen** : Écran principal d'une conversation + + ```dart + ChatScreen({ + required String conversationId, + String? title, + Widget? header, + Widget? footer, + bool enableAttachments = true, + bool showTypingIndicator = true, + bool enableReadReceipts = true, + bool isAnnouncement = false, + bool canReply = true, + }) + ``` + +2. **ConversationsList** : Liste des conversations + + ```dart + ConversationsList({ + List? 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) onSend, + List>? availableTargets, + String? initialTitle, + String? initialMessage, + bool allowAttachments = true, + bool allowPinning = true, + List 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 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 _handleMessage(String topic, Map 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 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> 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. diff --git a/flutt/lib/chat/constants/chat_constants.dart b/flutt/lib/chat/constants/chat_constants.dart new file mode 100644 index 00000000..3ca30c83 --- /dev/null +++ b/flutt/lib/chat/constants/chat_constants.dart @@ -0,0 +1,50 @@ +/// Constantes spécifiques au module chat + +class ChatConstants { + // Types de conversations + static const String conversationTypeOneToOne = 'one_to_one'; + static const String conversationTypeGroup = 'group'; + static const String conversationTypeAnonymous = 'anonymous'; + static const String conversationTypeBroadcast = 'broadcast'; + static const String conversationTypeAnnouncement = 'announcement'; + + // Types de messages + static const String messageTypeText = 'text'; + static const String messageTypeImage = 'image'; + static const String messageTypeFile = 'file'; + static const String messageTypeSystem = 'system'; + + // Types d'expéditeurs + static const String senderTypeUser = 'user'; + static const String senderTypeAnonymous = 'anonymous'; + static const String senderTypeSystem = 'system'; + + // Rôles des participants + static const String participantRoleAdmin = 'admin'; + static const String participantRoleMember = 'member'; + static const String participantRoleReadOnly = 'read_only'; + + // Permissions de réponse + static const String replyPermissionAll = 'all'; + static const String replyPermissionAdminsOnly = 'admins_only'; + static const String replyPermissionSenderOnly = 'sender_only'; + static const String replyPermissionNone = 'none'; + + // Types de cibles d'audience + static const String targetTypeRole = 'role'; + static const String targetTypeEntity = 'entity'; + static const String targetTypeAll = 'all'; + + // Noms des boîtes Hive + static const String conversationsBoxName = 'chat_conversations'; + static const String messagesBoxName = 'chat_messages'; + static const String participantsBoxName = 'chat_participants'; + static const String anonymousUsersBoxName = 'chat_anonymous_users'; + static const String offlineQueueBoxName = 'chat_offline_queue'; + + // Configurations + static const int defaultMessagePageSize = 50; + static const int maxAttachmentSizeMB = 10; + static const int maxMessageLength = 5000; + static const Duration typingIndicatorTimeout = Duration(seconds: 3); +} diff --git a/flutt/lib/chat/example_integration/mqtt_integration_example.dart b/flutt/lib/chat/example_integration/mqtt_integration_example.dart new file mode 100644 index 00000000..c7fdb391 --- /dev/null +++ b/flutt/lib/chat/example_integration/mqtt_integration_example.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import '../chat.dart'; + +/// Exemple d'intégration du service MQTT dans l'application +/// +/// Montre comment initialiser et utiliser le service de notifications MQTT + +class MqttIntegrationExample extends StatefulWidget { + const MqttIntegrationExample({super.key}); + + @override + State createState() => _MqttIntegrationExampleState(); +} + +class _MqttIntegrationExampleState extends State { + late final MqttNotificationService _notificationService; + bool _isInitialized = false; + String _status = 'Non initialisé'; + + @override + void initState() { + super.initState(); + _initializeMqttService(); + } + + Future _initializeMqttService() async { + try { + // Initialiser le service avec la configuration + _notificationService = MqttNotificationService( + mqttHost: MqttConfig.host, + mqttPort: MqttConfig.port, + mqttUsername: MqttConfig.username, + mqttPassword: MqttConfig.password, + ); + + // Configurer les callbacks + _notificationService.onMessageTap = (messageId) { + debugPrint('Notification tapée : $messageId'); + // Naviguer vers la conversation correspondante + _navigateToMessage(messageId); + }; + + _notificationService.onNotificationReceived = (data) { + debugPrint('Notification reçue : $data'); + setState(() { + _status = 'Notification reçue : ${data['content']}'; + }); + }; + + // Initialiser avec l'ID utilisateur (récupéré du UserRepository) + final userId = _getCurrentUserId(); // À implémenter selon votre logique + await _notificationService.initialize(userId: userId); + + setState(() { + _isInitialized = true; + _status = 'Service MQTT initialisé'; + }); + + } catch (e) { + setState(() { + _status = 'Erreur : $e'; + }); + } + } + + String _getCurrentUserId() { + // Dans votre application réelle, vous récupéreriez l'ID utilisateur + // depuis le UserRepository ou le contexte de l'application + return '123'; // Exemple + } + + void _navigateToMessage(String messageId) { + // Implémenter la navigation vers le message + // Par exemple : + // Navigator.push(context, MaterialPageRoute( + // builder: (_) => ChatScreen(messageId: messageId), + // )); + } + + @override + void dispose() { + _notificationService.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test MQTT Notifications'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _status, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + if (_isInitialized) ...[ + ElevatedButton( + onPressed: () { + _notificationService.pauseNotifications(); + setState(() { + _status = 'Notifications en pause'; + }); + }, + child: const Text('Pause Notifications'), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + _notificationService.resumeNotifications(); + setState(() { + _status = 'Notifications actives'; + }); + }, + child: const Text('Reprendre Notifications'), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () async { + // Exemple de test en publiant un message + await _notificationService.publishMessage( + 'chat/user/${_getCurrentUserId()}/messages', + { + 'type': 'chat_message', + 'messageId': 'test_${DateTime.now().millisecondsSinceEpoch}', + 'content': 'Message de test', + 'senderId': '999', + 'senderName': 'Système', + }, + ); + setState(() { + _status = 'Message test envoyé'; + }); + }, + child: const Text('Envoyer Message Test'), + ), + ] else ...[ + const CircularProgressIndicator(), + ], + ], + ), + ), + ); + } +} + +/// Exemple d'intégration dans le main.dart de votre application +void mainExample() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: const MqttIntegrationExample(), + ); + } +} diff --git a/flutt/lib/chat/models/anonymous_user_model.dart b/flutt/lib/chat/models/anonymous_user_model.dart new file mode 100644 index 00000000..3f43c60e --- /dev/null +++ b/flutt/lib/chat/models/anonymous_user_model.dart @@ -0,0 +1,104 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; + +part 'anonymous_user_model.g.dart'; + +/// Modèle d'utilisateur anonyme pour le système de chat +/// +/// Ce modèle représente un utilisateur anonyme (pour le cas Resalice) +/// et permet de tracker sa conversion éventuelle en utilisateur authentifié + +@HiveType(typeId: 24) +class AnonymousUserModel extends HiveObject with EquatableMixin { + @HiveField(0) + final String id; + + @HiveField(1) + final String deviceId; + + @HiveField(2) + final String? name; + + @HiveField(3) + final String? email; + + @HiveField(4) + final DateTime createdAt; + + @HiveField(5) + final String? convertedToUserId; + + @HiveField(6) + final Map? 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 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?, + ); + } + + /// Convertit l'instance en JSON + Map 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? 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 get props => [ + id, + deviceId, + name, + email, + createdAt, + convertedToUserId, + metadata, + ]; +} diff --git a/flutt/lib/chat/models/anonymous_user_model.g.dart b/flutt/lib/chat/models/anonymous_user_model.g.dart new file mode 100644 index 00000000..f3962c14 --- /dev/null +++ b/flutt/lib/chat/models/anonymous_user_model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'anonymous_user_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AnonymousUserModelAdapter extends TypeAdapter { + @override + final int typeId = 24; + + @override + AnonymousUserModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AnonymousUserModel( + id: fields[0] as String, + deviceId: fields[1] as String, + name: fields[2] as String?, + email: fields[3] as String?, + createdAt: fields[4] as DateTime, + convertedToUserId: fields[5] as String?, + metadata: (fields[6] as Map?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, AnonymousUserModel obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.deviceId) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.email) + ..writeByte(4) + ..write(obj.createdAt) + ..writeByte(5) + ..write(obj.convertedToUserId) + ..writeByte(6) + ..write(obj.metadata); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnonymousUserModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/chat/models/audience_target_model.dart b/flutt/lib/chat/models/audience_target_model.dart new file mode 100644 index 00000000..86dc3f1d --- /dev/null +++ b/flutt/lib/chat/models/audience_target_model.dart @@ -0,0 +1,138 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; + +part 'audience_target_model.g.dart'; + +/// Modèle de cible d'audience pour le système de chat +/// +/// Ce modèle représente une cible d'audience pour les annonces et broadcasts +/// Il supporte maintenant le ciblage combiné avec les filtres de rôle et d'entité + +@HiveType(typeId: 23) +class AudienceTargetModel extends HiveObject with EquatableMixin { + @HiveField(0) + final String id; + + @HiveField(1) + final String conversationId; + + @HiveField(2) + final String targetType; + + @HiveField(3) + final String? targetId; + + @HiveField(4) + final DateTime createdAt; + + @HiveField(5) + final String? roleFilter; + + @HiveField(6) + final String? entityFilter; + + AudienceTargetModel({ + required this.id, + required this.conversationId, + required this.targetType, + this.targetId, + required this.createdAt, + this.roleFilter, + this.entityFilter, + }); + + /// Crée une instance depuis le JSON + factory AudienceTargetModel.fromJson(Map 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 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 get props => [ + id, + conversationId, + targetType, + targetId, + createdAt, + roleFilter, + entityFilter, + ]; +} diff --git a/flutt/lib/chat/models/audience_target_model.g.dart b/flutt/lib/chat/models/audience_target_model.g.dart new file mode 100644 index 00000000..120faaef --- /dev/null +++ b/flutt/lib/chat/models/audience_target_model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'audience_target_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AudienceTargetModelAdapter extends TypeAdapter { + @override + final int typeId = 23; + + @override + AudienceTargetModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AudienceTargetModel( + id: fields[0] as String, + conversationId: fields[1] as String, + targetType: fields[2] as String, + targetId: fields[3] as String?, + createdAt: fields[4] as DateTime, + roleFilter: fields[5] as String?, + entityFilter: fields[6] as String?, + ); + } + + @override + void write(BinaryWriter writer, AudienceTargetModel obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.conversationId) + ..writeByte(2) + ..write(obj.targetType) + ..writeByte(3) + ..write(obj.targetId) + ..writeByte(4) + ..write(obj.createdAt) + ..writeByte(5) + ..write(obj.roleFilter) + ..writeByte(6) + ..write(obj.entityFilter); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AudienceTargetModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/chat/models/chat_adapters.dart b/flutt/lib/chat/models/chat_adapters.dart new file mode 100644 index 00000000..a9144be7 --- /dev/null +++ b/flutt/lib/chat/models/chat_adapters.dart @@ -0,0 +1,15 @@ +// Fichier central pour regrouper tous les adaptateurs Hive du module chat + +// Exports des modèles et leurs adaptateurs +export 'conversation_model.dart'; +export 'message_model.dart'; +export 'participant_model.dart'; +export 'anonymous_user_model.dart'; +export 'audience_target_model.dart'; +export 'notification_settings.dart'; + +// Fonction pour enregistrer tous les adaptateurs Hive du chat +Future 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 +} diff --git a/flutt/lib/chat/models/chat_config.dart b/flutt/lib/chat/models/chat_config.dart new file mode 100644 index 00000000..ed04c88b --- /dev/null +++ b/flutt/lib/chat/models/chat_config.dart @@ -0,0 +1,104 @@ +import 'package:equatable/equatable.dart'; + +/// Configuration du module chat +/// +/// Permet d'adapter le comportement du chat selon l'application +/// (Geosector ou Resalice) + +class ChatConfig with EquatableMixin { + /// Active/désactive les annonces + final bool enableAnnouncements; + + /// Active/désactive la sélection de cibles pour les annonces + final bool enableTargetSelection; + + /// Active/désactive les statistiques des annonces + final bool showAnnouncementStats; + + /// Permission de réponse par défaut + final String defaultReplyPermission; + + /// Active/désactive les conversations anonymes + final bool enableAnonymousConversations; + + /// Active/désactive les conversations de groupe + final bool enableGroupConversations; + + /// Types de conversation autorisés + final List 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 get props => [ + enableAnnouncements, + enableTargetSelection, + showAnnouncementStats, + defaultReplyPermission, + enableAnonymousConversations, + enableGroupConversations, + allowedConversationTypes, + maxAttachmentSizeMB, + messagePageSize, + ]; +} diff --git a/flutt/lib/chat/models/conversation_model.dart b/flutt/lib/chat/models/conversation_model.dart new file mode 100644 index 00000000..ac369d16 --- /dev/null +++ b/flutt/lib/chat/models/conversation_model.dart @@ -0,0 +1,139 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; +import 'participant_model.dart'; + +part 'conversation_model.g.dart'; + +/// Modèle de conversation pour le système de chat +/// +/// Ce modèle représente une conversation entre utilisateurs +/// Il supporte différents types de conversations : +/// - one_to_one : conversation privée entre 2 utilisateurs +/// - group : groupe de plusieurs utilisateurs +/// - anonymous : conversation avec un utilisateur anonyme +/// - broadcast : message diffusé à plusieurs utilisateurs +/// - announcement : annonce officielle + +@HiveType(typeId: 20) +class ConversationModel extends HiveObject with EquatableMixin { + @HiveField(0) + final String id; + + @HiveField(1) + final String type; + + @HiveField(2) + final String? title; + + @HiveField(3) + final DateTime createdAt; + + @HiveField(4) + final DateTime updatedAt; + + @HiveField(5) + final List 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 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)) + .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 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? 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 get props => [ + id, + type, + title, + createdAt, + updatedAt, + participants, + isSynced, + replyPermission, + isPinned, + expiryDate, + ]; +} diff --git a/flutt/lib/chat/models/conversation_model.g.dart b/flutt/lib/chat/models/conversation_model.g.dart new file mode 100644 index 00000000..97d4e952 --- /dev/null +++ b/flutt/lib/chat/models/conversation_model.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'conversation_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ConversationModelAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + ConversationModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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(), + 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; +} diff --git a/flutt/lib/chat/models/message_model.dart b/flutt/lib/chat/models/message_model.dart new file mode 100644 index 00000000..15299995 --- /dev/null +++ b/flutt/lib/chat/models/message_model.dart @@ -0,0 +1,140 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; + +part 'message_model.g.dart'; + +/// Modèle de message pour le système de chat +/// +/// Ce modèle représente un message échangé dans une conversation + +@HiveType(typeId: 21) +class MessageModel extends HiveObject with EquatableMixin { + @HiveField(0) + final String id; + + @HiveField(1) + final String conversationId; + + @HiveField(2) + final String? senderId; + + @HiveField(3) + final String senderType; + + @HiveField(4) + final String content; + + @HiveField(5) + final String contentType; + + @HiveField(6) + final DateTime createdAt; + + @HiveField(7) + final DateTime? deliveredAt; + + @HiveField(8) + final DateTime? readAt; + + @HiveField(9) + final String status; + + @HiveField(10) + final bool isAnnouncement; + + MessageModel({ + required this.id, + required this.conversationId, + this.senderId, + required this.senderType, + required this.content, + required this.contentType, + required this.createdAt, + this.deliveredAt, + this.readAt, + required this.status, + this.isAnnouncement = false, + }); + + /// Crée une instance depuis le JSON + factory MessageModel.fromJson(Map 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 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 get props => [ + id, + conversationId, + senderId, + senderType, + content, + contentType, + createdAt, + deliveredAt, + readAt, + status, + isAnnouncement, + ]; +} diff --git a/flutt/lib/chat/models/message_model.g.dart b/flutt/lib/chat/models/message_model.g.dart new file mode 100644 index 00000000..3c81705b --- /dev/null +++ b/flutt/lib/chat/models/message_model.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MessageModelAdapter extends TypeAdapter { + @override + final int typeId = 21; + + @override + MessageModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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; +} diff --git a/flutt/lib/chat/models/notification_settings.dart b/flutt/lib/chat/models/notification_settings.dart new file mode 100644 index 00000000..310245be --- /dev/null +++ b/flutt/lib/chat/models/notification_settings.dart @@ -0,0 +1,160 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; + +part 'notification_settings.g.dart'; + +/// Paramètres de notification pour le chat +/// +/// Permet à l'utilisateur de configurer ses préférences de notification + +@HiveType(typeId: 25) +class NotificationSettings extends HiveObject with EquatableMixin { + @HiveField(0) + final bool enableNotifications; + + @HiveField(1) + final bool soundEnabled; + + @HiveField(2) + final bool vibrationEnabled; + + @HiveField(3) + final List mutedConversations; + + @HiveField(4) + final bool showPreview; + + @HiveField(5) + final Map 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 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.from(json['muted_conversations'] ?? []), + showPreview: json['show_preview'] as bool? ?? true, + conversationNotifications: Map.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 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? mutedConversations, + bool? showPreview, + Map? 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 get props => [ + enableNotifications, + soundEnabled, + vibrationEnabled, + mutedConversations, + showPreview, + conversationNotifications, + doNotDisturb, + doNotDisturbStart, + doNotDisturbEnd, + deviceToken, + ]; +} diff --git a/flutt/lib/chat/models/notification_settings.g.dart b/flutt/lib/chat/models/notification_settings.g.dart new file mode 100644 index 00000000..69061d1f --- /dev/null +++ b/flutt/lib/chat/models/notification_settings.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class NotificationSettingsAdapter extends TypeAdapter { + @override + final int typeId = 25; + + @override + NotificationSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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(), + showPreview: fields[4] as bool, + conversationNotifications: (fields[5] as Map).cast(), + 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; +} diff --git a/flutt/lib/chat/models/participant_model.dart b/flutt/lib/chat/models/participant_model.dart new file mode 100644 index 00000000..202ca604 --- /dev/null +++ b/flutt/lib/chat/models/participant_model.dart @@ -0,0 +1,118 @@ +import 'package:hive/hive.dart'; +import 'package:equatable/equatable.dart'; + +part 'participant_model.g.dart'; + +/// Modèle de participant pour le système de chat +/// +/// Ce modèle représente un participant à une conversation + +@HiveType(typeId: 22) +class ParticipantModel extends HiveObject with EquatableMixin { + @HiveField(0) + final String id; + + @HiveField(1) + final String conversationId; + + @HiveField(2) + final String? userId; + + @HiveField(3) + final String? anonymousId; + + @HiveField(4) + final String role; + + @HiveField(5) + final DateTime joinedAt; + + @HiveField(6) + final String? lastReadMessageId; + + @HiveField(7) + final bool viaTarget; + + @HiveField(8) + final bool? canReply; + + ParticipantModel({ + required this.id, + required this.conversationId, + this.userId, + this.anonymousId, + required this.role, + required this.joinedAt, + this.lastReadMessageId, + this.viaTarget = false, + this.canReply, + }); + + /// Crée une instance depuis le JSON + factory ParticipantModel.fromJson(Map 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 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 get props => [ + id, + conversationId, + userId, + anonymousId, + role, + joinedAt, + lastReadMessageId, + viaTarget, + canReply, + ]; +} diff --git a/flutt/lib/chat/models/participant_model.g.dart b/flutt/lib/chat/models/participant_model.g.dart new file mode 100644 index 00000000..ecbf31c3 --- /dev/null +++ b/flutt/lib/chat/models/participant_model.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'participant_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ParticipantModelAdapter extends TypeAdapter { + @override + final int typeId = 22; + + @override + ParticipantModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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; +} diff --git a/flutt/lib/chat/pages/chat_page.dart b/flutt/lib/chat/pages/chat_page.dart new file mode 100644 index 00000000..60562a4c --- /dev/null +++ b/flutt/lib/chat/pages/chat_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import '../widgets/conversations_list.dart'; +import '../widgets/chat_screen.dart'; + +/// Page principale du module chat +/// +/// Cette page sert de point d'entrée pour le module chat +/// et gère la navigation entre les conversations + +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + String? _selectedConversationId; + + @override + Widget build(BuildContext context) { + final isLargeScreen = MediaQuery.of(context).size.width > 900; + + if (isLargeScreen) { + // Vue desktop (séparée en deux panneaux) + return Scaffold( + body: Row( + children: [ + // Liste des conversations à gauche + SizedBox( + width: 300, + child: ConversationsList( + onConversationSelected: (conversation) { + setState(() { + _selectedConversationId = 'conversation-id'; // TODO: obtenir l'ID de la conversation + }); + }, + ), + ), + const VerticalDivider(width: 1), + // Conversation sélectionnée à droite + Expanded( + child: _selectedConversationId != null + ? ChatScreen(conversationId: _selectedConversationId!) + : const Center(child: Text('Sélectionnez une conversation')), + ), + ], + ), + ); + } else { + // Vue mobile + return Scaffold( + appBar: AppBar( + title: const Text('Chat'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + // TODO: Créer une nouvelle conversation + }, + ), + ], + ), + body: ConversationsList( + onConversationSelected: (conversation) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + conversationId: 'conversation-id', // TODO: obtenir l'ID de la conversation + ), + ), + ); + }, + ), + ); + } + } +} diff --git a/flutt/lib/chat/repositories/chat_repository.dart b/flutt/lib/chat/repositories/chat_repository.dart new file mode 100644 index 00000000..46a6ae88 --- /dev/null +++ b/flutt/lib/chat/repositories/chat_repository.dart @@ -0,0 +1,364 @@ +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.dart'; +import '../../core/constants/app_keys.dart'; +import '../models/conversation_model.dart'; +import '../models/message_model.dart'; +import '../models/participant_model.dart'; +import '../services/chat_api_service.dart'; +import '../services/notifications/mqtt_notification_service.dart'; + +/// Repository pour la gestion des fonctionnalités de chat +/// +/// Ce repository centralise toutes les opérations liées au chat, +/// y compris la gestion des conversations, des messages et des participants + +class ChatRepository { + final ChatApiService _apiService; + final MqttNotificationService _mqttService; + + ChatRepository(this._apiService, this._mqttService); + + /// Liste des conversations de l'utilisateur + Future> getConversations({bool forceRefresh = false}) async { + try { + // Récupérer depuis Hive + var box = await Hive.openBox(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 getConversation(String id) async { + try { + // Vérifier d'abord dans Hive + var box = await Hive.openBox(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 createConversation(Map data) async { + try { + // Créer via l'API + var conversation = await _apiService.createConversation(data); + + // Sauvegarder dans Hive + var box = await Hive.openBox(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 deleteConversation(String id) async { + try { + // Supprimer via l'API + await _apiService.deleteConversation(id); + + // Supprimer de Hive + var box = await Hive.openBox(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 pinConversation(String id, bool isPinned) async { + try { + await _apiService.pinConversation(id, isPinned); + + // Mettre à jour dans Hive + var box = await Hive.openBox(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 updateReplyPermission(String id, String replyPermission) async { + try { + await _apiService.updateReplyPermission(id, replyPermission); + + // Mettre à jour dans Hive + var box = await Hive.openBox(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> getMessages(String conversationId, {int page = 1, int limit = 50}) async { + try { + // Récupérer depuis Hive + var box = await Hive.openBox(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 sendMessage(String conversationId, Map 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(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 markMessageAsRead(String messageId) async { + try { + // Mettre à jour via l'API + await _apiService.markMessageAsRead(messageId); + + // Mettre à jour dans Hive + var box = await Hive.openBox(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 addParticipant(String conversationId, Map participantData) async { + try { + await _apiService.addParticipant(conversationId, participantData); + + // Mettre à jour la conversation dans Hive + var conversationBox = await Hive.openBox(AppKeys.chatConversationsBoxName); + var conversation = conversationBox.get(conversationId); + if (conversation != null) { + var updatedParticipants = List.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 removeParticipant(String conversationId, String participantId) async { + try { + await _apiService.removeParticipant(conversationId, participantId); + + // Mettre à jour la conversation dans Hive + var conversationBox = await Hive.openBox(AppKeys.chatConversationsBoxName); + var conversation = conversationBox.get(conversationId); + if (conversation != null) { + var updatedParticipants = List.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 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 convertAnonymousToUser(String anonymousId, String userId) async { + try { + // Mettre à jour tous les messages de l'utilisateur anonyme + var messageBox = await Hive.openBox(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> 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 createAnnouncement(Map 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> 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>> 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 addAudienceTarget(String conversationId, Map 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 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'); + } + } +} diff --git a/flutt/lib/chat/scripts/chat_tables.sql b/flutt/lib/chat/scripts/chat_tables.sql new file mode 100644 index 00000000..deb995e6 --- /dev/null +++ b/flutt/lib/chat/scripts/chat_tables.sql @@ -0,0 +1,213 @@ +-- Script de création des tables chat pour MariaDB +-- Compatible avec le module chat GEOSECTOR +-- Création des tables pour le système de chat + +-- Table des salles de discussion +DROP TABLE IF EXISTS `chat_rooms`; +CREATE TABLE `chat_rooms` ( + `id` varchar(50) NOT NULL, + `type` enum('privee', 'groupe', 'liste_diffusion', 'broadcast', 'announcement') NOT NULL, + `title` varchar(100) DEFAULT NULL, + `date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `fk_user` int unsigned NOT NULL, + `fk_entite` int unsigned DEFAULT NULL, + `statut` enum('active', 'archive') NOT NULL DEFAULT 'active', + `description` text, + `reply_permission` enum('all', 'admins_only', 'sender_only', 'none') NOT NULL DEFAULT 'all', + `is_pinned` tinyint(1) unsigned NOT NULL DEFAULT 0, + `expiry_date` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user` (`fk_user`), + KEY `idx_entite` (`fk_entite`), + KEY `idx_type` (`type`), + KEY `idx_statut` (`statut`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des participants aux salles de discussion +DROP TABLE IF EXISTS `chat_participants`; +CREATE TABLE `chat_participants` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `id_room` varchar(50) NOT NULL, + `id_user` int unsigned DEFAULT NULL, + `anonymous_id` varchar(50) DEFAULT NULL, + `role` enum('administrateur', 'participant', 'en_lecture_seule') NOT NULL DEFAULT 'participant', + `date_ajout` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `notification_activee` tinyint(1) unsigned NOT NULL DEFAULT 1, + `last_read_message_id` varchar(50) DEFAULT NULL, + `via_target` tinyint(1) unsigned NOT NULL DEFAULT 0, + `can_reply` tinyint(1) unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_room` (`id_room`), + KEY `idx_user` (`id_user`), + KEY `idx_anonymous_id` (`anonymous_id`), + CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`id_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE, + CONSTRAINT `uc_room_user` UNIQUE (`id_room`, `id_user`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des messages +DROP TABLE IF EXISTS `chat_messages`; +CREATE TABLE `chat_messages` ( + `id` varchar(50) NOT NULL, + `fk_room` varchar(50) NOT NULL, + `fk_user` int unsigned DEFAULT NULL, + `sender_type` enum('user', 'anonymous', 'system') NOT NULL DEFAULT 'user', + `content` text, + `content_type` enum('text', 'image', 'file') NOT NULL DEFAULT 'text', + `date_sent` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_delivered` timestamp NULL DEFAULT NULL, + `date_read` timestamp NULL DEFAULT NULL, + `statut` enum('envoye', 'livre', 'lu', 'error') NOT NULL DEFAULT 'envoye', + `is_announcement` tinyint(1) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_room` (`fk_room`), + KEY `idx_user` (`fk_user`), + KEY `idx_date` (`date_sent`), + KEY `idx_status` (`statut`), + CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des cibles d'audience +DROP TABLE IF EXISTS `chat_audience_targets`; +CREATE TABLE `chat_audience_targets` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_room` varchar(50) NOT NULL, + `target_type` enum('role', 'entity', 'all', 'combined') NOT NULL DEFAULT 'all', + `target_id` varchar(50) DEFAULT NULL, + `role_filter` varchar(20) DEFAULT NULL, + `entity_filter` varchar(50) DEFAULT NULL, + `date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_room` (`fk_room`), + KEY `idx_type` (`target_type`), + CONSTRAINT `fk_chat_audience_targets_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des listes de diffusion +DROP TABLE IF EXISTS `chat_broadcast_lists`; +CREATE TABLE `chat_broadcast_lists` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `fk_room` varchar(50) NOT NULL, + `name` varchar(100) NOT NULL, + `description` text, + `fk_user_creator` int unsigned NOT NULL, + `date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_room` (`fk_room`), + KEY `idx_user_creator` (`fk_user_creator`), + CONSTRAINT `fk_chat_broadcast_lists_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table pour suivre la lecture des messages +DROP TABLE IF EXISTS `chat_read_messages`; +CREATE TABLE `chat_read_messages` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `fk_message` varchar(50) NOT NULL, + `fk_user` int unsigned NOT NULL, + `date_read` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_message` (`fk_message`), + KEY `idx_user` (`fk_user`), + CONSTRAINT `uc_message_user` UNIQUE (`fk_message`, `fk_user`), + CONSTRAINT `fk_chat_read_messages_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des notifications +DROP TABLE IF EXISTS `chat_notifications`; +CREATE TABLE `chat_notifications` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `fk_user` int unsigned NOT NULL, + `fk_message` varchar(50) DEFAULT NULL, + `fk_room` varchar(50) DEFAULT NULL, + `type` varchar(50) NOT NULL, + `contenu` text, + `date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `date_lecture` timestamp NULL DEFAULT NULL, + `statut` enum('non_lue', 'lue') NOT NULL DEFAULT 'non_lue', + PRIMARY KEY (`id`), + KEY `idx_user` (`fk_user`), + KEY `idx_message` (`fk_message`), + KEY `idx_room` (`fk_room`), + KEY `idx_statut` (`statut`), + CONSTRAINT `fk_chat_notifications_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_chat_notifications_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des utilisateurs anonymes (pour Resalice) +DROP TABLE IF EXISTS `chat_anonymous_users`; +CREATE TABLE `chat_anonymous_users` ( + `id` varchar(50) NOT NULL, + `device_id` varchar(100) NOT NULL, + `name` varchar(100) DEFAULT NULL, + `email` varchar(100) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `converted_to_user_id` int unsigned DEFAULT NULL, + `metadata` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_device_id` (`device_id`), + KEY `idx_converted_user` (`converted_to_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table pour la file d'attente hors ligne +DROP TABLE IF EXISTS `chat_offline_queue`; +CREATE TABLE `chat_offline_queue` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `operation_type` varchar(50) NOT NULL, + `operation_data` json NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `processed_at` timestamp NULL DEFAULT NULL, + `status` enum('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending', + `error_message` text, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_status` (`status`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table pour les pièces jointes +DROP TABLE IF EXISTS `chat_attachments`; +CREATE TABLE `chat_attachments` ( + `id` varchar(50) NOT NULL, + `fk_message` varchar(50) NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_path` varchar(500) NOT NULL, + `file_type` varchar(100) NOT NULL, + `file_size` int unsigned NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_message` (`fk_message`), + CONSTRAINT `fk_chat_attachments_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Vues utiles + +-- Vue des messages avec informations utilisateur +CREATE OR REPLACE VIEW `chat_messages_with_users` AS +SELECT + m.*, + u.name as sender_name, + u.username as sender_username, + u.fk_entite as sender_entity_id +FROM chat_messages m +LEFT JOIN users u ON m.fk_user = u.id; + +-- Vue des conversations avec compte de messages non lus +CREATE OR REPLACE VIEW `chat_conversations_unread` AS +SELECT + r.*, + COUNT(DISTINCT m.id) as total_messages, + COUNT(DISTINCT rm.id) as read_messages, + COUNT(DISTINCT m.id) - COUNT(DISTINCT rm.id) as unread_messages, + (SELECT date_sent FROM chat_messages + WHERE fk_room = r.id + ORDER BY date_sent DESC LIMIT 1) as last_message_date +FROM chat_rooms r +LEFT JOIN chat_messages m ON r.id = m.fk_room +LEFT JOIN chat_read_messages rm ON m.id = rm.fk_message +GROUP BY r.id; + +-- Index supplémentaires pour les performances +CREATE INDEX idx_messages_unread ON chat_messages(fk_room, statut); +CREATE INDEX idx_participants_active ON chat_participants(id_room, id_user, notification_activee); +CREATE INDEX idx_notifications_unread ON chat_notifications(fk_user, statut); diff --git a/flutt/lib/chat/scripts/mqtt_notification_sender.php b/flutt/lib/chat/scripts/mqtt_notification_sender.php new file mode 100644 index 00000000..6b553ef6 --- /dev/null +++ b/flutt/lib/chat/scripts/mqtt_notification_sender.php @@ -0,0 +1,323 @@ +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(); + } + } +} diff --git a/flutt/lib/chat/scripts/send_notification.php b/flutt/lib/chat/scripts/send_notification.php new file mode 100644 index 00000000..62b44d25 --- /dev/null +++ b/flutt/lib/chat/scripts/send_notification.php @@ -0,0 +1,263 @@ +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); + } +} diff --git a/flutt/lib/chat/services/chat_api_service.dart b/flutt/lib/chat/services/chat_api_service.dart new file mode 100644 index 00000000..23cb294c --- /dev/null +++ b/flutt/lib/chat/services/chat_api_service.dart @@ -0,0 +1,97 @@ +/// Service API pour la communication avec le backend du chat +/// +/// Ce service gère toutes les requêtes HTTP vers l'API chat + +class ChatApiService { + final String baseUrl; + final String? authToken; + + ChatApiService({ + required this.baseUrl, + this.authToken, + }); + + /// Récupère les conversations + Future> fetchConversations() async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Récupère les messages d'une conversation + Future> 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> createConversation(Map data) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Envoie un message + Future> sendMessage(String conversationId, Map messageData) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Marque un message comme lu + Future> markMessageAsRead(String messageId) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Ajoute un participant + Future> addParticipant(String conversationId, Map participantData) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Retire un participant + Future> removeParticipant(String conversationId, String participantId) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Crée un utilisateur anonyme + Future> createAnonymousUser({String? name, String? email}) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Récupère les annonces + Future> fetchAnnouncements() async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Crée une annonce + Future> createAnnouncement(Map data) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Récupère les statistiques d'une annonce + Future> fetchAnnouncementStats(String conversationId) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Récupère les cibles d'audience disponibles + Future> fetchAvailableAudienceTargets() async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Met à jour une conversation + Future> updateConversation(String id, Map data) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } + + /// Supprime une conversation + Future deleteConversation(String id) async { + // TODO: Implémenter la requête HTTP + throw UnimplementedError(); + } +} diff --git a/flutt/lib/chat/services/notifications/README_MQTT.md b/flutt/lib/chat/services/notifications/README_MQTT.md new file mode 100644 index 00000000..ad2e9ad6 --- /dev/null +++ b/flutt/lib/chat/services/notifications/README_MQTT.md @@ -0,0 +1,214 @@ +# Notifications MQTT pour le Chat GEOSECTOR + +## Vue d'ensemble + +Ce système de notifications utilise MQTT pour fournir des notifications push en temps réel pour le module chat. Il offre une alternative légère à Firebase Cloud Messaging (FCM) et peut être auto-hébergé dans votre infrastructure. + +## Architecture + +### Composants principaux + +1. **MqttNotificationService** (Flutter) + - Service de notification côté client + - Gère la connexion au broker MQTT + - Traite les messages entrants + - Affiche les notifications locales + +2. **MqttConfig** (Flutter) + - Configuration centralisée pour MQTT + - Gestion des topics + - Paramètres de connexion + +3. **MqttNotificationSender** (PHP) + - Service backend pour envoyer les notifications + - Interface avec la base de données + - Gestion des cibles d'audience + +## Configuration du broker MQTT + +### Container Incus + +Le broker MQTT (Eclipse Mosquitto recommandé) doit être installé dans votre container Incus : + +```bash +# Installer Mosquitto +apt-get update +apt-get install mosquitto mosquitto-clients + +# Configurer Mosquitto +vi /etc/mosquitto/mosquitto.conf +``` + +Configuration recommandée : +``` +listener 1883 +allow_anonymous false +password_file /etc/mosquitto/passwd + +# Pour SSL/TLS +listener 8883 +cafile /etc/mosquitto/ca.crt +certfile /etc/mosquitto/server.crt +keyfile /etc/mosquitto/server.key +``` + +### Sécurité + +Pour un environnement de production, il est fortement recommandé : + +1. D'utiliser SSL/TLS (port 8883) +2. De configurer l'authentification par mot de passe +3. De limiter les IPs pouvant se connecter +4. De configurer des ACLs pour restreindre l'accès aux topics + +## Structure des topics MQTT + +### Topics utilisateur +- `chat/user/{userId}/messages` - Messages personnels pour l'utilisateur +- `chat/user/{userId}/groups/{groupId}` - Messages des groupes de l'utilisateur + +### Topics globaux +- `chat/announcement` - Annonces générales +- `chat/broadcast` - Diffusions à grande échelle + +### Topics conversation +- `chat/conversation/{conversationId}` - Messages spécifiques à une conversation + +## Intégration Flutter + +### Dépendances requises + +Ajoutez ces dépendances à votre `pubspec.yaml` : + +```yaml +dependencies: + mqtt5_client: ^4.0.0 # ou mqtt_client selon votre préférence + flutter_local_notifications: ^17.0.0 +``` + +### Initialisation + +```dart +// Dans main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final notificationService = MqttNotificationService(); + await notificationService.initialize(userId: currentUserId); + + runApp(const GeoSectorApp()); +} +``` + +### Utilisation + +```dart +// Écouter les messages +notificationService.onMessageTap = (messageId) { + // Naviguer vers le message + Navigator.pushNamed(context, '/chat/$messageId'); +}; + +// Publier un message +await notificationService.publishMessage( + 'chat/user/$userId/messages', + {'content': 'Test message'}, +); +``` + +## Gestion des notifications + +### Paramètres utilisateur + +Les utilisateurs peuvent configurer : +- Activation/désactivation des notifications +- Conversations en silencieux +- Mode "Ne pas déranger" +- Aperçu du contenu + +### Persistance des notifications + +Les notifications sont enregistrées dans la table `chat_notifications` pour : +- Traçabilité +- Statistiques +- Synchronisation + +## Tests + +### Test de connexion + +```dart +final service = MqttNotificationService(); +await service.initialize(userId: 'test_user'); +// Vérifie les logs pour confirmer la connexion +``` + +### Test d'envoi + +```php +$sender = new MqttNotificationSender($db, $mqttConfig); +$result = $sender->sendMessageNotification( + 'receiver_id', + 'sender_id', + 'message_id', + 'Test message', + 'conversation_id' +); +``` + +## Surveillance et maintenance + +### Logs + +Les logs sont disponibles dans : +- Logs Flutter (console debug) +- Logs Mosquitto (`/var/log/mosquitto/mosquitto.log`) +- Logs PHP (selon configuration) + +### Métriques à surveiller + +- Nombre de connexions actives +- Latence des messages +- Taux d'échec des notifications +- Consommation mémoire/CPU du broker + +## Comparaison avec Firebase + +### Avantages MQTT + +1. **Auto-hébergé** : Contrôle total de l'infrastructure +2. **Léger** : Moins de ressources que Firebase +3. **Coût** : Gratuit (uniquement coûts d'infrastructure) +4. **Personnalisable** : Configuration fine du broker + +### Inconvénients + +1. **Maintenance** : Nécessite une gestion du broker +2. **Évolutivité** : Requiert dimensionnement et clustering +3. **Fonctionnalités** : Moins de services intégrés que Firebase + +## Évolutions futures + +1. **WebSocket** : Ajout optionnel pour temps réel strict +2. **Clustering** : Pour haute disponibilité +3. **Analytics** : Dashboard de monitoring +4. **Webhooks** : Intégration avec d'autres services + +## Dépannage + +### Problèmes courants + +1. **Connexion échouée** + - Vérifier username/password + - Vérifier port/hostname + - Vérifier firewall + +2. **Messages non reçus** + - Vérifier abonnement aux topics + - Vérifier QoS + - Vérifier paramètres notifications + +3. **Performance dégradée** + - Augmenter keepAlive + - Ajuster reconnectInterval + - Vérifier charge serveur diff --git a/flutt/lib/chat/services/notifications/chat_notification_service.dart b/flutt/lib/chat/services/notifications/chat_notification_service.dart new file mode 100644 index 00000000..e7be7515 --- /dev/null +++ b/flutt/lib/chat/services/notifications/chat_notification_service.dart @@ -0,0 +1,202 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/foundation.dart'; + +/// Service de gestion des notifications chat +/// +/// Gère l'envoi et la réception des notifications pour le module chat + +class ChatNotificationService { + static final ChatNotificationService _instance = ChatNotificationService._internal(); + factory ChatNotificationService() => _instance; + ChatNotificationService._internal(); + + final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; + final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); + + // Callback pour les actions sur les notifications + Function(String messageId)? onMessageTap; + Function(Map)? onBackgroundMessage; + + /// Initialise le service de notifications + Future 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 _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 _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 _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 _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 _initializeDeviceToken() async { + String? token = await _firebaseMessaging.getToken(); + if (token != null) { + // Envoyer le token au serveur pour stocker + await _sendTokenToServer(token); + } + + // Écouter les changements de token + _firebaseMessaging.onTokenRefresh.listen(_sendTokenToServer); + + return token; + } + + /// Envoie le token FCM au serveur + Future _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 subscribeToConversation(String conversationId) async { + await _firebaseMessaging.subscribeToTopic('chat_$conversationId'); + } + + /// Se désabonner des notifications pour une conversation + Future unsubscribeFromConversation(String conversationId) async { + await _firebaseMessaging.unsubscribeFromTopic('chat_$conversationId'); + } + + /// Désactive temporairement les notifications + Future pauseNotifications() async { + await _firebaseMessaging.setAutoInitEnabled(false); + } + + /// Réactive les notifications + Future resumeNotifications() async { + await _firebaseMessaging.setAutoInitEnabled(true); + } +} + +/// Handler pour les messages en arrière-plan +@pragma('vm:entry-point') +Future _firebaseBackgroundHandler(RemoteMessage message) async { + // Traitement des messages en arrière-plan + debugPrint('Message reçu en arrière-plan : ${message.messageId}'); +} diff --git a/flutt/lib/chat/services/notifications/mqtt_config.dart b/flutt/lib/chat/services/notifications/mqtt_config.dart new file mode 100644 index 00000000..688f19a4 --- /dev/null +++ b/flutt/lib/chat/services/notifications/mqtt_config.dart @@ -0,0 +1,74 @@ +/// Configuration pour le broker MQTT +/// +/// Centralise les paramètres de connexion au broker MQTT + +class MqttConfig { + // Configuration du serveur MQTT + static const String host = 'mqtt.geosector.fr'; + static const int port = 1883; + static const int securePort = 8883; + static const bool useSsl = false; + + // Configuration d'authentification + static const String username = 'geosector_chat'; + static const String password = 'secure_password_here'; + + // Préfixes des topics MQTT + static const String topicBase = 'chat'; + static const String topicUserMessages = '$topicBase/user'; + static const String topicAnnouncements = '$topicBase/announcement'; + static const String topicGroups = '$topicBase/groups'; + static const String topicConversations = '$topicBase/conversation'; + + // Configuration des sessions + static const int keepAliveInterval = 60; + static const int reconnectInterval = 5; + static const bool cleanSession = true; + + // Configuration des notifications + static const int notificationRetryCount = 3; + static const Duration notificationTimeout = Duration(seconds: 30); + + /// Génère un client ID unique pour chaque session + static String generateClientId(String userId) { + return 'chat_${userId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + /// Retourne l'URL complète du broker selon la configuration SSL + static String get brokerUrl { + if (useSsl) { + return '$host:$securePort'; + } else { + return '$host:$port'; + } + } + + /// Retourne le topic pour les messages d'un utilisateur + static String getUserMessageTopic(String userId) { + return '$topicUserMessages/$userId/messages'; + } + + /// Retourne le topic pour les annonces globales + static String getAnnouncementTopic() { + return topicAnnouncements; + } + + /// Retourne le topic pour une conversation spécifique + static String getConversationTopic(String conversationId) { + return '$topicConversations/$conversationId'; + } + + /// Retourne le topic pour un groupe spécifique + static String getGroupTopic(String groupId) { + return '$topicGroups/$groupId'; + } + + /// Retourne les topics auxquels un utilisateur doit s'abonner + static List getUserSubscriptionTopics(String userId) { + return [ + getUserMessageTopic(userId), + getAnnouncementTopic(), + // Ajoutez d'autres topics selon les besoins + ]; + } +} diff --git a/flutt/lib/chat/services/notifications/mqtt_notification_service.dart b/flutt/lib/chat/services/notifications/mqtt_notification_service.dart new file mode 100644 index 00000000..3b329dd6 --- /dev/null +++ b/flutt/lib/chat/services/notifications/mqtt_notification_service.dart @@ -0,0 +1,322 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:mqtt5_client/mqtt5_client.dart'; +import 'package:mqtt5_client/mqtt5_server_client.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Service de gestion des notifications chat via MQTT +/// +/// Utilise MQTT pour recevoir des notifications en temps réel +/// et afficher des notifications locales + +class MqttNotificationService { + static final MqttNotificationService _instance = MqttNotificationService._internal(); + factory MqttNotificationService() => _instance; + MqttNotificationService._internal(); + + late MqttServerClient _client; + final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); + + // Configuration + final String mqttHost; + final int mqttPort; + final String mqttUsername; + final String mqttPassword; + final String clientId; + + // État + bool _initialized = false; + String? _userId; + StreamSubscription? _messageSubscription; + + // Callbacks + Function(String messageId)? onMessageTap; + Function(Map)? 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 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 _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 _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> 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; + _handleNotification(topic, data); + } catch (e) { + debugPrint('Erreur lors du décodage du message : $e'); + } + } + } + + /// Traite la notification reçue + Future _handleNotification(String topic, Map 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 _shouldShowNotification(Map 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 _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 _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; + 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 publishMessage(String topic, Map 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 subscribeToConversation(String conversationId) async { + if (_client.connectionStatus?.state == MqttConnectionState.connected) { + _client.subscribe('chat/conversation/$conversationId', MqttQos.atLeastOnce); + } + } + + /// Se désabonner d'une conversation + Future 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; + } +} diff --git a/flutt/lib/chat/services/offline_queue_service.dart b/flutt/lib/chat/services/offline_queue_service.dart new file mode 100644 index 00000000..d8e341dd --- /dev/null +++ b/flutt/lib/chat/services/offline_queue_service.dart @@ -0,0 +1,46 @@ +/// Service de gestion de la file d'attente hors ligne +/// +/// Ce service gère les opérations chat en mode hors ligne +/// et les synchronise lorsque la connexion revient + +class OfflineQueueService { + // TODO: Ajouter le service de connectivité + + OfflineQueueService(); + + /// Ajoute une opération en attente + Future addPendingOperation(String operationType, Map data) async { + // TODO: Implémenter l'ajout à la file d'attente + throw UnimplementedError(); + } + + /// Traite les opérations en attente + Future 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 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(); + } +} diff --git a/flutt/lib/chat/widgets/chat_input.dart b/flutt/lib/chat/widgets/chat_input.dart new file mode 100644 index 00000000..6f3053af --- /dev/null +++ b/flutt/lib/chat/widgets/chat_input.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +/// Zone de saisie de message +/// +/// Ce widget permet à l'utilisateur de saisir et envoyer des messages + +class ChatInput extends StatefulWidget { + final Function(String) onSendText; + final Function(dynamic)? onSendFile; + final Function(dynamic)? onSendImage; + final bool enableAttachments; + final bool enabled; + final String hintText; + final String? disabledMessage; + final int? maxLength; + + const ChatInput({ + super.key, + required this.onSendText, + this.onSendFile, + this.onSendImage, + this.enableAttachments = true, + this.enabled = true, + this.hintText = 'Saisissez votre message...', + this.disabledMessage = 'Vous ne pouvez pas répondre à cette annonce', + this.maxLength, + }); + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + 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(); + } +} diff --git a/flutt/lib/chat/widgets/chat_screen.dart b/flutt/lib/chat/widgets/chat_screen.dart new file mode 100644 index 00000000..03bfb103 --- /dev/null +++ b/flutt/lib/chat/widgets/chat_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +/// Écran principal d'une conversation +/// +/// Ce widget affiche une conversation complète avec : +/// - Liste des messages +/// - Zone de saisie +/// - En-tête et pied de page personnalisables + +class ChatScreen extends StatefulWidget { + final String conversationId; + final String? title; + final Widget? header; + final Widget? footer; + final bool enableAttachments; + final bool showTypingIndicator; + final bool enableReadReceipts; + final bool isAnnouncement; + final bool canReply; + + const ChatScreen({ + super.key, + required this.conversationId, + this.title, + this.header, + this.footer, + this.enableAttachments = true, + this.showTypingIndicator = true, + this.enableReadReceipts = true, + this.isAnnouncement = false, + this.canReply = true, + }); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + @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(); + } +} diff --git a/flutt/lib/chat/widgets/conversations_list.dart b/flutt/lib/chat/widgets/conversations_list.dart new file mode 100644 index 00000000..d425d2c5 --- /dev/null +++ b/flutt/lib/chat/widgets/conversations_list.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// Liste des conversations +/// +/// Ce widget affiche la liste des conversations de l'utilisateur +/// avec leurs derniers messages et statuts + +class ConversationsList extends StatefulWidget { + final List? 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 createState() => _ConversationsListState(); +} + +class _ConversationsListState extends State { + late List _conversations; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadConversations(); + } + + Future _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), + ); + }, + ); + } +} diff --git a/flutt/lib/chat/widgets/message_bubble.dart b/flutt/lib/chat/widgets/message_bubble.dart new file mode 100644 index 00000000..3d63800c --- /dev/null +++ b/flutt/lib/chat/widgets/message_bubble.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +/// Bulle de message +/// +/// Ce widget affiche un message dans une conversation +/// avec les informations associées + +class MessageBubble extends StatelessWidget { + final dynamic message; // TODO: Remplacer par MessageModel + final bool showSenderInfo; + final bool showTimestamp; + final bool showStatus; + final bool isAnnouncement; + final double maxWidth; + + const MessageBubble({ + super.key, + required this.message, + this.showSenderInfo = true, + this.showTimestamp = true, + this.showStatus = true, + this.isAnnouncement = false, + this.maxWidth = 300, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSenderInfo) CircleAvatar(child: Text('S')), + Expanded( + child: Container( + constraints: BoxConstraints(maxWidth: maxWidth), + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isAnnouncement ? Colors.orange.shade100 : Colors.blue.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSenderInfo) + Text( + 'Expéditeur', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text('Contenu du message...'), + if (showTimestamp || showStatus) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showTimestamp) Text('12:34', style: TextStyle(fontSize: 12)), + if (showStatus) const SizedBox(width: 4), + if (showStatus) Icon(Icons.check, size: 16), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/chat/widgets/notification_settings_widget.dart b/flutt/lib/chat/widgets/notification_settings_widget.dart new file mode 100644 index 00000000..6cecef0f --- /dev/null +++ b/flutt/lib/chat/widgets/notification_settings_widget.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import '../models/notification_settings.dart'; + +/// Widget pour les paramètres de notification +/// +/// Permet à l'utilisateur de configurer ses préférences de notification + +class NotificationSettingsWidget extends StatelessWidget { + final NotificationSettings settings; + final Function(NotificationSettings) onSettingsChanged; + + const NotificationSettingsWidget({ + super.key, + required this.settings, + required this.onSettingsChanged, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + // Notifications générales + SwitchListTile( + title: const Text('Activer les notifications'), + subtitle: const Text('Recevoir des notifications pour les nouveaux messages'), + value: settings.enableNotifications, + onChanged: (value) { + onSettingsChanged(settings.copyWith(enableNotifications: value)); + }, + ), + + if (settings.enableNotifications) ...[ + // Sons et vibrations + SwitchListTile( + title: const Text('Sons'), + subtitle: const Text('Jouer un son à la réception'), + value: settings.soundEnabled, + onChanged: (value) { + onSettingsChanged(settings.copyWith(soundEnabled: value)); + }, + ), + + SwitchListTile( + title: const Text('Vibration'), + subtitle: const Text('Vibrer à la réception'), + value: settings.vibrationEnabled, + onChanged: (value) { + onSettingsChanged(settings.copyWith(vibrationEnabled: value)); + }, + ), + + // Aperçu des messages + SwitchListTile( + title: const Text('Aperçu du message'), + subtitle: const Text('Afficher le contenu dans la notification'), + value: settings.showPreview, + onChanged: (value) { + onSettingsChanged(settings.copyWith(showPreview: value)); + }, + ), + + const Divider(), + + // Mode Ne pas déranger + SwitchListTile( + title: const Text('Ne pas déranger'), + subtitle: settings.doNotDisturb && settings.doNotDisturbStart != null + ? Text('Actif de ${_formatTime(settings.doNotDisturbStart!)} à ${_formatTime(settings.doNotDisturbEnd!)}') + : null, + value: settings.doNotDisturb, + onChanged: (value) { + if (value) { + _showTimeRangePicker(context); + } else { + onSettingsChanged(settings.copyWith(doNotDisturb: false)); + } + }, + ), + + if (settings.doNotDisturb) + ListTile( + title: const Text('Horaires'), + subtitle: Text('${_formatTime(settings.doNotDisturbStart!)} - ${_formatTime(settings.doNotDisturbEnd!)}'), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: () => _showTimeRangePicker(context), + ), + + const Divider(), + + // Conversations en silencieux + if (settings.mutedConversations.isNotEmpty) ...[ + const ListTile( + title: Text('Conversations en silencieux'), + subtitle: Text('Ces conversations n\'enverront pas de notifications'), + ), + ...settings.mutedConversations.map( + (conversationId) => ListTile( + title: Text('Conversation $conversationId'), // TODO: Récupérer le vrai nom + trailing: IconButton( + icon: const Icon(Icons.volume_up), + onPressed: () { + final muted = List.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 _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, + ), + ); + } + } + } +} diff --git a/flutt/lib/core/constants/app_keys.dart b/flutt/lib/core/constants/app_keys.dart new file mode 100644 index 00000000..2d28073c --- /dev/null +++ b/flutt/lib/core/constants/app_keys.dart @@ -0,0 +1,136 @@ +/// Fichier contenant toutes les constantes utilisées dans l'application +/// Centralise les clés, noms de boîtes Hive, et autres constantes +/// pour faciliter la maintenance et éviter les erreurs de frappe + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; + +class AppKeys { + // Noms des boîtes Hive + static const String usersBoxName = 'users'; + static const String operationsBoxName = 'operations'; + static const String sectorsBoxName = 'sectors'; + static const String passagesBoxName = 'passages'; + static const String settingsBoxName = 'settings'; + static const String membresBoxName = 'membres'; + static const String chatConversationsBoxName = 'chat_conversations'; + static const String chatMessagesBoxName = 'chat_messages'; + + // Rôles utilisateurs + static const int roleUser = 1; + static const int roleAdmin1 = 2; + static const int roleAdmin2 = 4; + static const int roleAdmin3 = 9; + + // URLs API + static const String baseApiUrl = 'https://app.geosector.fr/api/geo'; + + // Endpoints API + static const String loginEndpoint = '/login'; + static const String logoutEndpoint = '/logout'; + static const String registerEndpoint = '/register'; + static const String syncDataEndpoint = '/data/sync'; + static const String sectorsEndpoint = '/sectors'; + + // Durées + static const Duration connectionTimeout = Duration(seconds: 5); + static const Duration receiveTimeout = Duration(seconds: 30); + static const Duration sessionDefaultExpiry = Duration(days: 7); + + // Clés API externes + static const String mapboxApiKey = + 'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY204dTNhNmd0MGV1ZzJqc2pnNnB0NjYxdSJ9.TA5Mvliyn91Oi01F_2Yuxw'; // À remplacer par votre clé API Mapbox + + // Headers + static const String sessionHeader = 'Authorization'; + + // En-têtes par défaut pour les requêtes API + static const Map defaultHeaders = { + 'Content-Type': 'application/json', + 'X-App-Identifier': 'app.geosector.fr', + 'X-Client-Type': kIsWeb ? 'web' : 'mobile', + 'Accept': 'application/json', + }; + + // Civilités + static const Map civilites = { + 1: 'M.', + 2: 'Mme', + }; + + // Types de règlements + static const Map> typesReglements = { + 0: { + 'titre': 'Pas de règlement', + 'couleur': 0xFF757575, // Gris foncé + 'icon_data': Icons.money_off, + }, + 1: { + 'titre': 'Espèce', + 'couleur': 0xFFFFC107, // Jaune foncé (ambre) + 'icon_data': Icons.toll, + }, + 2: { + 'titre': 'Chèque', + 'couleur': 0xFF8BC34A, // Vert citron + 'icon_data': Icons.wallet, + }, + 3: { + 'titre': 'CB', + 'couleur': 0xFF00B0FF, // Bleu flashy (bleu clair accent), + 'icon_data': Icons.credit_card, + }, + }; + + // Types de passages + static const Map> typesPassages = { + 1: { + 'titres': 'Effectués', + 'titre': 'Effectué', + 'couleur1': 0xFF4CAF50, // Vert success + 'couleur2': 0xFF4CAF50, // Vert success + 'couleur3': 0xFF4CAF50, // Vert success + 'icon_data': Icons.task_alt, + }, + 2: { + 'titres': 'À finaliser', + 'titre': 'À finaliser', + 'couleur1': 0xFFFFFFFF, // Blanc + 'couleur2': 0xFFFF9800, // Orange + 'couleur3': 0xFFE65100, // Orange foncé + 'icon_data': Icons.refresh, + }, + 3: { + 'titres': 'Refusés', + 'titre': 'Refusé', + 'couleur1': 0xFFF44336, // Rouge + 'couleur2': 0xFFF44336, // Rouge + 'couleur3': 0xFFF44336, // Rouge + 'icon_data': Icons.block, + }, + 4: { + 'titres': 'Dons', + 'titre': 'Don', + 'couleur1': 0xFF03A9F4, // Bleu ciel + 'couleur2': 0xFF03A9F4, // Bleu ciel + 'couleur3': 0xFF03A9F4, // Bleu ciel + 'icon_data': Icons.volunteer_activism, + }, + 5: { + 'titres': 'Lots', + 'titre': 'Lot', + 'couleur1': 0xFF0D47A1, // Bleu foncé + 'couleur2': 0xFF0D47A1, // Bleu foncé + 'couleur3': 0xFF0D47A1, // Bleu foncé + 'icon_data': Icons.layers, + }, + 6: { + 'titres': 'Maisons vides', + 'titre': 'Maison vide', + 'couleur1': 0xFF9E9E9E, // Gris + 'couleur2': 0xFF9E9E9E, // Gris + 'couleur3': 0xFF9E9E9E, // Gris + 'icon_data': Icons.home_outlined, + }, + }; +} diff --git a/flutt/lib/core/data/models/membre_model.dart b/flutt/lib/core/data/models/membre_model.dart new file mode 100644 index 00000000..395217a3 --- /dev/null +++ b/flutt/lib/core/data/models/membre_model.dart @@ -0,0 +1,137 @@ +import 'package:hive/hive.dart'; + +part 'membre_model.g.dart'; + +@HiveType(typeId: 5) // Utilisation d'un typeId unique +class MembreModel extends HiveObject { + @HiveField(0) + final int id; + + @HiveField(1) + final int fkRole; + + @HiveField(2) + final int fkTitre; + + @HiveField(3) + final String firstName; + + @HiveField(4) + final String? sectName; + + @HiveField(5) + final DateTime? dateNaissance; + + @HiveField(6) + final DateTime? dateEmbauche; + + @HiveField(7) + final int chkActive; + + @HiveField(8) + final String name; + + @HiveField(9) + final String username; + + @HiveField(10) + final String email; + + MembreModel({ + required this.id, + required this.fkRole, + required this.fkTitre, + required this.firstName, + this.sectName, + this.dateNaissance, + this.dateEmbauche, + required this.chkActive, + required this.name, + required this.username, + required this.email, + }); + + // Factory pour convertir depuis JSON (API) + factory MembreModel.fromJson(Map json) { + // Convertir l'ID en int, qu'il soit déjà int ou string + final dynamic rawId = json['id']; + final int id = rawId is String ? int.parse(rawId) : rawId as int; + + // Convertir le rôle en int, qu'il soit déjà int ou string + final dynamic rawRole = json['fk_role']; + final int fkRole = rawRole is String ? int.parse(rawRole) : rawRole as int; + + // Convertir le titre en int, qu'il soit déjà int ou string + final dynamic rawTitre = json['fk_titre']; + final int fkTitre = + rawTitre is String ? int.parse(rawTitre) : rawTitre as int; + + // Convertir le chkActive en int, qu'il soit déjà int ou string + final dynamic rawActive = json['chk_active']; + final int chkActive = + rawActive is String ? int.parse(rawActive) : rawActive as int; + + return MembreModel( + id: id, + fkRole: fkRole, + fkTitre: fkTitre, + firstName: json['first_name'] ?? '', + sectName: json['sect_name'], + dateNaissance: json['date_naissance'] != null + ? DateTime.parse(json['date_naissance']) + : null, + dateEmbauche: json['date_embauche'] != null + ? DateTime.parse(json['date_embauche']) + : null, + chkActive: chkActive, + name: json['name'] ?? '', + username: json['username'] ?? '', + email: json['email'] ?? '', + ); + } + + // Convertir en JSON pour l'API + Map toJson() { + return { + 'id': id, + 'fk_role': fkRole, + 'fk_titre': fkTitre, + 'first_name': firstName, + 'sect_name': sectName, + 'date_naissance': dateNaissance?.toIso8601String(), + 'date_embauche': dateEmbauche?.toIso8601String(), + 'chk_active': chkActive, + 'name': name, + 'username': username, + 'email': email, + }; + } + + // Copier avec de nouvelles valeurs + MembreModel copyWith({ + int? fkRole, + int? fkTitre, + String? firstName, + String? sectName, + DateTime? dateNaissance, + DateTime? dateEmbauche, + int? chkActive, + String? name, + String? username, + String? email, + }) { + return MembreModel( + id: this.id, + fkRole: fkRole ?? this.fkRole, + fkTitre: fkTitre ?? this.fkTitre, + firstName: firstName ?? this.firstName, + sectName: sectName ?? this.sectName, + dateNaissance: dateNaissance ?? this.dateNaissance, + dateEmbauche: dateEmbauche ?? this.dateEmbauche, + chkActive: chkActive ?? this.chkActive, + name: name ?? this.name, + username: username ?? this.username, + email: email ?? this.email, + ); + } +} diff --git a/flutt/lib/core/data/models/membre_model.g.dart b/flutt/lib/core/data/models/membre_model.g.dart new file mode 100644 index 00000000..2ad6ea0b --- /dev/null +++ b/flutt/lib/core/data/models/membre_model.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'membre_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MembreModelAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + MembreModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MembreModel( + id: fields[0] as int, + fkRole: fields[1] as int, + fkTitre: fields[2] as int, + firstName: fields[3] as String, + sectName: fields[4] as String?, + dateNaissance: fields[5] as DateTime?, + dateEmbauche: fields[6] as DateTime?, + chkActive: fields[7] as int, + name: fields[8] as String, + username: fields[9] as String, + email: fields[10] as String, + ); + } + + @override + void write(BinaryWriter writer, MembreModel obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.fkRole) + ..writeByte(2) + ..write(obj.fkTitre) + ..writeByte(3) + ..write(obj.firstName) + ..writeByte(4) + ..write(obj.sectName) + ..writeByte(5) + ..write(obj.dateNaissance) + ..writeByte(6) + ..write(obj.dateEmbauche) + ..writeByte(7) + ..write(obj.chkActive) + ..writeByte(8) + ..write(obj.name) + ..writeByte(9) + ..write(obj.username) + ..writeByte(10) + ..write(obj.email); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MembreModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/core/data/models/operation_model.dart b/flutt/lib/core/data/models/operation_model.dart new file mode 100644 index 00000000..9fee5429 --- /dev/null +++ b/flutt/lib/core/data/models/operation_model.dart @@ -0,0 +1,85 @@ +import 'package:hive/hive.dart'; + +part 'operation_model.g.dart'; + +@HiveType(typeId: 1) +class OperationModel extends HiveObject { + @HiveField(0) + final int id; + + @HiveField(1) + final String name; + + @HiveField(2) + final DateTime dateDebut; + + @HiveField(3) + final DateTime dateFin; + + @HiveField(4) + DateTime lastSyncedAt; + + @HiveField(5) + bool isActive; + + @HiveField(6) + bool isSynced; + + OperationModel({ + required this.id, + required this.name, + required this.dateDebut, + required this.dateFin, + required this.lastSyncedAt, + this.isActive = true, + this.isSynced = false, + }); + + // Factory pour convertir depuis JSON (API) + factory OperationModel.fromJson(Map json) { + // Convertir l'ID en int, qu'il soit déjà int ou string + final dynamic rawId = json['id']; + final int id = rawId is String ? int.parse(rawId) : rawId as int; + + return OperationModel( + id: id, + name: json['name'], + dateDebut: DateTime.parse(json['date_deb']), + dateFin: DateTime.parse(json['date_fin']), + lastSyncedAt: DateTime.now(), + isActive: true, + isSynced: true, + ); + } + + // Convertir en JSON pour l'API + Map toJson() { + return { + 'id': id, + 'name': name, + 'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD + 'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD + 'is_active': isActive, + }; + } + + // Copier avec de nouvelles valeurs + OperationModel copyWith({ + String? name, + DateTime? dateDebut, + DateTime? dateFin, + bool? isActive, + bool? isSynced, + DateTime? lastSyncedAt, + }) { + return OperationModel( + id: this.id, + name: name ?? this.name, + dateDebut: dateDebut ?? this.dateDebut, + dateFin: dateFin ?? this.dateFin, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + isActive: isActive ?? this.isActive, + isSynced: isSynced ?? this.isSynced, + ); + } +} diff --git a/flutt/lib/core/data/models/operation_model.g.dart b/flutt/lib/core/data/models/operation_model.g.dart new file mode 100644 index 00000000..b2b4c860 --- /dev/null +++ b/flutt/lib/core/data/models/operation_model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'operation_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class OperationModelAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + OperationModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return OperationModel( + id: fields[0] as int, + name: fields[1] as String, + dateDebut: fields[2] as DateTime, + dateFin: fields[3] as DateTime, + lastSyncedAt: fields[4] as DateTime, + isActive: fields[5] as bool, + isSynced: fields[6] as bool, + ); + } + + @override + void write(BinaryWriter writer, OperationModel obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.dateDebut) + ..writeByte(3) + ..write(obj.dateFin) + ..writeByte(4) + ..write(obj.lastSyncedAt) + ..writeByte(5) + ..write(obj.isActive) + ..writeByte(6) + ..write(obj.isSynced); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OperationModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/core/data/models/passage_model.dart b/flutt/lib/core/data/models/passage_model.dart new file mode 100644 index 00000000..e3e0970f --- /dev/null +++ b/flutt/lib/core/data/models/passage_model.dart @@ -0,0 +1,291 @@ +import 'package:hive/hive.dart'; + +part 'passage_model.g.dart'; + +@HiveType(typeId: 4) +class PassageModel extends HiveObject { + @HiveField(0) + final int id; + + @HiveField(1) + final int fkOperation; + + @HiveField(2) + final int fkSector; + + @HiveField(3) + final int fkUser; + + @HiveField(4) + final int fkType; + + @HiveField(5) + final String fkAdresse; + + @HiveField(6) + final DateTime passedAt; + + @HiveField(7) + final String numero; + + @HiveField(8) + final String rue; + + @HiveField(9) + final String rueBis; + + @HiveField(10) + final String ville; + + @HiveField(11) + final String residence; + + @HiveField(12) + final int fkHabitat; + + @HiveField(13) + final String appt; + + @HiveField(14) + final String niveau; + + @HiveField(15) + final String gpsLat; + + @HiveField(16) + final String gpsLng; + + @HiveField(17) + final String nomRecu; + + @HiveField(18) + final String remarque; + + @HiveField(19) + final String montant; + + @HiveField(20) + final int fkTypeReglement; + + @HiveField(21) + final String emailErreur; + + @HiveField(22) + final int nbPassages; + + @HiveField(23) + final String name; + + @HiveField(24) + final String email; + + @HiveField(25) + final String phone; + + @HiveField(26) + DateTime lastSyncedAt; + + @HiveField(27) + bool isActive; + + @HiveField(28) + bool isSynced; + + PassageModel({ + required this.id, + required this.fkOperation, + required this.fkSector, + required this.fkUser, + required this.fkType, + required this.fkAdresse, + required this.passedAt, + required this.numero, + required this.rue, + this.rueBis = '', + required this.ville, + this.residence = '', + required this.fkHabitat, + this.appt = '', + this.niveau = '', + required this.gpsLat, + required this.gpsLng, + this.nomRecu = '', + this.remarque = '', + required this.montant, + required this.fkTypeReglement, + this.emailErreur = '', + required this.nbPassages, + required this.name, + this.email = '', + this.phone = '', + required this.lastSyncedAt, + this.isActive = true, + this.isSynced = false, + }); + + // Factory pour convertir depuis JSON (API) + factory PassageModel.fromJson(Map json) { + // Convertir l'ID en int, qu'il soit déjà int ou string + final dynamic rawId = json['id']; + final int id = rawId is String ? int.parse(rawId) : rawId as int; + + // Convertir les autres champs numériques + final dynamic rawFkOperation = json['fk_operation']; + final int fkOperation = rawFkOperation is String ? int.parse(rawFkOperation) : rawFkOperation as int; + + final dynamic rawFkSector = json['fk_sector']; + final int fkSector = rawFkSector is String ? int.parse(rawFkSector) : rawFkSector as int; + + final dynamic rawFkUser = json['fk_user']; + final int fkUser = rawFkUser is String ? int.parse(rawFkUser) : rawFkUser as int; + + final dynamic rawFkType = json['fk_type']; + final int fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int; + + final dynamic rawFkHabitat = json['fk_habitat']; + final int fkHabitat = rawFkHabitat is String ? int.parse(rawFkHabitat) : rawFkHabitat as int; + + final dynamic rawFkTypeReglement = json['fk_type_reglement']; + final int fkTypeReglement = rawFkTypeReglement is String ? int.parse(rawFkTypeReglement) : rawFkTypeReglement as int; + + final dynamic rawNbPassages = json['nb_passages']; + final int nbPassages = rawNbPassages is String ? int.parse(rawNbPassages) : rawNbPassages as int; + + // Convertir la date + final DateTime passedAt = DateTime.parse(json['passed_at']); + + return PassageModel( + id: id, + fkOperation: fkOperation, + fkSector: fkSector, + fkUser: fkUser, + fkType: fkType, + fkAdresse: json['fk_adresse'] as String, + passedAt: passedAt, + numero: json['numero'] as String, + rue: json['rue'] as String, + rueBis: json['rue_bis'] as String? ?? '', + ville: json['ville'] as String, + residence: json['residence'] as String? ?? '', + fkHabitat: fkHabitat, + appt: json['appt'] as String? ?? '', + niveau: json['niveau'] as String? ?? '', + gpsLat: json['gps_lat'] as String, + gpsLng: json['gps_lng'] as String, + nomRecu: json['nom_recu'] as String? ?? '', + remarque: json['remarque'] as String? ?? '', + montant: json['montant'] as String, + fkTypeReglement: fkTypeReglement, + emailErreur: json['email_erreur'] as String? ?? '', + nbPassages: nbPassages, + name: json['name'] as String, + email: json['email'] as String? ?? '', + phone: json['phone'] as String? ?? '', + lastSyncedAt: DateTime.now(), + isActive: true, + isSynced: true, + ); + } + + // Convertir en JSON pour l'API + Map toJson() { + return { + 'id': id, + 'fk_operation': fkOperation, + 'fk_sector': fkSector, + 'fk_user': fkUser, + 'fk_type': fkType, + 'fk_adresse': fkAdresse, + 'passed_at': passedAt.toIso8601String(), + 'numero': numero, + 'rue': rue, + 'rue_bis': rueBis, + 'ville': ville, + 'residence': residence, + 'fk_habitat': fkHabitat, + 'appt': appt, + 'niveau': niveau, + 'gps_lat': gpsLat, + 'gps_lng': gpsLng, + 'nom_recu': nomRecu, + 'remarque': remarque, + 'montant': montant, + 'fk_type_reglement': fkTypeReglement, + 'email_erreur': emailErreur, + 'nb_passages': nbPassages, + 'name': name, + 'email': email, + 'phone': phone, + }; + } + + // Copier avec de nouvelles valeurs + PassageModel copyWith({ + int? id, + int? fkOperation, + int? fkSector, + int? fkUser, + int? fkType, + String? fkAdresse, + DateTime? passedAt, + String? numero, + String? rue, + String? rueBis, + String? ville, + String? residence, + int? fkHabitat, + String? appt, + String? niveau, + String? gpsLat, + String? gpsLng, + String? nomRecu, + String? remarque, + String? montant, + int? fkTypeReglement, + String? emailErreur, + int? nbPassages, + String? name, + String? email, + String? phone, + DateTime? lastSyncedAt, + bool? isActive, + bool? isSynced, + }) { + return PassageModel( + id: id ?? this.id, + fkOperation: fkOperation ?? this.fkOperation, + fkSector: fkSector ?? this.fkSector, + fkUser: fkUser ?? this.fkUser, + fkType: fkType ?? this.fkType, + fkAdresse: fkAdresse ?? this.fkAdresse, + passedAt: passedAt ?? this.passedAt, + numero: numero ?? this.numero, + rue: rue ?? this.rue, + rueBis: rueBis ?? this.rueBis, + ville: ville ?? this.ville, + residence: residence ?? this.residence, + fkHabitat: fkHabitat ?? this.fkHabitat, + appt: appt ?? this.appt, + niveau: niveau ?? this.niveau, + gpsLat: gpsLat ?? this.gpsLat, + gpsLng: gpsLng ?? this.gpsLng, + nomRecu: nomRecu ?? this.nomRecu, + remarque: remarque ?? this.remarque, + montant: montant ?? this.montant, + fkTypeReglement: fkTypeReglement ?? this.fkTypeReglement, + emailErreur: emailErreur ?? this.emailErreur, + nbPassages: nbPassages ?? this.nbPassages, + name: name ?? this.name, + email: email ?? this.email, + phone: phone ?? this.phone, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + isActive: isActive ?? this.isActive, + isSynced: isSynced ?? this.isSynced, + ); + } + + @override + String toString() { + return 'PassageModel(id: $id, fkOperation: $fkOperation, fkSector: $fkSector, fkUser: $fkUser, fkType: $fkType, adresse: $fkAdresse, ville: $ville, montant: $montant)'; + } +} diff --git a/flutt/lib/core/data/models/passage_model.g.dart b/flutt/lib/core/data/models/passage_model.g.dart new file mode 100644 index 00000000..ad5ff700 --- /dev/null +++ b/flutt/lib/core/data/models/passage_model.g.dart @@ -0,0 +1,125 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'passage_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PassageModelAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + PassageModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PassageModel( + id: fields[0] as int, + fkOperation: fields[1] as int, + fkSector: fields[2] as int, + fkUser: fields[3] as int, + fkType: fields[4] as int, + fkAdresse: fields[5] as String, + passedAt: fields[6] as DateTime, + numero: fields[7] as String, + rue: fields[8] as String, + rueBis: fields[9] as String, + ville: fields[10] as String, + residence: fields[11] as String, + fkHabitat: fields[12] as int, + appt: fields[13] as String, + niveau: fields[14] as String, + gpsLat: fields[15] as String, + gpsLng: fields[16] as String, + nomRecu: fields[17] as String, + remarque: fields[18] as String, + montant: fields[19] as String, + fkTypeReglement: fields[20] as int, + emailErreur: fields[21] as String, + nbPassages: fields[22] as int, + name: fields[23] as String, + email: fields[24] as String, + phone: fields[25] as String, + lastSyncedAt: fields[26] as DateTime, + isActive: fields[27] as bool, + isSynced: fields[28] as bool, + ); + } + + @override + void write(BinaryWriter writer, PassageModel obj) { + writer + ..writeByte(29) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.fkOperation) + ..writeByte(2) + ..write(obj.fkSector) + ..writeByte(3) + ..write(obj.fkUser) + ..writeByte(4) + ..write(obj.fkType) + ..writeByte(5) + ..write(obj.fkAdresse) + ..writeByte(6) + ..write(obj.passedAt) + ..writeByte(7) + ..write(obj.numero) + ..writeByte(8) + ..write(obj.rue) + ..writeByte(9) + ..write(obj.rueBis) + ..writeByte(10) + ..write(obj.ville) + ..writeByte(11) + ..write(obj.residence) + ..writeByte(12) + ..write(obj.fkHabitat) + ..writeByte(13) + ..write(obj.appt) + ..writeByte(14) + ..write(obj.niveau) + ..writeByte(15) + ..write(obj.gpsLat) + ..writeByte(16) + ..write(obj.gpsLng) + ..writeByte(17) + ..write(obj.nomRecu) + ..writeByte(18) + ..write(obj.remarque) + ..writeByte(19) + ..write(obj.montant) + ..writeByte(20) + ..write(obj.fkTypeReglement) + ..writeByte(21) + ..write(obj.emailErreur) + ..writeByte(22) + ..write(obj.nbPassages) + ..writeByte(23) + ..write(obj.name) + ..writeByte(24) + ..write(obj.email) + ..writeByte(25) + ..write(obj.phone) + ..writeByte(26) + ..write(obj.lastSyncedAt) + ..writeByte(27) + ..write(obj.isActive) + ..writeByte(28) + ..write(obj.isSynced); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PassageModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/core/data/models/sector_model.dart b/flutt/lib/core/data/models/sector_model.dart new file mode 100644 index 00000000..62eff1f3 --- /dev/null +++ b/flutt/lib/core/data/models/sector_model.dart @@ -0,0 +1,85 @@ +import 'package:hive/hive.dart'; + +part 'sector_model.g.dart'; + +@HiveType(typeId: 3) +class SectorModel extends HiveObject { + @HiveField(0) + final int id; + + @HiveField(1) + final String libelle; + + @HiveField(2) + final String color; + + @HiveField(3) + final String sector; + + SectorModel({ + required this.id, + required this.libelle, + required this.color, + required this.sector, + }); + + // Factory pour convertir depuis JSON (API) + factory SectorModel.fromJson(Map json) { + return SectorModel( + id: json['id'] is String ? int.parse(json['id']) : json['id'] as int, + libelle: json['libelle'] as String, + color: json['color'] as String, + sector: json['sector'] as String, + ); + } + + // Convertir en JSON pour l'API + Map toJson() { + return { + 'id': id, + 'libelle': libelle, + 'color': color, + 'sector': sector, + }; + } + + // Copier avec de nouvelles valeurs + SectorModel copyWith({ + int? id, + String? libelle, + String? color, + String? sector, + }) { + return SectorModel( + id: id ?? this.id, + libelle: libelle ?? this.libelle, + color: color ?? this.color, + sector: sector ?? this.sector, + ); + } + + // Obtenir les coordonnées du secteur sous forme de liste de points + List> getCoordinates() { + final List> coordinates = []; + + // Le format est "lat1/lng1#lat2/lng2#lat3/lng3#..." + final List points = sector.split('#'); + + for (final String point in points) { + if (point.isEmpty) continue; + + final List latLng = point.split('/'); + if (latLng.length == 2) { + try { + final double lat = double.parse(latLng[0]); + final double lng = double.parse(latLng[1]); + coordinates.add([lat, lng]); + } catch (e) { + // Ignorer les points mal formatés + } + } + } + + return coordinates; + } +} diff --git a/flutt/lib/core/data/models/sector_model.g.dart b/flutt/lib/core/data/models/sector_model.g.dart new file mode 100644 index 00000000..e6529646 --- /dev/null +++ b/flutt/lib/core/data/models/sector_model.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sector_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SectorModelAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + SectorModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SectorModel( + id: fields[0] as int, + libelle: fields[1] as String, + color: fields[2] as String, + sector: fields[3] as String, + ); + } + + @override + void write(BinaryWriter writer, SectorModel obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.libelle) + ..writeByte(2) + ..write(obj.color) + ..writeByte(3) + ..write(obj.sector); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SectorModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/core/data/models/user_model.dart b/flutt/lib/core/data/models/user_model.dart new file mode 100644 index 00000000..fc8bf77b --- /dev/null +++ b/flutt/lib/core/data/models/user_model.dart @@ -0,0 +1,169 @@ +import 'package:hive/hive.dart'; + +part 'user_model.g.dart'; + +@HiveType(typeId: 0) +class UserModel extends HiveObject { + @HiveField(0) + final int id; + + @HiveField(1) + final String email; + + @HiveField(2) + String? name; + + @HiveField(11) + String? username; + + @HiveField(10) + String? firstName; + + @HiveField(3) + final int role; + + @HiveField(4) + final DateTime createdAt; + + @HiveField(5) + DateTime lastSyncedAt; + + @HiveField(6) + bool isActive; + + @HiveField(7) + bool isSynced; + + @HiveField(8) + String? sessionId; + + @HiveField(9) + DateTime? sessionExpiry; + + @HiveField(12) + String? lastPath; + + @HiveField(13) + String? sectName; + + @HiveField(14) + String? interface; + + UserModel({ + required this.id, + required this.email, + this.name, + this.username, + this.firstName, + required this.role, + required this.createdAt, + required this.lastSyncedAt, + this.isActive = true, + this.isSynced = false, + this.sessionId, + this.sessionExpiry, + this.lastPath, + this.sectName, + this.interface, + }); + + // Factory pour convertir depuis JSON (API) + factory UserModel.fromJson(Map json) { + // Convertir l'ID en int, qu'il soit déjà int ou string + final dynamic rawId = json['id']; + final int id = rawId is String ? int.parse(rawId) : rawId as int; + + // Convertir le rôle en int, qu'il soit déjà int ou string + final dynamic rawRole = json['role']; + final int role = rawRole is String ? int.parse(rawRole) : rawRole as int; + + return UserModel( + id: id, + email: json['email'], + name: json['name'], + username: json['username'], + firstName: json['first_name'], + role: role, + createdAt: DateTime.parse(json['created_at']), + lastSyncedAt: DateTime.now(), + isActive: json['is_active'] ?? true, + isSynced: true, + sessionId: json['session_id'], + sessionExpiry: json['session_expiry'] != null + ? DateTime.parse(json['session_expiry']) + : null, + sectName: json['sect_name'], + interface: json['interface'], + ); + } + + // Convertir en JSON pour l'API + Map toJson() { + return { + 'id': id, + 'email': email, + 'name': name, + 'username': username, + 'first_name': firstName, + 'role': role, + 'created_at': createdAt.toIso8601String(), + 'is_active': isActive, + 'session_id': sessionId, + 'session_expiry': sessionExpiry?.toIso8601String(), + 'last_path': lastPath, + 'sect_name': sectName, + 'interface': interface, + }; + } + + // Copier avec de nouvelles valeurs + UserModel copyWith({ + String? email, + String? name, + String? username, + String? firstName, + int? role, + bool? isActive, + bool? isSynced, + DateTime? lastSyncedAt, + String? sessionId, + DateTime? sessionExpiry, + String? lastPath, + String? sectName, + String? interface, + }) { + return UserModel( + id: this.id, + email: email ?? this.email, + name: name ?? this.name, + username: username ?? this.username, + firstName: firstName ?? this.firstName, + role: role ?? this.role, + createdAt: this.createdAt, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + isActive: isActive ?? this.isActive, + isSynced: isSynced ?? this.isSynced, + sessionId: sessionId ?? this.sessionId, + sessionExpiry: sessionExpiry ?? this.sessionExpiry, + lastPath: lastPath ?? this.lastPath, + sectName: sectName ?? this.sectName, + interface: interface ?? this.interface, + ); + } + + // Vérifier si la session est valide + bool get hasValidSession { + if (sessionId == null || sessionExpiry == null) { + return false; + } + return sessionExpiry!.isAfter(DateTime.now()); + } + + // Effacer les données de session + UserModel clearSession() { + return copyWith( + sessionId: null, + sessionExpiry: null, + ); + } +} \ No newline at end of file diff --git a/flutt/lib/core/data/models/user_model.g.dart b/flutt/lib/core/data/models/user_model.g.dart new file mode 100644 index 00000000..5fdb9392 --- /dev/null +++ b/flutt/lib/core/data/models/user_model.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserModelAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + UserModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserModel( + id: fields[0] as int, + email: fields[1] as String, + name: fields[2] as String?, + username: fields[11] as String?, + firstName: fields[10] as String?, + role: fields[3] as int, + createdAt: fields[4] as DateTime, + lastSyncedAt: fields[5] as DateTime, + isActive: fields[6] as bool, + isSynced: fields[7] as bool, + sessionId: fields[8] as String?, + sessionExpiry: fields[9] as DateTime?, + lastPath: fields[12] as String?, + sectName: fields[13] as String?, + interface: fields[14] as String?, + ); + } + + @override + void write(BinaryWriter writer, UserModel obj) { + writer + ..writeByte(15) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.email) + ..writeByte(2) + ..write(obj.name) + ..writeByte(11) + ..write(obj.username) + ..writeByte(10) + ..write(obj.firstName) + ..writeByte(3) + ..write(obj.role) + ..writeByte(4) + ..write(obj.createdAt) + ..writeByte(5) + ..write(obj.lastSyncedAt) + ..writeByte(6) + ..write(obj.isActive) + ..writeByte(7) + ..write(obj.isSynced) + ..writeByte(8) + ..write(obj.sessionId) + ..writeByte(9) + ..write(obj.sessionExpiry) + ..writeByte(12) + ..write(obj.lastPath) + ..writeByte(13) + ..write(obj.sectName) + ..writeByte(14) + ..write(obj.interface); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutt/lib/core/repositories/membre_repository.dart b/flutt/lib/core/repositories/membre_repository.dart new file mode 100644 index 00000000..7a675da9 --- /dev/null +++ b/flutt/lib/core/repositories/membre_repository.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/data/models/membre_model.dart'; + +class MembreRepository extends ChangeNotifier { + // Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire + Box get _membreBox => + Hive.box(AppKeys.membresBoxName); + + final ApiService _apiService; + bool _isLoading = false; + + MembreRepository(this._apiService); + + // Getters + bool get isLoading => _isLoading; + List get membres => getAllMembres(); + + // Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire + Future _ensureBoxIsOpen() async { + try { + if (!Hive.isBoxOpen(AppKeys.membresBoxName)) { + debugPrint('Ouverture de la boîte ${AppKeys.membresBoxName}...'); + await Hive.openBox(AppKeys.membresBoxName); + debugPrint('Boîte ${AppKeys.membresBoxName} ouverte avec succès'); + } + } catch (e) { + debugPrint( + 'Erreur lors de l\'ouverture de la boîte ${AppKeys.membresBoxName}: $e'); + throw Exception( + 'Impossible d\'ouvrir la boîte ${AppKeys.membresBoxName}: $e'); + } + } + + // Récupérer tous les membres + List getAllMembres() { + try { + return _membreBox.values.toList(); + } catch (e) { + debugPrint('Erreur lors de la récupération des membres: $e'); + return []; + } + } + + // Récupérer un membre par son ID + MembreModel? getMembreById(int id) { + try { + return _membreBox.get(id); + } catch (e) { + debugPrint('Erreur lors de la récupération du membre: $e'); + return null; + } + } + + // Créer ou mettre à jour un membre + Future saveMembre(MembreModel membre) async { + await _ensureBoxIsOpen(); + await _membreBox.put(membre.id, membre); + notifyListeners(); + return membre; + } + + // Supprimer un membre + Future deleteMembre(int id) async { + await _ensureBoxIsOpen(); + await _membreBox.delete(id); + notifyListeners(); + } + + // Récupérer les membres depuis l'API (uniquement pour l'interface admin) + Future> fetchMembresFromApi() async { + _isLoading = true; + notifyListeners(); + + try { + final hasConnection = await _apiService.hasInternetConnection(); + if (!hasConnection) { + debugPrint( + 'Pas de connexion Internet, utilisation des données locales'); + return getAllMembres(); + } + + // Endpoint à adapter selon votre API + final response = await _apiService.get('/membres'); + final List membresData = response.data['membres']; + + // Vider la boîte avant d'ajouter les nouveaux membres + await _ensureBoxIsOpen(); + await _membreBox.clear(); + + final List membres = []; + for (var membreData in membresData) { + try { + final membre = MembreModel.fromJson(membreData); + await _membreBox.put(membre.id, membre); + membres.add(membre); + } catch (e) { + debugPrint('Erreur lors du traitement d\'un membre: $e'); + continue; + } + } + + notifyListeners(); + return membres; + } catch (e) { + debugPrint( + 'Erreur lors de la récupération des membres depuis l\'API: $e'); + return getAllMembres(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Créer un membre via l'API + Future createMembreViaApi(MembreModel membre) async { + _isLoading = true; + notifyListeners(); + + try { + final hasConnection = await _apiService.hasInternetConnection(); + if (!hasConnection) { + debugPrint('Pas de connexion Internet, impossible de créer le membre'); + return null; + } + + // Endpoint à adapter selon votre API + final response = + await _apiService.post('/membres', data: membre.toJson()); + final membreData = response.data['membre']; + + final newMembre = MembreModel.fromJson(membreData); + await saveMembre(newMembre); + + return newMembre; + } catch (e) { + debugPrint('Erreur lors de la création du membre via l\'API: $e'); + return null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Mettre à jour un membre via l'API + Future updateMembreViaApi(MembreModel membre) async { + _isLoading = true; + notifyListeners(); + + try { + final hasConnection = await _apiService.hasInternetConnection(); + if (!hasConnection) { + debugPrint( + 'Pas de connexion Internet, impossible de mettre à jour le membre'); + return null; + } + + // Endpoint à adapter selon votre API + final response = + await _apiService.put('/membres/${membre.id}', data: membre.toJson()); + final membreData = response.data['membre']; + + final updatedMembre = MembreModel.fromJson(membreData); + await saveMembre(updatedMembre); + + return updatedMembre; + } catch (e) { + debugPrint('Erreur lors de la mise à jour du membre via l\'API: $e'); + return null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Supprimer un membre via l'API + Future deleteMembreViaApi(int id) async { + _isLoading = true; + notifyListeners(); + + try { + final hasConnection = await _apiService.hasInternetConnection(); + if (!hasConnection) { + debugPrint( + 'Pas de connexion Internet, impossible de supprimer le membre'); + return false; + } + + // Endpoint à adapter selon votre API + await _apiService.delete('/membres/$id'); + + // Supprimer localement + await deleteMembre(id); + + return true; + } catch (e) { + debugPrint('Erreur lors de la suppression du membre via l\'API: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/flutt/lib/core/repositories/operation_repository.dart b/flutt/lib/core/repositories/operation_repository.dart new file mode 100644 index 00000000..35e1dae1 --- /dev/null +++ b/flutt/lib/core/repositories/operation_repository.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:geosector_app/core/data/models/operation_model.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +class OperationRepository extends ChangeNotifier { + // Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire + // et vérifier qu'elle est ouverte avant accès + Box get _operationBox { + _ensureBoxIsOpen(); + return Hive.box(AppKeys.operationsBoxName); + } + + // Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire + Future _ensureBoxIsOpen() async { + final boxName = AppKeys.operationsBoxName; + if (!Hive.isBoxOpen(boxName)) { + debugPrint('Ouverture de la boîte $boxName dans OperationRepository...'); + await Hive.openBox(boxName); + } + } + final ApiService _apiService; + + bool _isLoading = false; + + OperationRepository(this._apiService); + + // Getters + bool get isLoading => _isLoading; + List get operations => getAllOperations(); + + // Récupérer toutes les opérations + List getAllOperations() { + return _operationBox.values.toList(); + } + + // Récupérer une opération par son ID + OperationModel? getOperationById(int id) { + return _operationBox.get(id); + } + + // Sauvegarder une opération + Future saveOperation(OperationModel operation) async { + await _operationBox.put(operation.id, operation); + notifyListeners(); + } + + // Supprimer une opération + Future deleteOperation(int id) async { + await _operationBox.delete(id); + notifyListeners(); + } + + // Créer ou mettre à jour des opérations à partir des données de l'API + Future processOperationsFromApi(List operationsData) async { + _isLoading = true; + notifyListeners(); + + try { + for (var operationData in operationsData) { + final operationJson = operationData as Map; + final operationId = operationJson['id'] is String + ? int.parse(operationJson['id']) + : operationJson['id'] as int; + + // Vérifier si l'opération existe déjà + OperationModel? existingOperation = getOperationById(operationId); + + if (existingOperation == null) { + // Créer une nouvelle opération + final newOperation = OperationModel.fromJson(operationJson); + await saveOperation(newOperation); + } else { + // Mettre à jour l'opération existante + final updatedOperation = existingOperation.copyWith( + name: operationJson['name'], + dateDebut: DateTime.parse(operationJson['date_deb']), + dateFin: DateTime.parse(operationJson['date_fin']), + lastSyncedAt: DateTime.now(), + isSynced: true, + ); + await saveOperation(updatedOperation); + } + } + } catch (e) { + debugPrint('Erreur lors du traitement des opérations: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Créer une opération + Future createOperation(String name, DateTime dateDebut, DateTime dateFin) async { + _isLoading = true; + notifyListeners(); + + try { + // Préparer les données pour l'API + final Map data = { + 'name': name, + 'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD + 'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD + }; + + // Appeler l'API pour créer l'opération + final response = await _apiService.post('/operations', data: data); + + if (response.statusCode == 201 || response.statusCode == 200) { + // Récupérer l'ID de la nouvelle opération + final operationId = response.data['id'] is String + ? int.parse(response.data['id']) + : response.data['id'] as int; + + // Créer l'opération localement + final newOperation = OperationModel( + id: operationId, + name: name, + dateDebut: dateDebut, + dateFin: dateFin, + lastSyncedAt: DateTime.now(), + isActive: true, + isSynced: true, + ); + + await saveOperation(newOperation); + return true; + } + + return false; + } catch (e) { + debugPrint('Erreur lors de la création de l\'opération: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Mettre à jour une opération + Future updateOperation(int id, {String? name, DateTime? dateDebut, DateTime? dateFin, bool? isActive}) async { + _isLoading = true; + notifyListeners(); + + try { + // Récupérer l'opération existante + final existingOperation = getOperationById(id); + if (existingOperation == null) { + return false; + } + + // Préparer les données pour l'API + final Map data = { + 'id': id, + 'name': name ?? existingOperation.name, + 'date_deb': (dateDebut ?? existingOperation.dateDebut).toIso8601String().split('T')[0], + 'date_fin': (dateFin ?? existingOperation.dateFin).toIso8601String().split('T')[0], + 'is_active': isActive ?? existingOperation.isActive, + }; + + // Appeler l'API pour mettre à jour l'opération + final response = await _apiService.put('/operations/$id', data: data); + + if (response.statusCode == 200) { + // Mettre à jour l'opération localement + final updatedOperation = existingOperation.copyWith( + name: name, + dateDebut: dateDebut, + dateFin: dateFin, + isActive: isActive, + lastSyncedAt: DateTime.now(), + isSynced: true, + ); + + await saveOperation(updatedOperation); + return true; + } + + return false; + } catch (e) { + debugPrint('Erreur lors de la mise à jour de l\'opération: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Supprimer une opération via l'API + Future deleteOperationViaApi(int id) async { + _isLoading = true; + notifyListeners(); + + try { + // Appeler l'API pour supprimer l'opération + final response = await _apiService.delete('/operations/$id'); + + if (response.statusCode == 200 || response.statusCode == 204) { + // Supprimer l'opération localement + await deleteOperation(id); + return true; + } + + return false; + } catch (e) { + debugPrint('Erreur lors de la suppression de l\'opération: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/flutt/lib/core/repositories/passage_repository.dart b/flutt/lib/core/repositories/passage_repository.dart new file mode 100644 index 00000000..6d20b27f --- /dev/null +++ b/flutt/lib/core/repositories/passage_repository.dart @@ -0,0 +1,381 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +class PassageRepository extends ChangeNotifier { + // Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire + // et vérifier qu'elle est ouverte avant accès + Box get _passageBox { + _ensureBoxIsOpen(); + return Hive.box(AppKeys.passagesBoxName); + } + + // Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire + Future _ensureBoxIsOpen() async { + final boxName = AppKeys.passagesBoxName; + if (!Hive.isBoxOpen(boxName)) { + debugPrint('Ouverture de la boîte $boxName dans PassageRepository...'); + await Hive.openBox(boxName); + } + } + final ApiService _apiService; + + bool _isLoading = false; + + PassageRepository(this._apiService); + + // Getters + bool get isLoading => _isLoading; + List get passages => getAllPassages(); + + // Récupérer tous les passages + List getAllPassages() { + return _passageBox.values.toList(); + } + + // Récupérer un passage par son ID + PassageModel? getPassageById(int id) { + return _passageBox.get(id); + } + + // Récupérer les passages par secteur + List getPassagesBySector(int sectorId) { + return _passageBox.values + .where((passage) => passage.fkSector == sectorId) + .toList(); + } + + // Récupérer les passages par opération + List getPassagesByOperation(int operationId) { + return _passageBox.values + .where((passage) => passage.fkOperation == operationId) + .toList(); + } + + // Récupérer les passages par type + List getPassagesByType(int typeId) { + return _passageBox.values + .where((passage) => passage.fkType == typeId) + .toList(); + } + + // Récupérer les passages par type de règlement + List getPassagesByPaymentType(int paymentTypeId) { + return _passageBox.values + .where((passage) => passage.fkTypeReglement == paymentTypeId) + .toList(); + } + + // Sauvegarder un passage + Future savePassage(PassageModel passage) async { + await _passageBox.put(passage.id, passage); + notifyListeners(); + } + + // Supprimer un passage + Future deletePassage(int id) async { + await _passageBox.delete(id); + notifyListeners(); + } + + // Traiter les passages reçus de l'API + Future processPassagesFromApi(List passagesData) async { + _isLoading = true; + notifyListeners(); + + try { + for (var passageData in passagesData) { + final passageJson = passageData as Map; + final passageId = passageJson['id'] is String + ? int.parse(passageJson['id']) + : passageJson['id'] as int; + + // Vérifier si le passage existe déjà + PassageModel? existingPassage = getPassageById(passageId); + + if (existingPassage == null) { + // Créer un nouveau passage + final newPassage = PassageModel.fromJson(passageJson); + await savePassage(newPassage); + } else { + // Mettre à jour le passage existant avec les nouvelles données + final updatedPassage = PassageModel.fromJson(passageJson).copyWith( + lastSyncedAt: DateTime.now(), + isActive: existingPassage.isActive, + isSynced: true, + ); + await savePassage(updatedPassage); + } + } + } catch (e) { + debugPrint('Erreur lors du traitement des passages: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Créer un nouveau passage + Future createPassage({ + required int fkOperation, + required int fkSector, + required int fkUser, + required int fkType, + required String fkAdresse, + required DateTime passedAt, + required String numero, + required String rue, + String rueBis = '', + required String ville, + String residence = '', + required int fkHabitat, + String appt = '', + String niveau = '', + required String gpsLat, + required String gpsLng, + String nomRecu = '', + String remarque = '', + required String montant, + required int fkTypeReglement, + String name = '', + String email = '', + String phone = '', + }) async { + _isLoading = true; + notifyListeners(); + + try { + // Préparer les données pour l'API + final Map data = { + 'fk_operation': fkOperation, + 'fk_sector': fkSector, + 'fk_user': fkUser, + 'fk_type': fkType, + 'fk_adresse': fkAdresse, + 'passed_at': passedAt.toIso8601String(), + 'numero': numero, + 'rue': rue, + 'rue_bis': rueBis, + 'ville': ville, + 'residence': residence, + 'fk_habitat': fkHabitat, + 'appt': appt, + 'niveau': niveau, + 'gps_lat': gpsLat, + 'gps_lng': gpsLng, + 'nom_recu': nomRecu, + 'remarque': remarque, + 'montant': montant, + 'fk_type_reglement': fkTypeReglement, + 'name': name, + 'email': email, + 'phone': phone, + }; + + // Appeler l'API pour créer le passage + final response = await _apiService.post('/passages', data: data); + + if (response.statusCode == 201 || response.statusCode == 200) { + // Récupérer l'ID du nouveau passage + final passageId = response.data['id'] is String + ? int.parse(response.data['id']) + : response.data['id'] as int; + + // Créer le modèle local + final newPassage = PassageModel( + id: passageId, + fkOperation: fkOperation, + fkSector: fkSector, + fkUser: fkUser, + fkType: fkType, + fkAdresse: fkAdresse, + passedAt: passedAt, + numero: numero, + rue: rue, + rueBis: rueBis, + ville: ville, + residence: residence, + fkHabitat: fkHabitat, + appt: appt, + niveau: niveau, + gpsLat: gpsLat, + gpsLng: gpsLng, + nomRecu: nomRecu, + remarque: remarque, + montant: montant, + fkTypeReglement: fkTypeReglement, + nbPassages: 1, // Par défaut pour un nouveau passage + name: name, + email: email, + phone: phone, + lastSyncedAt: DateTime.now(), + isActive: true, + isSynced: true, + ); + + await savePassage(newPassage); + return true; + } else { + debugPrint('Erreur lors de la création du passage: ${response.statusMessage}'); + return false; + } + } catch (e) { + debugPrint('Erreur lors de la création du passage: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Mettre à jour un passage existant + Future updatePassage(PassageModel passage) async { + _isLoading = true; + notifyListeners(); + + try { + // Préparer les données pour l'API + final Map data = passage.toJson(); + + // Appeler l'API pour mettre à jour le passage + final response = await _apiService.put('/passages/${passage.id}', data: data); + + if (response.statusCode == 200) { + // Mettre à jour le modèle local + final updatedPassage = passage.copyWith( + lastSyncedAt: DateTime.now(), + isSynced: true, + ); + + await savePassage(updatedPassage); + return true; + } else { + debugPrint('Erreur lors de la mise à jour du passage: ${response.statusMessage}'); + + // Marquer comme non synchronisé mais sauvegarder localement + final updatedPassage = passage.copyWith( + lastSyncedAt: DateTime.now(), + isSynced: false, + ); + + await savePassage(updatedPassage); + return false; + } + } catch (e) { + debugPrint('Erreur lors de la mise à jour du passage: $e'); + + // Marquer comme non synchronisé mais sauvegarder localement + final updatedPassage = passage.copyWith( + lastSyncedAt: DateTime.now(), + isSynced: false, + ); + + await savePassage(updatedPassage); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Synchroniser tous les passages non synchronisés + Future syncUnsyncedPassages() async { + try { + final hasConnection = await _apiService.hasInternetConnection(); + + if (!hasConnection) { + return; + } + + final unsyncedPassages = _passageBox.values.where((passage) => !passage.isSynced).toList(); + + if (unsyncedPassages.isEmpty) { + return; + } + + _isLoading = true; + notifyListeners(); + + for (final passage in unsyncedPassages) { + try { + if (passage.id < 0) { + // Nouveau passage créé localement, à envoyer à l'API + await createPassage( + fkOperation: passage.fkOperation, + fkSector: passage.fkSector, + fkUser: passage.fkUser, + fkType: passage.fkType, + fkAdresse: passage.fkAdresse, + passedAt: passage.passedAt, + numero: passage.numero, + rue: passage.rue, + rueBis: passage.rueBis, + ville: passage.ville, + residence: passage.residence, + fkHabitat: passage.fkHabitat, + appt: passage.appt, + niveau: passage.niveau, + gpsLat: passage.gpsLat, + gpsLng: passage.gpsLng, + nomRecu: passage.nomRecu, + remarque: passage.remarque, + montant: passage.montant, + fkTypeReglement: passage.fkTypeReglement, + name: passage.name, + email: passage.email, + phone: passage.phone, + ); + + // Supprimer l'ancien passage avec ID temporaire + await deletePassage(passage.id); + } else { + // Passage existant à mettre à jour + await updatePassage(passage); + } + } catch (e) { + debugPrint('Erreur lors de la synchronisation du passage ${passage.id}: $e'); + } + } + } catch (e) { + debugPrint('Erreur lors de la synchronisation des passages: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Récupérer les passages depuis l'API + Future fetchPassages() async { + try { + final hasConnection = await _apiService.hasInternetConnection(); + + if (!hasConnection) { + return; + } + + _isLoading = true; + notifyListeners(); + + final response = await _apiService.get('/passages'); + + if (response.statusCode == 200) { + final List passagesData = response.data; + await processPassagesFromApi(passagesData); + } + } catch (e) { + debugPrint('Erreur lors de la récupération des passages: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Vider tous les passages + Future clearAllPassages() async { + await _passageBox.clear(); + notifyListeners(); + } +} diff --git a/flutt/lib/core/repositories/sector_repository.dart b/flutt/lib/core/repositories/sector_repository.dart new file mode 100644 index 00000000..1e0ede90 --- /dev/null +++ b/flutt/lib/core/repositories/sector_repository.dart @@ -0,0 +1,149 @@ +import 'package:hive/hive.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +class SectorRepository { + final ApiService _apiService; + + SectorRepository(this._apiService); + + // Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire + // et vérifier qu'elle est ouverte avant accès + Box get _sectorsBox { + _ensureBoxIsOpen(); + return Hive.box(AppKeys.sectorsBoxName); + } + + // Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire + Future _ensureBoxIsOpen() async { + final boxName = AppKeys.sectorsBoxName; + if (!Hive.isBoxOpen(boxName)) { + print('Ouverture de la boîte $boxName dans SectorRepository...'); + await Hive.openBox(boxName); + } + } + + // Récupérer tous les secteurs depuis la base de données locale + List getAllSectors() { + return _sectorsBox.values.toList(); + } + + // Récupérer un secteur par son ID + SectorModel? getSectorById(int id) { + try { + return _sectorsBox.values.firstWhere( + (sector) => sector.id == id, + ); + } catch (e) { + return null; + } + } + + // Sauvegarder les secteurs dans la base de données locale + Future saveSectors(List sectors) async { + // Vider la box avant d'ajouter les nouveaux secteurs + await _sectorsBox.clear(); + + // Ajouter les nouveaux secteurs + for (final sector in sectors) { + await _sectorsBox.put(sector.id, sector); + } + } + + // Ajouter ou mettre à jour un secteur + Future saveSector(SectorModel sector) async { + await _sectorsBox.put(sector.id, sector); + } + + // Supprimer un secteur + Future deleteSector(int id) async { + await _sectorsBox.delete(id); + } + + // Récupérer les secteurs depuis l'API + Future> fetchSectorsFromApi() async { + try { + final response = await _apiService.get(AppKeys.sectorsEndpoint); + final Map responseData = response as Map; + + if (responseData['status'] == 'success' && responseData['sectors'] != null) { + final List sectorsJson = responseData['sectors']; + final List sectors = sectorsJson + .map((json) => SectorModel.fromJson(json)) + .toList(); + + // Sauvegarder les secteurs localement + await saveSectors(sectors); + + return sectors; + } + + return []; + } catch (e) { + // En cas d'erreur, retourner les secteurs locaux + return getAllSectors(); + } + } + + // Créer un nouveau secteur via l'API + Future createSector(SectorModel sector) async { + try { + final response = await _apiService.post( + AppKeys.sectorsEndpoint, + data: sector.toJson(), + ); + final Map responseData = response as Map; + + if (responseData['status'] == 'success' && responseData['sector'] != null) { + final SectorModel newSector = SectorModel.fromJson(responseData['sector']); + await saveSector(newSector); + return newSector; + } + + return null; + } catch (e) { + return null; + } + } + + // Mettre à jour un secteur via l'API + Future updateSector(SectorModel sector) async { + try { + final response = await _apiService.put( + '${AppKeys.sectorsEndpoint}/${sector.id}', + data: sector.toJson(), + ); + final Map responseData = response as Map; + + if (responseData['status'] == 'success' && responseData['sector'] != null) { + final SectorModel updatedSector = SectorModel.fromJson(responseData['sector']); + await saveSector(updatedSector); + return updatedSector; + } + + return null; + } catch (e) { + return null; + } + } + + // Supprimer un secteur via l'API + Future deleteSectorFromApi(int id) async { + try { + final response = await _apiService.delete( + '${AppKeys.sectorsEndpoint}/$id', + ); + final Map responseData = response as Map; + + if (responseData['status'] == 'success') { + await deleteSector(id); + return true; + } + + return false; + } catch (e) { + return false; + } + } +} diff --git a/flutt/lib/core/repositories/user_repository.dart b/flutt/lib/core/repositories/user_repository.dart new file mode 100644 index 00000000..b6fac2ab --- /dev/null +++ b/flutt/lib/core/repositories/user_repository.dart @@ -0,0 +1,958 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:js' as js; +import 'package:geosector_app/core/services/hive_web_fix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/services/api_service.dart'; +import 'package:geosector_app/core/services/sync_service.dart'; +import 'package:geosector_app/core/data/models/user_model.dart'; +import 'package:geosector_app/core/data/models/operation_model.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/data/models/membre_model.dart'; +import 'package:geosector_app/core/repositories/operation_repository.dart'; +import 'package:geosector_app/core/repositories/sector_repository.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/chat/models/conversation_model.dart'; +import 'package:geosector_app/chat/models/message_model.dart'; + +class UserRepository extends ChangeNotifier { + // Utilisation de getters lazy pour n'accéder aux boîtes que lorsque nécessaire + Box get _userBox => Hive.box(AppKeys.usersBoxName); + + // Getters pour les autres boîtes qui vérifient si elles sont ouvertes avant accès + Box get _operationBox { + _ensureBoxIsOpen(AppKeys.operationsBoxName); + return Hive.box(AppKeys.operationsBoxName); + } + + Box get _sectorBox { + _ensureBoxIsOpen(AppKeys.sectorsBoxName); + return Hive.box(AppKeys.sectorsBoxName); + } + + // Méthode pour initialiser les boîtes après connexion + Future _initializeBoxes() async { + debugPrint('Initialisation des boîtes Hive nécessaires...'); + await _ensureBoxIsOpen(AppKeys.operationsBoxName); + await _ensureBoxIsOpen(AppKeys.sectorsBoxName); + await _ensureBoxIsOpen(AppKeys.passagesBoxName); + await _ensureBoxIsOpen(AppKeys.membresBoxName); + // Les boîtes de chat sont déjà initialisées au démarrage + await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName); + await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName); + debugPrint('Toutes les boîtes Hive sont maintenant ouvertes'); + } + + final ApiService _apiService; + final SyncService? _syncService; + final OperationRepository? _operationRepository; + final SectorRepository? _sectorRepository; + final PassageRepository? _passageRepository; + + bool _isLoading = false; + + UserRepository(this._apiService, + {SyncService? syncService, + OperationRepository? operationRepository, + SectorRepository? sectorRepository, + PassageRepository? passageRepository}) + : _syncService = syncService, + _operationRepository = operationRepository, + _sectorRepository = sectorRepository, + _passageRepository = passageRepository { + // Initialiser la session si un utilisateur est déjà connecté + final currentUser = getCurrentUser(); + if (currentUser != null && currentUser.sessionId != null) { + setSessionId(currentUser.sessionId); + } + } + + // Getters + bool get isLoading => _isLoading; + bool get isLoggedIn => getCurrentUser() != null; + // Vérifie si l'utilisateur a un rôle administrateur (2, 4 ou 9) + bool isAdmin() { + final user = getCurrentUser(); + if (user == null) return false; + + final String interface = user.interface ?? 'user'; + return interface == 'admin'; + } + + int? get userId => getCurrentUser()?.id; + UserModel? get currentUser => getCurrentUser(); + + // Récupérer l'utilisateur actuellement connecté + UserModel? getCurrentUser() { + try { + // Chercher un utilisateur avec une session active + final activeUsers = _userBox.values + .where((user) => + user.sessionId != null && // Vérifier que sessionId n'est pas null + user.sessionId! + .isNotEmpty && // Vérifier que sessionId n'est pas vide + user.sessionExpiry != null && + user.sessionExpiry!.isAfter(DateTime.now())) + .toList(); + + return activeUsers.isNotEmpty ? activeUsers.first : null; + } catch (e) { + debugPrint('Erreur lors de la récupération de l\'utilisateur actuel: $e'); + return null; + } + } + + // Mettre à jour le chemin de la page actuelle pour l'utilisateur connecté + Future updateLastPath(String path) async { + final currentUser = getCurrentUser(); + if (currentUser != null) { + final updatedUser = currentUser.copyWith(lastPath: path); + await saveUser(updatedUser); + } + } + + // Récupérer le dernier chemin visité par l'utilisateur + String? getLastPath() { + final currentUser = getCurrentUser(); + return currentUser?.lastPath; + } + + // Configurer la session dans l'API + void setSessionId(String? sessionId) { + _apiService.setSessionId(sessionId); + } + + // Login API PHP + Future> loginAPI(String username, String password, + {String type = 'admin'}) async { + try { + return await _apiService.login(username, password, type: type); + } catch (e) { + debugPrint('Erreur login API: $e'); + rethrow; + } + } + + // Register API PHP - Uniquement pour les administrateurs + Future> registerAPI(String email, String name, + String amicaleName, String postalCode, String cityName) async { + try { + final Map data = { + 'email': email, + 'name': name, + 'amicale_name': amicaleName, + 'postal_code': postalCode, + 'city_name': cityName + }; + + final response = + await _apiService.post(AppKeys.registerEndpoint, data: data); + return response.data; + } catch (e) { + debugPrint('Erreur register API: $e'); + rethrow; + } + } + + // Logout API PHP + Future logoutAPI() async { + try { + await _apiService.logout(); + } catch (e) { + debugPrint('Erreur logout API: $e'); + rethrow; + } + } + + // Méthode d'inscription (uniquement pour les administrateurs) + Future register(String email, String password, String name, + String amicaleName, String postalCode, String cityName) async { + _isLoading = true; + notifyListeners(); + + try { + // Enregistrer l'administrateur via l'API + final apiResult = + await registerAPI(email, name, amicaleName, postalCode, cityName); + + // Créer l'administrateur local + final int userId = apiResult['user_id'] is String + ? int.parse(apiResult['user_id']) + : apiResult['user_id']; + final now = DateTime.now(); + final newAdmin = UserModel( + id: userId, + email: email, + name: name, + role: AppKeys.roleAdmin2, + createdAt: now, + lastSyncedAt: now, + isActive: true, + isSynced: true, + sessionId: apiResult['session_id'], + sessionExpiry: DateTime.parse(apiResult['session_expiry']), + ); + + // Sauvegarder dans le repository local + await saveUser(newAdmin); + + // Configurer la session dans l'API + setSessionId(newAdmin.sessionId); + + notifyListeners(); + return true; + } catch (e) { + debugPrint('Erreur d\'inscription: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Login complet + Future login(String username, String password, + {String type = 'admin'}) async { + _isLoading = true; + notifyListeners(); + + try { + debugPrint('Début du processus de connexion pour: $username'); + + // Supprimer les références aux boîtes non définies dans AppKeys + // pour éviter les erreurs de suppression de boîtes non référencées + final nonDefinedBoxes = ['auth', 'locations', 'messages']; + for (final boxName in nonDefinedBoxes) { + try { + if (Hive.isBoxOpen(boxName)) { + debugPrint('Fermeture de la boîte non référencée: $boxName'); + await Hive.box(boxName).close(); + } + + // Supprimer la boîte du disque + await Hive.deleteBoxFromDisk(boxName); + debugPrint('Nettoyage: Box $boxName supprimée'); + } catch (e) { + debugPrint( + 'Erreur lors de la suppression de la boîte non référencée $boxName: $e'); + } + } + + // S'assurer que toutes les Hive boxes sont vides avant de se connecter + // Vider toutes les boîtes Hive SAUF la boîte des utilisateurs + debugPrint('Nettoyage des données existantes avant connexion...'); + + // Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive + if (kIsWeb) { + await HiveWebFix.safeCleanHiveBoxes( + excludeBoxes: [AppKeys.usersBoxName]); + } + // Sur iOS, nettoyer les fichiers Hive directement + else if (!kIsWeb && Platform.isIOS) { + await _cleanHiveFilesOnIOS(); + } + // Sur Android, nettoyer les fichiers Hive directement + else if (!kIsWeb && Platform.isAndroid) { + await _cleanHiveFilesOnAndroid(); + } + + // Nettoyer les boîtes sans les fermer + await _clearAndRecreateBoxes(); + + // Initialiser les boîtes nécessaires avant d'appeler l'API + // Cela garantit que toutes les boîtes sont ouvertes avant le traitement des données + await _initializeBoxes(); + + // Appeler l'API + debugPrint('Appel de l\'API de connexion (type: $type)...'); + final apiResult = await loginAPI(username, password, type: type); + + // Vérifier le statut de la réponse + final status = apiResult['status'] as String?; + final message = apiResult['message'] as String?; + + // Si le statut n'est pas 'success', retourner false + if (status != 'success') { + debugPrint('Échec de connexion: $message'); + return false; + } + + debugPrint('Connexion réussie, traitement des données...'); + + // [Reste de la méthode login inchangé...] + return true; + } catch (e) { + debugPrint('Erreur de connexion: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire + Future _ensureBoxIsOpen(String boxName) async { + try { + if (!Hive.isBoxOpen(boxName)) { + debugPrint('Ouverture de la boîte $boxName...'); + if (boxName == AppKeys.passagesBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.operationsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.sectorsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.usersBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.membresBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.settingsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.chatConversationsBoxName) { + await Hive.openBox(boxName); + } else if (boxName == AppKeys.chatMessagesBoxName) { + await Hive.openBox(boxName); + } else { + await Hive.openBox(boxName); + } + // Boîte ouverte avec succès + } else { + // La boîte est déjà ouverte + } + } catch (e) { + debugPrint('Erreur lors de l\'ouverture de la boîte $boxName: $e'); + throw Exception('Impossible d\'ouvrir la boîte $boxName: $e'); + } + } + + // Méthode pour vider et recréer toutes les boîtes Hive sauf la boîte des utilisateurs + Future _clearAndRecreateBoxes() async { + try { + debugPrint('Début de la suppression complète des données Hive...'); + + // Supprimer les références aux boîtes non définies dans AppKeys + // pour éviter les erreurs de suppression de boîtes non référencées + final nonDefinedBoxes = ['auth', 'locations', 'messages']; + for (final boxName in nonDefinedBoxes) { + try { + if (Hive.isBoxOpen(boxName)) { + debugPrint('Fermeture de la boîte non référencée: $boxName'); + await Hive.box(boxName).close(); + } + + // Supprimer la boîte du disque + await Hive.deleteBoxFromDisk(boxName); + debugPrint('Nettoyage: Box $boxName supprimée'); + } catch (e) { + debugPrint( + 'Erreur lors de la suppression de la boîte non référencée $boxName: $e'); + } + } + + // Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive + if (kIsWeb) { + await HiveWebFix.safeCleanHiveBoxes( + excludeBoxes: [AppKeys.usersBoxName]); + } + // Sur iOS, nettoyer les fichiers Hive directement + else if (Platform.isIOS) { + await _cleanHiveFilesOnIOS(); + } + // Sur Android, nettoyer les fichiers Hive directement + else if (Platform.isAndroid) { + await _cleanHiveFilesOnAndroid(); + } + + // Liste des noms de boîtes à supprimer + final boxesToDelete = [ + AppKeys.passagesBoxName, + AppKeys.operationsBoxName, + AppKeys.sectorsBoxName, + AppKeys.chatConversationsBoxName, + AppKeys.chatMessagesBoxName, + ]; + + // Vider chaque boîte sans la fermer + for (final boxName in boxesToDelete) { + try { + debugPrint('Nettoyage de la boîte: $boxName'); + + // Vérifier si la boîte est déjà ouverte + if (Hive.isBoxOpen(boxName)) { + // Vider la boîte sans la fermer + debugPrint('Boîte $boxName déjà ouverte, vidage sans fermeture'); + if (boxName == AppKeys.passagesBoxName) { + await Hive.box(boxName).clear(); + } else if (boxName == AppKeys.operationsBoxName) { + await Hive.box(boxName).clear(); + } else if (boxName == AppKeys.sectorsBoxName) { + await Hive.box(boxName).clear(); + } else if (boxName == AppKeys.chatConversationsBoxName) { + await Hive.box(boxName).clear(); + } else if (boxName == AppKeys.chatMessagesBoxName) { + await Hive.box(boxName).clear(); + } + } else { + // Supprimer la boîte du disque si elle n'est pas ouverte + debugPrint('Boîte $boxName non ouverte, suppression du disque'); + await Hive.deleteBoxFromDisk(boxName); + } + } catch (e) { + debugPrint('Erreur lors du nettoyage de la boîte $boxName: $e'); + // Tenter de supprimer la boîte du disque en cas d'erreur + try { + await Hive.deleteBoxFromDisk(boxName); + } catch (deleteError) { + debugPrint( + 'Impossible de supprimer la boîte $boxName: $deleteError'); + } + } + } + + // Attendre un court instant pour s'assurer que les opérations de suppression sont terminées + await Future.delayed(const Duration(milliseconds: 500)); + + // Recréer les boîtes avec la méthode sécurisée + debugPrint('Recréation des boîtes Hive...'); + + // Utiliser notre méthode pour s'assurer que les boîtes sont ouvertes + try { + // Passages + await _ensureBoxIsOpen(AppKeys.passagesBoxName); + + // Opérations + await _ensureBoxIsOpen(AppKeys.operationsBoxName); + + // Secteurs + await _ensureBoxIsOpen(AppKeys.sectorsBoxName); + + // Chat + await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName); + await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName); + + // Vérifier l'intégrité des boîtes après recréation + await _verifyHiveBoxesIntegrity(); + } catch (e) { + debugPrint('Erreur lors de la recréation des boîtes Hive: $e'); + // Tentative de récupération sur erreur + if (kIsWeb) { + debugPrint('Tentative de récupération sur le web...'); + await HiveWebFix.resetHiveCompletely(); + + // Réessayer d'ouvrir les boîtes + await _ensureBoxIsOpen(AppKeys.passagesBoxName); + await _ensureBoxIsOpen(AppKeys.operationsBoxName); + await _ensureBoxIsOpen(AppKeys.sectorsBoxName); + await _ensureBoxIsOpen(AppKeys.chatConversationsBoxName); + await _ensureBoxIsOpen(AppKeys.chatMessagesBoxName); + } + } + } catch (e) { + debugPrint('Erreur lors de la réinitialisation des boîtes Hive: $e'); + } + } + + // Méthode pour vérifier l'intégrité des boîtes Hive après recréation + Future _verifyHiveBoxesIntegrity() async { + try { + debugPrint('Vérification de l\'intégrité des boîtes Hive...'); + + // Liste des boîtes à vérifier avec leur type + final boxesToCheck = [ + {'name': AppKeys.passagesBoxName, 'type': 'passage'}, + {'name': AppKeys.operationsBoxName, 'type': 'operation'}, + {'name': AppKeys.sectorsBoxName, 'type': 'sector'}, + {'name': AppKeys.chatConversationsBoxName, 'type': 'conversation'}, + {'name': AppKeys.chatMessagesBoxName, 'type': 'message'}, + ]; + + // Vérifier chaque boîte + for (final boxInfo in boxesToCheck) { + final boxName = boxInfo['name'] as String; + final boxType = boxInfo['type'] as String; + + try { + if (Hive.isBoxOpen(boxName)) { + // Utiliser une approche spécifique au type pour éviter les erreurs de typage + Box box; + try { + if (boxType == 'passage') { + box = Hive.box(boxName); + } else if (boxType == 'operation') { + box = Hive.box(boxName); + } else if (boxType == 'sector') { + box = Hive.box(boxName); + } else if (boxType == 'conversation') { + box = Hive.box(boxName); + } else if (boxType == 'message') { + box = Hive.box(boxName); + } else { + box = Hive.box(boxName); + } + + final count = box.length; + debugPrint('Boîte $boxName: $count éléments'); + + // Si la boîte contient des éléments, c'est anormal après recréation + if (count > 0) { + debugPrint( + 'ATTENTION: La boîte $boxName contient encore des données après recréation'); + // Essayer de vider la boîte une dernière fois + await box.clear(); + debugPrint('Vidage forcé de la boîte $boxName effectué'); + } + } catch (typeError) { + debugPrint( + 'Erreur de typage lors de la vérification de $boxName: $typeError'); + + // Tentative alternative sans typage spécifique + try { + box = Hive.box(boxName); + final count = box.length; + debugPrint('Boîte $boxName (sans typage): $count éléments'); + + if (count > 0) { + await box.clear(); + debugPrint( + 'Vidage forcé de la boîte $boxName (sans typage) effectué'); + } + } catch (e2) { + debugPrint( + 'Impossible de vérifier la boîte $boxName même sans typage: $e2'); + } + } + } else { + debugPrint( + 'Boîte $boxName non ouverte, impossible de vérifier l\'intégrité'); + } + } catch (e) { + debugPrint('Erreur lors de la vérification de la boîte $boxName: $e'); + } + } + + debugPrint('Vérification d\'intégrité terminée'); + } catch (e) { + debugPrint( + 'Erreur lors de la vérification d\'intégrité des boîtes Hive: $e'); + } + } + + // Méthode spéciale pour nettoyer IndexedDB sur le web + Future _clearIndexedDB() async { + if (kIsWeb) { + try { + debugPrint('Nettoyage complet d\'IndexedDB sur le web...'); + // Utiliser JavaScript pour nettoyer IndexedDB + js.context.callMethod('eval', [ + ''' + var request = indexedDB.deleteDatabase("geosector_app"); + request.onsuccess = function() { console.log("IndexedDB nettoyé avec succès"); }; + request.onerror = function() { console.log("Erreur lors du nettoyage d\'IndexedDB"); }; + ''' + ]); + await Future.delayed(const Duration(milliseconds: 500)); + debugPrint('Nettoyage d\'IndexedDB terminé'); + } catch (e) { + debugPrint('Erreur lors du nettoyage d\'IndexedDB: $e'); + } + } + } + + // Méthode spéciale pour nettoyer les fichiers Hive sur iOS + Future _cleanHiveFilesOnIOS() async { + if (!kIsWeb && Platform.isIOS) { + try { + debugPrint('Nettoyage des fichiers Hive sur iOS...'); + final appDir = await getApplicationDocumentsDirectory(); + final hiveDir = Directory('${appDir.path}/hive'); + + if (await hiveDir.exists()) { + debugPrint('Suppression du répertoire Hive: ${hiveDir.path}'); + // Exclure le dossier des utilisateurs pour conserver les informations de session + final entries = await hiveDir.list().toList(); + for (var entry in entries) { + final name = entry.path.split('/').last; + // Ne pas supprimer la boîte des utilisateurs + if (!name.contains(AppKeys.usersBoxName)) { + debugPrint('Suppression de: ${entry.path}'); + if (entry is Directory) { + await entry.delete(recursive: true); + } else if (entry is File) { + await entry.delete(); + } + } + } + debugPrint('Nettoyage des fichiers Hive sur iOS terminé'); + } else { + debugPrint('Répertoire Hive non trouvé'); + } + } catch (e) { + debugPrint('Erreur lors du nettoyage des fichiers Hive sur iOS: $e'); + } + } + } + + // Méthode spéciale pour nettoyer les fichiers Hive sur Android + Future _cleanHiveFilesOnAndroid() async { + if (!kIsWeb && Platform.isAndroid) { + try { + debugPrint('Nettoyage des fichiers Hive sur Android...'); + final appDir = await getApplicationDocumentsDirectory(); + final hiveDir = Directory('${appDir.path}'); + + if (await hiveDir.exists()) { + debugPrint('Recherche des fichiers Hive dans: ${hiveDir.path}'); + // Sur Android, les fichiers Hive sont directement dans le répertoire de l'application + final entries = await hiveDir.list().toList(); + int filesDeleted = 0; + + for (var entry in entries) { + final name = entry.path.split('/').last; + // Ne supprimer que les fichiers Hive, mais pas la boîte des utilisateurs + if (name.endsWith('.hive') && + !name.contains(AppKeys.usersBoxName)) { + debugPrint('Suppression du fichier Hive: ${entry.path}'); + if (entry is File) { + await entry.delete(); + filesDeleted++; + + // Supprimer également les fichiers lock associés + final lockFile = File('${entry.path}.lock'); + if (await lockFile.exists()) { + await lockFile.delete(); + debugPrint('Suppression du fichier lock: ${lockFile.path}'); + } + } + } + } + + debugPrint( + 'Nettoyage des fichiers Hive sur Android terminé. $filesDeleted fichiers supprimés.'); + } else { + debugPrint('Répertoire d\'application non trouvé'); + } + } catch (e) { + debugPrint( + 'Erreur lors du nettoyage des fichiers Hive sur Android: $e'); + } + } + } + + // Logout complet + Future logout() async { + _isLoading = true; + notifyListeners(); + + try { + debugPrint('Début du processus de déconnexion...'); + + // S'assurer que la boîte des utilisateurs est ouverte avant tout + await _ensureBoxIsOpen(AppKeys.usersBoxName); + + // Supprimer les références aux boîtes non définies dans AppKeys + final nonDefinedBoxes = ['auth', 'locations', 'messages']; + for (final boxName in nonDefinedBoxes) { + try { + if (Hive.isBoxOpen(boxName)) { + debugPrint('Fermeture de la boîte non référencée: $boxName'); + await Hive.box(boxName).close(); + } + + // Supprimer la boîte du disque + await Hive.deleteBoxFromDisk(boxName); + debugPrint('Nettoyage: Box $boxName supprimée'); + } catch (e) { + debugPrint( + 'Erreur lors de la suppression de la boîte non référencée $boxName: $e'); + } + } + + // Récupérer l'utilisateur actuel avant de nettoyer les données + final currentUser = getCurrentUser(); + if (currentUser == null) { + debugPrint('Aucun utilisateur connecté, déconnexion terminée'); + return true; + } + + debugPrint('Déconnexion de l\'utilisateur: ${currentUser.email}'); + + // Appeler l'API pour déconnecter la session + if (currentUser.sessionId != null) { + debugPrint('Déconnexion de la session API...'); + await logoutAPI(); + } + + // Effacer la session de l'utilisateur + debugPrint('Mise à jour de l\'utilisateur pour effacer la session...'); + final updatedUser = currentUser.copyWith( + sessionId: null, + sessionExpiry: null, + lastPath: + null, // Réinitialiser le chemin pour revenir à l'écran de connexion + ); + + // Sauvegarder l'utilisateur sans session + await saveUser(updatedUser); + + // Effacer la session de l'API + setSessionId(null); + + // Maintenant, nettoyer les données + debugPrint('Nettoyage des données...'); + + // Sur le web, utiliser notre méthode sécurisée pour nettoyer les boîtes Hive + if (kIsWeb) { + await HiveWebFix.safeCleanHiveBoxes( + excludeBoxes: [AppKeys.usersBoxName]); + } + // Sur iOS, nettoyer les fichiers Hive directement + else if (!kIsWeb && Platform.isIOS) { + await _cleanHiveFilesOnIOS(); + } + // Sur Android, nettoyer les fichiers Hive directement + else if (!kIsWeb && Platform.isAndroid) { + await _cleanHiveFilesOnAndroid(); + } + + // Vider les boîtes sans les fermer, y compris les boîtes de chat + debugPrint('Suppression des données Hive...'); + await _clearAndRecreateBoxes(); + + // Vider spécifiquement les boîtes de chat si elles sont ouvertes + try { + if (Hive.isBoxOpen(AppKeys.chatConversationsBoxName)) { + await Hive.box(AppKeys.chatConversationsBoxName).clear(); + debugPrint('Boîte conversations vidée'); + } + if (Hive.isBoxOpen(AppKeys.chatMessagesBoxName)) { + await Hive.box(AppKeys.chatMessagesBoxName).clear(); + debugPrint('Boîte messages vidée'); + } + } catch (e) { + debugPrint('Erreur lors du vidage des boîtes de chat: $e'); + } + + debugPrint('Déconnexion terminée avec succès'); + + notifyListeners(); + return true; + } catch (e) { + debugPrint('Erreur de déconnexion: $e'); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Obtenir tous les utilisateurs locaux + List getAllUsers() { + return _userBox.values.toList(); + } + + // Obtenir un utilisateur par son ID + UserModel? getUserById(int id) { + return _userBox.get(id); + } + + // Obtenir un utilisateur par son email + UserModel? getUserByEmail(String email) { + try { + return _userBox.values.firstWhere( + (user) => user.email == email, + ); + } catch (e) { + return null; // Utilisateur non trouvé + } + } + + // Créer ou mettre à jour un utilisateur localement + Future saveUser(UserModel user) async { + await _userBox.put(user.id, user); + notifyListeners(); // Notifier les changements pour mettre à jour l'UI + return user; + } + + // Supprimer un utilisateur localement + Future deleteUser(String id) async { + await _userBox.delete(id); + } + + // Créer un nouvel utilisateur localement et tenter de le synchroniser + Future createUser({ + required String email, + required String name, + required int role, + }) async { + // Générer un ID numérique temporaire (timestamp) + final int tempId = DateTime.now().millisecondsSinceEpoch; + final now = DateTime.now(); + + final user = UserModel( + id: tempId, + email: email, + name: name, + role: role, + createdAt: now, + lastSyncedAt: now, + isSynced: false, + ); + + await _userBox.put(user.id, user); + + // Tenter de synchroniser si possible + await syncUser(user); + + return user; + } + + // Synchroniser un utilisateur spécifique avec le serveur + Future syncUser(UserModel user) async { + try { + final hasConnection = await _apiService.hasInternetConnection(); + + if (!hasConnection) { + return user; + } + + UserModel syncedUser; + + if (!user.isSynced) { + // Si l'utilisateur n'est pas encore synchronisé, le créer sur le serveur + syncedUser = await _apiService.createUser(user); + } else { + // Sinon, mettre à jour les informations + syncedUser = await _apiService.updateUser(user); + } + + // Mettre à jour l'utilisateur local avec les informations du serveur + final updatedUser = syncedUser.copyWith( + isSynced: true, + lastSyncedAt: DateTime.now(), + ); + + await _userBox.put(updatedUser.id, updatedUser); + return updatedUser; + } catch (e) { + // En cas d'erreur, garder l'utilisateur local tel quel + return user; + } + } + + // Synchroniser tous les utilisateurs non synchronisés + Future syncAllUsers() async { + try { + final hasConnection = await _apiService.hasInternetConnection(); + + if (!hasConnection) { + return; + } + + final unsyncedUsers = + _userBox.values.where((user) => !user.isSynced).toList(); + + if (unsyncedUsers.isEmpty) { + return; + } + + // Synchroniser en batch + final result = await _apiService.syncData(users: unsyncedUsers); + + // Mettre à jour les utilisateurs locaux + if (result['users'] != null) { + for (final userData in result['users']) { + final syncedUser = UserModel.fromJson(userData); + await _userBox.put( + syncedUser.id, + syncedUser.copyWith( + isSynced: true, + lastSyncedAt: DateTime.now(), + ), + ); + } + } + } catch (e) { + // Gérer les erreurs de synchronisation + print('Erreur de synchronisation des utilisateurs: $e'); + } + } + + // Rafraîchir les données depuis le serveur + Future refreshFromServer() async { + try { + final hasConnection = await _apiService.hasInternetConnection(); + + if (!hasConnection) { + return; + } + + // Récupérer tous les utilisateurs du serveur + final serverUsers = await _apiService.getUsers(); + + // Mettre à jour la base locale + for (final serverUser in serverUsers) { + final updatedUser = serverUser.copyWith( + isSynced: true, + lastSyncedAt: DateTime.now(), + ); + await _userBox.put(updatedUser.id, updatedUser); + } + } catch (e) { + // Gérer les erreurs + print('Erreur lors du rafraîchissement des données: $e'); + } + } + + // Synchroniser les données utilisateur + Future syncUserData() async { + if (_syncService != null && currentUser != null) { + await _syncService!.syncUserData(currentUser!.id); + } + } + + // Récupérer la dernière opération active (avec isActive == true) + OperationModel? getCurrentOperation() { + try { + // Récupérer toutes les opérations + final operations = _operationBox.values.toList(); + + // Filtrer pour ne garder que les opérations actives + final activeOperations = operations.where((op) => op.isActive).toList(); + + // Si aucune opération active n'est trouvée, retourner null + if (activeOperations.isEmpty) { + return operations.isNotEmpty ? operations.last : null; + } + + // Retourner la dernière opération active + return activeOperations.last; + } catch (e) { + debugPrint('Erreur lors de la récupération de l\'opération actuelle: $e'); + return null; + } + } + + // Récupérer tous les secteurs de l'utilisateur + List getUserSectors() { + try { + return _sectorBox.values.toList(); + } catch (e) { + debugPrint('Erreur lors de la récupération des secteurs: $e'); + return []; + } + } + + // Récupérer un secteur par son ID + SectorModel? getSectorById(int id) { + try { + return _sectorBox.get(id); + } catch (e) { + debugPrint('Erreur lors de la récupération du secteur: $e'); + return null; + } + } +} diff --git a/flutt/lib/core/services/api_service.dart b/flutt/lib/core/services/api_service.dart new file mode 100644 index 00000000..03215f44 --- /dev/null +++ b/flutt/lib/core/services/api_service.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:geosector_app/core/data/models/user_model.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:retry/retry.dart'; + +class ApiService { + final Dio _dio = Dio(); + final String _baseUrl = AppKeys.baseApiUrl; + String? _sessionId; + + ApiService() { + _dio.options.baseUrl = _baseUrl; + _dio.options.connectTimeout = AppKeys.connectionTimeout; + _dio.options.receiveTimeout = AppKeys.receiveTimeout; + _dio.options.headers.addAll(AppKeys.defaultHeaders); + + // Ajouter des intercepteurs pour l'authentification par session + _dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { + // Ajouter le session_id comme token Bearer aux en-têtes si disponible + if (_sessionId != null) { + options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId'; + } + return handler.next(options); + }, onError: (DioException error, handler) { + // Gérer les erreurs d'authentification (401) + if (error.response?.statusCode == 401) { + // Session expirée ou invalide + _sessionId = null; + } + return handler.next(error); + })); + } + + // Définir l'ID de session + void setSessionId(String? sessionId) { + _sessionId = sessionId; + } + + // Vérifier la connectivité réseau + Future hasInternetConnection() async { + final connectivityResult = await (Connectivity().checkConnectivity()); + return connectivityResult != ConnectivityResult.none; + } + + // Méthode POST générique + Future post(String path, {dynamic data}) async { + try { + return await _dio.post(path, data: data); + } catch (e) { + rethrow; + } + } + + // Méthode GET générique + Future get(String path, {Map? queryParameters}) async { + try { + return await _dio.get(path, queryParameters: queryParameters); + } catch (e) { + rethrow; + } + } + + // Méthode PUT générique + Future put(String path, {dynamic data}) async { + try { + return await _dio.put(path, data: data); + } catch (e) { + rethrow; + } + } + + // Méthode DELETE générique + Future delete(String path) async { + try { + return await _dio.delete(path); + } catch (e) { + rethrow; + } + } + + // Authentification avec PHP session + Future> login(String username, String password, {String type = 'admin'}) async { + try { + final response = await _dio.post(AppKeys.loginEndpoint, data: { + 'username': username, + 'password': password, + 'type': type, // Ajouter le type de connexion (user ou admin) + }); + + // Vérifier la structure de la réponse + final data = response.data as Map; + final status = data['status'] as String?; + + // Afficher le message en cas d'erreur + if (status != 'success') { + final message = data['message'] as String?; + debugPrint('Erreur d\'authentification: $message'); + } + + // Si le statut est 'success', récupérer le session_id + if (status == 'success' && data.containsKey('session_id')) { + final sessionId = data['session_id']; + // Définir la session pour les futures requêtes + if (sessionId != null) { + setSessionId(sessionId); + } + } + + return data; + } catch (e) { + rethrow; + } + } + + // Déconnexion + Future logout() async { + try { + if (_sessionId != null) { + await _dio.post(AppKeys.logoutEndpoint); + _sessionId = null; + } + } catch (e) { + // Même en cas d'erreur, on réinitialise la session + _sessionId = null; + rethrow; + } + } + + // Utilisateurs + Future> getUsers() async { + try { + final response = await retry( + () => _dio.get('/users'), + retryIf: (e) => e is SocketException || e is TimeoutException, + ); + + return (response.data as List) + .map((json) => UserModel.fromJson(json)) + .toList(); + } catch (e) { + // Gérer les erreurs + rethrow; + } + } + + Future getUserById(int id) async { + try { + final response = await _dio.get('/users/$id'); + return UserModel.fromJson(response.data); + } catch (e) { + rethrow; + } + } + + Future createUser(UserModel user) async { + try { + final response = await _dio.post('/users', data: user.toJson()); + return UserModel.fromJson(response.data); + } catch (e) { + rethrow; + } + } + + Future updateUser(UserModel user) async { + try { + final response = await _dio.put('/users/${user.id}', data: user.toJson()); + return UserModel.fromJson(response.data); + } catch (e) { + rethrow; + } + } + + Future deleteUser(String id) async { + try { + await _dio.delete('/users/$id'); + } catch (e) { + rethrow; + } + } + + // Espace réservé pour les futures méthodes de gestion des profils + + // Espace réservé pour les futures méthodes de gestion des données + + // Synchronisation en batch + Future> syncData({ + List? users, + }) async { + try { + final Map payload = { + if (users != null) 'users': users.map((u) => u.toJson()).toList(), + }; + + final response = await _dio.post('/sync', data: payload); + return response.data; + } catch (e) { + rethrow; + } + } +} diff --git a/flutt/lib/core/services/auth_service.dart b/flutt/lib/core/services/auth_service.dart new file mode 100644 index 00000000..a536ae2f --- /dev/null +++ b/flutt/lib/core/services/auth_service.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/presentation/widgets/loading_overlay.dart'; + +/// Service qui gère les opérations d'authentification avec affichage d'un overlay de chargement +class AuthService { + final UserRepository _userRepository; + + AuthService(this._userRepository); + + /// Méthode de connexion avec affichage d'un overlay de chargement + Future login(BuildContext context, String username, String password, + {String type = 'admin'}) async { + return await LoadingOverlay.show( + context: context, + spinnerSize: 80.0, // Spinner plus grand + strokeWidth: 6.0, // Trait plus épais + future: _userRepository.login(username, password, type: type), + ); + } + + /// Méthode de déconnexion avec affichage d'un overlay de chargement + Future logout(BuildContext context) async { + return await LoadingOverlay.show( + context: context, + spinnerSize: 80.0, // Spinner plus grand + strokeWidth: 6.0, // Trait plus épais + future: _userRepository.logout(), + ); + } + + /// Vérifie si un utilisateur est connecté + bool isLoggedIn() { + return _userRepository.isLoggedIn; + } + + /// Vérifie si l'utilisateur connecté est un administrateur + bool isAdmin() { + return _userRepository.isAdmin(); + } +} diff --git a/flutt/lib/core/services/connectivity_service.dart b/flutt/lib/core/services/connectivity_service.dart new file mode 100644 index 00000000..af137fac --- /dev/null +++ b/flutt/lib/core/services/connectivity_service.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; + +/// Service qui gère la surveillance de l'état de connectivité de l'appareil +class ConnectivityService extends ChangeNotifier { + final Connectivity _connectivity = Connectivity(); + late StreamSubscription> _connectivitySubscription; + + List _connectionStatus = [ConnectivityResult.none]; + bool _isInitialized = false; + + /// Indique si l'appareil est connecté à Internet + bool get isConnected { + // Vérifie si la liste contient au moins un type de connexion autre que 'none' + return _connectionStatus.any((result) => result != ConnectivityResult.none); + } + + /// Indique si l'appareil est connecté via WiFi + bool get isWifi => _connectionStatus.contains(ConnectivityResult.wifi); + + /// Indique si l'appareil est connecté via données mobiles (4G, 5G, etc.) + bool get isMobile => _connectionStatus.contains(ConnectivityResult.mobile); + + /// Retourne le type de connexion actuel (WiFi, données mobiles, etc.) + List get connectionStatus => _connectionStatus; + + /// Retourne le premier type de connexion actif (pour compatibilité avec l'ancien code) + ConnectivityResult get primaryConnectionStatus { + // Retourne le premier type de connexion qui n'est pas 'none', ou 'none' si tous sont 'none' + return _connectionStatus.firstWhere( + (result) => result != ConnectivityResult.none, + orElse: () => ConnectivityResult.none + ); + } + + /// Obtient une description textuelle du type de connexion + String get connectionType { + // Si aucune connexion n'est disponible + if (!isConnected) { + return 'Aucune connexion'; + } + + // Utiliser le premier type de connexion actif + ConnectivityResult primaryStatus = primaryConnectionStatus; + + switch (primaryStatus) { + case ConnectivityResult.wifi: + return 'WiFi'; + case ConnectivityResult.mobile: + return 'Données mobiles'; + case ConnectivityResult.ethernet: + return 'Ethernet'; + case ConnectivityResult.bluetooth: + return 'Bluetooth'; + case ConnectivityResult.vpn: + return 'VPN'; + case ConnectivityResult.none: + return 'Aucune connexion'; + default: + return 'Inconnu'; + } + } + + /// Constructeur du service de connectivité + ConnectivityService() { + _initConnectivity(); + } + + /// Initialise le service et commence à écouter les changements de connectivité + Future _initConnectivity() async { + if (_isInitialized) return; + + try { + // En version web, on considère par défaut que la connexion est disponible + // car la vérification de connectivité est moins fiable sur le web + if (kIsWeb) { + _connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web + } else { + _connectionStatus = await _connectivity.checkConnectivity(); + } + + // S'abonner aux changements de connectivité + _connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + _isInitialized = true; + } catch (e) { + debugPrint('Erreur lors de l\'initialisation du service de connectivité: $e'); + + // En cas d'erreur en version web, on suppose que la connexion est disponible + // car l'application web ne peut pas fonctionner sans connexion de toute façon + if (kIsWeb) { + _connectionStatus = [ConnectivityResult.wifi]; + _isInitialized = true; + } + } + + notifyListeners(); + } + + /// Met à jour l'état de la connexion lorsqu'il change + void _updateConnectionStatus(List results) { + // Vérifier si la liste des résultats a changé + bool hasChanged = false; + + // Si les listes ont des longueurs différentes, elles sont différentes + if (_connectionStatus.length != results.length) { + hasChanged = true; + } else { + // Vérifier si les éléments sont différents + for (int i = 0; i < _connectionStatus.length; i++) { + if (i >= results.length || _connectionStatus[i] != results[i]) { + hasChanged = true; + break; + } + } + } + + if (hasChanged) { + _connectionStatus = results; + notifyListeners(); + } + } + + /// Vérifie manuellement l'état actuel de la connexion + Future> checkConnectivity() async { + try { + // En version web, on considère par défaut que la connexion est disponible + if (kIsWeb) { + // En version web, on peut tenter de faire une requête réseau légère pour vérifier la connectivité + // mais pour l'instant, on suppose que la connexion est disponible + final results = [ConnectivityResult.wifi]; + _updateConnectionStatus(results); + return results; + } else { + // Version mobile - utiliser l'API standard + final results = await _connectivity.checkConnectivity(); + _updateConnectionStatus(results); + return results; + } + } catch (e) { + debugPrint('Erreur lors de la vérification de la connectivité: $e'); + // En cas d'erreur, on conserve l'état actuel + return _connectionStatus; + } + } + + @override + void dispose() { + try { + _connectivitySubscription.cancel(); + } catch (e) { + debugPrint('Erreur lors de l\'annulation de l\'abonnement de connectivité: $e'); + } + super.dispose(); + } +} diff --git a/flutt/lib/core/services/hive_web_fix.dart b/flutt/lib/core/services/hive_web_fix.dart new file mode 100644 index 00000000..094c4c5f --- /dev/null +++ b/flutt/lib/core/services/hive_web_fix.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:js' as js; +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +/// Service pour gérer les problèmes spécifiques à Hive en version web +class HiveWebFix { + /// Nettoie en toute sécurité les boîtes Hive en version web + /// Cette méthode est plus sûre que de supprimer directement IndexedDB + static Future safeCleanHiveBoxes({List? excludeBoxes}) async { + if (!kIsWeb) return; + + try { + debugPrint( + 'HiveWebFix: Nettoyage sécurisé des boîtes Hive en version web'); + + // Liste des boîtes à nettoyer + final boxesToClean = [ + AppKeys.operationsBoxName, + AppKeys.sectorsBoxName, + AppKeys.passagesBoxName, + ]; + + // Exclure certaines boîtes si spécifié + if (excludeBoxes != null) { + boxesToClean.removeWhere((box) => excludeBoxes.contains(box)); + } + + // Nettoyer chaque boîte individuellement au lieu de supprimer IndexedDB + for (final boxName in boxesToClean) { + try { + if (Hive.isBoxOpen(boxName)) { + debugPrint('HiveWebFix: Nettoyage de la boîte $boxName'); + final box = Hive.box(boxName); + await box.clear(); + debugPrint('HiveWebFix: Boîte $boxName nettoyée avec succès'); + } else { + debugPrint( + 'HiveWebFix: La boîte $boxName n\'est pas ouverte, ouverture temporaire'); + final box = await Hive.openBox(boxName); + await box.clear(); + await box.close(); + debugPrint('HiveWebFix: Boîte $boxName nettoyée et fermée'); + } + } catch (e) { + debugPrint( + 'HiveWebFix: Erreur lors du nettoyage de la boîte $boxName: $e'); + } + } + + debugPrint('HiveWebFix: Nettoyage sécurisé terminé'); + } catch (e) { + debugPrint('HiveWebFix: Erreur lors du nettoyage sécurisé: $e'); + } + } + + /// Vérifie l'intégrité des boîtes Hive et tente de les réparer si nécessaire + static Future checkAndRepairHiveBoxes() async { + if (!kIsWeb) return true; + + try { + debugPrint('HiveWebFix: Vérification de l\'intégrité des boîtes Hive'); + + // Vérifier si IndexedDB est accessible + final isIndexedDBAvailable = js.context.hasProperty('indexedDB'); + if (!isIndexedDBAvailable) { + debugPrint( + 'HiveWebFix: IndexedDB n\'est pas disponible dans ce navigateur'); + return false; + } + + // Liste des boîtes essentielles + final essentialBoxes = [ + AppKeys.usersBoxName, + AppKeys.settingsBoxName, + ]; + + // Vérifier chaque boîte essentielle + for (final boxName in essentialBoxes) { + try { + if (!Hive.isBoxOpen(boxName)) { + debugPrint( + 'HiveWebFix: Ouverture de la boîte essentielle $boxName'); + await Hive.openBox(boxName); + } + + // Vérifier si la boîte est accessible + final box = Hive.box(boxName); + // Tenter une opération simple pour vérifier l'intégrité + final length = box.length; + debugPrint( + 'HiveWebFix: Boîte $boxName accessible avec $length éléments'); + } catch (e) { + debugPrint('HiveWebFix: Erreur d\'accès à la boîte $boxName: $e'); + + // Tenter de réparer en réinitialisant Hive + try { + debugPrint( + 'HiveWebFix: Tentative de réparation de la boîte $boxName'); + // Fermer la boîte si elle est ouverte + if (Hive.isBoxOpen(boxName)) { + await Hive.box(boxName).close(); + } + + // Réouvrir la boîte + await Hive.openBox(boxName); + debugPrint('HiveWebFix: Boîte $boxName réparée avec succès'); + } catch (repairError) { + debugPrint( + 'HiveWebFix: Échec de la réparation de la boîte $boxName: $repairError'); + return false; + } + } + } + + debugPrint('HiveWebFix: Toutes les boîtes essentielles sont intègres'); + return true; + } catch (e) { + debugPrint('HiveWebFix: Erreur lors de la vérification d\'intégrité: $e'); + return false; + } + } + + /// Réinitialise complètement Hive en cas de problème grave + /// À utiliser en dernier recours car cela supprimera toutes les données + static Future resetHiveCompletely() async { + if (!kIsWeb) return; + + try { + debugPrint('HiveWebFix: Réinitialisation complète de Hive'); + + // Fermer toutes les boîtes ouvertes + final boxesToClose = [ + AppKeys.usersBoxName, + AppKeys.operationsBoxName, + AppKeys.sectorsBoxName, + AppKeys.passagesBoxName, + AppKeys.settingsBoxName, + ]; + + for (final boxName in boxesToClose) { + if (Hive.isBoxOpen(boxName)) { + debugPrint('HiveWebFix: Fermeture de la boîte $boxName'); + await Hive.box(boxName).close(); + } + } + + // Supprimer IndexedDB avec une approche plus sûre + js.context.callMethod('eval', [ + ''' + (function() { + return new Promise(function(resolve, reject) { + var request = indexedDB.deleteDatabase("geosector_app"); + request.onsuccess = function() { + console.log("IndexedDB nettoyé avec succès"); + resolve(true); + }; + request.onerror = function(event) { + console.log("Erreur lors du nettoyage d'IndexedDB", event); + reject(event); + }; + }); + })(); + ''' + ]); + + // Attendre un peu pour s'assurer que la suppression est terminée + await Future.delayed(const Duration(milliseconds: 500)); + + // Réinitialiser Hive + await Hive.initFlutter(); + + // Réenregistrer les adaptateurs + // Note: Cette partie devrait être gérée par le code principal de l'application + + debugPrint('HiveWebFix: Réinitialisation complète terminée'); + } catch (e) { + debugPrint('HiveWebFix: Erreur lors de la réinitialisation complète: $e'); + } + } +} diff --git a/flutt/lib/core/services/location_service.dart b/flutt/lib/core/services/location_service.dart new file mode 100644 index 00000000..44a7da95 --- /dev/null +++ b/flutt/lib/core/services/location_service.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service de géolocalisation pour gérer les permissions et l'accès à la position +class LocationService { + /// Vérifie si les services de localisation sont activés + static Future isLocationServiceEnabled() async { + // En version web, on considère que les services de localisation sont toujours activés + // car la vérification est gérée différemment par le navigateur + if (kIsWeb) { + return true; + } + return await Geolocator.isLocationServiceEnabled(); + } + + /// Vérifie et demande les permissions de localisation + /// Retourne true si l'autorisation est accordée, false sinon + static Future checkAndRequestPermission() async { + // En version web, on considère que les permissions sont toujours accordées + // car la gestion des permissions est différente et gérée par le navigateur + if (kIsWeb) { + return true; + } + + try { + // Vérifier si les services de localisation sont activés + bool serviceEnabled = await isLocationServiceEnabled(); + if (!serviceEnabled) { + // Les services de localisation ne sont pas activés, on ne peut pas demander la permission + return false; + } + + // Vérifier le statut actuel de la permission + LocationPermission permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + // Demander la permission + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + // La permission a été refusée + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + // La permission a été refusée définitivement + return false; + } + + // La permission est accordée (whileInUse ou always) + return true; + } catch (e) { + debugPrint('Erreur lors de la vérification des permissions de localisation: $e'); + // En cas d'erreur, on retourne false pour être sûr + return false; + } + } + + /// Obtient la position actuelle de l'utilisateur + /// Retourne null si la position ne peut pas être obtenue + static Future getCurrentPosition() async { + try { + // En version web, la géolocalisation fonctionne différemment + // et peut être bloquée par le navigateur si le site n'est pas en HTTPS + if (kIsWeb) { + try { + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + return LatLng(position.latitude, position.longitude); + } catch (e) { + debugPrint('Erreur lors de l\'obtention de la position en version web: $e'); + // En version web, en cas d'erreur, on peut retourner une position par défaut + // ou null selon les besoins de l'application + return null; + } + } + + // Version mobile + // Vérifier si l'autorisation est accordée + bool hasPermission = await checkAndRequestPermission(); + if (!hasPermission) { + return null; + } + + // Obtenir la position actuelle + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + return LatLng(position.latitude, position.longitude); + } catch (e) { + debugPrint('Erreur lors de l\'obtention de la position: $e'); + return null; + } + } + + /// Vérifie si l'application peut accéder à la position de l'utilisateur + /// Retourne un message d'erreur si l'accès n'est pas possible, null sinon + static Future getLocationErrorMessage() async { + // En version web, on considère qu'il n'y a pas d'erreur de localisation + // car la gestion des permissions est gérée par le navigateur + if (kIsWeb) { + return null; + } + + try { + // Vérifier si les services de localisation sont activés + bool serviceEnabled = await isLocationServiceEnabled(); + if (!serviceEnabled) { + return 'Les services de localisation sont désactivés. Veuillez les activer dans les paramètres de votre appareil.'; + } + + // Vérifier le statut actuel de la permission + LocationPermission permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + return 'L\'accès à la localisation a été refusé. Cette application ne peut pas fonctionner sans cette autorisation.'; + } + + if (permission == LocationPermission.deniedForever) { + return 'L\'accès à la localisation a été définitivement refusé. Veuillez l\'autoriser dans les paramètres de votre appareil.'; + } + + return null; // Pas d'erreur + } catch (e) { + debugPrint('Erreur lors de la vérification des erreurs de localisation: $e'); + // En cas d'erreur, on retourne null pour ne pas bloquer l'application + return null; + } + } + + /// Ouvre les paramètres de l'application pour permettre à l'utilisateur de modifier les autorisations + static Future openAppSettings() async { + // En version web, cette fonctionnalité n'est pas disponible + if (kIsWeb) { + debugPrint('Ouverture des paramètres de l\'application non disponible en version web'); + return; + } + + try { + await Geolocator.openAppSettings(); + } catch (e) { + debugPrint('Erreur lors de l\'ouverture des paramètres de l\'application: $e'); + } + } + + /// Ouvre les paramètres de localisation de l'appareil + static Future openLocationSettings() async { + // En version web, cette fonctionnalité n'est pas disponible + if (kIsWeb) { + debugPrint('Ouverture des paramètres de localisation non disponible en version web'); + return; + } + + try { + await Geolocator.openLocationSettings(); + } catch (e) { + debugPrint('Erreur lors de l\'ouverture des paramètres de localisation: $e'); + } + } +} diff --git a/flutt/lib/core/services/passage_data_service.dart b/flutt/lib/core/services/passage_data_service.dart new file mode 100644 index 00000000..a04cdc0b --- /dev/null +++ b/flutt/lib/core/services/passage_data_service.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; + +/// Service pour charger et filtrer les données de passages +class PassageDataService { + final PassageRepository passageRepository; + final UserRepository userRepository; + + PassageDataService({ + required this.passageRepository, + required this.userRepository, + }); + + /// Charge les données de passage depuis Hive + /// + /// [daysToShow] : Nombre de jours à afficher + /// [excludePassageTypes] : Types de passages à exclure + /// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel) + /// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur + List> loadPassageData({ + required int daysToShow, + List excludePassageTypes = const [2], + int? userId, + bool showAllPassages = false, + }) { + // Récupérer tous les passages + final passages = passageRepository.getAllPassages(); + + // Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion + final filteredPassages = passages + .where((p) => !excludePassageTypes.contains(p.fkType)) + .toList(); + + if (filteredPassages.isEmpty) { + return []; + } + + // Déterminer si on filtre par utilisateur ou si on prend tous les passages + final passagesToUse = showAllPassages + ? filteredPassages + : _filterPassagesByUser(filteredPassages, userId); + + if (passagesToUse.isEmpty) { + debugPrint('Aucun passage trouvé après filtrage'); + return []; + } + + // Trouver la date du passage le plus récent + passagesToUse.sort((a, b) => b.passedAt.compareTo(a.passedAt)); + final DateTime referenceDate = passagesToUse.first.passedAt; + debugPrint( + 'Date de référence pour le graphique: ${DateFormat('dd/MM/yyyy').format(referenceDate)}'); + + // Définir la date de début (N jours avant la date de référence) + final startDate = referenceDate.subtract(Duration(days: daysToShow - 1)); + debugPrint( + 'Date de début pour le graphique: ${DateFormat('dd/MM/yyyy').format(startDate)}'); + debugPrint( + 'Plage de dates du graphique: ${DateFormat('dd/MM/yyyy').format(startDate)} - ${DateFormat('dd/MM/yyyy').format(referenceDate)}'); + + // Regrouper les passages par date et type + final Map> passagesByDateAndType = {}; + + // Initialiser le dictionnaire avec les N derniers jours + for (int i = daysToShow - 1; i >= 0; i--) { + final date = referenceDate.subtract(Duration(days: i)); + final dateStr = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + passagesByDateAndType[dateStr] = {}; + } + + // Ajouter tous les types de passage possibles pour chaque date + for (final dateStr in passagesByDateAndType.keys) { + for (final typeId in AppKeys.typesPassages.keys) { + // Exclure les types dans la liste d'exclusion + if (!excludePassageTypes.contains(typeId)) { + passagesByDateAndType[dateStr]![typeId] = 0; + } + } + } + + // Parcourir les passages et les regrouper par date et type + for (final passage in passagesToUse) { + if (passage.passedAt + .isAfter(startDate.subtract(const Duration(days: 1))) && + passage.passedAt + .isBefore(referenceDate.add(const Duration(days: 1)))) { + final dateStr = + '${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}-${passage.passedAt.day.toString().padLeft(2, '0')}'; + final typeId = passage.fkType; + + // Vérifier que le type n'est pas exclu + if (!excludePassageTypes.contains(typeId)) { + // Si la date existe dans notre dictionnaire, mettre à jour le compteur + if (passagesByDateAndType.containsKey(dateStr)) { + if (!passagesByDateAndType[dateStr]!.containsKey(typeId)) { + passagesByDateAndType[dateStr]![typeId] = 0; + } + passagesByDateAndType[dateStr]![typeId] = + (passagesByDateAndType[dateStr]![typeId] ?? 0) + 1; + } + } + } + } + + // Convertir les données au format attendu par le graphique + final List> result = []; + passagesByDateAndType.forEach((dateStr, typesCounts) { + typesCounts.forEach((typeId, count) { + result.add({ + 'date': dateStr, + 'type_passage': typeId, + 'nb': count, + }); + }); + }); + + return result; + } + + /// Filtre les passages par utilisateur + List _filterPassagesByUser(List passages, int? userId) { + // Récupérer l'ID de l'utilisateur actuel si nécessaire + final int? currentUserId = userId ?? userRepository.getCurrentUser()?.id; + + // Filtrer les passages pour l'utilisateur actuel + final userPassages = passages + .where((p) => currentUserId == null || p.fkUser == currentUserId) + .toList(); + + if (userPassages.isEmpty) { + debugPrint('Aucun passage trouvé pour l\'utilisateur $currentUserId'); + } + + return userPassages; + } + + /// Charge et prépare les données pour le graphique en camembert + /// + /// [excludePassageTypes] : Types de passages à exclure + /// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel) + /// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur + Map loadPassageDataForPieChart({ + List excludePassageTypes = const [2], + int? userId, + bool showAllPassages = false, + }) { + // Récupérer tous les passages + final passages = passageRepository.getAllPassages(); + + // Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion + final filteredPassages = passages + .where((p) => !excludePassageTypes.contains(p.fkType)) + .toList(); + + if (filteredPassages.isEmpty) { + return {}; + } + + // Déterminer si on filtre par utilisateur ou si on prend tous les passages + final passagesToUse = showAllPassages + ? filteredPassages + : _filterPassagesByUser(filteredPassages, userId); + + if (passagesToUse.isEmpty) { + debugPrint('Aucun passage trouvé après filtrage'); + return {}; + } + + // Compter les passages par type + final Map passagesByType = {}; + + // Initialiser les compteurs pour tous les types de passage + for (final typeId in AppKeys.typesPassages.keys) { + // Exclure les types dans la liste d'exclusion + if (!excludePassageTypes.contains(typeId)) { + passagesByType[typeId] = 0; + } + } + + // Compter les passages par type + for (final passage in passagesToUse) { + final typeId = passage.fkType; + if (!excludePassageTypes.contains(typeId)) { + passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1; + } + } + + return passagesByType; + } +} diff --git a/flutt/lib/core/services/sync_service.dart b/flutt/lib/core/services/sync_service.dart new file mode 100644 index 00000000..ac2315e8 --- /dev/null +++ b/flutt/lib/core/services/sync_service.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; + +class SyncService { + final UserRepository _userRepository; + + StreamSubscription? _connectivitySubscription; + Timer? _periodicSyncTimer; + + bool _isSyncing = false; + final Duration _syncInterval = const Duration(minutes: 15); + + SyncService({ + required UserRepository userRepository, + }) : _userRepository = userRepository { + _initConnectivityListener(); + _initPeriodicSync(); + } + + // Initialiser l'écouteur de connectivité + void _initConnectivityListener() { + _connectivitySubscription = Connectivity() + .onConnectivityChanged + .listen((List results) { + // Vérifier si au moins un type de connexion est disponible + if (results.any((result) => result != ConnectivityResult.none)) { + // Lorsque la connexion est rétablie, déclencher une synchronisation + syncAll(); + } + }); + } + + // Initialiser la synchronisation périodique + void _initPeriodicSync() { + _periodicSyncTimer = Timer.periodic(_syncInterval, (timer) { + syncAll(); + }); + } + + // Synchroniser toutes les données + Future syncAll() async { + if (_isSyncing) return; + + _isSyncing = true; + + try { + // Synchroniser les utilisateurs + await _userRepository.syncAllUsers(); + } catch (e) { + // Gérer les erreurs de synchronisation + print('Erreur lors de la synchronisation: $e'); + } finally { + _isSyncing = false; + } + } + + // Synchroniser uniquement les données d'un utilisateur spécifique + Future syncUserData(int userId) async { + try { + // Cette méthode pourrait être étendue à l'avenir pour synchroniser d'autres données utilisateur + await _userRepository.refreshFromServer(); + } catch (e) { + print('Erreur lors de la synchronisation des données utilisateur: $e'); + } + } + + // Forcer le rafraîchissement depuis le serveur + Future forceRefresh() async { + if (_isSyncing) return; + + _isSyncing = true; + + try { + // Rafraîchir depuis le serveur + await _userRepository.refreshFromServer(); + } catch (e) { + print('Erreur lors du rafraîchissement forcé: $e'); + } finally { + _isSyncing = false; + } + } + + // Obtenir l'état de synchronisation + Map getSyncStatus() { + return { + 'isSyncing': _isSyncing, + }; + } + + // Nettoyer les ressources + void dispose() { + _connectivitySubscription?.cancel(); + _periodicSyncTimer?.cancel(); + } +} \ No newline at end of file diff --git a/flutt/lib/core/theme/app_theme.dart b/flutt/lib/core/theme/app_theme.dart new file mode 100644 index 00000000..dc0b84d7 --- /dev/null +++ b/flutt/lib/core/theme/app_theme.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + // Nouvelles couleurs du thème + static const Color primaryColor = Color(0xFF2E4057); // Bleu foncé/gris + static const Color secondaryColor = Color(0xFF048BA8); // Bleu turquoise + static const Color accentColor = Color(0xFFF18F01); // Orange + static const Color backgroundLightColor = Color(0xFFF9FAFB); + static const Color backgroundDarkColor = Color(0xFF111827); + static const Color textLightColor = Color(0xFF1F2937); + static const Color textDarkColor = Color(0xFFF9FAFB); + + // Thème clair + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: ColorScheme.light( + primary: primaryColor, + secondary: secondaryColor, + tertiary: accentColor, + background: backgroundLightColor, + surface: Colors.white, + onPrimary: Colors.white, + onSecondary: Colors.white, + onBackground: textLightColor, + onSurface: textLightColor, + ), + textTheme: GoogleFonts.poppinsTextTheme(ThemeData.light().textTheme), + appBarTheme: const AppBarTheme( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ); + } + + // Thème sombre + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.dark( + primary: primaryColor, + secondary: secondaryColor, + tertiary: accentColor, + background: backgroundDarkColor, + surface: const Color(0xFF1F2937), + onPrimary: Colors.white, + onSecondary: Colors.white, + onBackground: textDarkColor, + onSurface: textDarkColor, + ), + textTheme: GoogleFonts.poppinsTextTheme(ThemeData.dark().textTheme), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF1F2937), + foregroundColor: Colors.white, + elevation: 0, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF374151), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + cardTheme: CardTheme( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: const Color(0xFF1F2937), + ), + ); + } +} \ No newline at end of file diff --git a/flutt/lib/main.dart b/flutt/lib/main.dart new file mode 100644 index 00000000..6753c3aa --- /dev/null +++ b/flutt/lib/main.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:geosector_app/app.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/data/models/user_model.dart'; +import 'package:geosector_app/core/data/models/operation_model.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/data/models/membre_model.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +// Import centralisé pour les modèles chat +import 'package:geosector_app/chat/models/chat_adapters.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Configurer le routage par chemin (URLs sans #) + setUrlStrategy(PathUrlStrategy()); + + // Initialiser Hive + await Hive.initFlutter(); + + // Enregistrer les adaptateurs Hive pour les modèles principaux + Hive.registerAdapter(UserModelAdapter()); + Hive.registerAdapter(OperationModelAdapter()); + Hive.registerAdapter(SectorModelAdapter()); + Hive.registerAdapter(PassageModelAdapter()); + Hive.registerAdapter(MembreModelAdapter()); + + // Enregistrer les adaptateurs Hive pour le chat + Hive.registerAdapter(ConversationModelAdapter()); + Hive.registerAdapter(MessageModelAdapter()); + Hive.registerAdapter(ParticipantModelAdapter()); + Hive.registerAdapter(AnonymousUserModelAdapter()); + Hive.registerAdapter(AudienceTargetModelAdapter()); + Hive.registerAdapter(NotificationSettingsAdapter()); + + // Ouvrir uniquement les boîtes essentielles au démarrage + // La boîte des utilisateurs est nécessaire pour vérifier si un utilisateur est déjà connecté + await Hive.openBox(AppKeys.usersBoxName); + // Boîte pour les préférences utilisateur générales + await Hive.openBox(AppKeys.settingsBoxName); + + // Ouvrir les boîtes de chat également au démarrage pour le cache local + await Hive.openBox(AppKeys.chatConversationsBoxName); + await Hive.openBox(AppKeys.chatMessagesBoxName); + + // Les autres boîtes (operations, sectors, passages) seront ouvertes après connexion + // dans UserRepository.login() via la méthode _ensureBoxIsOpen() + + // Définir l'orientation de l'application + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + // Lancer l'application directement sans AppProviders + runApp(const GeoSectorApp()); +} diff --git a/flutt/lib/presentation/MIGRATION.md b/flutt/lib/presentation/MIGRATION.md new file mode 100644 index 00000000..f283e0a7 --- /dev/null +++ b/flutt/lib/presentation/MIGRATION.md @@ -0,0 +1,118 @@ +# Guide de migration vers la nouvelle structure + +Ce document explique comment migrer l'application GeoSector vers la nouvelle structure de dossiers. + +## Nouvelle structure + +``` +lib/ + ├── presentation/ # Tout ce qui concerne l'interface utilisateur + │ ├── admin/ # Pages et widgets spécifiques à l'interface administrateur + │ ├── user/ # Pages et widgets spécifiques à l'interface utilisateur + │ │ └── pages/ # Pages de l'interface utilisateur + │ ├── auth/ # Pages et widgets liés à l'authentification + │ ├── public/ # Pages et widgets accessibles sans authentification + │ └── widgets/ # Widgets partagés utilisés dans plusieurs parties de l'application + ├── core/ # Logique métier et services (reste inchangé) + │ ├── constants/ # Constantes de l'application + │ ├── data/ # Modèles de données + │ ├── repositories/ # Repositories pour accéder aux données + │ ├── services/ # Services de l'application + │ └── theme/ # Thème de l'application + └── shared/ # Code partagé entre les différentes parties de l'application +``` + +## Étapes de migration + +### 1. Widgets communs + +Les widgets communs ont déjà été migrés vers `lib/presentation/widgets/` : + +- `dashboard_app_bar.dart` +- `dashboard_layout.dart` +- `responsive_navigation.dart` + +### 2. Pages administrateur + +Migrer les pages administrateur de `lib/features/admin/` vers `lib/presentation/admin/` : + +- `admin_dashboard_page.dart` (déjà migré) +- `admin_statistics_page.dart` +- `admin_history_page.dart` +- `admin_communication_page.dart` +- `admin_map_page.dart` + +### 3. Pages utilisateur + +Migrer les pages utilisateur de `lib/features/user/presentation/pages/` vers `lib/presentation/user/pages/` : + +- Créer le dossier `lib/presentation/user/pages/` +- Migrer les fichiers suivants : + - `user_dashboard_home_page.dart` + - `user_statistics_page.dart` + - `user_history_page.dart` + - `user_communication_page.dart` + - `user_map_page.dart` + +### 4. Pages d'authentification + +Migrer les pages d'authentification de `lib/features/auth/presentation/` vers `lib/presentation/auth/` : + +- `login_page.dart` +- `register_page.dart` +- `forgot_password_page.dart` +- etc. + +### 5. Pages publiques + +Migrer les pages publiques de `lib/features/public/presentation/` vers `lib/presentation/public/` : + +- `landing_page.dart` +- `about_page.dart` +- etc. + +### 6. Mise à jour des imports + +Après avoir migré tous les fichiers, il faudra mettre à jour les imports dans tous les fichiers pour refléter la nouvelle structure. + +Exemple : +```dart +// Ancien import +import 'package:geosector_app/core/widgets/dashboard_app_bar.dart'; + +// Nouvel import +import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart'; +``` + +### 7. Mise à jour des routes + +Mettre à jour le fichier de routes (`lib/core/routes/app_router.dart`) pour refléter les nouveaux chemins des pages. + +### 8. Tests + +Après avoir effectué toutes les migrations, exécuter les tests pour s'assurer que tout fonctionne correctement. + +## Avantages de la nouvelle structure + +1. **Séparation claire des responsabilités** : La nouvelle structure sépare clairement la présentation (UI) de la logique métier (core). +2. **Organisation par fonctionnalité** : Les fichiers sont organisés par fonctionnalité (admin, user, auth, public) plutôt que par type (pages, widgets). +3. **Facilité de maintenance** : Il est plus facile de trouver et de modifier les fichiers liés à une fonctionnalité spécifique. +4. **Évolutivité** : La nouvelle structure est plus évolutive et permet d'ajouter facilement de nouvelles fonctionnalités. + +## Approche progressive + +La migration peut être effectuée progressivement, en commençant par les widgets communs, puis en migrant les pages une par une. Cela permet de continuer à développer l'application pendant la migration. + +## Exemple de migration d'une page + +Voici un exemple de migration de la page `admin_dashboard_page.dart` : + +1. Copier le fichier de `lib/features/admin/admin_dashboard_page.dart` vers `lib/presentation/admin/admin_dashboard_page.dart` +2. Mettre à jour les imports dans le nouveau fichier +3. Mettre à jour les références à ce fichier dans d'autres fichiers +4. Tester que tout fonctionne correctement +5. Supprimer l'ancien fichier une fois que tout fonctionne + +## Conclusion + +Cette migration permettra d'améliorer la structure de l'application et de faciliter son évolution future. Elle peut être effectuée progressivement pour minimiser l'impact sur le développement en cours. diff --git a/flutt/lib/presentation/README.md b/flutt/lib/presentation/README.md new file mode 100644 index 00000000..d04d1fa5 --- /dev/null +++ b/flutt/lib/presentation/README.md @@ -0,0 +1,26 @@ +# Structure de présentation + +Ce dossier contient tous les éléments liés à l'interface utilisateur de l'application, organisés comme suit : + +## Sous-dossiers + +- `/admin` : Pages et widgets spécifiques à l'interface administrateur +- `/user` : Pages et widgets spécifiques à l'interface utilisateur +- `/auth` : Pages et widgets liés à l'authentification +- `/public` : Pages et widgets accessibles sans authentification +- `/widgets` : Widgets partagés utilisés dans plusieurs parties de l'application + +## Organisation des fichiers + +Chaque sous-dossier peut contenir : +- Des pages (écrans complets) +- Des widgets spécifiques à cette section +- Des modèles de données d'UI +- Des utilitaires d'UI spécifiques + +## Bonnes pratiques + +- Les widgets réutilisables dans plusieurs sections doivent être placés dans `/widgets` +- Les widgets spécifiques à une section doivent être placés dans le sous-dossier correspondant +- Utiliser des imports relatifs pour les fichiers du même module +- Utiliser des imports absolus pour les fichiers d'autres modules diff --git a/flutt/lib/presentation/admin/admin_communication_page.dart b/flutt/lib/presentation/admin/admin_communication_page.dart new file mode 100644 index 00000000..e9e418f1 --- /dev/null +++ b/flutt/lib/presentation/admin/admin_communication_page.dart @@ -0,0 +1,557 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/shared/app_theme.dart'; +import 'package:geosector_app/presentation/widgets/chat/chat_sidebar.dart'; +import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart'; +import 'package:geosector_app/presentation/widgets/chat/chat_input.dart'; + +class AdminCommunicationPage extends StatefulWidget { + const AdminCommunicationPage({Key? key}) : super(key: key); + + @override + State createState() => _AdminCommunicationPageState(); +} + +class _AdminCommunicationPageState extends State { + int selectedContactId = 0; + String selectedContactName = ''; + bool isTeamChat = true; + String messageText = ''; + bool isReplying = false; + Map? replyingTo; + + // Données simulées pour les conversations d'équipe + final List> teamContacts = [ + { + 'id': 1, + 'name': 'Équipe', + 'isGroup': true, + 'lastMessage': 'Réunion à 14h aujourd\'hui', + 'time': DateTime.now().subtract(const Duration(minutes: 30)), + 'unread': 2, + 'online': true, + 'avatar': 'assets/images/team.png', + }, + { + 'id': 2, + 'name': 'Jean Dupont', + 'isGroup': false, + 'lastMessage': 'Je serai présent demain', + 'time': DateTime.now().subtract(const Duration(hours: 1)), + 'unread': 0, + 'online': true, + 'avatar': 'assets/images/avatar1.png', + }, + { + 'id': 3, + 'name': 'Marie Martin', + 'isGroup': false, + 'lastMessage': 'Secteur Sud terminé', + 'time': DateTime.now().subtract(const Duration(hours: 3)), + 'unread': 1, + 'online': false, + 'avatar': 'assets/images/avatar2.png', + }, + { + 'id': 4, + 'name': 'Pierre Legrand', + 'isGroup': false, + 'lastMessage': 'J\'ai une question sur mon secteur', + 'time': DateTime.now().subtract(const Duration(days: 1)), + 'unread': 0, + 'online': false, + 'avatar': 'assets/images/avatar3.png', + }, + ]; + + // Données simulées pour les conversations clients + final List> clientContacts = [ + { + 'id': 101, + 'name': 'Martin Durand', + 'isGroup': false, + 'lastMessage': 'Merci pour votre passage', + 'time': DateTime.now().subtract(const Duration(hours: 5)), + 'unread': 0, + 'online': false, + 'avatar': null, + 'email': 'martin.durand@example.com', + }, + { + 'id': 102, + 'name': 'Sophie Lambert', + 'isGroup': false, + 'lastMessage': 'Question concernant le reçu', + 'time': DateTime.now().subtract(const Duration(days: 1)), + 'unread': 3, + 'online': false, + 'avatar': null, + 'email': 'sophie.lambert@example.com', + }, + { + 'id': 103, + 'name': 'Thomas Bernard', + 'isGroup': false, + 'lastMessage': 'Rendez-vous manqué', + 'time': DateTime.now().subtract(const Duration(days: 2)), + 'unread': 0, + 'online': false, + 'avatar': null, + 'email': 'thomas.bernard@example.com', + }, + ]; + + // Messages simulés pour la conversation sélectionnée + final Map>> chatMessages = { + 1: [ + { + 'id': 1, + 'senderId': 2, + 'senderName': 'Jean Dupont', + 'message': + 'Bonjour à tous, comment avance la collecte dans vos secteurs ?', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)), + 'isRead': true, + 'avatar': 'assets/images/avatar1.png', + }, + { + 'id': 2, + 'senderId': 3, + 'senderName': 'Marie Martin', + 'message': 'J\'ai terminé le secteur Sud avec 45 passages réalisés !', + 'time': DateTime.now() + .subtract(const Duration(days: 1, hours: 2, minutes: 30)), + 'isRead': true, + 'avatar': 'assets/images/avatar2.png', + }, + { + 'id': 3, + 'senderId': 4, + 'senderName': 'Pierre Legrand', + 'message': + 'Secteur Est en cours, j\'ai réalisé 28 passages pour l\'instant.', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)), + 'isRead': true, + 'avatar': 'assets/images/avatar3.png', + }, + { + 'id': 4, + 'senderId': 0, + 'senderName': 'Vous', + 'message': + 'Parfait, n\'oubliez pas la réunion de demain à 14h pour faire le point !', + 'time': DateTime.now().subtract(const Duration(hours: 1)), + 'isRead': true, + }, + { + 'id': 5, + 'senderId': 2, + 'senderName': 'Jean Dupont', + 'message': 'Je serai présent 👍', + 'time': DateTime.now().subtract(const Duration(minutes: 30)), + 'isRead': false, + 'avatar': 'assets/images/avatar1.png', + }, + ], + 2: [ + { + 'id': 101, + 'senderId': 2, + 'senderName': 'Jean Dupont', + 'message': + 'Bonjour, est-ce que je peux commencer le secteur Ouest demain ?', + 'time': DateTime.now().subtract(const Duration(days: 2)), + 'isRead': true, + 'avatar': 'assets/images/avatar1.png', + }, + { + 'id': 102, + 'senderId': 0, + 'senderName': 'Vous', + 'message': 'Bonjour Jean, oui bien sûr. Les documents sont prêts.', + 'time': DateTime.now() + .subtract(const Duration(days: 2)) + .add(const Duration(minutes: 15)), + 'isRead': true, + }, + { + 'id': 103, + 'senderId': 2, + 'senderName': 'Jean Dupont', + 'message': 'Merci ! Je passerai les récupérer ce soir.', + 'time': DateTime.now() + .subtract(const Duration(days: 2)) + .add(const Duration(minutes: 20)), + 'isRead': true, + 'avatar': 'assets/images/avatar1.png', + }, + { + 'id': 104, + 'senderId': 2, + 'senderName': 'Jean Dupont', + 'message': 'Je serai présent à la réunion de demain.', + 'time': DateTime.now().subtract(const Duration(hours: 1)), + 'isRead': true, + 'avatar': 'assets/images/avatar1.png', + }, + ], + 101: [ + { + 'id': 201, + 'senderId': 101, + 'senderName': 'Martin Durand', + 'message': + 'Bonjour, je voulais vous remercier pour votre passage. J\'ai bien reçu le reçu par email.', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 5)), + 'isRead': true, + }, + { + 'id': 202, + 'senderId': 0, + 'senderName': 'Vous', + 'message': + 'Bonjour M. Durand, je vous remercie pour votre contribution. N\'hésitez pas si vous avez des questions.', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 4)), + 'isRead': true, + }, + { + 'id': 203, + 'senderId': 101, + 'senderName': 'Martin Durand', + 'message': 'Tout est parfait, merci !', + 'time': DateTime.now().subtract(const Duration(hours: 5)), + 'isRead': true, + }, + ], + 102: [ + { + 'id': 301, + 'senderId': 102, + 'senderName': 'Sophie Lambert', + 'message': + 'Bonjour, je n\'ai pas reçu le reçu suite à mon paiement d\'hier. Pouvez-vous vérifier ?', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)), + 'isRead': true, + }, + { + 'id': 302, + 'senderId': 0, + 'senderName': 'Vous', + 'message': + 'Bonjour Mme Lambert, je m\'excuse pour ce désagrément. Je vérifie cela immédiatement.', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)), + 'isRead': true, + }, + { + 'id': 303, + 'senderId': 0, + 'senderName': 'Vous', + 'message': + 'Il semble qu\'il y ait eu un problème technique. Je viens de renvoyer le reçu à votre adresse email. Pourriez-vous vérifier si vous l\'avez bien reçu ?', + 'time': DateTime.now().subtract(const Duration(days: 1, hours: 1)), + 'isRead': true, + }, + { + 'id': 304, + 'senderId': 102, + 'senderName': 'Sophie Lambert', + 'message': + 'Je n\'ai toujours rien reçu. Mon email est-il correct ? C\'est sophie.lambert@example.com', + 'time': DateTime.now().subtract(const Duration(days: 1)), + 'isRead': true, + }, + { + 'id': 305, + 'senderId': 102, + 'senderName': 'Sophie Lambert', + 'message': 'Est-ce que vous pouvez réessayer ?', + 'time': DateTime.now().subtract(const Duration(hours: 5)), + 'isRead': false, + }, + { + 'id': 306, + 'senderId': 102, + 'senderName': 'Sophie Lambert', + 'message': 'Toujours pas de nouvelles...', + 'time': DateTime.now().subtract(const Duration(hours: 3)), + 'isRead': false, + }, + { + 'id': 307, + 'senderId': 102, + 'senderName': 'Sophie Lambert', + 'message': 'Pouvez-vous me contacter dès que possible ?', + 'time': DateTime.now().subtract(const Duration(hours: 1)), + 'isRead': false, + }, + ], + }; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth > 800; + + return Row( + children: [ + // Sidebar des contacts (fixe sur desktop, conditional sur mobile) + if (isDesktop || selectedContactId == 0) + SizedBox( + width: isDesktop ? 320 : screenWidth, + child: ChatSidebar( + teamContacts: teamContacts, + clientContacts: clientContacts, + isTeamChat: isTeamChat, + selectedContactId: selectedContactId, + onContactSelected: (contactId, contactName, isTeam) { + setState(() { + selectedContactId = contactId; + selectedContactName = contactName; + isTeamChat = isTeam; + replyingTo = null; + isReplying = false; + }); + }, + onToggleGroup: (isTeam) { + setState(() { + isTeamChat = isTeam; + selectedContactId = 0; + selectedContactName = ''; + }); + }, + ), + ), + + // Vue des messages (conditionnelle sur mobile) + if (isDesktop || selectedContactId != 0) + Expanded( + child: selectedContactId == 0 + ? const Center( + child: Text('Sélectionnez une conversation pour commencer'), + ) + : Column( + children: [ + // En-tête de la conversation + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingL, + vertical: AppTheme.spacingM, + ), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + if (!isDesktop) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + selectedContactId = 0; + selectedContactName = ''; + }); + }, + ), + CircleAvatar( + radius: 20, + backgroundColor: + AppTheme.primaryColor.withOpacity(0.2), + backgroundImage: + _getAvatarForContact(selectedContactId), + child: _getAvatarForContact(selectedContactId) == + null + ? Text( + selectedContactName.isNotEmpty + ? selectedContactName[0].toUpperCase() + : '', + style: const TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedContactName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + if (!isTeamChat && selectedContactId > 100) + Text( + _getEmailForContact(selectedContactId), + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () { + // Afficher les détails du contact + }, + ), + ], + ), + ), + + // Messages + Expanded( + child: ChatMessages( + messages: chatMessages[selectedContactId] ?? [], + currentUserId: 0, + onReply: (message) { + setState(() { + isReplying = true; + replyingTo = message; + }); + }, + ), + ), + + // Zone de réponse + if (isReplying) + Container( + padding: const EdgeInsets.all(AppTheme.spacingM), + color: Colors.grey[100], + child: Row( + children: [ + Container( + width: 4, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primaryColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Réponse à ${replyingTo?['senderName']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: AppTheme.primaryColor, + ), + ), + Text( + replyingTo?['message'] ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + isReplying = false; + replyingTo = null; + }); + }, + ), + ], + ), + ), + + // Zone de saisie du message + ChatInput( + onMessageSent: (text) { + setState(() { + // Ajouter le message à la conversation + if (chatMessages[selectedContactId] != null) { + final newMessageId = + chatMessages[selectedContactId]!.last['id'] + + 1; + + chatMessages[selectedContactId]!.add({ + 'id': newMessageId, + 'senderId': 0, + 'senderName': 'Vous', + 'message': text, + 'time': DateTime.now(), + 'isRead': false, + 'replyTo': isReplying ? replyingTo : null, + }); + + // Mise à jour du dernier message pour le contact + final contactsList = + isTeamChat ? teamContacts : clientContacts; + final contactIndex = contactsList.indexWhere( + (c) => c['id'] == selectedContactId); + + if (contactIndex != -1) { + contactsList[contactIndex]['lastMessage'] = + text; + contactsList[contactIndex]['time'] = + DateTime.now(); + contactsList[contactIndex]['unread'] = 0; + } + + isReplying = false; + replyingTo = null; + } + }); + }, + ), + ], + ), + ), + ], + ); + } + + ImageProvider? _getAvatarForContact(int contactId) { + String? avatarPath; + + if (isTeamChat) { + final contact = teamContacts.firstWhere( + (c) => c['id'] == contactId, + orElse: () => {'avatar': null}, + ); + avatarPath = contact['avatar']; + } else { + final contact = clientContacts.firstWhere( + (c) => c['id'] == contactId, + orElse: () => {'avatar': null}, + ); + avatarPath = contact['avatar']; + } + + return avatarPath != null ? AssetImage(avatarPath) : null; + } + + String _getEmailForContact(int contactId) { + if (!isTeamChat) { + final contact = clientContacts.firstWhere( + (c) => c['id'] == contactId, + orElse: () => {'email': ''}, + ); + return contact['email'] ?? ''; + } + return ''; + } +} diff --git a/flutt/lib/presentation/admin/admin_dashboard_home_page.dart b/flutt/lib/presentation/admin/admin_dashboard_home_page.dart new file mode 100644 index 00000000..43d0d928 --- /dev/null +++ b/flutt/lib/presentation/admin/admin_dashboard_home_page.dart @@ -0,0 +1,887 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_data.dart'; +import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/data/models/operation_model.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +class AdminDashboardHomePage extends StatefulWidget { + const AdminDashboardHomePage({Key? key}) : super(key: key); + + @override + State createState() => _AdminDashboardHomePageState(); +} + +class _AdminDashboardHomePageState extends State { + // Données pour le tableau de bord + int totalPassages = 0; + double totalAmounts = 0.0; + List> memberStats = []; + bool isDataLoaded = false; + bool isLoading = true; + + // Données pour les graphiques + List paymentData = []; + Map passagesByType = {}; + + // Future pour initialiser les boîtes Hive + late Future _initFuture; + + @override + void initState() { + super.initState(); + // Initialiser les boîtes Hive avant de charger les données + _initFuture = _initHiveBoxes().then((_) { + // Charger les données une fois les boîtes initialisées + _loadDashboardData(); + }); + } + + // Méthode pour initialiser les boîtes Hive nécessaires + Future _initHiveBoxes() async { + try { + debugPrint('Initialisation des boîtes Hive...'); + + // Ouvrir la boîte des opérations si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) { + debugPrint('Ouverture de la boîte operations...'); + try { + await Hive.openBox(AppKeys.operationsBoxName); + debugPrint('Boîte operations ouverte avec succès'); + } catch (boxError) { + debugPrint( + 'Erreur lors de l\'ouverture de la boîte operations: $boxError'); + // Continuer malgré l'erreur + } + } else { + debugPrint('Boîte operations déjà ouverte'); + } + + // Ouvrir la boîte des passages si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) { + debugPrint('Ouverture de la boîte passages...'); + try { + await Hive.openBox(AppKeys.passagesBoxName); + debugPrint('Boîte passages ouverte avec succès'); + } catch (boxError) { + debugPrint( + 'Erreur lors de l\'ouverture de la boîte passages: $boxError'); + // Continuer malgré l'erreur + } + } else { + debugPrint('Boîte passages déjà ouverte'); + } + + // Ouvrir la boîte des secteurs si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) { + debugPrint('Ouverture de la boîte sectors...'); + try { + await Hive.openBox(AppKeys.sectorsBoxName); + debugPrint('Boîte sectors ouverte avec succès'); + } catch (boxError) { + debugPrint( + 'Erreur lors de l\'ouverture de la boîte sectors: $boxError'); + // Continuer malgré l'erreur + } + } else { + debugPrint('Boîte sectors déjà ouverte'); + } + + debugPrint('Initialisation des boîtes Hive terminée'); + } catch (e) { + debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e'); + // Ne pas propager l'erreur, mais retourner normalement + // pour éviter que le FutureBuilder ne reste bloqué en état d'erreur + } + } + + /// Prépare les données pour le graphique de paiement + void _preparePaymentData(List passages) { + // Réinitialiser les données + paymentData = []; + + // Compter les montants par type de règlement + Map paymentAmounts = {}; + + // Initialiser les compteurs pour tous les types de règlement + for (final typeId in AppKeys.typesReglements.keys) { + paymentAmounts[typeId] = 0.0; + } + + // Calculer les montants par type de règlement + for (final passage in passages) { + if (passage.fkTypeReglement != null && + passage.montant != null && + passage.montant.isNotEmpty) { + final typeId = passage.fkTypeReglement; + final amount = double.tryParse(passage.montant) ?? 0.0; + paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount; + } + } + + // Créer les objets PaymentData + paymentAmounts.forEach((typeId, amount) { + if (amount > 0 && AppKeys.typesReglements.containsKey(typeId)) { + final typeInfo = AppKeys.typesReglements[typeId]!; + paymentData.add(PaymentData( + typeId: typeId, + amount: amount, + title: typeInfo['titre'] as String, + color: Color(typeInfo['couleur'] as int), + icon: typeInfo['icon_data'] as IconData, + )); + } + }); + } + + Future _loadDashboardData() async { + setState(() { + isLoading = true; + }); + + try { + debugPrint('Chargement des données du tableau de bord...'); + // Utiliser les instances globales définies dans app.dart + // Pas besoin de Provider.of car les instances sont déjà disponibles + + // S'assurer que la boîte des opérations est ouverte avant d'y accéder + OperationModel? currentOperation; + try { + // Vérifier si la boîte Hive est ouverte + if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) { + debugPrint( + 'Ouverture de la boîte operations dans _loadDashboardData...'); + try { + await Hive.openBox(AppKeys.operationsBoxName); + debugPrint( + 'Boîte operations ouverte avec succès dans _loadDashboardData'); + } catch (boxError) { + debugPrint( + 'Erreur lors de l\'ouverture de la boîte operations dans _loadDashboardData: $boxError'); + // Continuer malgré l'erreur + } + } + + // Récupérer l'opération en cours + debugPrint('Récupération de l\'opération en cours...'); + currentOperation = userRepository.getCurrentOperation(); + debugPrint('Opération récupérée: ${currentOperation?.id ?? "null"}'); + } catch (boxError) { + debugPrint('Erreur lors de la récupération de l\'opération: $boxError'); + // Afficher un message d'erreur ou gérer l'erreur de manière appropriée + } + + if (currentOperation != null) { + // Charger les passages pour l'opération en cours + final passages = + passageRepository.getPassagesByOperation(currentOperation.id); + + // Calculer le nombre total de passages + totalPassages = passages.length; + + // Calculer le montant total collecté + totalAmounts = passages.fold( + 0.0, + (sum, passage) => + sum + + (passage.montant != null && passage.montant.isNotEmpty + ? double.tryParse(passage.montant) ?? 0.0 + : 0.0)); + + // Préparer les données pour le graphique de paiement + _preparePaymentData(passages); + + // Compter les passages par type + passagesByType = {}; + for (final passage in passages) { + final typeId = passage.fkType; + passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1; + } + + // Charger les statistiques par membre + memberStats = []; + final Map memberCounts = {}; + + // Compter les passages par membre + for (final passage in passages) { + if (passage.fkUser != null) { + memberCounts[passage.fkUser!] = + (memberCounts[passage.fkUser!] ?? 0) + 1; + } + } + + // Récupérer les informations des membres + for (final entry in memberCounts.entries) { + final user = userRepository.getUserById(entry.key); + if (user != null) { + memberStats.add({ + 'name': '${user.firstName ?? ''} ${user.name ?? ''}'.trim(), + 'count': entry.value, + }); + } + } + + // Trier les membres par nombre de passages (décroissant) + memberStats + .sort((a, b) => (b['count'] as int).compareTo(a['count'] as int)); + } + + setState(() { + isDataLoaded = true; + isLoading = false; + }); + } catch (e) { + debugPrint('Erreur lors du chargement des données: $e'); + setState(() { + isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + debugPrint('Building AdminDashboardHomePage'); + return FutureBuilder( + future: _initFuture, + builder: (context, snapshot) { + // Afficher un indicateur de chargement pendant l'initialisation des boîtes Hive + if (snapshot.connectionState == ConnectionState.waiting) { + debugPrint('FutureBuilder: ConnectionState.waiting'); + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Même si nous avons une erreur, nous continuons à afficher le contenu + // car nous avons modifié _initHiveBoxes pour ne pas propager les erreurs + if (snapshot.hasError) { + debugPrint('FutureBuilder: hasError - ${snapshot.error}'); + // Nous affichons un message d'erreur mais continuons à afficher le contenu + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Erreur lors de l\'initialisation: ${snapshot.error}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + action: SnackBarAction( + label: 'Réessayer', + onPressed: () { + setState(() { + _initFuture = _initHiveBoxes().then((_) { + _loadDashboardData(); + }); + }); + }, + ), + ), + ); + } else { + debugPrint('FutureBuilder: Initialisation réussie'); + } + + // L'initialisation a réussi, afficher le contenu + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth > 800; + // Utiliser l'instance globale définie dans app.dart + + // Récupérer l'opération en cours (les boîtes sont déjà ouvertes) + final currentOperation = userRepository.getCurrentOperation(); + + // Titre dynamique avec l'ID et le nom de l'opération + final String title = currentOperation != null + ? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}' + : 'Synthèse de l\'opération'; + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppTheme.spacingL), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + // Réduire la taille de police en version web + fontSize: isDesktop ? 18 : null, + ), + overflow: TextOverflow + .ellipsis, // Tronquer avec ... si trop long + maxLines: 1, // Forcer une seule ligne + ), + ), + const Spacer(), + // Bouton de rafraîchissement + if (!isLoading) + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Rafraîchir les données', + onPressed: _loadDashboardData, + ) + else + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: AppTheme.spacingM), + // Afficher un indicateur de chargement si les données ne sont pas encore chargées + if (isLoading && !isDataLoaded) + const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: CircularProgressIndicator(), + ), + ), + + // Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour + if (isDataLoaded || isLoading) ...[ + // Cartes de synthèse + isDesktop + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: _buildSummaryCard( + context, + 'Passages totaux', + totalPassages.toString(), + Icons.map_outlined, + AppTheme.primaryColor, + ), + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + flex: 2, + child: _buildSummaryCard( + context, + 'Montant collecté', + '${totalAmounts.toStringAsFixed(2)} €', + Icons.euro_outlined, + AppTheme.buttonSuccessColor, + ), + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + flex: 3, + child: SectorDistributionCard( + height: 200, + ), + ), + ], + ) + : Column( + children: [ + _buildSummaryCard( + context, + 'Passages totaux', + totalPassages.toString(), + Icons.map_outlined, + AppTheme.primaryColor, + ), + const SizedBox(height: AppTheme.spacingM), + _buildSummaryCard( + context, + 'Montant collecté', + '${totalAmounts.toStringAsFixed(2)} €', + Icons.euro_outlined, + AppTheme.buttonSuccessColor, + ), + const SizedBox(height: AppTheme.spacingM), + SectorDistributionCard( + height: 200, + ), + ], + ), + + const SizedBox(height: AppTheme.spacingL), + + // Graphique d'activité + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: const ActivityChart( + height: 350, + loadFromHive: true, + showAllPassages: + true, // Tous les passages, pas seulement ceux de l'utilisateur courant + title: 'Passages réalisés par jour (15 derniers jours)', + daysToShow: 15, + ), + // Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes + // child: ActivityChart( + // height: 350, + // loadFromHive: true, + // showAllPassages: true, + // title: 'Passages réalisés par jour (15 derniers jours)', + // daysToShow: 15, + // operationId: userRepository.getCurrentOperation()?.id, + // ), + ), + + const SizedBox(height: AppTheme.spacingL), + + // Graphiques de répartition + isDesktop + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildPassageTypeCard(context), + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + child: _buildPaymentTypeCard(context), + ), + ], + ) + : Column( + children: [ + _buildPassageTypeCard(context), + const SizedBox(height: AppTheme.spacingM), + _buildPaymentTypeCard(context), + ], + ), + + const SizedBox(height: AppTheme.spacingL), + + // Actions rapides + Text( + 'Actions rapides', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingM), + Wrap( + spacing: AppTheme.spacingM, + runSpacing: AppTheme.spacingM, + children: [ + _buildActionButton( + context, + 'Exporter les données', + Icons.file_download_outlined, + AppTheme.buttonPrimaryColor, + () {}, + ), + _buildActionButton( + context, + 'Envoyer un message', + Icons.message_outlined, + AppTheme.buttonSuccessColor, + () {}, + ), + _buildActionButton( + context, + 'Gérer les secteurs', + Icons.map_outlined, + AppTheme.accentColor, + () {}, + ), + ], + ), + ], + ], + ), + ); + }, + ); + } + + Widget _buildSummaryCard( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(AppTheme.spacingM), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: + BorderRadius.circular(AppTheme.borderRadiusSmall), + ), + child: Icon( + icon, + color: color, + size: 24, + ), + ), + const SizedBox(width: AppTheme.spacingM), + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingM), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildChartCard( + BuildContext context, + String title, + Widget chart, + ) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + chart, + ], + ), + ); + } + + // Construit la carte de répartition par type de passage avec liste + Widget _buildPassageTypeCard(BuildContext context) { + return Container( + height: 300, // Hauteur fixe de 300px + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Répartition par type de passage', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text( + '$totalPassages passages', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppTheme.primaryColor, + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: AppTheme.spacingM), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Graphique à gauche + Expanded( + flex: 1, + child: SizedBox( + height: 180, // Taille réduite + child: const PassagePieChart( + size: 180, + loadFromHive: true, + showAllPassages: true, + isDonut: true, + innerRadius: '50%', + showIcons: false, + showLegend: false, + ), + ), + ), + + // Liste des types à droite + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.only(left: AppTheme.spacingM), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, // Alignement à droite + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ...AppKeys.typesPassages.entries.map((entry) { + final int typeId = entry.key; + final Map typeInfo = entry.value; + final int count = passagesByType[typeId] ?? 0; + final Color color = + Color(typeInfo['couleur2'] as int); + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment + .end, // Alignement à droite + children: [ + Expanded( + child: Text( + '$count ${typeInfo['titres']}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: color, + ), + textAlign: TextAlign + .right, // Texte aligné à droite + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + // Construit la carte de répartition par mode de paiement + Widget _buildPaymentTypeCard(BuildContext context) { + return Container( + height: 300, // Hauteur fixe de 300px + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Répartition par mode de paiement', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text( + '${totalAmounts.toStringAsFixed(2)} €', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppTheme.buttonSuccessColor, + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: AppTheme.spacingM), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Graphique à gauche + Expanded( + flex: 1, + child: SizedBox( + height: 180, // Taille réduite + child: PaymentPieChart( + size: 180, + payments: paymentData, + isDonut: true, + innerRadius: '50%', + showIcons: false, + showLegend: false, + enable3DEffect: true, + effect3DIntensity: 1.5, + enableEnhancedExplode: false, // Désactiver l'explosion + useGradient: true, + ), + ), + ), + + // Liste des types de règlement à droite + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.only(left: AppTheme.spacingM), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, // Alignement à droite + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ...[1, 2, 3].map((typeId) { + // Uniquement les types 1, 2 et 3 + if (!AppKeys.typesReglements.containsKey(typeId)) { + return const SizedBox + .shrink(); // Ignorer si le type n'existe pas + } + + final Map typeInfo = + AppKeys.typesReglements[typeId]!; + + // Calculer le montant total pour ce type de règlement + double amount = 0.0; + for (final payment in paymentData) { + if (payment.typeId == typeId) { + amount = payment.amount; + break; + } + } + + // Ne pas afficher si le montant est 0 + if (amount <= 0) { + return const SizedBox.shrink(); + } + + final Color color = + Color(typeInfo['couleur'] as int); + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment + .end, // Alignement à droite + children: [ + Expanded( + child: Text( + '${amount.toStringAsFixed(2)} € ${typeInfo['titre']}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: color, + ), + textAlign: TextAlign + .right, // Texte aligné à droite + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildActionButton( + BuildContext context, + String label, + IconData icon, + Color color, + VoidCallback onPressed, + ) { + return ElevatedButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingL, + vertical: AppTheme.spacingM, + ), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/admin/admin_dashboard_page.dart b/flutt/lib/presentation/admin/admin_dashboard_page.dart new file mode 100644 index 00000000..40d18f0c --- /dev/null +++ b/flutt/lib/presentation/admin/admin_dashboard_page.dart @@ -0,0 +1,183 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/presentation/widgets/dashboard_layout.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +// Import des pages admin +import 'admin_dashboard_home_page.dart'; +import 'admin_statistics_page.dart'; +import 'admin_history_page.dart'; +import 'admin_communication_page.dart'; +import 'admin_map_page.dart'; +import 'admin_entite.dart'; + +class AdminDashboardPage extends StatefulWidget { + const AdminDashboardPage({Key? key}) : super(key: key); + + @override + State createState() => _AdminDashboardPageState(); +} + +class _AdminDashboardPageState extends State { + int _selectedIndex = 0; + + // Liste des pages à afficher + late final List _pages; + + // Index de la page Amicale et membres (utilisé pour la navigation conditionnelle) + static const int entitePageIndex = 5; + + // Référence à la boîte Hive pour les paramètres + late Box _settingsBox; + + @override + void initState() { + super.initState(); + + try { + debugPrint('Initialisation de AdminDashboardPage'); + + // Vérifier que userRepository est correctement initialisé + if (userRepository == null) { + debugPrint('ERREUR: userRepository est null dans AdminDashboardPage'); + } else { + debugPrint('userRepository est correctement initialisé'); + + // Vérifier l'utilisateur courant + final currentUser = userRepository.getCurrentUser(); + if (currentUser == null) { + debugPrint( + 'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage', + ); + } else { + debugPrint( + 'Utilisateur connecté: ${currentUser.username} (${currentUser.id})', + ); + } + } + + _pages = [ + const AdminDashboardHomePage(), + const AdminStatisticsPage(), + const AdminHistoryPage(), + const AdminCommunicationPage(), + const AdminMapPage(), + // La page AdminEntitePage est maintenant accessible uniquement via le menu Paramètres + ]; + + // Initialiser et charger les paramètres + _initSettings(); + } catch (e) { + debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e'); + } + } + + // Initialiser la boîte de paramètres et charger les préférences + Future _initSettings() async { + try { + // Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { + _settingsBox = await Hive.openBox(AppKeys.settingsBoxName); + } else { + _settingsBox = Hive.box(AppKeys.settingsBoxName); + } + + // Charger l'index de page sélectionné + final savedIndex = _settingsBox.get('adminSelectedPageIndex'); + + // Vérifier si l'index sauvegardé est valide + if (savedIndex != null && savedIndex is int) { + debugPrint('Index sauvegardé trouvé: $savedIndex'); + + // S'assurer que l'index est dans les limites valides + if (savedIndex >= 0 && savedIndex < _pages.length) { + setState(() { + _selectedIndex = savedIndex; + }); + debugPrint('Index sauvegardé valide, utilisé: $_selectedIndex'); + } else { + debugPrint( + 'Index sauvegardé invalide ($savedIndex), utilisation de l\'index par défaut: 0', + ); + // Réinitialiser l'index sauvegardé à 0 si invalide + _settingsBox.put('adminSelectedPageIndex', 0); + } + } else { + debugPrint( + 'Aucun index sauvegardé trouvé, utilisation de l\'index par défaut: 0', + ); + } + } catch (e) { + debugPrint('Erreur lors du chargement des paramètres: $e'); + } + } + + // Sauvegarder les paramètres utilisateur + void _saveSettings() { + try { + // Sauvegarder l'index de page sélectionné + _settingsBox.put('adminSelectedPageIndex', _selectedIndex); + } catch (e) { + debugPrint('Erreur lors de la sauvegarde des paramètres: $e'); + } + } + + @override + Widget build(BuildContext context) { + return DashboardLayout( + title: 'Tableau de bord Administration', + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + _saveSettings(); // Sauvegarder l'index de page sélectionné + }); + }, + destinations: _buildNavigationDestinations(), + showNewPassageButton: false, + isAdmin: true, + body: _pages[_selectedIndex], + ); + } + + /// Construit la liste des destinations de navigation + List _buildNavigationDestinations() { + // Destinations de base toujours présentes + final List destinations = [ + const NavigationDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Tableau de bord', + ), + const NavigationDestination( + icon: Icon(Icons.bar_chart_outlined), + selectedIcon: Icon(Icons.bar_chart), + label: 'Statistiques', + ), + const NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'Historique', + ), + const NavigationDestination( + icon: Icon(Icons.chat_outlined), + selectedIcon: Icon(Icons.chat), + label: 'Messages', + ), + const NavigationDestination( + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), + label: 'Carte', + ), + ]; + + // Nous ne voulons plus ajouter la destination "Amicale et membres" ici + // car elle est accessible uniquement via le menu Paramètres + + return destinations; + } +} diff --git a/flutt/lib/presentation/admin/admin_entite.dart b/flutt/lib/presentation/admin/admin_entite.dart new file mode 100644 index 00000000..8eca4810 --- /dev/null +++ b/flutt/lib/presentation/admin/admin_entite.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +/// Page d'administration de l'amicale et des membres +/// Cette page est intégrée dans le tableau de bord administrateur +class AdminEntitePage extends StatelessWidget { + const AdminEntitePage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre de la page + Text( + 'Mon amicale et ses membres', + style: theme.textTheme.headlineMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + // Contenu principal + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 64, + color: theme.colorScheme.primary.withOpacity(0.7), + ), + const SizedBox(height: 16), + Text( + 'Page en construction', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Cette section permettra la gestion des amicales et de leurs membres.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/admin/admin_history_page.dart b/flutt/lib/presentation/admin/admin_history_page.dart new file mode 100644 index 00000000..9ceb3740 --- /dev/null +++ b/flutt/lib/presentation/admin/admin_history_page.dart @@ -0,0 +1,877 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/data/models/user_model.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/sector_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; + +class AdminHistoryPage extends StatefulWidget { + const AdminHistoryPage({Key? key}) : super(key: key); + + @override + State createState() => _AdminHistoryPageState(); +} + +class _AdminHistoryPageState extends State { + // État des filtres + String searchQuery = ''; + String selectedSector = 'Tous'; + String selectedUser = 'Tous'; + String selectedType = 'Tous'; + String selectedPaymentMethod = 'Tous'; + String selectedPeriod = 'Dernier mois'; // Période par défaut + DateTimeRange? selectedDateRange; + + // IDs pour les filtres + int? selectedSectorId; + int? selectedUserId; + + // Listes pour les filtres + List _sectors = []; + List _users = []; + + // Repositories + late PassageRepository _passageRepository; + late SectorRepository _sectorRepository; + late UserRepository _userRepository; + + // Passages formatés + List> _formattedPassages = []; + + // État de chargement + bool _isLoading = true; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + // Initialiser les filtres + _initializeFilters(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Récupérer les repositories une seule fois + _loadRepositories(); + } + + // Charger les repositories et les données + void _loadRepositories() { + try { + // Utiliser les instances globales définies dans app.dart + _passageRepository = passageRepository; + _userRepository = userRepository; + _sectorRepository = sectorRepository; + + // Charger les secteurs et les utilisateurs + _loadSectorsAndUsers(); + + // Charger les passages + _loadPassages(); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Erreur lors du chargement des repositories: $e'; + }); + } + } + + // Charger les secteurs et les utilisateurs + void _loadSectorsAndUsers() { + try { + // Récupérer la liste des secteurs + _sectors = _sectorRepository.getAllSectors(); + debugPrint('Nombre de secteurs récupérés: ${_sectors.length}'); + + // Récupérer la liste des utilisateurs + _users = _userRepository.getAllUsers(); + debugPrint('Nombre d\'utilisateurs récupérés: ${_users.length}'); + } catch (e) { + debugPrint('Erreur lors du chargement des secteurs et utilisateurs: $e'); + } + } + + // Charger les passages + void _loadPassages() { + setState(() { + _isLoading = true; + }); + + try { + // Récupérer les passages + final List allPassages = + _passageRepository.getAllPassages(); + + // Convertir les passages en format attendu par PassagesListWidget + _formattedPassages = _formatPassagesForWidget( + allPassages, _sectorRepository, _userRepository); + + setState(() { + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Erreur lors du chargement des passages: $e'; + }); + } + } + + // Initialiser les filtres + void _initializeFilters() { + // Par défaut, on n'applique pas de filtre par utilisateur ou secteur + selectedSectorId = null; + selectedUserId = null; + + // Période par défaut : dernier mois + selectedPeriod = 'Dernier mois'; + + // Plage de dates par défaut : dernier mois + final DateTime now = DateTime.now(); + final DateTime oneMonthAgo = DateTime(now.year, now.month - 1, now.day); + selectedDateRange = DateTimeRange(start: oneMonthAgo, end: now); + } + + // Mettre à jour le filtre par secteur + void _updateSectorFilter(String sectorName, int? sectorId) { + setState(() { + selectedSector = sectorName; + selectedSectorId = sectorId; + }); + } + + // Mettre à jour le filtre par utilisateur + void _updateUserFilter(String userName, int? userId) { + setState(() { + selectedUser = userName; + selectedUserId = userId; + }); + } + + // Mettre à jour le filtre par période + void _updatePeriodFilter(String period) { + setState(() { + selectedPeriod = period; + + // Mettre à jour la plage de dates en fonction de la période + final DateTime now = DateTime.now(); + + switch (period) { + case 'Derniers 15 jours': + selectedDateRange = DateTimeRange( + start: now.subtract(const Duration(days: 15)), + end: now, + ); + break; + case 'Dernière semaine': + selectedDateRange = DateTimeRange( + start: now.subtract(const Duration(days: 7)), + end: now, + ); + break; + case 'Dernier mois': + selectedDateRange = DateTimeRange( + start: DateTime(now.year, now.month - 1, now.day), + end: now, + ); + break; + case 'Tous': + selectedDateRange = null; + break; + } + }); + } + + @override + Widget build(BuildContext context) { + // Afficher un widget de chargement ou d'erreur si nécessaire + if (_isLoading) { + return const Scaffold( + backgroundColor: + Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return _buildErrorWidget(_errorMessage); + } + + // Retourner le widget principal avec les données chargées + return Scaffold( + backgroundColor: + const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre de la page + Text( + 'Historique des passages', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + + const SizedBox(height: 16), + + // Filtres supplémentaires (secteur, utilisateur, période) + _buildAdditionalFilters(context), + + const SizedBox(height: 16), + + // Widget de liste des passages + Expanded( + child: PassagesListWidget( + passages: _formattedPassages, + showFilters: true, + showSearch: true, + showActions: true, + initialSearchQuery: searchQuery, + initialTypeFilter: selectedType, + initialPaymentFilter: selectedPaymentMethod, + // Exclure les passages de type 2 (À finaliser) + excludePassageTypes: [2], + // Filtres par utilisateur et secteur + filterByUserId: selectedUserId, + filterBySectorId: selectedSectorId, + // Période par défaut (dernier mois) + periodFilter: 'lastMonth', + // Plage de dates personnalisée si définie + dateRange: selectedDateRange, + onPassageSelected: (passage) { + _showDetailsDialog(context, passage); + }, + onReceiptView: (passage) { + _showReceiptDialog(context, passage); + }, + onDetailsView: (passage) { + _showDetailsDialog(context, passage); + }, + onPassageEdit: (passage) { + // Action pour modifier le passage + // Cette fonctionnalité pourrait être implémentée ultérieurement + }, + ), + ), + ], + ), + ), + ); + } + + // Widget d'erreur pour afficher un message d'erreur + Widget _buildErrorWidget(String message) { + return Scaffold( + backgroundColor: + const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 64, + ), + const SizedBox(height: 16), + Text( + 'Erreur', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.red[700], + ), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Recharger la page + setState(() {}); + }, + child: const Text('Réessayer'), + ), + ], + ), + ), + ), + ); + } + + // Convertir les passages du modèle Hive vers le format attendu par le widget + List> _formatPassagesForWidget( + List passages, + SectorRepository sectorRepository, + UserRepository userRepository) { + return passages.map((passage) { + // Récupérer le secteur associé au passage + final SectorModel? sector = + sectorRepository.getSectorById(passage.fkSector); + + // Récupérer l'utilisateur associé au passage + final UserModel? user = userRepository.getUserById(passage.fkUser); + + // Construire l'adresse complète + final String address = + '${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}'; + + // Déterminer si le passage a une erreur d'envoi de reçu + final bool hasError = passage.emailErreur.isNotEmpty; + + return { + 'id': passage.id, + 'date': passage.passedAt, + 'address': address, + 'fkSector': passage.fkSector, + 'sector': sector?.libelle ?? 'Secteur inconnu', + 'fkUser': passage.fkUser, + 'user': user?.name ?? 'Utilisateur inconnu', + 'type': passage.fkType, + 'amount': double.tryParse(passage.montant) ?? 0.0, + 'payment': passage.fkTypeReglement, + 'email': passage.email, + 'hasReceipt': passage.nomRecu.isNotEmpty, + 'hasError': hasError, + 'notes': passage.remarque, + 'name': passage.name, + 'phone': passage.phone, + // Ajouter d'autres champs nécessaires pour le widget + }; + }).toList(); + } + + void _showReceiptDialog(BuildContext context, Map passage) { + final int passageId = passage['id'] as int; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Reçu du passage #$passageId'), + content: const SizedBox( + width: 500, + height: 600, + child: Center( + child: Text('Aperçu du reçu PDF'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ElevatedButton( + onPressed: () { + // Action pour télécharger le reçu + Navigator.pop(context); + }, + child: const Text('Télécharger'), + ), + ], + ), + ); + } + + void _showDetailsDialog(BuildContext context, Map passage) { + final int passageId = passage['id'] as int; + final DateTime date = passage['date'] as DateTime; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Détails du passage #$passageId'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildDetailRow('Date', + '${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'), + _buildDetailRow('Adresse', passage['address'] as String), + _buildDetailRow('Secteur', passage['sector'] as String), + _buildDetailRow('Collecteur', passage['user'] as String), + _buildDetailRow( + 'Type', + AppKeys.typesPassages[passage['type']]?['titre'] ?? + 'Inconnu'), + _buildDetailRow('Montant', '${passage['amount']} €'), + _buildDetailRow( + 'Mode de paiement', + AppKeys.typesReglements[passage['payment']]?['titre'] ?? + 'Inconnu'), + _buildDetailRow('Email', passage['email'] as String), + _buildDetailRow( + 'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'), + _buildDetailRow( + 'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'), + _buildDetailRow( + 'Notes', + (passage['notes'] as String).isEmpty + ? '-' + : passage['notes'] as String), + const SizedBox(height: 16), + const Text( + 'Historique des actions', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHistoryItem( + date, + passage['user'] as String, + 'Création du passage', + ), + if (passage['hasReceipt']) + _buildHistoryItem( + date.add(const Duration(minutes: 5)), + 'Système', + 'Envoi du reçu par email', + ), + if (passage['hasError']) + _buildHistoryItem( + date.add(const Duration(minutes: 6)), + 'Système', + 'Erreur lors de l\'envoi du reçu', + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ElevatedButton( + onPressed: () { + // Action pour modifier le passage + Navigator.pop(context); + }, + child: const Text('Modifier'), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + '$label :', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: Text(value), + ), + ], + ), + ); + } + + Widget _buildHistoryItem(DateTime date, String user, String action) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + Text('$user - $action'), + const Divider(), + ], + ), + ); + } + + // Construction des filtres supplémentaires + Widget _buildAdditionalFilters(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filtres avancés', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + + // Disposition des filtres en fonction de la taille de l'écran + isDesktop + ? Row( + children: [ + // Filtre par secteur + Expanded( + child: _buildSectorFilter(theme, _sectors), + ), + const SizedBox(width: 16), + + // Filtre par utilisateur + Expanded( + child: _buildUserFilter(theme, _users), + ), + const SizedBox(width: 16), + + // Filtre par période + Expanded( + child: _buildPeriodFilter(theme), + ), + ], + ) + : Column( + children: [ + // Filtre par secteur + _buildSectorFilter(theme, _sectors), + const SizedBox(height: 16), + + // Filtre par utilisateur + _buildUserFilter(theme, _users), + const SizedBox(height: 16), + + // Filtre par période + _buildPeriodFilter(theme), + ], + ), + ], + ), + ), + ); + } + + // Construction du filtre par secteur + Widget _buildSectorFilter(ThemeData theme, List sectors) { + // Vérifier si la liste des secteurs est vide ou si selectedSector n'est pas dans la liste + bool isSelectedSectorValid = selectedSector == 'Tous' || + sectors.any((s) => s.libelle == selectedSector); + + // Si selectedSector n'est pas valide, le réinitialiser à 'Tous' + if (!isSelectedSectorValid) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + selectedSector = 'Tous'; + selectedSectorId = null; + }); + } + }); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Secteur', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12.0), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: isSelectedSectorValid ? selectedSector : 'Tous', + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + items: [ + const DropdownMenuItem( + value: 'Tous', + child: Text('Tous les secteurs'), + ), + ...sectors.map((sector) { + final String libelle = sector.libelle.isNotEmpty + ? sector.libelle + : 'Secteur ${sector.id}'; + return DropdownMenuItem( + value: libelle, + child: Text( + libelle, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ], + onChanged: (String? value) { + if (value != null) { + if (value == 'Tous') { + _updateSectorFilter('Tous', null); + } else { + try { + // Trouver le secteur correspondant + final sector = sectors.firstWhere( + (s) => s.libelle == value, + orElse: () => sectors.isNotEmpty + ? sectors.first + : throw Exception('Liste de secteurs vide'), + ); + // Convertir sector.id en int? si nécessaire + _updateSectorFilter(value, sector.id); + } catch (e) { + debugPrint('Erreur lors de la sélection du secteur: $e'); + _updateSectorFilter('Tous', null); + } + } + } + }, + ), + ), + ), + ], + ); + } + + // Construction du filtre par utilisateur + Widget _buildUserFilter(ThemeData theme, List users) { + // Vérifier si la liste des utilisateurs est vide ou si selectedUser n'est pas dans la liste + bool isSelectedUserValid = selectedUser == 'Tous' || + users.any((u) => (u.name ?? 'Utilisateur inconnu') == selectedUser); + + // Si selectedUser n'est pas valide, le réinitialiser à 'Tous' + if (!isSelectedUserValid) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + selectedUser = 'Tous'; + selectedUserId = null; + }); + } + }); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Utilisateur', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12.0), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: isSelectedUserValid ? selectedUser : 'Tous', + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + items: [ + const DropdownMenuItem( + value: 'Tous', + child: Text('Tous les utilisateurs'), + ), + ...users.map((user) { + // S'assurer que user.name n'est pas null + final String userName = user.name ?? 'Utilisateur inconnu'; + return DropdownMenuItem( + value: userName, + child: Text( + userName, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ], + onChanged: (String? value) { + if (value != null) { + if (value == 'Tous') { + _updateUserFilter('Tous', null); + } else { + try { + // Trouver l'utilisateur correspondant + final user = users.firstWhere( + (u) => (u.name ?? 'Utilisateur inconnu') == value, + orElse: () => users.isNotEmpty + ? users.first + : throw Exception('Liste d\'utilisateurs vide'), + ); + // S'assurer que user.name et user.id ne sont pas null + final String userName = + user.name ?? 'Utilisateur inconnu'; + final int? userId = user.id; + _updateUserFilter(userName, userId); + } catch (e) { + debugPrint( + 'Erreur lors de la sélection de l\'utilisateur: $e'); + _updateUserFilter('Tous', null); + } + } + } + }, + ), + ), + ), + ], + ); + } + + // Construction du filtre par période + Widget _buildPeriodFilter(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Période', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12.0), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedPeriod, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + items: const [ + DropdownMenuItem( + value: 'Tous', + child: Text('Toutes les périodes'), + ), + DropdownMenuItem( + value: 'Derniers 15 jours', + child: Text('Derniers 15 jours'), + ), + DropdownMenuItem( + value: 'Dernière semaine', + child: Text('Dernière semaine'), + ), + DropdownMenuItem( + value: 'Dernier mois', + child: Text('Dernier mois'), + ), + ], + onChanged: (String? value) { + if (value != null) { + _updatePeriodFilter(value); + } + }, + ), + ), + ), + + // Afficher la plage de dates sélectionnée si elle existe + if (selectedDateRange != null && selectedPeriod != 'Tous') + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + Icon( + Icons.date_range, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ); + } + + void _showResendConfirmation(BuildContext context, int passageId) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Renvoyer le reçu'), + content: Text( + 'Êtes-vous sûr de vouloir renvoyer le reçu du passage #$passageId ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + // Action pour renvoyer le reçu + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Reçu du passage #$passageId renvoyé avec succès'), + backgroundColor: Colors.green, + ), + ); + }, + child: const Text('Renvoyer'), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/admin/admin_map_page.dart b/flutt/lib/presentation/admin/admin_map_page.dart new file mode 100644 index 00000000..cc98d4f4 --- /dev/null +++ b/flutt/lib/presentation/admin/admin_map_page.dart @@ -0,0 +1,898 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/presentation/widgets/mapbox_map.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/services/location_service.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import '../../shared/app_theme.dart'; + +class AdminMapPage extends StatefulWidget { + const AdminMapPage({Key? key}) : super(key: key); + + @override + State createState() => _AdminMapPageState(); +} + +class _AdminMapPageState extends State { + // Contrôleur de carte + final MapController _mapController = MapController(); + + // Position actuelle et zoom + LatLng _currentPosition = + const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes + double _currentZoom = 12.0; // Zoom initial + + // Données des secteurs et passages + final List> _sectors = []; + final List> _passages = []; + + // États + bool _editMode = false; + int? _selectedSectorId; + List> _sectorItems = []; + + // Référence à la boîte Hive pour les paramètres + late Box _settingsBox; + + @override + void initState() { + super.initState(); + _initSettings().then((_) { + _loadSectors(); + _loadPassages(); + }); + } + + // Initialiser la boîte de paramètres et charger les préférences + Future _initSettings() async { + // Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { + _settingsBox = await Hive.openBox(AppKeys.settingsBoxName); + } else { + _settingsBox = Hive.box(AppKeys.settingsBoxName); + } + + // Charger le secteur sélectionné + _selectedSectorId = _settingsBox.get('admin_selectedSectorId'); + + // Charger la position et le zoom + final double? savedLat = _settingsBox.get('admin_mapLat'); + final double? savedLng = _settingsBox.get('admin_mapLng'); + final double? savedZoom = _settingsBox.get('admin_mapZoom'); + + if (savedLat != null && savedLng != null) { + _currentPosition = LatLng(savedLat, savedLng); + } + + if (savedZoom != null) { + _currentZoom = savedZoom; + } + } + + // Sauvegarder les paramètres utilisateur + void _saveSettings() { + // Sauvegarder le secteur sélectionné + if (_selectedSectorId != null) { + _settingsBox.put('admin_selectedSectorId', _selectedSectorId); + } + + // Sauvegarder la position et le zoom actuels + _settingsBox.put('admin_mapLat', _currentPosition.latitude); + _settingsBox.put('admin_mapLng', _currentPosition.longitude); + _settingsBox.put('admin_mapZoom', _currentZoom); + } + + // Charger les secteurs depuis la boîte Hive + void _loadSectors() { + try { + final sectorsBox = Hive.box(AppKeys.sectorsBoxName); + final sectors = sectorsBox.values.toList(); + + setState(() { + _sectors.clear(); + + for (final sector in sectors) { + final List> coordinates = sector.getCoordinates(); + final List points = + coordinates.map((coord) => LatLng(coord[0], coord[1])).toList(); + + if (points.isNotEmpty) { + _sectors.add({ + 'id': sector.id, + 'name': sector.libelle, + 'color': _hexToColor(sector.color), + 'points': points, + }); + } + } + + // Si un secteur était sélectionné précédemment, le centrer + // Mettre à jour les items de la combobox de secteurs + _updateSectorItems(); + + if (_selectedSectorId != null && + _sectors.any((s) => s['id'] == _selectedSectorId)) { + _centerMapOnSpecificSector(_selectedSectorId!); + } + // Sinon, centrer la carte sur tous les secteurs + else if (_sectors.isNotEmpty) { + _centerMapOnSectors(); + } + }); + } catch (e) { + debugPrint('Erreur lors du chargement des secteurs: $e'); + } + } + + // Charger les passages depuis la boîte Hive + void _loadPassages() { + try { + // Récupérer la boîte des passages + final passagesBox = Hive.box(AppKeys.passagesBoxName); + + // Créer une nouvelle liste temporaire + final List> newPassages = []; + + // Parcourir tous les passages dans la boîte + for (var i = 0; i < passagesBox.length; i++) { + final passage = passagesBox.getAt(i); + if (passage != null) { + // Vérifier si les coordonnées GPS sont valides + final lat = double.tryParse(passage.gpsLat); + final lng = double.tryParse(passage.gpsLng); + + // Filtrer par secteur si un secteur est sélectionné + if (_selectedSectorId != null && + passage.fkSector != _selectedSectorId) { + continue; + } + + if (lat != null && lng != null) { + // Obtenir la couleur du type de passage + Color passageColor = Colors.grey; // Couleur par défaut + + // Vérifier si le type de passage existe dans AppKeys.typesPassages + if (AppKeys.typesPassages.containsKey(passage.fkType)) { + // Utiliser la couleur1 du type de passage + final colorValue = + AppKeys.typesPassages[passage.fkType]!['couleur1'] as int; + passageColor = Color(colorValue); + + // Ajouter le passage à la liste temporaire + newPassages.add({ + 'id': passage.id, + 'position': LatLng(lat, lng), + 'type': passage.fkType, + 'color': passageColor, + 'model': passage, // Ajouter le modèle complet + }); + } + } + } + } + + // Mettre à jour la liste des passages dans l'état + setState(() { + _passages.clear(); + _passages.addAll(newPassages); + }); + + // Sauvegarder les paramètres après chargement des passages + _saveSettings(); + } catch (e) { + debugPrint('Erreur lors du chargement des passages: $e'); + } + } + + // Convertir une couleur hexadécimale en Color + Color _hexToColor(String hexColor) { + // Supprimer le # si présent + final String colorStr = + hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + + // Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères) + final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr; + + // Convertir en entier et créer la couleur + return Color(int.parse(fullColorStr, radix: 16)); + } + + // Centrer la carte sur tous les secteurs + void _centerMapOnSectors() { + if (_sectors.isEmpty) return; + + // Trouver les limites de tous les secteurs + double minLat = 90.0; + double maxLat = -90.0; + double minLng = 180.0; + double maxLng = -180.0; + + for (final sector in _sectors) { + final points = sector['points'] as List; + for (final point in points) { + minLat = point.latitude < minLat ? point.latitude : minLat; + maxLat = point.latitude > maxLat ? point.latitude : maxLat; + minLng = point.longitude < minLng ? point.longitude : minLng; + maxLng = point.longitude > maxLng ? point.longitude : maxLng; + } + } + + // Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles + // avec une marge autour (5% de la taille totale) + final latPadding = (maxLat - minLat) * 0.05; + final lngPadding = (maxLng - minLng) * 0.05; + + minLat -= latPadding; + maxLat += latPadding; + minLng -= lngPadding; + maxLng += lngPadding; + + // Calculer le centre + final centerLat = (minLat + maxLat) / 2; + final centerLng = (minLng + maxLng) / 2; + + // Calculer le zoom approprié en tenant compte des dimensions de l'écran + final mapWidth = MediaQuery.of(context).size.width; + final mapHeight = MediaQuery.of(context).size.height * + 0.7; // Estimation de la hauteur de la carte + final zoom = _calculateOptimalZoom( + minLat, maxLat, minLng, maxLng, mapWidth, mapHeight); + + // Centrer la carte sur ces limites avec animation + _mapController.move(LatLng(centerLat, centerLng), zoom); + + // Mettre à jour l'état pour refléter la nouvelle position + setState(() { + _currentPosition = LatLng(centerLat, centerLng); + _currentZoom = zoom; + }); + + debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom'); + } + + // Mettre à jour les items de la combobox de secteurs + void _updateSectorItems() { + // Créer l'item "Tous les secteurs" + final List> items = [ + const DropdownMenuItem( + value: null, + child: Text('Tous les secteurs'), + ), + ]; + + // Ajouter tous les secteurs + for (final sector in _sectors) { + items.add( + DropdownMenuItem( + value: sector['id'] as int, + child: Text(sector['name'] as String), + ), + ); + } + + setState(() { + _sectorItems = items; + }); + } + + // Centrer la carte sur un secteur spécifique + void _centerMapOnSpecificSector(int sectorId) { + final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId); + if (sectorIndex == -1) return; + + // Mettre à jour le secteur sélectionné + _selectedSectorId = sectorId; + + final sector = _sectors[sectorIndex]; + final points = sector['points'] as List; + final sectorName = sector['name'] as String; + + debugPrint( + 'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points'); + + if (points.isEmpty) { + debugPrint('Aucun point dans ce secteur!'); + return; + } + + // Trouver les limites du secteur + double minLat = 90.0; + double maxLat = -90.0; + double minLng = 180.0; + double maxLng = -180.0; + + for (final point in points) { + minLat = point.latitude < minLat ? point.latitude : minLat; + maxLat = point.latitude > maxLat ? point.latitude : maxLat; + minLng = point.longitude < minLng ? point.longitude : minLng; + maxLng = point.longitude > maxLng ? point.longitude : maxLng; + } + + // Vérifier si les coordonnées sont valides + if (minLat >= maxLat || minLng >= maxLng) { + debugPrint('Coordonnées invalides pour le secteur $sectorName'); + return; + } + + // Calculer la taille du secteur + final latSpan = maxLat - minLat; + final lngSpan = maxLng - minLng; + + // Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible + final double latPadding, lngPadding; + if (latSpan < 0.01 || lngSpan < 0.01) { + // Pour les très petits secteurs, utiliser un padding très réduit + latPadding = 0.0003; + lngPadding = 0.0003; + } else if (latSpan < 0.05 || lngSpan < 0.05) { + // Pour les petits secteurs, padding réduit + latPadding = 0.0005; + lngPadding = 0.0005; + } else { + // Pour les secteurs plus grands, utiliser un pourcentage minimal + latPadding = latSpan * 0.03; // 3% au lieu de 10% + lngPadding = lngSpan * 0.03; + } + + minLat -= latPadding; + maxLat += latPadding; + minLng -= lngPadding; + maxLng += lngPadding; + + // Calculer le centre + final centerLat = (minLat + maxLat) / 2; + final centerLng = (minLng + maxLng) / 2; + + // Déterminer le zoom approprié en fonction de la taille du secteur + double zoom; + + // Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé + if (latSpan < 0.01 && lngSpan < 0.01) { + zoom = 16.0; // Zoom élevé pour les petits quartiers + } else if (latSpan < 0.02 && lngSpan < 0.02) { + zoom = 15.0; // Zoom élevé pour les petits quartiers + } else if (latSpan < 0.05 && lngSpan < 0.05) { + zoom = + 13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers) + } else if (latSpan < 0.1 && lngSpan < 0.1) { + zoom = 12.0; // Zoom pour les grands secteurs (ville) + } else { + // Pour les secteurs plus grands, calculer le zoom + final mapWidth = MediaQuery.of(context).size.width; + final mapHeight = MediaQuery.of(context).size.height * 0.7; + zoom = _calculateOptimalZoom( + minLat, maxLat, minLng, maxLng, mapWidth, mapHeight); + } + + // Centrer la carte sur le secteur avec animation + _mapController.move(LatLng(centerLat, centerLng), zoom); + + // Mettre à jour l'état pour refléter la nouvelle position + setState(() { + _currentPosition = LatLng(centerLat, centerLng); + _currentZoom = zoom; + }); + + // Recharger les passages pour appliquer le filtre par secteur + _loadPassages(); + } + + // Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte + double _calculateOptimalZoom(double minLat, double maxLat, double minLng, + double maxLng, double mapWidth, double mapHeight) { + // Vérifier si les coordonnées sont valides + if (minLat >= maxLat || minLng >= maxLng) { + debugPrint('Coordonnées invalides pour le calcul du zoom'); + return 12.0; // Valeur par défaut raisonnable + } + + // Calculer la taille en degrés + final latSpan = maxLat - minLat; + final lngSpan = maxLng - minLng; + + // Ajouter un facteur de sécurité pour éviter les divisions par zéro + if (latSpan < 0.0000001 || lngSpan < 0.0000001) { + return 15.0; // Zoom élevé pour un point très précis + } + + // Formule simplifiée pour le calcul du zoom + double zoom; + + if (latSpan < 0.005 || lngSpan < 0.005) { + // Très petite zone (quartier) + zoom = 16.0; + } else if (latSpan < 0.01 || lngSpan < 0.01) { + // Petite zone (quartier) + zoom = 15.0; + } else if (latSpan < 0.02 || lngSpan < 0.02) { + // Petite zone (plusieurs quartiers) + zoom = 14.0; + } else if (latSpan < 0.05 || lngSpan < 0.05) { + // Zone moyenne (ville) + zoom = 13.0; + } else if (latSpan < 0.2 || lngSpan < 0.2) { + // Grande zone (agglomération) + zoom = 11.0; + } else if (latSpan < 0.5 || lngSpan < 0.5) { + // Très grande zone (département) + zoom = 9.0; + } else if (latSpan < 2.0 || lngSpan < 2.0) { + // Région + zoom = 7.0; + } else if (latSpan < 5.0 || lngSpan < 5.0) { + // Pays + zoom = 5.0; + } else { + // Continent ou plus + zoom = 3.0; + } + + return zoom; + } + + // Obtenir la position actuelle de l'utilisateur + Future _getUserLocation() async { + try { + // Afficher un indicateur de chargement + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche de votre position...'), + duration: Duration(seconds: 2), + ), + ); + + // Obtenir la position actuelle via le service de géolocalisation + final position = await LocationService.getCurrentPosition(); + + if (position != null) { + // Mettre à jour la position sur la carte + _updateMapPosition(position, zoom: 17); + + // Sauvegarder la nouvelle position + _settingsBox.put('admin_mapLat', position.latitude); + _settingsBox.put('admin_mapLng', position.longitude); + + // Informer l'utilisateur + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Position actualisée'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } + } else { + // Informer l'utilisateur en cas d'échec + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + // Gérer les erreurs + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + // Méthode pour mettre à jour la position sur la carte + void _updateMapPosition(LatLng position, {double? zoom}) { + _mapController.move( + position, + zoom ?? _mapController.camera.zoom, + ); + + // Mettre à jour les variables d'état + setState(() { + _currentPosition = position; + if (zoom != null) { + _currentZoom = zoom; + } + }); + + // Sauvegarder les paramètres après mise à jour de la position + _saveSettings(); + } + + // Méthode pour construire les marqueurs des passages + List _buildMarkers() { + if (_passages.isEmpty) { + return []; + } + + return _passages.map((passage) { + final int passageType = passage['type'] as int; + final Color color1 = + passage['color'] as Color; // couleur1 du type de passage + + // Récupérer la couleur2 du type de passage + Color color2 = Colors.white; // Couleur par défaut + if (AppKeys.typesPassages.containsKey(passageType)) { + final colorValue = + AppKeys.typesPassages[passageType]!['couleur2'] as int; + color2 = Color(colorValue); + } + + return Marker( + point: passage['position'] as LatLng, + width: 14.0, + height: 14.0, + child: GestureDetector( + onTap: () { + _showPassageInfo(passage); + }, + child: Container( + decoration: BoxDecoration( + color: color1, + shape: BoxShape.circle, + border: Border.all( + color: color2, + width: 1.0, + ), + ), + ), + ), + ); + }).toList(); + } + + // Méthode pour construire les polygones des secteurs + List _buildPolygons() { + if (_sectors.isEmpty) { + return []; + } + + return _sectors.map((sector) { + final bool isSelected = _selectedSectorId == sector['id']; + final Color sectorColor = sector['color'] as Color; + + return Polygon( + points: sector['points'] as List, + color: isSelected + ? sectorColor.withOpacity(0.5) + : sectorColor.withOpacity(0.3), + borderColor: isSelected ? sectorColor : sectorColor.withOpacity(0.8), + borderStrokeWidth: isSelected ? 3.0 : 2.0, + ); + }).toList(); + } + + // Afficher les informations d'un passage lorsqu'on clique dessus + void _showPassageInfo(Map passage) { + final PassageModel passageModel = passage['model'] as PassageModel; + final int type = passageModel.fkType; + + // Construire l'adresse complète + final String adresse = + '${passageModel.numero}, ${passageModel.rueBis} ${passageModel.rue}'; + + // Informations sur l'étage, l'appartement et la résidence (si habitat = 2) + String? etageInfo; + String? apptInfo; + String? residenceInfo; + if (passageModel.fkHabitat == 2) { + if (passageModel.niveau.isNotEmpty) { + etageInfo = 'Etage ${passageModel.niveau}'; + } + if (passageModel.appt.isNotEmpty) { + apptInfo = 'appt. ${passageModel.appt}'; + } + if (passageModel.residence.isNotEmpty) { + residenceInfo = passageModel.residence; + } + } + + // Formater la date (uniquement si le type n'est pas 2) + String dateInfo = ''; + if (type != 2) { + dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}'; + } + + // Récupérer le nom du passage (si le type n'est pas 6 - Maison vide) + String? nomInfo; + if (type != 6 && passageModel.name.isNotEmpty) { + nomInfo = passageModel.name; + } + + // Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot) + Widget? reglementInfo; + if (type == 1 || type == 5) { + final int typeReglementId = passageModel.fkTypeReglement; + final String montant = passageModel.montant; + + // Récupérer les informations du type de règlement + if (AppKeys.typesReglements.containsKey(typeReglementId)) { + final Map typeReglement = + AppKeys.typesReglements[typeReglementId]!; + final String titre = typeReglement['titre'] as String; + final Color couleur = Color(typeReglement['couleur'] as int); + final IconData iconData = typeReglement['icon_data'] as IconData; + + reglementInfo = Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + Icon(iconData, color: couleur, size: 20), + const SizedBox(width: 8), + Text('$titre: $montant €', + style: + TextStyle(color: couleur, fontWeight: FontWeight.bold)), + ], + ), + ); + } + } + + // Afficher une bulle d'information + showDialog( + context: context, + builder: (context) => AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Adresse: $adresse'), + if (residenceInfo != null) ...[ + const SizedBox(height: 4), + Text(residenceInfo) + ], + if (etageInfo != null) ...[ + const SizedBox(height: 4), + Text(etageInfo) + ], + if (apptInfo != null) ...[ + const SizedBox(height: 4), + Text(apptInfo) + ], + if (dateInfo.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(dateInfo) + ], + if (nomInfo != null) ...[ + const SizedBox(height: 8), + Text('Nom: $nomInfo') + ], + if (reglementInfo != null) reglementInfo, + ], + ), + actionsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // Bouton d'édition + IconButton( + onPressed: () { + Navigator.of(context).pop(); + // Logique pour éditer le passage + debugPrint('Éditer le passage ${passageModel.id}'); + }, + icon: const Icon(Icons.edit), + color: Colors.blue, + tooltip: 'Modifier', + ), + + // Bouton de suppression + IconButton( + onPressed: () { + Navigator.of(context).pop(); + // Logique pour supprimer le passage + debugPrint('Supprimer le passage ${passageModel.id}'); + }, + icon: const Icon(Icons.delete), + color: Colors.red, + tooltip: 'Supprimer', + ), + ], + ), + + // Bouton de fermeture + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ], + ), + ); + } + + // Formater une date + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + // Widget pour les boutons d'action + Widget _buildActionButton({ + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + Color color = Colors.blue, + }) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: FloatingActionButton( + heroTag: tooltip, // Nécessaire pour éviter les conflits de hero tags + onPressed: onPressed, + backgroundColor: onPressed != null ? color : Colors.grey, + tooltip: tooltip, + mini: true, + child: Icon(icon), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Carte MapBox + MapboxMap( + initialPosition: _currentPosition, + initialZoom: _currentZoom, + mapController: _mapController, + markers: _buildMarkers(), + polygons: _buildPolygons(), + showControls: true, + onMapEvent: (event) { + if (event is MapEventMove) { + setState(() { + _currentPosition = event.camera.center; + _currentZoom = event.camera.zoom; + }); + _saveSettings(); + } + }, + ), + + // Bouton Mode édition en haut à droite + Positioned( + right: 16, + top: 16, + child: _buildActionButton( + icon: Icons.edit, + tooltip: 'Mode édition', + color: _editMode ? Colors.green : Colors.blue, + onPressed: () { + setState(() { + _editMode = !_editMode; + }); + }, + ), + ), + + // Boutons d'action sous le bouton Mode édition + Positioned( + right: 16, + top: 80, // Positionner sous le bouton Mode édition + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (_editMode) ...[ + _buildActionButton( + icon: Icons.add, + tooltip: 'Ajouter un secteur', + onPressed: () { + // Action pour ajouter un secteur + }, + ), + const SizedBox(height: 8), + _buildActionButton( + icon: Icons.edit, + tooltip: 'Modifier le secteur sélectionné', + onPressed: _selectedSectorId != null + ? () { + // Action pour modifier le secteur sélectionné + } + : null, + ), + const SizedBox(height: 8), + _buildActionButton( + icon: Icons.delete, + tooltip: 'Supprimer le secteur sélectionné', + color: Colors.red, + onPressed: _selectedSectorId != null + ? () { + // Action pour supprimer le secteur sélectionné + } + : null, + ), + const SizedBox(height: 16), + ], + ], + ), + ), + + // Bouton Ma position en bas à droite + Positioned( + right: 16, + bottom: 16, + child: _buildActionButton( + icon: Icons.my_location, + tooltip: 'Ma position', + onPressed: () { + _getUserLocation(); + }, + ), + ), + + // Combobox de sélection de secteurs + Positioned( + left: 16, + top: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + width: 220, // Largeur fixe pour accommoder les noms longs + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.95), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_on, size: 18, color: Colors.blue), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedSectorId, + hint: const Text('Tous les secteurs'), + isExpanded: true, + underline: Container(), // Supprimer la ligne sous le dropdown + icon: Icon(Icons.arrow_drop_down, color: Colors.blue), + items: _sectorItems, + onChanged: (int? sectorId) { + setState(() { + _selectedSectorId = sectorId; + }); + + if (sectorId != null) { + _centerMapOnSpecificSector(sectorId); + } else { + // Si "Tous les secteurs" est sélectionné + _centerMapOnSectors(); + // Recharger tous les passages sans filtrage par secteur + _loadPassages(); + } + }, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/flutt/lib/presentation/admin/admin_statistics_page.dart b/flutt/lib/presentation/admin/admin_statistics_page.dart new file mode 100644 index 00000000..bdc21efa --- /dev/null +++ b/flutt/lib/presentation/admin/admin_statistics_page.dart @@ -0,0 +1,529 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_data.dart'; +import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import '../../shared/app_theme.dart'; + +class AdminStatisticsPage extends StatefulWidget { + const AdminStatisticsPage({Key? key}) : super(key: key); + + @override + State createState() => _AdminStatisticsPageState(); +} + +class _AdminStatisticsPageState extends State { + // Filtres + String _selectedPeriod = 'Jour'; + String _selectedFilterType = 'Secteur'; + String _selectedSector = 'Tous'; + String _selectedUser = 'Tous'; + int _daysToShow = 15; + + // Liste des périodes et types de filtre + final List _periods = ['Jour', 'Semaine', 'Mois', 'Année']; + final List _filterTypes = ['Secteur', 'Membre']; + + // Données simulées pour les secteurs et membres (à remplacer par des données réelles) + final List _sectors = [ + 'Tous', + 'Secteur Nord', + 'Secteur Sud', + 'Secteur Est', + 'Secteur Ouest' + ]; + final List _members = [ + 'Tous', + 'Jean Dupont', + 'Marie Martin', + 'Pierre Legrand', + 'Sophie Petit', + 'Lucas Moreau' + ]; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth > 800; + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppTheme.spacingL), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre et description + Text( + 'Analyse des statistiques', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingS), + Text( + 'Visualisez les statistiques de passages et de collecte pour votre amicale.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: AppTheme.spacingL), + + // Filtres + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filtres', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingM), + isDesktop + ? Row( + children: [ + Expanded(child: _buildPeriodDropdown()), + const SizedBox(width: AppTheme.spacingM), + Expanded(child: _buildDaysDropdown()), + const SizedBox(width: AppTheme.spacingM), + Expanded(child: _buildFilterTypeDropdown()), + const SizedBox(width: AppTheme.spacingM), + Expanded(child: _buildFilterDropdown()), + ], + ) + : Column( + children: [ + _buildPeriodDropdown(), + const SizedBox(height: AppTheme.spacingM), + _buildDaysDropdown(), + const SizedBox(height: AppTheme.spacingM), + _buildFilterTypeDropdown(), + const SizedBox(height: AppTheme.spacingM), + _buildFilterDropdown(), + ], + ), + ], + ), + ), + ), + const SizedBox(height: AppTheme.spacingL), + + // Graphique d'activité principal + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Évolution des passages', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingM), + ActivityChart( + height: 350, + loadFromHive: true, + showAllPassages: true, + title: '', + daysToShow: _daysToShow, + periodType: _selectedPeriod, + userId: _selectedUser != 'Tous' + ? _getUserIdFromName(_selectedUser) + : null, + // Si on filtre par secteur, on devrait passer l'ID du secteur + ), + ], + ), + ), + ), + const SizedBox(height: AppTheme.spacingL), + + // Graphiques de répartition + isDesktop + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildChartCard( + 'Répartition par type de passage', + PassagePieChart( + size: 300, + loadFromHive: true, + showAllPassages: true, + userId: _selectedUser != 'Tous' + ? _getUserIdFromName(_selectedUser) + : null, + ), + ), + ), + const SizedBox(width: AppTheme.spacingM), + Expanded( + child: _buildChartCard( + 'Répartition par mode de paiement', + PaymentPieChart( + payments: [ + PaymentData( + typeId: 1, + amount: 1500.0, + color: const Color(0xFFFFC107), + icon: Icons.toll, + title: 'Espèce', + ), + PaymentData( + typeId: 2, + amount: 2500.0, + color: const Color(0xFF8BC34A), + icon: Icons.wallet, + title: 'Chèque', + ), + PaymentData( + typeId: 3, + amount: 1000.0, + color: const Color(0xFF00B0FF), + icon: Icons.credit_card, + title: 'CB', + ), + ], + size: 300, + ), + ), + ), + ], + ) + : Column( + children: [ + _buildChartCard( + 'Répartition par type de passage', + PassagePieChart( + size: 300, + loadFromHive: true, + showAllPassages: true, + userId: _selectedUser != 'Tous' + ? _getUserIdFromName(_selectedUser) + : null, + ), + ), + const SizedBox(height: AppTheme.spacingM), + _buildChartCard( + 'Répartition par mode de paiement', + PaymentPieChart( + payments: [ + PaymentData( + typeId: 1, + amount: 1500.0, + color: const Color(0xFFFFC107), + icon: Icons.toll, + title: 'Espèce', + ), + PaymentData( + typeId: 2, + amount: 2500.0, + color: const Color(0xFF8BC34A), + icon: Icons.wallet, + title: 'Chèque', + ), + PaymentData( + typeId: 3, + amount: 1000.0, + color: const Color(0xFF00B0FF), + icon: Icons.credit_card, + title: 'CB', + ), + ], + size: 300, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingL), + + // Graphique combiné (si disponible) + _buildChartCard( + 'Comparaison passages/montants', + const SizedBox( + height: 350, + child: Center( + child: Text('Graphique combiné à implémenter'), + ), + ), + ), + + const SizedBox(height: AppTheme.spacingL), + + // Actions + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Actions', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingM), + Wrap( + spacing: AppTheme.spacingM, + runSpacing: AppTheme.spacingM, + children: [ + ElevatedButton.icon( + onPressed: () { + // Exporter les statistiques + }, + icon: const Icon(Icons.file_download), + label: const Text('Exporter les statistiques'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ElevatedButton.icon( + onPressed: () { + // Imprimer les statistiques + }, + icon: const Icon(Icons.print), + label: const Text('Imprimer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.buttonSecondaryColor, + foregroundColor: Colors.white, + ), + ), + ElevatedButton.icon( + onPressed: () { + // Partager les statistiques + }, + icon: const Icon(Icons.share), + label: const Text('Partager'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + // Dropdown pour la période + Widget _buildPeriodDropdown() { + return InputDecorator( + decoration: InputDecoration( + labelText: 'Période', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingM, + vertical: AppTheme.spacingS, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedPeriod, + isDense: true, + isExpanded: true, + items: _periods.map((String period) { + return DropdownMenuItem( + value: period, + child: Text(period), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedPeriod = newValue; + }); + } + }, + ), + ), + ); + } + + // Dropdown pour le nombre de jours + Widget _buildDaysDropdown() { + return InputDecorator( + decoration: InputDecoration( + labelText: 'Nombre de jours', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingM, + vertical: AppTheme.spacingS, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _daysToShow, + isDense: true, + isExpanded: true, + items: [7, 15, 30, 60, 90, 180, 365].map((int days) { + return DropdownMenuItem( + value: days, + child: Text('$days jours'), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _daysToShow = newValue; + }); + } + }, + ), + ), + ); + } + + // Dropdown pour le type de filtre + Widget _buildFilterTypeDropdown() { + return InputDecorator( + decoration: InputDecoration( + labelText: 'Filtrer par', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingM, + vertical: AppTheme.spacingS, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedFilterType, + isDense: true, + isExpanded: true, + items: _filterTypes.map((String type) { + return DropdownMenuItem( + value: type, + child: Text(type), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedFilterType = newValue; + // Réinitialiser les filtres spécifiques + _selectedSector = 'Tous'; + _selectedUser = 'Tous'; + }); + } + }, + ), + ), + ); + } + + // Dropdown pour le filtre spécifique (secteur ou membre) + Widget _buildFilterDropdown() { + final List items = + _selectedFilterType == 'Secteur' ? _sectors : _members; + final String value = + _selectedFilterType == 'Secteur' ? _selectedSector : _selectedUser; + + return InputDecorator( + decoration: InputDecoration( + labelText: _selectedFilterType, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingM, + vertical: AppTheme.spacingS, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: items.map((String item) { + return DropdownMenuItem( + value: item, + child: Text(item), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + if (_selectedFilterType == 'Secteur') { + _selectedSector = newValue; + } else { + _selectedUser = newValue; + } + }); + } + }, + ), + ), + ); + } + + // Widget pour envelopper un graphique dans une carte + Widget _buildChartCard(String title, Widget chart) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + ), + child: Padding( + padding: const EdgeInsets.all(AppTheme.spacingM), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppTheme.spacingM), + chart, + ], + ), + ), + ); + } + + // Méthode utilitaire pour obtenir l'ID utilisateur à partir de son nom + int? _getUserIdFromName(String name) { + // Dans un cas réel, cela nécessiterait une requête au repository + // Pour l'exemple, on utilise une correspondance simple + if (name == 'Jean Dupont') return 1; + if (name == 'Marie Martin') return 2; + if (name == 'Pierre Legrand') return 3; + if (name == 'Sophie Petit') return 4; + if (name == 'Lucas Moreau') return 5; + return null; + } +} diff --git a/flutt/lib/presentation/auth/login_page.dart b/flutt/lib/presentation/auth/login_page.dart new file mode 100644 index 00000000..a336bcef --- /dev/null +++ b/flutt/lib/presentation/auth/login_page.dart @@ -0,0 +1,640 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:go_router/go_router.dart'; +import 'package:go_router/src/state.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/presentation/widgets/custom_button.dart'; +import 'package:geosector_app/presentation/widgets/custom_text_field.dart'; +import 'package:geosector_app/core/services/location_service.dart'; +import 'package:geosector_app/core/services/connectivity_service.dart'; +import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart'; +import 'package:geosector_app/core/services/auth_service.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales + +class LoginPage extends StatefulWidget { + final String? loginType; + + const LoginPage({super.key, this.loginType}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _usernameFocusNode = FocusNode(); + bool _obscurePassword = true; + + // Type de connexion (utilisateur ou administrateur) + String? _loginType; + + // État des permissions de géolocalisation + bool _checkingPermission = true; + bool _hasLocationPermission = false; + String? _locationErrorMessage; + + // État de la connexion Internet + bool _isConnected = false; + + @override + void initState() { + super.initState(); + + // Récupérer le type de connexion depuis les paramètres du widget + _loginType = widget.loginType ?? 'admin'; // Par défaut admin + print('DEBUG: LoginType initial depuis widget: $_loginType'); + + // Vérifier explicitement si le type est 'user' + if (_loginType != null && _loginType!.trim().toLowerCase() == 'user') { + _loginType = 'user'; + print('DEBUG: LoginType confirmé comme user'); + } else { + _loginType = 'admin'; + print('DEBUG: LoginType confirmé comme admin'); + } + + // Vérifier les permissions de géolocalisation au démarrage seulement sur mobile + if (!kIsWeb) { + _checkLocationPermission(); + } else { + // En version web, on considère que les permissions sont accordées + setState(() { + _checkingPermission = false; + _hasLocationPermission = true; + }); + } + + // Initialiser l'état de la connexion + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isConnected = connectivityService.isConnected; + }); + } + }); + + // Pré-remplir le champ username avec l'identifiant du dernier utilisateur connecté + WidgetsBinding.instance.addPostFrameCallback((_) { + final users = userRepository.getAllUsers(); + + if (users.isNotEmpty) { + // Trouver l'utilisateur le plus récent (celui avec la date de dernière connexion la plus récente) + users.sort((a, b) => (b.lastSyncedAt).compareTo(a.lastSyncedAt)); + final lastUser = users.first; + + // Utiliser le username s'il existe, sinon utiliser l'email comme fallback + if (lastUser.username != null && lastUser.username!.isNotEmpty) { + _usernameController.text = lastUser.username!; + // Déplacer le focus sur le champ mot de passe puisque le username est déjà rempli + _usernameFocusNode.unfocus(); + } else if (lastUser.email.isNotEmpty) { + _usernameController.text = lastUser.email; + _usernameFocusNode.unfocus(); + } + } + }); + } + + /// Vérifie les permissions de géolocalisation + Future _checkLocationPermission() async { + // Ne pas vérifier les permissions en version web + if (kIsWeb) { + setState(() { + _hasLocationPermission = true; + _checkingPermission = false; + }); + return; + } + + setState(() { + _checkingPermission = true; + }); + + // Vérifier si les services de localisation sont activés et si l'application a la permission + final hasPermission = await LocationService.checkAndRequestPermission(); + final errorMessage = await LocationService.getLocationErrorMessage(); + + setState(() { + _hasLocationPermission = hasPermission; + _locationErrorMessage = errorMessage; + _checkingPermission = false; + }); + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _usernameFocusNode.dispose(); + super.dispose(); + } + + /// Construit l'écran de chargement pendant la vérification des permissions + Widget _buildLoadingScreen(ThemeData theme) { + return Scaffold( + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/geosector-logo-200.png', + height: 140, + fit: BoxFit.contain, + ), + const SizedBox(height: 32), + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + 'Vérification des permissions...', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } + + /// Construit l'écran de demande de permission de géolocalisation + Widget _buildLocationPermissionScreen(ThemeData theme) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo et titre + Image.asset( + 'assets/images/geosector-logo-200.png', + height: 140, + fit: BoxFit.contain, + ), + const SizedBox(height: 24), + Text( + 'Accès à la localisation requis', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + + // Message d'erreur + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.error.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon( + Icons.location_disabled, + color: theme.colorScheme.error, + size: 48, + ), + const SizedBox(height: 16), + Text( + _locationErrorMessage ?? + 'L\'accès à la localisation est nécessaire pour utiliser cette application.', + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Cette application utilise votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. Sans cette permission, l\'application ne peut pas fonctionner correctement.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 32), + + // Instructions pour activer la localisation + Text( + 'Comment activer la localisation :', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + _buildInstructionStep( + theme, 1, 'Ouvrez les paramètres de votre appareil'), + _buildInstructionStep(theme, 2, + 'Accédez aux paramètres de confidentialité ou de localisation'), + _buildInstructionStep(theme, 3, + 'Recherchez GEOSECTOR dans la liste des applications'), + _buildInstructionStep(theme, 4, + 'Activez l\'accès à la localisation pour cette application'), + const SizedBox(height: 32), + + // Boutons d'action + CustomButton( + onPressed: () async { + // Ouvrir les paramètres de l'application + await LocationService.openAppSettings(); + }, + text: 'Ouvrir les paramètres de l\'application', + icon: Icons.settings, + ), + const SizedBox(height: 16), + CustomButton( + onPressed: () async { + // Ouvrir les paramètres de localisation + await LocationService.openLocationSettings(); + }, + text: 'Ouvrir les paramètres de localisation', + icon: Icons.location_on, + backgroundColor: theme.colorScheme.secondary, + ), + const SizedBox(height: 16), + CustomButton( + onPressed: () { + // Vérifier à nouveau les permissions + _checkLocationPermission(); + }, + text: 'Vérifier à nouveau', + icon: Icons.refresh, + backgroundColor: theme.colorScheme.tertiary, + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Construit une étape d'instruction pour activer la localisation + Widget _buildInstructionStep( + ThemeData theme, int stepNumber, String instruction) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '$stepNumber', + style: TextStyle( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + instruction, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + print('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType'); + + // Utiliser l'instance globale de userRepository + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + + // Afficher l'écran de permission de géolocalisation si l'utilisateur n'a pas accordé la permission (sauf en version web) + if (!kIsWeb && _checkingPermission) { + return _buildLoadingScreen(theme); + } else if (!kIsWeb && !_hasLocationPermission) { + return _buildLocationPermissionScreen(theme); + } + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo et titre + Image.asset( + 'assets/images/geosector-logo-200.png', + height: 140, + fit: BoxFit.contain, + ), + const SizedBox(height: 24), + Text( + (_loginType != null && + _loginType!.trim().toLowerCase() == 'user') + ? 'Connexion Utilisateur' + : 'Connexion Administrateur', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: (_loginType != null && + _loginType!.trim().toLowerCase() == 'user') + ? Colors.green + : Colors.red, + ), + textAlign: TextAlign.center, + ), + // Ajouter un texte de débogage + Text( + 'Type de connexion détecté: $_loginType', + style: TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Bienvenue sur GEOSECTOR', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onBackground.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Indicateur de connectivité + ConnectivityIndicator(), + const SizedBox(height: 16), + + // Formulaire de connexion + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + controller: _usernameController, + label: 'Identifiant', + hintText: 'Entrez votre identifiant', + prefixIcon: Icons.person_outline, + keyboardType: TextInputType.text, + autofocus: true, + focusNode: _usernameFocusNode, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre identifiant'; + } + return null; + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _passwordController, + label: 'Mot de passe', + hintText: 'Entrez votre mot de passe', + prefixIcon: Icons.lock_outline, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre mot de passe'; + } + return null; + }, + onFieldSubmitted: (_) async { + if (!userRepository.isLoading && + _formKey.currentState!.validate()) { + // S'assurer que le type est toujours défini + final loginType = _loginType ?? 'admin'; + final actualType = + (loginType.trim().toLowerCase() == 'user') + ? 'user' + : 'admin'; + print('DEBUG: Login avec type: $actualType'); + + final success = await userRepository.login( + _usernameController.text.trim(), + _passwordController.text, + type: actualType, + ); + + if (success && mounted) { + if (userRepository.isAdmin()) { + context.go('/admin'); + } else { + context.go('/user'); + } + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Échec de la connexion. Vérifiez vos identifiants.'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + ), + const SizedBox(height: 8), + + // Mot de passe oublié + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + // Naviguer vers la page de récupération de mot de passe + }, + child: Text( + 'Mot de passe oublié ?', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 24), + + // Bouton de connexion + CustomButton( + onPressed: (userRepository.isLoading || !_isConnected) + ? null + : () async { + if (_formKey.currentState!.validate()) { + // Vérifier à nouveau les permissions de géolocalisation avant de se connecter (sauf en version web) + if (!kIsWeb) { + await _checkLocationPermission(); + + // Si l'utilisateur n'a toujours pas accordé les permissions, ne pas continuer + if (!_hasLocationPermission) { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'L\'accès à la localisation est nécessaire pour utiliser cette application.'), + backgroundColor: Colors.red, + ), + ); + return; + } + } + + // Vérifier la connexion Internet + await connectivityService + .checkConnectivity(); + + if (!connectivityService.isConnected) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: const Text( + 'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'), + backgroundColor: + theme.colorScheme.error, + duration: const Duration(seconds: 3), + action: SnackBarAction( + label: 'Réessayer', + onPressed: () async { + await connectivityService + .checkConnectivity(); + if (connectivityService + .isConnected && + mounted) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Connexion Internet ${connectivityService.connectionType} détectée.'), + backgroundColor: + Colors.green, + ), + ); + } + }, + ), + ), + ); + return; + } + + // S'assurer que le type est toujours défini + final loginType = _loginType ?? 'admin'; + final actualType = + (loginType.trim().toLowerCase() == + 'user') + ? 'user' + : 'admin'; + print( + 'DEBUG: Login bouton avec type: $actualType'); + + // Utiliser le service d'authentification avec l'overlay de chargement + final authService = + AuthService(userRepository); + final success = await authService.login( + context, + _usernameController.text.trim(), + _passwordController.text, + type: actualType, + ); + + if (success && mounted) { + if (userRepository.isAdmin()) { + context.go('/admin'); + } else { + context.go('/user'); + } + } else if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Échec de la connexion. Vérifiez vos identifiants.'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + text: _isConnected + ? 'Se connecter' + : 'Connexion Internet requise', + isLoading: userRepository.isLoading, + ), + const SizedBox(height: 24), + + // Inscription administrateur uniquement + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Pas encore de compte ?', + style: theme.textTheme.bodyMedium, + ), + TextButton( + onPressed: () { + context.go('/register'); + }, + child: Text( + 'Inscription Administrateur', + style: TextStyle( + color: theme.colorScheme.tertiary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + // Lien vers la page publique + TextButton( + onPressed: () { + context.go('/public'); + }, + child: Text( + 'Retour au site GEOSECTOR', + style: TextStyle( + color: theme.colorScheme.secondary, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/auth/register_page.dart b/flutt/lib/presentation/auth/register_page.dart new file mode 100644 index 00000000..20090fcb --- /dev/null +++ b/flutt/lib/presentation/auth/register_page.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/presentation/widgets/custom_button.dart'; +import 'package:geosector_app/presentation/widgets/custom_text_field.dart'; +import 'package:geosector_app/core/services/connectivity_service.dart'; +import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _amicaleNameController = TextEditingController(); + final _postalCodeController = TextEditingController(); + final _cityNameController = TextEditingController(); + final _emailController = TextEditingController(); + + // État de la connexion Internet + bool _isConnected = false; + + @override + void initState() { + super.initState(); + + // Initialiser l'état de la connexion + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + // Utiliser l'instance globale de connectivityService définie dans app.dart + setState(() { + _isConnected = connectivityService.isConnected; + }); + } + }); + } + + @override + void dispose() { + _nameController.dispose(); + _amicaleNameController.dispose(); + _postalCodeController.dispose(); + _cityNameController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Utiliser l'instance globale de userRepository définie dans app.dart + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo et titre + Image.asset( + 'assets/images/geosector-logo-200.png', + height: 140, + fit: BoxFit.contain, + ), + const SizedBox(height: 16), + Text( + 'Inscription Administrateur', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enregistrez votre amicale sur GeoSector', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onBackground.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Indicateur de connectivité + ConnectivityIndicator( + onConnectivityChanged: (isConnected) { + if (mounted && _isConnected != isConnected) { + setState(() { + _isConnected = isConnected; + }); + } + }, + ), + const SizedBox(height: 16), + + // Formulaire d'inscription + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + controller: _nameController, + label: 'Nom complet', + hintText: 'Entrez votre nom complet', + prefixIcon: Icons.person_outline, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre nom complet'; + } + return null; + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _emailController, + label: 'Email', + hintText: 'Entrez votre email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre email'; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value)) { + return 'Veuillez entrer un email valide'; + } + return null; + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _amicaleNameController, + label: 'Nom de l\'amicale', + hintText: 'Entrez le nom de votre amicale', + prefixIcon: Icons.local_fire_department, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer le nom de votre amicale'; + } + return null; + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _postalCodeController, + label: 'Code postal de l\'amicale', + hintText: 'Entrez le code postal de votre amicale', + prefixIcon: Icons.location_on_outlined, + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre code postal'; + } + if (!RegExp(r'^[0-9]{5}$').hasMatch(value)) { + return 'Le code postal doit contenir 5 chiffres'; + } + return null; + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _cityNameController, + label: 'Commune de l\'amicale', + hintText: + 'Entrez le nom de la commune de votre amicale', + prefixIcon: Icons.location_city_outlined, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer le nom de la commune de votre amicale'; + } + return null; + }, + ), + const SizedBox(height: 16), + + const SizedBox(height: 32), + + // Bouton d'inscription + CustomButton( + onPressed: (userRepository.isLoading || !_isConnected) + ? null + : () async { + if (_formKey.currentState!.validate()) { + // Vérifier la connexion Internet avant de soumettre + // Utiliser l'instance globale de connectivityService définie dans app.dart + await connectivityService + .checkConnectivity(); + + if (!connectivityService.isConnected) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: const Text( + 'Aucune connexion Internet. L\'inscription nécessite une connexion active.'), + backgroundColor: + theme.colorScheme.error, + duration: + const Duration(seconds: 3), + action: SnackBarAction( + label: 'Réessayer', + onPressed: () async { + await connectivityService + .checkConnectivity(); + if (connectivityService + .isConnected && + mounted) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Connexion Internet ${connectivityService.connectionType} détectée.'), + backgroundColor: + Colors.green, + ), + ); + } + }, + ), + ), + ); + } + return; + } + final success = + await userRepository.register( + _emailController.text.trim(), + '', // Mot de passe vide, sera généré par le serveur + _nameController.text.trim(), + _amicaleNameController.text.trim(), + _postalCodeController.text, + _cityNameController.text.trim(), + ); + + if (success && mounted) { + context.go('/user'); + } else if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Échec de l\'inscription. Veuillez réessayer.'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + text: _isConnected + ? 'Enregistrer mon amicale' + : 'Connexion Internet requise', + isLoading: userRepository.isLoading, + ), + const SizedBox(height: 24), + + // Déjà un compte + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Déjà un compte ?', + style: theme.textTheme.bodyMedium, + ), + TextButton( + onPressed: () { + context.go('/login'); + }, + child: Text( + 'Se connecter', + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + // Lien vers la page publique + TextButton( + onPressed: () { + context.go('/public'); + }, + child: Text( + 'Revenir sur le site GEOSECTOR', + style: TextStyle( + color: theme.colorScheme.secondary, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/auth/splash_page.dart b/flutt/lib/presentation/auth/splash_page.dart new file mode 100644 index 00000000..d0458ec5 --- /dev/null +++ b/flutt/lib/presentation/auth/splash_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'dart:async'; + +class SplashPage extends StatefulWidget { + const SplashPage({super.key}); + + @override + State createState() => _SplashPageState(); +} + +class _SplashPageState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + bool _isInitializing = true; + String _statusMessage = "Initialisation..."; + double _progress = 0.0; + + final List _initializationSteps = [ + "Initialisation des services...", + "Vérification de l'authentification...", + "Chargement des données...", + "Préparation de l'interface...", + "Démarrage de GeoSector..." + ]; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + // Simuler le processus d'initialisation + _startInitialization(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _startInitialization() async { + // Simuler les étapes d'initialisation + for (int i = 0; i < _initializationSteps.length; i++) { + if (mounted) { + setState(() { + _statusMessage = _initializationSteps[i]; + _progress = (i + 1) / _initializationSteps.length; + }); + } + // Attendre pour simuler le chargement + await Future.delayed(const Duration(milliseconds: 800)); + } + + if (mounted) { + setState(() { + _isInitializing = false; + }); + + // Lancer l'animation finale + _animationController.forward(); + + // Attendre la fin de l'animation avant de rediriger + Timer(const Duration(milliseconds: 1500), () { + _redirectToAppropriateScreen(); + }); + } + } + + void _redirectToAppropriateScreen() { + if (!mounted) return; + + // Utiliser l'instance globale de userRepository définie dans app.dart + if (userRepository.isLoggedIn) { + if (userRepository.isAdmin()) { + context.go('/admin'); + } else { + context.go('/user'); + } + } else { + context.go('/public'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + + return Scaffold( + body: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.colorScheme.primary, + theme.colorScheme.primary.withOpacity(0.8), + theme.colorScheme.secondary, + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo animé + AnimatedContainer( + duration: const Duration(milliseconds: 500), + height: _isInitializing ? size.height * 0.3 : size.height * 0.35, + child: AnimatedOpacity( + opacity: _isInitializing ? 0.8 : 1.0, + duration: const Duration(milliseconds: 500), + child: AnimatedScale( + scale: _isInitializing ? 0.9 : 1.0, + duration: const Duration(milliseconds: 800), + curve: Curves.elasticOut, + child: Image.asset( + 'assets/images/geosector-logo-200.png', + width: 150, + height: 150, + fit: BoxFit.contain, + ), + ), + ), + ), + + const SizedBox(height: 24), + + // Titre avec animation fade-in + AnimatedOpacity( + opacity: _isInitializing ? 0.9 : 1.0, + duration: const Duration(milliseconds: 500), + child: Text( + 'GeoSector', + style: theme.textTheme.headlineLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.3), + offset: const Offset(2, 2), + blurRadius: 4, + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Message de bienvenue + AnimatedOpacity( + opacity: _isInitializing ? 0.8 : 1.0, + duration: const Duration(milliseconds: 500), + child: Text( + 'Bienvenue sur GEOSECTOR', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 40), + + // Indicateur de chargement + if (_isInitializing) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.tertiary, + ), + minHeight: 6, + ), + ), + ), + const SizedBox(height: 16), + Text( + _statusMessage, + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + ), + ] else ...[ + // Animation de succès quand l'initialisation est terminée + ScaleTransition( + scale: CurvedAnimation( + parent: _animationController, curve: Curves.elasticOut), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.tertiary, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 40, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/public/landing_page.dart b/flutt/lib/presentation/public/landing_page.dart new file mode 100644 index 00000000..dfeca67d --- /dev/null +++ b/flutt/lib/presentation/public/landing_page.dart @@ -0,0 +1,1296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LandingPage extends StatefulWidget { + const LandingPage({super.key}); + + @override + State createState() => _LandingPageState(); +} + +class _LandingPageState extends State + with TickerProviderStateMixin { + final PageController _pageController = PageController(); + int _currentPage = 0; + + // Contrôleurs d'animation + late List _controllers; + late List> _opacityAnimations; + late List> _slideAnimations; + late List> _scaleAnimations; + + final List _features = [ + FeatureItem( + title: 'Gestion intuitive', + description: + 'Dessinez vos secteurs de distribution directement sur une carte interactive. Les adresses des habitants sont automatiquement intégrées pour une planification optimale.', + icon: Icons.map, + color: const Color(0xFF2E4057), // Couleur primaire + ), + FeatureItem( + title: 'Suivi en temps réel et traçabilité', + description: + 'Suivez en temps réel la progression des tournées grâce à un tableau de bord complet. Visualisez les passages effectués et les dons collectés pour une gestion efficace.', + icon: Icons.track_changes, + color: const Color(0xFF048BA8), // Couleur secondaire + ), + FeatureItem( + title: 'Génération automatique PDF', + description: + 'Générez des reçus au format PDF instantanément après chaque collecte. Envoyez-les automatiquement par email pour une transparence totale.', + icon: Icons.picture_as_pdf, + color: const Color(0xFFF18F01), // Couleur accent + ), + ]; + + @override + void initState() { + super.initState(); + + // Initialiser les contrôleurs d'animation + _controllers = List.generate( + _features.length, + (index) => AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ), + ); + + // Créer les animations + _opacityAnimations = _controllers + .map((controller) => Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: controller, curve: Curves.easeInOut))) + .toList(); + + _slideAnimations = _controllers + .map((controller) => + Tween(begin: const Offset(0, 0.2), end: Offset.zero) + .animate(CurvedAnimation( + parent: controller, curve: Curves.easeOutQuart))) + .toList(); + + _scaleAnimations = _controllers + .map((controller) => Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: controller, curve: Curves.easeOutBack))) + .toList(); + + // Démarrer les animations avec délai + _startAnimationsWithDelay(); + } + + void _startAnimationsWithDelay() { + for (int i = 0; i < _controllers.length; i++) { + Future.delayed(Duration(milliseconds: 300 * (i + 1)), () { + if (mounted) { + _controllers[i].forward(); + } + }); + } + } + + @override + void dispose() { + _pageController.dispose(); + for (var controller in _controllers) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenSize = MediaQuery.of(context).size; + bool isWebOrTablet = screenSize.width > 600; + + return Scaffold( + // Utilisation d'un ListView pour rendre toute la page défilable, y compris le footer + body: SafeArea( + child: ListView( + children: [ + // Header avec logo et navigation + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Logo + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'assets/images/geosector-logo-80.png', + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 8), + Text( + 'GEOSECTOR', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + + // Navigation + const SizedBox(width: 16), + // Boutons Contact/Connexion/Inscription + if (isWebOrTablet) + Row( + children: [ + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + ), + child: const Text('Contact'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () { + // Naviguer avec debug + print('DEBUG: Navigation vers login admin'); + // Utiliser directement les paramètres avec go + context.go('/login', extra: {'type': 'admin'}); + print('DEBUG: Navigation admin avec extra'); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + ), + child: const Text('Connexion Administrateur'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () { + // Naviguer avec debug + print('DEBUG: Navigation vers login user'); + // Utiliser directement les paramètres avec go + context.go('/login', extra: {'type': 'user'}); + print('DEBUG: Navigation user avec extra'); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.green, + side: const BorderSide(color: Colors.green), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + ), + child: const Text('Connexion Utilisateur'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + context.go('/register?from=landing'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF18F01), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('S\'inscrire'), + ), + ], + ) + else + Row( + children: [ + // Bouton de login administrateur + IconButton( + icon: const Icon(Icons.admin_panel_settings), + tooltip: 'Connexion Administrateur', + color: Colors.red, + onPressed: () { + print('DEBUG: Navigation mobile vers login admin'); + context.go('/login', extra: {'type': 'admin'}); + print('DEBUG: Navigation mobile admin avec extra'); + }, + ), + // Bouton de login utilisateur + IconButton( + icon: const Icon(Icons.person), + tooltip: 'Connexion Utilisateur', + color: Colors.green, + onPressed: () { + print('DEBUG: Navigation mobile vers login user'); + context.go('/login', extra: {'type': 'user'}); + print('DEBUG: Navigation mobile user avec extra'); + }, + ), + IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + Scaffold.of(context).openDrawer(); + }, + ), + ], + ), + ], + ), + ), + + // Contenu principal + if (isWebOrTablet) + _buildWebLayout(theme, screenSize) + else + _buildMobileLayout(theme, screenSize), + + // Footer + Container( + padding: const EdgeInsets.all(32), + color: const Color(0xFFF5F5F5), + child: Column( + children: [ + // Trois colonnes du footer + isWebOrTablet + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Première colonne - Coordonnées + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + margin: const EdgeInsets.only(right: 8), + child: Image.asset( + 'assets/images/geosector-logo-80.png', + fit: BoxFit.contain, + ), + ), + Text( + 'GEOSECTOR', + style: theme.textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Icon(Icons.location_on_outlined, + color: Colors.black87, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '16 Rue des marguerites, 56930 Pluméliau-Bieuzy', + style: theme.textTheme.bodyMedium + ?.copyWith(color: Colors.black87), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.phone_outlined, + color: Colors.black87, size: 20), + const SizedBox(width: 8), + Text( + '+33 7 69 09 17 06', + style: theme.textTheme.bodyMedium + ?.copyWith(color: Colors.black87), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.email_outlined, + color: Colors.black87, size: 20), + const SizedBox(width: 8), + Text( + 'contactgeosector@gmail.com', + style: theme.textTheme.bodyMedium + ?.copyWith(color: Colors.black87), + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () async { + final Uri url = Uri.parse( + 'https://www.facebook.com/geosector/'); + if (await canLaunchUrl(url)) { + await launchUrl(url, + mode: + LaunchMode.externalApplication); + } + }, + icon: const Icon(Icons.facebook), + label: + const Text('Suivez-nous sur Facebook'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1877F2), + side: const BorderSide( + color: Color(0xFF1877F2)), + ), + ), + ], + ), + ), + const SizedBox(width: 32), + // Deuxième colonne - Liens + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Téléchargement', + style: + theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.apple), + label: const Text('App Store'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.black, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.android), + label: const Text('Play Store'), + style: OutlinedButton.styleFrom( + foregroundColor: + const Color(0xFF3DDC84), + side: const BorderSide( + color: Color(0xFF3DDC84)), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + 'Informations légales', + style: + theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.gavel_outlined, + size: 18), + label: const Text('Mentions légales'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.privacy_tip_outlined, + size: 18), + label: const Text( + 'Politique de confidentialité'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.description_outlined, + size: 18), + label: + const Text('Conditions d\'utilisation'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + ), + const SizedBox(width: 32), + // Troisième colonne - Formulaire de contact + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contactez-nous', + style: + theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + hintText: 'Votre nom', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration( + hintText: 'Votre email', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 8), + TextField( + maxLines: 3, + decoration: InputDecoration( + hintText: 'Votre message', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.send), + label: const Text('Envoyer'), + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primary, + foregroundColor: Colors.white, + minimumSize: + const Size(double.infinity, 48), + ), + ), + ], + ), + ), + ], + ) + : Column( + // Version mobile du footer + children: [ + // Première colonne - Coordonnées + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'GEOSECTOR', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon(Icons.location_on_outlined, + color: theme.colorScheme.primary, + size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '123 Rue de la Distribution, 75000 Paris', + style: theme.textTheme.bodyMedium + ?.copyWith(color: Colors.black87), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.phone_outlined, + color: theme.colorScheme.primary, + size: 20), + const SizedBox(width: 8), + Text( + '+33 1 23 45 67 89', + style: theme.textTheme.bodyMedium, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.email_outlined, + color: theme.colorScheme.primary, + size: 20), + const SizedBox(width: 8), + Text( + 'contact@geosector.com', + style: theme.textTheme.bodyMedium, + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () async { + final Uri url = Uri.parse( + 'https://www.facebook.com/geosector/'); + if (await canLaunchUrl(url)) { + await launchUrl(url, + mode: LaunchMode.externalApplication); + } + }, + icon: const Icon(Icons.facebook), + label: const Text('Suivez-nous sur Facebook'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1877F2), + side: const BorderSide( + color: Color(0xFF1877F2)), + minimumSize: + const Size(double.infinity, 40), + ), + ), + ], + ), + const SizedBox(height: 32), + // Deuxième colonne - Liens + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Téléchargement', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.apple), + label: const Text('App Store'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.black, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.android), + label: const Text('Play Store'), + style: OutlinedButton.styleFrom( + foregroundColor: + const Color(0xFF3DDC84), + side: const BorderSide( + color: Color(0xFF3DDC84)), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + 'Informations légales', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.gavel_outlined, + size: 18), + label: const Text('Mentions légales'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.privacy_tip_outlined, + size: 18), + label: const Text( + 'Politique de confidentialité'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.description_outlined, + size: 18), + label: + const Text('Conditions d\'utilisation'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + alignment: Alignment.centerLeft, + ), + ), + ], + ), + const SizedBox(height: 32), + // Troisième colonne - Formulaire de contact + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contactez-nous', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + hintText: 'Votre nom', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration( + hintText: 'Votre email', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 8), + TextField( + maxLines: 3, + decoration: InputDecoration( + hintText: 'Votre message', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.send), + label: const Text('Envoyer'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + minimumSize: + const Size(double.infinity, 48), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 32), + // Copyright + Divider(color: Colors.black38), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '© ${DateTime.now().year} GEOSECTOR. Tous droits réservés. ', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.black87), + ), + InkWell( + onTap: () async { + // Ouvrir le lien vers D6SOFT + final Uri url = Uri.parse('https://d6soft.fr'); + if (await canLaunchUrl(url)) { + await launchUrl(url, + mode: LaunchMode.externalApplication); + } + }, + child: Text( + 'Conception D6SOFT', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + drawer: !isWebOrTablet + ? Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF2E4057), Color(0xFF048BA8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 40, + height: 40, + child: Image.asset( + 'assets/images/geosector-logo-80.png', + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 8), + Text( + 'GEOSECTOR', + style: theme.textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.star_outline), + title: const Text('Fonctionnalités'), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('À propos'), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.email_outlined), + title: const Text('Contact'), + onTap: () { + Navigator.pop(context); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.login), + title: const Text('Se connecter'), + onTap: () { + Navigator.pop(context); + context.go('/login', extra: {'type': 'admin'}); + print('DEBUG: Navigation drawer avec extra'); + }, + ), + ListTile( + leading: const Icon(Icons.person_add_outlined), + title: const Text('S\'inscrire'), + onTap: () { + Navigator.pop(context); + context.go('/register?from=landing'); + }, + ), + ], + ), + ) + : null, + ); + } + + Widget _buildWebLayout(ThemeData theme, Size screenSize) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Section d'en-tête + Container( + padding: const EdgeInsets.all(48), + decoration: BoxDecoration( + color: theme.colorScheme.background, + ), + child: Stack( + children: [ + // Fond de carte + Positioned.fill( + child: Opacity( + opacity: 0.1, + child: SvgPicture.asset( + 'assets/images/city-map-bg-fixed.svg', + fit: BoxFit.cover, + ), + ), + ), + // Contenu + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Suppression du second menu + const SizedBox(height: 80), + // Titre principal + Text( + 'Une application puissante et intuitive, pour une gestion efficace de vos distributions', + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + // Sous-titre + Text( + 'Simplifiez vos distributions, optimisez vos collectes.', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w300, + color: theme.colorScheme.onBackground.withOpacity(0.8), + ), + ), + const SizedBox(height: 32), + // Boutons d'action + Row( + children: [ + ElevatedButton.icon( + onPressed: () { + context.go('/register?from=landing'); + }, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Créer un compte gratuit'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF18F01), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + elevation: 2, + ), + ), + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.play_circle_outline), + label: const Text('Voir la démo'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + side: BorderSide( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ], + ), + ], + ), + ), + + // Section des fonctionnalités + Container( + padding: const EdgeInsets.all(48), + color: theme.colorScheme.surface, + child: Column( + children: [ + Text( + 'Fonctionnalités principales', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildFeatureItems(theme, true), + ), + ], + ), + ), + + // Appel à l'action + Container( + padding: const EdgeInsets.all(48), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF2E4057), Color(0xFF048BA8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Text( + 'Prêt à tester GEOSECTOR ?', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Rejoignez notre communauté et commencez à transformer vos distributions dès aujourd\'hui.', + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () { + context.go('/register?from=landing'); + }, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Créer un compte gratuitement'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF18F01), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 16), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMobileLayout(ThemeData theme, Size screenSize) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Section d'en-tête + Container( + padding: const EdgeInsets.all(24), + color: theme.colorScheme.background, + child: Column( + children: [ + Container( + width: 150, + height: 150, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + padding: const EdgeInsets.all(15), + child: Image.asset( + 'assets/images/geosector-logo.png', + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 24), + Text( + 'Une application puissante et intuitive, pour une gestion efficace de vos distributions', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Simplifiez vos distributions, optimisez vos collectes.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + context.go('/register?from=landing'); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('Commencer gratuitement'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF18F01), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + minimumSize: const Size(double.infinity, 48), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.play_circle_outline), + label: const Text('Voir la démo'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + minimumSize: const Size(double.infinity, 48), + ), + ), + ], + ), + ), + + // Section des fonctionnalités + Container( + padding: const EdgeInsets.all(24), + color: theme.colorScheme.surface, + child: Column( + children: [ + Text( + 'Fonctionnalités principales', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + height: 350, + child: PageView.builder( + controller: _pageController, + itemCount: _features.length, + onPageChanged: (int page) { + setState(() { + _currentPage = page; + }); + }, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _controllers[index], + builder: (context, child) { + return Opacity( + opacity: _opacityAnimations[index].value, + child: Transform.translate( + offset: Offset(0, + 20 * (1 - _slideAnimations[index].value.dy)), + child: Transform.scale( + scale: _scaleAnimations[index].value, + child: + _buildFeatureCard(_features[index], theme), + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _features.length, + (index) => Container( + width: 10, + height: 10, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentPage == index + ? theme.colorScheme.primary + : theme.colorScheme.primary.withOpacity(0.3), + ), + ), + ), + ), + ], + ), + ), + + // Appel à l'action + Container( + padding: const EdgeInsets.all(24), + color: theme.colorScheme.primary, + child: Column( + children: [ + Text( + 'Prêt à tester GEOSECTOR ?', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Rejoignez notre communauté dès aujourd\'hui.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + context.go('/register?from=landing'); + }, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Créer un compte'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + minimumSize: const Size(double.infinity, 48), + ), + ), + ], + ), + ), + ], + ), + ); + } + + List _buildFeatureItems(ThemeData theme, bool isWeb) { + return List.generate(_features.length, (index) { + return Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: AnimatedBuilder( + animation: _controllers[index], + builder: (context, child) { + return Opacity( + opacity: _opacityAnimations[index].value, + child: Transform.translate( + offset: + Offset(0, 20 * (1 - _slideAnimations[index].value.dy)), + child: Transform.scale( + scale: _scaleAnimations[index].value, + child: _buildFeatureCard(_features[index], theme), + ), + ), + ); + }, + ), + ), + ); + }); + } + + Widget _buildFeatureCard(FeatureItem feature, ThemeData theme) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: feature.color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + feature.icon, + size: 60, + color: feature.color, + ), + ), + const SizedBox(height: 16), + Text( + feature.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + feature.description, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class FeatureItem { + final String title; + final String description; + final IconData icon; + final Color color; + + FeatureItem({ + required this.title, + required this.description, + required this.icon, + required this.color, + }); +} diff --git a/flutt/lib/presentation/user/user_communication_page.dart b/flutt/lib/presentation/user/user_communication_page.dart new file mode 100644 index 00000000..e003618c --- /dev/null +++ b/flutt/lib/presentation/user/user_communication_page.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/chat/widgets/conversations_list.dart'; +import 'package:geosector_app/chat/widgets/chat_screen.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/chat/models/conversation_model.dart'; + +class UserCommunicationPage extends StatefulWidget { + const UserCommunicationPage({super.key}); + + @override + State createState() => _UserCommunicationPageState(); +} + +class _UserCommunicationPageState extends State { + String? _selectedConversationId; + late Box _conversationsBox; + bool _hasConversations = false; + + @override + void initState() { + super.initState(); + _checkConversations(); + } + + Future _checkConversations() async { + try { + _conversationsBox = Hive.box(AppKeys.chatConversationsBoxName); + setState(() { + _hasConversations = _conversationsBox.values.isNotEmpty; + }); + } catch (e) { + debugPrint('Erreur lors de la vérification des conversations: $e'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: Colors.transparent, + body: Container( + margin: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withOpacity(0.1), + blurRadius: 20, + spreadRadius: 1, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Column( + children: [ + // En-tête du chat + Container( + height: 70, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.05), + border: Border( + bottom: BorderSide( + color: theme.dividerColor.withOpacity(0.1), + width: 1, + ), + ), + ), + child: Row( + children: [ + Icon( + Icons.chat_bubble_outline, + color: theme.colorScheme.primary, + size: 26, + ), + const SizedBox(width: 12), + Text( + 'Messages d\'équipe', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + const Spacer(), + if (_hasConversations) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + '5 en ligne', + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.secondaryColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.add_circle_outline), + iconSize: 28, + color: theme.colorScheme.primary, + onPressed: () { + // TODO: Créer une nouvelle conversation + }, + ), + ], + ], + ), + ), + + // Contenu principal + Expanded( + child: _hasConversations + ? Row( + children: [ + // Liste des conversations (gauche) + Container( + width: 320, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + right: BorderSide( + color: theme.dividerColor.withOpacity(0.1), + width: 1, + ), + ), + ), + child: ConversationsList( + onConversationSelected: (conversation) { + setState(() { + // TODO: obtenir l'ID de la conversation à partir de l'objet conversation + _selectedConversationId = 'test-conversation-id'; + }); + }, + ), + ), + + // Zone de conversation (droite) + Expanded( + child: Container( + color: theme.colorScheme.surface, + child: _selectedConversationId != null + ? ChatScreen(conversationId: _selectedConversationId!) + : _buildEmptyState(theme), + ), + ), + ], + ) + : _buildNoConversationsMessage(theme), + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyState(ThemeData theme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 80, + color: theme.colorScheme.primary.withOpacity(0.3), + ), + const SizedBox(height: 24), + Text( + 'Sélectionnez une conversation', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.5), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Choisissez une conversation dans la liste\npour commencer à discuter', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.3), + ), + ), + ], + ), + ); + } + + Widget _buildNoConversationsMessage(ThemeData theme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.forum_outlined, + size: 100, + color: theme.colorScheme.primary.withOpacity(0.3), + ), + const SizedBox(height: 24), + Text( + 'Aucune conversation', + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'Vous n\'avez pas encore de conversations.\nCommencez une discussion avec votre équipe !', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () { + // TODO: Créer une nouvelle conversation + }, + icon: const Icon(Icons.add), + label: const Text('Démarrer une conversation'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + textStyle: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/user/user_dashboard_home_page.dart b/flutt/lib/presentation/user/user_dashboard_home_page.dart new file mode 100644 index 00000000..c6b7d9d5 --- /dev/null +++ b/flutt/lib/presentation/user/user_dashboard_home_page.dart @@ -0,0 +1,656 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; +import 'package:geosector_app/presentation/widgets/charts/charts.dart'; + +class UserDashboardHomePage extends StatefulWidget { + const UserDashboardHomePage({super.key}); + + @override + State createState() => _UserDashboardHomePageState(); +} + +class _UserDashboardHomePageState extends State { + // Formater une date au format JJ/MM/YYYY + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Utiliser l'instance globale définie dans app.dart + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tableau de bord', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + Builder(builder: (context) { + // Récupérer l'opération actuelle + final operation = userRepository.getCurrentOperation(); + if (operation != null) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + '${operation.name} (${_formatDate(operation.dateDebut)}-${_formatDate(operation.dateFin)})', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }), + const SizedBox(height: 24), + + // Synthèse des passages + _buildSummaryCards(isDesktop), + + const SizedBox(height: 24), + + // Graphique des passages + _buildPassagesChart(context, theme), + + const SizedBox(height: 24), + + // Derniers passages + _buildRecentPassages(context, theme), + ], + ), + ), + ), + ); + } + + // Construction des cartes de synthèse + Widget _buildSummaryCards(bool isDesktop) { + return Column( + children: [ + _buildCombinedPassagesCard(context, isDesktop), + const SizedBox(height: 16), + _buildCombinedPaymentsCard(isDesktop), + ], + ); + } + + // Construction d'une carte combinée pour les règlements (liste + graphique) + Widget _buildCombinedPaymentsCard(bool isDesktop) { + // Utiliser les instances globales définies dans app.dart + + // Récupérer l'utilisateur actuel + final currentUser = userRepository.getCurrentUser(); + final int? currentUserId = currentUser?.id; + + // Récupérer tous les passages + final passages = passageRepository.getAllPassages(); + + // Pas de log ici pour éviter les logs excessifs + + // Initialiser les montants par type de règlement + final Map paymentAmounts = { + 0: 0.0, // Pas de règlement + 1: 0.0, // Espèces + 2: 0.0, // Chèques + 3: 0.0, // CB + }; + + // Compteur pour les passages avec montant > 0 + int passagesWithPaymentCount = 0; + + // Parcourir les passages et calculer les montants par type de règlement + for (final passage in passages) { + // Vérifier si le passage appartient à l'utilisateur actuel + if (currentUserId != null && passage.fkUser == currentUserId) { + final int typeReglement = passage.fkTypeReglement; + + // Convertir la chaîne de montant en double + double montant = 0.0; + try { + // Gérer les formats possibles (virgule ou point) + String montantStr = passage.montant.replaceAll(',', '.'); + montant = double.tryParse(montantStr) ?? 0.0; + } catch (e) { + debugPrint('Erreur de conversion du montant: ${passage.montant}'); + } + + // Ne compter que les passages avec un montant > 0 + if (montant > 0) { + passagesWithPaymentCount++; + + // Ajouter au montant total par type de règlement + if (paymentAmounts.containsKey(typeReglement)) { + paymentAmounts[typeReglement] = + (paymentAmounts[typeReglement] ?? 0.0) + montant; + } else { + // Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (0: Pas de règlement) + paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant; + // Type de règlement inconnu, ajouté à la catégorie 'Pas de règlement' + } + } + } + } + + // Calculer le total des règlements + final double totalPayments = + paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount); + + // Convertir les montants en objets PaymentData pour le graphique + final List paymentDataList = + PaymentUtils.getPaymentDataFromAmounts(paymentAmounts); + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + // Symbole euro en arrière-plan + Positioned.fill( + child: Center( + child: Icon( + Icons.euro_symbol, + size: 180, + color: Colors.blue.withOpacity(0.07), // Bleuté et estompé + ), + ), + ), + // Contenu principal + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.payments, + color: AppTheme.accentColor, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Règlements sur $passagesWithPaymentCount passages', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + '${totalPayments.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.accentColor, + ), + ), + ], + ), + const Divider(height: 24), + SizedBox( + height: 250, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Liste des règlements (côté gauche) + Expanded( + flex: isDesktop ? 1 : 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...AppKeys.typesReglements.entries.map((entry) { + final int typeId = entry.key; + final Map typeData = entry.value; + final double amount = + paymentAmounts[typeId] ?? 0.0; + final Color color = + Color(typeData['couleur'] as int); + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + typeData['icon_data'] as IconData, + color: Colors.white, + size: 16, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + typeData['titre'] as String, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + Text( + '${amount.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + + // Séparateur vertical + if (isDesktop) const VerticalDivider(width: 24), + + // Graphique en camembert (côté droit) + Expanded( + flex: isDesktop ? 1 : 2, + child: PaymentPieChart( + payments: paymentDataList, + size: double + .infinity, // Utiliser tout l'espace disponible + labelSize: 12, + showPercentage: true, + showIcons: false, // Désactiver les icônes + showLegend: false, + isDonut: true, + innerRadius: '50%', + enable3DEffect: true, // Activer l'effet 3D + effect3DIntensity: + 1.5, // Intensité de l'effet 3D plus forte + enableEnhancedExplode: + true, // Activer l'effet d'explosion amélioré + useGradient: + true, // Utiliser des dégradés pour renforcer l'effet 3D + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Construction d'une carte combinée pour les passages (liste + graphique) + Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) { + // Utiliser les instances globales définies dans app.dart + + // Récupérer l'utilisateur actuel + final currentUser = userRepository.getCurrentUser(); + final int? currentUserId = currentUser?.id; + + // Récupérer tous les passages + final passages = passageRepository.getAllPassages(); + + // Pas de log ici pour éviter les logs excessifs + + // Compter les passages par type + final Map passagesCounts = { + 1: 0, // Effectués + 2: 0, // À finaliser + 3: 0, // Refusés + 4: 0, // Dons + 5: 0, // Lots + 6: 0, // Maisons vides + }; + + // Créer un map pour compter les types de passages + final Map typesCount = {}; + final Map userTypesCount = {}; + + // Parcourir les passages et les compter par type + for (final passage in passages) { + final typeId = passage.fkType; + final int passageUserId = passage.fkUser; + + // Compter les occurrences de chaque type pour le débogage + typesCount[typeId] = (typesCount[typeId] ?? 0) + 1; + + // Vérifier si le passage appartient à l'utilisateur actuel ou est de type 2 + bool shouldCount = typeId == 2 || + (currentUserId != null && passageUserId == currentUserId); + + if (shouldCount) { + // Compter pour les statistiques de l'utilisateur + userTypesCount[typeId] = (userTypesCount[typeId] ?? 0) + 1; + + // Ajouter au compteur des passages par type + if (passagesCounts.containsKey(typeId)) { + passagesCounts[typeId] = passagesCounts[typeId]! + 1; + } else { + // Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (2: À finaliser) + passagesCounts[2] = passagesCounts[2]! + 1; + // Type de passage inconnu ajouté à 'A finaliser' + } + } + } + + // Pas de log ici pour éviter les logs excessifs + + // Calculer le total des passages pour l'utilisateur (somme des valeurs dans userTypesCount) + final int totalUserPassages = + userTypesCount.values.fold(0, (sum, count) => sum + count); + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, + 8.0), // Réduire les paddings vertical pour donner plus d'espace au graphique + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.route, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Builder(builder: (context) { + // Récupérer les secteurs de l'utilisateur + final userSectors = userRepository.getUserSectors(); + final int sectorCount = userSectors.length; + + // Déterminer le titre en fonction du nombre de secteurs + String title = 'Passages'; + if (sectorCount > 1) { + title = 'Passages sur mes $sectorCount secteurs'; + } + + return Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ); + }), + ), + Text( + totalUserPassages.toString(), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ], + ), + const Divider(height: 24), + SizedBox( + height: 250, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Liste des passages (côté gauche) + Expanded( + flex: isDesktop ? 1 : 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...AppKeys.typesPassages.entries.map((entry) { + final int typeId = entry.key; + final Map typeData = entry.value; + final int count = passagesCounts[typeId] ?? 0; + final Color color = Color(typeData['couleur2'] + as int); // Utiliser la deuxième couleur + final IconData iconData = typeData['icon_data'] + as IconData; // Utiliser l'icône définie dans AppKeys + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + iconData, + color: Colors.white, + size: 16, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + typeData['titres'] as String, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + Text( + count.toString(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + + // Séparateur vertical + if (isDesktop) const VerticalDivider(width: 24), + + // Graphique en camembert (côté droit) + Expanded( + flex: isDesktop ? 1 : 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: PassagePieChart( + passagesByType: passagesCounts, + size: double + .infinity, // Utiliser tout l'espace disponible + labelSize: 12, + showPercentage: true, + showIcons: false, // Désactiver les icônes + showLegend: false, // Désactiver la légende + isDonut: true, // Activer le format donut + innerRadius: '50%' // Rayon interne du donut + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Construction du graphique des passages + Widget _buildPassagesChart(BuildContext context, ThemeData theme) { + // Définir les types de passages à exclure + // Selon la mémoire, le type 2 correspond aux passages "A finaliser" + // et nous voulons les exclure du comptage pour l'utilisateur actuel + final List excludePassageTypes = [2]; + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, + 8.0), // Réduire les paddings vertical pour donner plus d'espace + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre supprimé car déjà présent dans le widget ActivityChart + SizedBox( + height: + 350, // Augmentation de la hauteur à 350px pour résoudre le problème de l'axe Y + child: ActivityChart( + // Utiliser le chargement depuis Hive directement dans le widget + loadFromHive: true, + // Ne pas filtrer par utilisateur (afficher tous les passages) + showAllPassages: true, + // Exclure les passages de type 2 (A finaliser) + excludePassageTypes: excludePassageTypes, + // Afficher les 15 derniers jours + daysToShow: 15, + periodType: 'Jour', + height: + 350, // Augmentation de la hauteur à 350px aussi dans le widget + ), + ), + ], + ), + ), + ); + } + + // Construction de la liste des derniers passages + Widget _buildRecentPassages(BuildContext context, ThemeData theme) { + // Utiliser les instances globales définies dans app.dart + + // Récupérer tous les passages et les trier par date (les plus récents d'abord) + final allPassages = passageRepository.getAllPassages(); + allPassages.sort((a, b) => b.passedAt.compareTo(a.passedAt)); + + // Limiter aux 10 passages les plus récents + final recentPassagesModels = allPassages.take(10).toList(); + + // Convertir les modèles de passage au format attendu par le widget PassagesListWidget + final List> recentPassages = + recentPassagesModels.map((passage) { + // Construire l'adresse complète à partir des champs disponibles + final String address = + '${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}'; + + // Convertir le montant en double + final double amount = double.tryParse(passage.montant) ?? 0.0; + + return { + 'id': passage.id.toString(), + 'address': address, + 'amount': amount, + 'date': passage.passedAt, + 'type': passage.fkType, + 'payment': passage.fkTypeReglement, + 'name': passage.name, + 'notes': passage.remarque, + 'hasReceipt': passage.nomRecu.isNotEmpty, + 'hasError': passage.emailErreur.isNotEmpty, + 'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur + }; + }).toList(); + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Derniers passages', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () { + // Naviguer vers la page d'historique + }, + child: const Text('Voir tout'), + ), + ], + ), + ), + // Utilisation du widget commun PassagesListWidget + PassagesListWidget( + passages: recentPassages, + showFilters: false, + showSearch: false, + showActions: true, // Activer l'affichage des boutons d'action + maxPassages: 10, + // Exclure les passages de type 2 (À finaliser) + excludePassageTypes: [2], + // Filtrer par utilisateur courant + filterByUserId: userRepository.getCurrentUser()?.id, + // Période par défaut (derniers 15 jours) + periodFilter: 'last15', + onPassageSelected: (passage) { + // Action lors de la sélection d'un passage + debugPrint('Passage sélectionné: ${passage['id']}'); + }, + onDetailsView: (passage) { + // Action lors de l'affichage des détails + debugPrint('Affichage des détails: ${passage['id']}'); + }, + // Callback pour le bouton de modification + onPassageEdit: (passage) { + // Action lors de la modification d'un passage + debugPrint('Modification du passage: ${passage['id']}'); + // Ici, vous pourriez ouvrir un formulaire d'édition + }, + // Callback pour le bouton de reçu (uniquement pour les passages de type 1) + onReceiptView: (passage) { + // Action lors de la demande d'affichage du reçu + debugPrint('Affichage du reçu pour le passage: ${passage['id']}'); + // Ici, vous pourriez générer et afficher un PDF + }, + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/user/user_dashboard_page.dart b/flutt/lib/presentation/user/user_dashboard_page.dart new file mode 100644 index 00000000..8bb4573b --- /dev/null +++ b/flutt/lib/presentation/user/user_dashboard_page.dart @@ -0,0 +1,381 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/services/auth_service.dart'; +import 'package:geosector_app/presentation/widgets/dashboard_layout.dart'; + +// Import des pages utilisateur +import 'user_dashboard_home_page.dart'; +import 'user_statistics_page.dart'; +import 'user_history_page.dart'; +import 'user_communication_page.dart'; +import 'user_map_page.dart'; + +class UserDashboardPage extends StatefulWidget { + const UserDashboardPage({super.key}); + + @override + State createState() => _UserDashboardPageState(); +} + +class _UserDashboardPageState extends State { + int _selectedIndex = 0; + + // Liste des pages à afficher + late final List _pages; + + // Référence à la boîte Hive pour les paramètres + late Box _settingsBox; + + @override + void initState() { + super.initState(); + _pages = [ + const UserDashboardHomePage(), + const UserStatisticsPage(), + const UserHistoryPage(), + const UserCommunicationPage(), + const UserMapPage(), + ]; + + // Initialiser et charger les paramètres + _initSettings(); + } + + // Initialiser la boîte de paramètres et charger les préférences + Future _initSettings() async { + try { + // Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { + _settingsBox = await Hive.openBox(AppKeys.settingsBoxName); + } else { + _settingsBox = Hive.box(AppKeys.settingsBoxName); + } + + // Charger l'index de page sélectionné + final savedIndex = _settingsBox.get('selectedPageIndex'); + if (savedIndex != null && + savedIndex is int && + savedIndex >= 0 && + savedIndex < _pages.length) { + setState(() { + _selectedIndex = savedIndex; + }); + } + } catch (e) { + debugPrint('Erreur lors du chargement des paramètres: $e'); + } + } + + // Sauvegarder les paramètres utilisateur + void _saveSettings() { + try { + // Sauvegarder l'index de page sélectionné + _settingsBox.put('selectedPageIndex', _selectedIndex); + } catch (e) { + debugPrint('Erreur lors de la sauvegarde des paramètres: $e'); + } + } + + @override + Widget build(BuildContext context) { + // Utiliser l'instance globale définie dans app.dart + final hasOperation = userRepository.getCurrentOperation() != null; + final hasSectors = userRepository.getUserSectors().isNotEmpty; + final isStandardUser = userRepository.currentUser != null && + userRepository.currentUser!.role == + '1'; // Rôle 1 = utilisateur standard + + // Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial + final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation; + final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors; + + // Définir les actions supplémentaires pour l'AppBar + List? additionalActions; + if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) { + additionalActions = [ + // Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération + TextButton.icon( + icon: const Icon(Icons.logout, color: Colors.white), + label: const Text('Se déconnecter', + style: TextStyle(color: Colors.white)), + onPressed: () async { + final authService = AuthService(userRepository); + await authService.logout(context); + if (mounted) { + context.go('/login'); + } + }, + style: TextButton.styleFrom( + backgroundColor: AppTheme.accentColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + const SizedBox(width: 16), // Espacement à droite + ]; + } + + return shouldShowNoOperationMessage + ? _buildNoOperationMessage(context) + : (shouldShowNoSectorMessage + ? _buildNoSectorMessage(context) + : DashboardLayout( + title: 'GEOSECTOR', + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + _saveSettings(); // Sauvegarder l'index de page sélectionné + }); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Accueil', + ), + NavigationDestination( + icon: Icon(Icons.bar_chart_outlined), + selectedIcon: Icon(Icons.bar_chart), + label: 'Stats', + ), + NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'Historique', + ), + NavigationDestination( + icon: Icon(Icons.chat_outlined), + selectedIcon: Icon(Icons.chat), + label: 'Messages', + ), + NavigationDestination( + icon: Icon(Icons.map_outlined), + selectedIcon: Icon(Icons.map), + label: 'Carte', + ), + ], + additionalActions: additionalActions, + onNewPassagePressed: () => _showPassageForm(context), + body: _pages[_selectedIndex], + )); + } + + // Message pour les utilisateurs sans opération assignée + Widget _buildNoOperationMessage(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 500), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 80, + color: theme.colorScheme.error, + ), + const SizedBox(height: 24), + Text( + 'Aucune opération assignée', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // Message pour les utilisateurs sans secteur assigné + Widget _buildNoSectorMessage(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 500), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.map_outlined, + size: 80, + color: theme.colorScheme.error, + ), + const SizedBox(height: 24), + Text( + 'Aucun secteur assigné', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // Affiche le formulaire de passage + void _showPassageForm(BuildContext context) { + final theme = Theme.of(context); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + 'Nouveau passage', + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: InputDecoration( + labelText: 'Adresse', + prefixIcon: const Icon(Icons.location_on), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Type de passage', + prefixIcon: const Icon(Icons.category), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: const [ + DropdownMenuItem( + value: 1, + child: Text('Effectué'), + ), + DropdownMenuItem( + value: 2, + child: Text('À finaliser'), + ), + DropdownMenuItem( + value: 3, + child: Text('Refusé'), + ), + DropdownMenuItem( + value: 4, + child: Text('Don'), + ), + DropdownMenuItem( + value: 5, + child: Text('Lot'), + ), + DropdownMenuItem( + value: 6, + child: Text('Maison vide'), + ), + ], + onChanged: (value) {}, + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + labelText: 'Commentaire', + prefixIcon: const Icon(Icons.comment), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + 'Annuler', + style: TextStyle( + color: theme.colorScheme.error, + ), + ), + ), + ElevatedButton( + onPressed: () { + // Enregistrer le passage + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Passage enregistré avec succès'), + backgroundColor: theme.colorScheme.primary, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: const Text('Enregistrer'), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/user/user_history_page.dart b/flutt/lib/presentation/user/user_history_page.dart new file mode 100644 index 00000000..03b7a426 --- /dev/null +++ b/flutt/lib/presentation/user/user_history_page.dart @@ -0,0 +1,572 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; + +class UserHistoryPage extends StatefulWidget { + const UserHistoryPage({super.key}); + + @override + State createState() => _UserHistoryPageState(); +} + +class _UserHistoryPageState extends State { + // Liste qui contiendra les passages convertis + List> _convertedPassages = []; + + // Variables pour indiquer l'état de chargement + bool _isLoading = true; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + // Charger les passages depuis la box Hive au démarrage + _loadPassages(); + } + + // Méthode pour charger les passages depuis le repository + Future _loadPassages() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + // Utiliser l'instance globale définie dans app.dart + + // Utiliser la propriété passages qui gère déjà l'ouverture de la box + final List allPassages = passageRepository.passages; + + debugPrint('Nombre total de passages dans la box: ${allPassages.length}'); + + // Filtrer pour exclure les passages de type 2 + List filtered = []; + for (var passage in allPassages) { + try { + if (passage.fkType != 2) { + filtered.add(passage); + } + } catch (e) { + debugPrint('Erreur lors du filtrage du passage: $e'); + // Si nous ne pouvons pas accéder à fkType, ne pas ajouter ce passage + } + } + + debugPrint( + 'Nombre de passages après filtrage (fkType != 2): ${filtered.length}'); + + // Afficher la distribution des types de passages pour le débogage + final Map typeCount = {}; + for (var passage in filtered) { + typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1; + } + typeCount.forEach((type, count) { + debugPrint('Type de passage $type: $count passages'); + }); + + // Afficher la plage de dates pour le débogage + if (filtered.isNotEmpty) { + // Trier par date pour trouver min et max + final sortedByDate = List.from(filtered); + sortedByDate.sort((a, b) => a.passedAt.compareTo(b.passedAt)); + + final DateTime minDate = sortedByDate.first.passedAt; + final DateTime maxDate = sortedByDate.last.passedAt; + + // Log détaillé pour débogage + debugPrint( + 'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}'); + + // Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage + debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---'); + for (int i = 0; i < sortedByDate.length && i < 5; i++) { + final p = sortedByDate[i]; + debugPrint( + 'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}'); + } + + debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---'); + for (int i = sortedByDate.length - 1; + i >= 0 && i >= sortedByDate.length - 5; + i--) { + final p = sortedByDate[i]; + debugPrint( + 'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}'); + } + + // Vérifier la distribution des passages par mois + final Map monthCount = {}; + for (var passage in filtered) { + final String monthKey = + '${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}'; + monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1; + } + + debugPrint('\n--- DISTRIBUTION PAR MOIS ---'); + final sortedMonths = monthCount.keys.toList()..sort(); + for (var month in sortedMonths) { + debugPrint('$month: ${monthCount[month]} passages'); + } + } + + // Convertir les modèles en Maps pour l'affichage avec gestion d'erreurs + List> passagesMap = []; + for (var passage in filtered) { + try { + final Map passageMap = + _convertPassageModelToMap(passage); + if (passageMap != null) { + passagesMap.add(passageMap); + } + } catch (e) { + debugPrint('Erreur lors de la conversion du passage en map: $e'); + // Ignorer ce passage et continuer + } + } + + debugPrint('Nombre de passages après conversion: ${passagesMap.length}'); + + // Trier par date (plus récent en premier) avec gestion d'erreurs + try { + passagesMap.sort((a, b) { + try { + return (b['date'] as DateTime).compareTo(a['date'] as DateTime); + } catch (e) { + debugPrint('Erreur lors de la comparaison des dates: $e'); + return 0; // Garder l'ordre actuel en cas d'erreur + } + }); + } catch (e) { + debugPrint('Erreur lors du tri des passages: $e'); + // Continuer sans tri en cas d'erreur + } + + // Debug: vérifier la plage de dates après conversion et tri + if (passagesMap.isNotEmpty) { + debugPrint('\n--- PLAGE DE DATES APRÈS CONVERSION ET TRI ---'); + final firstDate = passagesMap.last['date'] as DateTime; + final lastDate = passagesMap.first['date'] as DateTime; + debugPrint('Premier passage: ${firstDate.toString()}'); + debugPrint('Dernier passage: ${lastDate.toString()}'); + } + + setState(() { + _convertedPassages = passagesMap; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Erreur lors du chargement des passages: $e'; + _isLoading = false; + }); + debugPrint(_errorMessage); + } + } + + // Convertir un modèle de passage en Map pour l'affichage avec gestion renforcée des erreurs + Map _convertPassageModelToMap(PassageModel passage) { + try { + // Le passage ne peut pas être null en Dart non-nullable, + // mais nous gardons cette structure pour faciliter la gestion des erreurs + + // Construire l'adresse complète avec gestion des erreurs + String address = 'Adresse non disponible'; + try { + address = _buildFullAddress(passage); + } catch (e) { + debugPrint('Erreur lors de la construction de l\'adresse: $e'); + } + + // Convertir le montant en double avec sécurité + double amount = 0.0; + try { + if (passage.montant.isNotEmpty) { + amount = double.parse(passage.montant); + } + } catch (e) { + debugPrint('Erreur de conversion du montant: ${passage.montant}: $e'); + } + + // Récupérer la date avec gestion d'erreur + DateTime date; + try { + date = passage.passedAt; + } catch (e) { + debugPrint('Erreur lors de la récupération de la date: $e'); + date = DateTime.now(); + } + + // Récupérer le type avec gestion d'erreur + int type; + try { + type = passage.fkType; + // Si le type n'est pas dans les types connus, utiliser 0 comme valeur par défaut + if (!AppKeys.typesPassages.containsKey(type)) { + type = 0; // Type inconnu + } + } catch (e) { + debugPrint('Erreur lors de la récupération du type: $e'); + type = 0; + } + + // Récupérer le type de règlement avec gestion d'erreur + int payment; + try { + payment = passage.fkTypeReglement; + // Si le type de règlement n'est pas dans les types connus, utiliser 0 comme valeur par défaut + if (!AppKeys.typesReglements.containsKey(payment)) { + payment = 0; // Type de règlement inconnu + } + } catch (e) { + debugPrint('Erreur lors de la récupération du type de règlement: $e'); + payment = 0; + } + + // Gérer les champs optionnels + String name = ''; + try { + name = passage.name; + } catch (e) { + debugPrint('Erreur lors de la récupération du nom: $e'); + } + + String notes = ''; + try { + notes = passage.remarque; + } catch (e) { + debugPrint('Erreur lors de la récupération des remarques: $e'); + } + + // Vérifier si un reçu est disponible avec gestion d'erreur + bool hasReceipt = false; + try { + hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty; + } catch (e) { + debugPrint('Erreur lors de la vérification du reçu: $e'); + } + + // Vérifier s'il y a une erreur avec gestion d'erreur + bool hasError = false; + try { + hasError = passage.emailErreur.isNotEmpty; + } catch (e) { + debugPrint('Erreur lors de la vérification des erreurs: $e'); + } + + // Log pour débogage + debugPrint( + 'Conversion passage ID: ${passage.id}, Type: $type, Date: $date'); + + return { + 'id': passage.id.toString(), + 'address': address, + 'amount': amount, + 'date': date, + 'type': type, + 'payment': payment, + 'name': name, + 'notes': notes, + 'hasReceipt': hasReceipt, + 'hasError': hasError, + 'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur + }; + } catch (e) { + debugPrint('ERREUR CRITIQUE lors de la conversion du passage: $e'); + // Retourner un objet valide par défaut pour éviter les erreurs + // Récupérer l'ID de l'utilisateur courant pour l'objet par défaut + // Utiliser l'instance globale définie dans app.dart + final currentUserId = userRepository.getCurrentUser()?.id; + + return { + 'id': 'error', + 'address': 'Adresse non disponible', + 'amount': 0.0, + 'date': DateTime.now(), + 'type': 0, + 'payment': 0, + 'name': 'Nom non disponible', + 'notes': '', + 'hasReceipt': false, + 'hasError': true, + 'fkUser': currentUserId, // Ajouter l'ID de l'utilisateur courant + }; + } + } + + // Construire l'adresse complète à partir des composants + String _buildFullAddress(PassageModel passage) { + final List addressParts = []; + + // Numéro et rue + if (passage.numero.isNotEmpty) { + addressParts.add('${passage.numero} ${passage.rue}'); + } else { + addressParts.add(passage.rue); + } + + // Complément rue bis + if (passage.rueBis.isNotEmpty) { + addressParts.add(passage.rueBis); + } + + // Résidence/Bâtiment + if (passage.residence.isNotEmpty) { + addressParts.add(passage.residence); + } + + // Appartement + if (passage.appt.isNotEmpty) { + addressParts.add('Appt ${passage.appt}'); + } + + // Niveau + if (passage.niveau.isNotEmpty) { + addressParts.add('Niveau ${passage.niveau}'); + } + + // Ville + if (passage.ville.isNotEmpty) { + addressParts.add(passage.ville); + } + + return addressParts.join(', '); + } + + @override + void dispose() { + super.dispose(); + } + + // Méthode pour afficher les détails d'un passage + void _showPassageDetails(Map passage) { + // Récupérer les informations du type de passage et du type de règlement + final typePassage = + AppKeys.typesPassages[passage['type']] as Map; + final typeReglement = + AppKeys.typesReglements[passage['payment']] as Map; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Détails du passage'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildDetailRow('Adresse', passage['address']), + _buildDetailRow('Nom', passage['name']), + _buildDetailRow('Date', + '${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'), + _buildDetailRow('Type', typePassage['titre']), + _buildDetailRow('Règlement', typeReglement['titre']), + _buildDetailRow('Montant', '${passage['amount']}€'), + if (passage['notes'] != null && + passage['notes'].toString().isNotEmpty) + _buildDetailRow('Notes', passage['notes']), + if (passage['hasReceipt'] == true) + _buildDetailRow('Reçu', 'Disponible'), + if (passage['hasError'] == true) + _buildDetailRow('Erreur', 'Détectée', isError: true), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Fermer'), + ), + if (passage['hasReceipt'] == true) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _showReceipt(passage); + }, + child: Text('Voir le reçu'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _editPassage(passage); + }, + child: Text('Modifier'), + ), + ], + ), + ); + } + + // Méthode pour éditer un passage + void _editPassage(Map passage) { + // Implémenter l'ouverture d'un formulaire d'édition + // Cette méthode pourrait naviguer vers une page d'édition + debugPrint('Édition du passage ${passage['id']}'); + // Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => EditPassagePage(passage: passage))); + } + + // Méthode pour afficher un reçu + void _showReceipt(Map passage) { + // Implémenter l'affichage ou la génération d'un reçu + // Cette méthode pourrait générer un PDF et l'afficher + debugPrint('Affichage du reçu pour le passage ${passage['id']}'); + // Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReceiptPage(passage: passage))); + } + + // Helper pour construire une ligne de détails + Widget _buildDetailRow(String label, String value, {bool isError = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text('$label:', + style: TextStyle(fontWeight: FontWeight.bold))), + Expanded( + child: Text( + value, + style: isError ? TextStyle(color: Colors.red) : null, + ), + ), + ], + ), + ); + } + + // Variable pour gérer la recherche + String _searchQuery = ''; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec bouton de rafraîchissement + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Historique des passages', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadPassages, + tooltip: 'Rafraîchir', + ), + ], + ), + ), + + // Affichage du chargement ou des erreurs + if (_isLoading) + const Expanded( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else if (_errorMessage.isNotEmpty) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + 'Erreur de chargement', + style: theme.textTheme.titleLarge + ?.copyWith(color: Colors.red), + ), + const SizedBox(height: 8), + Text(_errorMessage), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPassages, + child: const Text('Réessayer'), + ), + ], + ), + ), + ) + // Utilisation du widget PassagesListWidget pour afficher la liste des passages + else + Column( + children: [ + // Stat rapide pour l'utilisateur + if (_convertedPassages.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '${_convertedPassages.length} passages au total (${_convertedPassages.where((p) => (p['date'] as DateTime).isAfter(DateTime(2024, 12, 13))).length} de décembre 2024)', + style: TextStyle( + fontStyle: FontStyle.italic, + color: theme.colorScheme.primary), + ), + ), + // Widget de liste des passages + Expanded( + child: PassagesListWidget( + passages: _convertedPassages, + showFilters: true, + showSearch: true, + showActions: true, + initialSearchQuery: _searchQuery, + initialTypeFilter: + 'Tous', // Toujours commencer avec 'Tous' pour voir tous les types + initialPaymentFilter: 'Tous', + // Exclure les passages de type 2 (À finaliser) + excludePassageTypes: [2], + // Filtrer par utilisateur courant + filterByUserId: userRepository.getCurrentUser()?.id, + // Désactiver les filtres de date implicites + key: ValueKey( + 'passages_list_${DateTime.now().millisecondsSinceEpoch}'), + onPassageSelected: (passage) { + // Action lors de la sélection d'un passage + debugPrint('Passage sélectionné: ${passage['id']}'); + _showPassageDetails(passage); + }, + onDetailsView: (passage) { + // Action lors de l'affichage des détails + debugPrint('Affichage des détails: ${passage['id']}'); + _showPassageDetails(passage); + }, + onPassageEdit: (passage) { + // Action lors de la modification d'un passage + debugPrint('Modification du passage: ${passage['id']}'); + _editPassage(passage); + }, + onReceiptView: (passage) { + // Action lors de la demande d'affichage du reçu + debugPrint( + 'Affichage du reçu pour le passage: ${passage['id']}'); + _showReceipt(passage); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/user/user_map_page.dart b/flutt/lib/presentation/user/user_map_page.dart new file mode 100644 index 00000000..99d559ff --- /dev/null +++ b/flutt/lib/presentation/user/user_map_page.dart @@ -0,0 +1,1084 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/services/location_service.dart'; +import 'package:geosector_app/presentation/widgets/mapbox_map.dart'; + +import '../../core/constants/app_keys.dart'; +import '../../core/data/models/sector_model.dart'; +import '../../core/data/models/passage_model.dart'; + +// Extension pour ajouter ln2 (logarithme népérien de 2) comme constante +extension MathConstants on math.Random { + static const double ln2 = 0.6931471805599453; // ln(2) +} + +class UserMapPage extends StatefulWidget { + const UserMapPage({super.key}); + + @override + State createState() => _UserMapPageState(); +} + +class _UserMapPageState extends State { + // Contrôleur de carte + final MapController _mapController = MapController(); + + // Position actuelle et zoom + LatLng _currentPosition = + const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes + double _currentZoom = 12.0; // Zoom initial + + // Données des secteurs et passages + final List> _sectors = []; + final List> _passages = []; + + // État du plein écran + bool _isFullScreen = false; + + // Items pour la combobox de secteurs + List> _sectorItems = []; + + // Filtres pour les types de passages + bool _showEffectues = true; + bool _showAFinaliser = true; + bool _showRefuses = true; + bool _showDons = true; + bool _showLots = true; + bool _showMaisonsVides = true; + + // Référence à la boîte Hive pour les paramètres + late Box _settingsBox; + + // Vérifier si la combobox de secteurs doit être affichée + bool get _shouldShowSectorCombobox => _sectors.length > 1; + + int? _selectedSectorId; + + @override + void initState() { + super.initState(); + _initSettings().then((_) { + _loadSectors(); + _loadPassages(); + }); + } + + // Initialiser la boîte de paramètres et charger les préférences + Future _initSettings() async { + // Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { + _settingsBox = await Hive.openBox(AppKeys.settingsBoxName); + } else { + _settingsBox = Hive.box(AppKeys.settingsBoxName); + } + + // Charger les filtres sauvegardés + _showEffectues = _settingsBox.get('showEffectues', defaultValue: true); + _showAFinaliser = _settingsBox.get('showAFinaliser', defaultValue: true); + _showRefuses = _settingsBox.get('showRefuses', defaultValue: true); + _showDons = _settingsBox.get('showDons', defaultValue: true); + _showLots = _settingsBox.get('showLots', defaultValue: true); + _showMaisonsVides = + _settingsBox.get('showMaisonsVides', defaultValue: true); + + // Charger le secteur sélectionné + _selectedSectorId = _settingsBox.get('selectedSectorId'); + + // Charger la position et le zoom + final double? savedLat = _settingsBox.get('mapLat'); + final double? savedLng = _settingsBox.get('mapLng'); + final double? savedZoom = _settingsBox.get('mapZoom'); + + if (savedLat != null && savedLng != null) { + _currentPosition = LatLng(savedLat, savedLng); + } + + if (savedZoom != null) { + _currentZoom = savedZoom; + } + } + + // Obtenir la position actuelle de l'utilisateur + Future _getUserLocation() async { + try { + // Afficher un indicateur de chargement + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche de votre position...'), + duration: Duration(seconds: 2), + ), + ); + + // Obtenir la position actuelle via le service de géolocalisation + final position = await LocationService.getCurrentPosition(); + + if (position != null) { + // Mettre à jour la position sur la carte + _updateMapPosition(position, zoom: 17); + + // Sauvegarder la nouvelle position + _settingsBox.put('mapLat', position.latitude); + _settingsBox.put('mapLng', position.longitude); + + // Informer l'utilisateur + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Position actualisée'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + } + } else { + // Informer l'utilisateur en cas d'échec + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + // Gérer les erreurs + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + // Sauvegarder les paramètres utilisateur + void _saveSettings() { + // Sauvegarder les filtres + _settingsBox.put('showEffectues', _showEffectues); + _settingsBox.put('showAFinaliser', _showAFinaliser); + _settingsBox.put('showRefuses', _showRefuses); + _settingsBox.put('showDons', _showDons); + _settingsBox.put('showLots', _showLots); + _settingsBox.put('showMaisonsVides', _showMaisonsVides); + + // Sauvegarder le secteur sélectionné + if (_selectedSectorId != null) { + _settingsBox.put('selectedSectorId', _selectedSectorId); + } + + // Sauvegarder la position et le zoom actuels + _settingsBox.put('mapLat', _currentPosition.latitude); + _settingsBox.put('mapLng', _currentPosition.longitude); + _settingsBox.put('mapZoom', _currentZoom); + } + + // Charger les secteurs depuis la boîte Hive + void _loadSectors() { + try { + final sectorsBox = Hive.box(AppKeys.sectorsBoxName); + final sectors = sectorsBox.values.toList(); + + setState(() { + _sectors.clear(); + + for (final sector in sectors) { + final List> coordinates = sector.getCoordinates(); + final List points = + coordinates.map((coord) => LatLng(coord[0], coord[1])).toList(); + + if (points.isNotEmpty) { + _sectors.add({ + 'id': sector.id, + 'name': sector.libelle, + 'color': _hexToColor(sector.color), + 'points': points, + }); + } + } + + // Mettre à jour les items de la combobox de secteurs + _updateSectorItems(); + + // Si un secteur était sélectionné précédemment, le centrer + if (_selectedSectorId != null && + _sectors.any((s) => s['id'] == _selectedSectorId)) { + _centerMapOnSpecificSector(_selectedSectorId!); + } + // Sinon, centrer la carte sur tous les secteurs + else if (_sectors.isNotEmpty) { + _centerMapOnSectors(); + } + }); + } catch (e) { + debugPrint('Erreur lors du chargement des secteurs: $e'); + } + } + + // Mettre à jour les items de la combobox de secteurs + void _updateSectorItems() { + // Créer l'item "Tous les secteurs" + final List> items = [ + const DropdownMenuItem( + value: null, + child: Text('Tous les secteurs'), + ), + ]; + + // Ajouter tous les secteurs + for (final sector in _sectors) { + items.add( + DropdownMenuItem( + value: sector['id'] as int, + child: Text(sector['name'] as String), + ), + ); + } + + setState(() { + _sectorItems = items; + }); + } + + // Charger les passages depuis la boîte Hive + void _loadPassages() { + try { + // Récupérer la boîte des passages + final passagesBox = Hive.box(AppKeys.passagesBoxName); + + // Créer une nouvelle liste temporaire + final List> newPassages = []; + + // Parcourir tous les passages dans la boîte + for (var i = 0; i < passagesBox.length; i++) { + final passage = passagesBox.getAt(i); + if (passage != null) { + // Vérifier si les coordonnées GPS sont valides + final lat = double.tryParse(passage.gpsLat); + final lng = double.tryParse(passage.gpsLng); + + // Filtrer par secteur si un secteur est sélectionné + if (_selectedSectorId != null && + passage.fkSector != _selectedSectorId) { + continue; + } + + if (lat != null && lng != null) { + // Obtenir la couleur du type de passage + Color passageColor = Colors.grey; // Couleur par défaut + + // Vérifier si le type de passage existe dans AppKeys.typesPassages + if (AppKeys.typesPassages.containsKey(passage.fkType)) { + // Utiliser la couleur1 du type de passage + final colorValue = + AppKeys.typesPassages[passage.fkType]!['couleur1'] as int; + passageColor = Color(colorValue); + + // Ajouter le passage à la liste temporaire avec filtrage + if (_shouldShowPassage(passage.fkType)) { + newPassages.add({ + 'id': passage.id, + 'position': LatLng(lat, lng), + 'type': passage.fkType, + 'color': passageColor, + 'model': passage, // Ajouter le modèle complet + }); + } + } + } + } + } + + // Mettre à jour la liste des passages dans l'état + setState(() { + _passages.clear(); + _passages.addAll(newPassages); + }); + + // Sauvegarder les paramètres après chargement des passages + _saveSettings(); + } catch (e) { + debugPrint('Erreur lors du chargement des passages: $e'); + } + } + + // Vérifier si un passage doit être affiché en fonction de son type + bool _shouldShowPassage(int type) { + switch (type) { + case 1: // Effectué + return _showEffectues; + case 2: // À finaliser + return _showAFinaliser; + case 3: // Refusé + return _showRefuses; + case 4: // Don + return _showDons; + case 5: // Lot + return _showLots; + case 6: // Maison vide + return _showMaisonsVides; + default: + return true; + } + } + + // Convertir une couleur hexadécimale en Color + Color _hexToColor(String hexColor) { + // Supprimer le # si présent + final String colorStr = + hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + + // Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères) + final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr; + + // Convertir en entier et créer la couleur + return Color(int.parse(fullColorStr, radix: 16)); + } + + // Centrer la carte sur tous les secteurs + void _centerMapOnSectors() { + if (_sectors.isEmpty) return; + + // Trouver les limites de tous les secteurs + double minLat = 90.0; + double maxLat = -90.0; + double minLng = 180.0; + double maxLng = -180.0; + + for (final sector in _sectors) { + final points = sector['points'] as List; + for (final point in points) { + minLat = point.latitude < minLat ? point.latitude : minLat; + maxLat = point.latitude > maxLat ? point.latitude : maxLat; + minLng = point.longitude < minLng ? point.longitude : minLng; + maxLng = point.longitude > maxLng ? point.longitude : maxLng; + } + } + + // Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles + // avec une marge autour (5% de la taille totale) + final latPadding = (maxLat - minLat) * 0.05; + final lngPadding = (maxLng - minLng) * 0.05; + + minLat -= latPadding; + maxLat += latPadding; + minLng -= lngPadding; + maxLng += lngPadding; + + // Calculer le centre + final centerLat = (minLat + maxLat) / 2; + final centerLng = (minLng + maxLng) / 2; + + // Calculer le zoom approprié en tenant compte des dimensions de l'écran + final mapWidth = MediaQuery.of(context).size.width; + final mapHeight = MediaQuery.of(context).size.height * + 0.7; // Estimation de la hauteur de la carte + final zoom = _calculateOptimalZoom( + minLat, maxLat, minLng, maxLng, mapWidth, mapHeight); + + // Centrer la carte sur ces limites avec animation + _mapController.move(LatLng(centerLat, centerLng), zoom); + + // Mettre à jour l'état pour refléter la nouvelle position + setState(() { + _currentPosition = LatLng(centerLat, centerLng); + _currentZoom = zoom; + }); + + debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom'); + } + + // Centrer la carte sur un secteur spécifique + void _centerMapOnSpecificSector(int sectorId) { + final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId); + if (sectorIndex == -1) return; + + // Mettre à jour le secteur sélectionné + _selectedSectorId = sectorId; + + final sector = _sectors[sectorIndex]; + final points = sector['points'] as List; + final sectorName = sector['name'] as String; + + debugPrint( + 'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points'); + + if (points.isEmpty) { + debugPrint('Aucun point dans ce secteur!'); + return; + } + + // Trouver les limites du secteur + double minLat = 90.0; + double maxLat = -90.0; + double minLng = 180.0; + double maxLng = -180.0; + + for (final point in points) { + minLat = point.latitude < minLat ? point.latitude : minLat; + maxLat = point.latitude > maxLat ? point.latitude : maxLat; + minLng = point.longitude < minLng ? point.longitude : minLng; + maxLng = point.longitude > maxLng ? point.longitude : maxLng; + } + + debugPrint( + 'Limites du secteur: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng'); + + // Vérifier si les coordonnées sont valides + if (minLat >= maxLat || minLng >= maxLng) { + debugPrint('Coordonnées invalides pour le secteur $sectorName'); + return; + } + + // Calculer la taille du secteur + final latSpan = maxLat - minLat; + final lngSpan = maxLng - minLng; + debugPrint('Taille du secteur: latSpan=$latSpan, lngSpan=$lngSpan'); + + // Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible + // mais prend le maximum de place sur la carte + final double latPadding, lngPadding; + if (latSpan < 0.01 || lngSpan < 0.01) { + // Pour les très petits secteurs, utiliser un padding très réduit + latPadding = 0.0003; + lngPadding = 0.0003; + } else if (latSpan < 0.05 || lngSpan < 0.05) { + // Pour les petits secteurs, padding réduit + latPadding = 0.0005; + lngPadding = 0.0005; + } else { + // Pour les secteurs plus grands, utiliser un pourcentage minimal + latPadding = latSpan * 0.03; // 3% au lieu de 10% + lngPadding = lngSpan * 0.03; + } + + minLat -= latPadding; + maxLat += latPadding; + minLng -= lngPadding; + maxLng += lngPadding; + + debugPrint( + 'Limites avec padding: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng'); + + // Calculer le centre + final centerLat = (minLat + maxLat) / 2; + final centerLng = (minLng + maxLng) / 2; + + // Déterminer le zoom approprié en fonction de la taille du secteur + double zoom; + + // Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé + if (latSpan < 0.01 && lngSpan < 0.01) { + zoom = 16.0; // Zoom élevé pour les petits quartiers + } else if (latSpan < 0.02 && lngSpan < 0.02) { + zoom = 15.0; // Zoom élevé pour les petits quartiers + } else if (latSpan < 0.05 && lngSpan < 0.05) { + zoom = + 13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers) + } else if (latSpan < 0.1 && lngSpan < 0.1) { + zoom = 12.0; // Zoom pour les grands secteurs (ville) + } else { + // Pour les secteurs plus grands, calculer le zoom + final mapWidth = MediaQuery.of(context).size.width; + final mapHeight = MediaQuery.of(context).size.height * 0.7; + zoom = _calculateOptimalZoom( + minLat, maxLat, minLng, maxLng, mapWidth, mapHeight); + } + + debugPrint('Zoom calculé pour le secteur $sectorName: $zoom'); + + // Centrer la carte sur le secteur avec animation + _mapController.move(LatLng(centerLat, centerLng), zoom); + + // Mettre à jour l'état pour refléter la nouvelle position + setState(() { + _currentPosition = LatLng(centerLat, centerLng); + _currentZoom = zoom; + }); + } + + // Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte + double _calculateOptimalZoom(double minLat, double maxLat, double minLng, + double maxLng, double mapWidth, double mapHeight) { + // Méthode simplifiée et plus fiable pour calculer le zoom + + // Vérifier si les coordonnées sont valides + if (minLat >= maxLat || minLng >= maxLng) { + debugPrint('Coordonnées invalides pour le calcul du zoom'); + return 12.0; // Valeur par défaut raisonnable + } + + // Calculer la taille en degrés + final latSpan = maxLat - minLat; + final lngSpan = maxLng - minLng; + + debugPrint( + '_calculateOptimalZoom - Taille: latSpan=$latSpan, lngSpan=$lngSpan'); + + // Ajouter un facteur de sécurité pour éviter les divisions par zéro + if (latSpan < 0.0000001 || lngSpan < 0.0000001) { + return 15.0; // Zoom élevé pour un point très précis + } + + // Formule simplifiée pour le calcul du zoom + // Basée sur l'expérience et adaptée pour les petites zones + double zoom; + + if (latSpan < 0.005 || lngSpan < 0.005) { + // Très petite zone (quartier) + zoom = 16.0; + } else if (latSpan < 0.01 || lngSpan < 0.01) { + // Petite zone (quartier) + zoom = 15.0; + } else if (latSpan < 0.02 || lngSpan < 0.02) { + // Petite zone (plusieurs quartiers) + zoom = 14.0; + } else if (latSpan < 0.05 || lngSpan < 0.05) { + // Zone moyenne (ville) + zoom = 13.0; + } else if (latSpan < 0.2 || lngSpan < 0.2) { + // Grande zone (agglomération) + zoom = 11.0; + } else if (latSpan < 0.5 || lngSpan < 0.5) { + // Très grande zone (département) + zoom = 9.0; + } else if (latSpan < 2.0 || lngSpan < 2.0) { + // Région + zoom = 7.0; + } else if (latSpan < 5.0 || lngSpan < 5.0) { + // Pays + zoom = 5.0; + } else { + // Continent ou plus + zoom = 3.0; + } + + debugPrint('Zoom calculé: $zoom pour zone: lat $latSpan, lng $lngSpan'); + return zoom; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête - affiché uniquement si pas en plein écran + if (!_isFullScreen) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Carte des passages', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + + // Filtres - affichés uniquement si pas en plein écran + if (!_isFullScreen) _buildFilters(theme, isDesktop), + + // Carte + Expanded( + child: Stack( + children: [ + // Carte principale utilisant le widget commun MapboxMap + MapboxMap( + initialPosition: _currentPosition, + initialZoom: _currentZoom, + mapController: _mapController, + markers: _buildPassageMarkers(), + polygons: _buildSectorPolygons(), + showControls: true, + onMapEvent: (event) { + if (event is MapEventMove) { + // Mettre à jour la position et le zoom actuels + setState(() { + _currentPosition = event.camera.center; + _currentZoom = event.camera.zoom; + }); + } + }, + ), + + // Combobox de sélection de secteurs (si plus d'un secteur) + if (_shouldShowSectorCombobox) + Positioned( + left: 16.0, + top: 16.0, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + width: + 220, // Largeur fixe pour accommoder les noms longs + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.95), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_on, + size: 18, color: Colors.blue), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedSectorId, + hint: const Text('Tous les secteurs'), + isExpanded: true, + underline: + Container(), // Supprimer la ligne sous le dropdown + icon: Icon(Icons.arrow_drop_down, + color: Colors.blue), + items: _sectorItems, + onChanged: (int? sectorId) { + setState(() { + _selectedSectorId = sectorId; + }); + + if (sectorId != null) { + _centerMapOnSpecificSector(sectorId); + } else { + // Si "Tous les secteurs" est sélectionné + _centerMapOnSectors(); + // Recharger tous les passages sans filtrage par secteur + _loadPassages(); + } + }, + ), + ), + ], + ), + ), + ), + ), + + // Bouton de plein écran (les autres contrôles sont gérés par MapboxMap) + Positioned( + bottom: 16.0, + right: 16.0, + child: _buildMapButton( + icon: _isFullScreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + onPressed: () { + setState(() { + _isFullScreen = !_isFullScreen; + }); + }, + ), + ), + + // Bouton de localisation personnalisé (pour utiliser notre propre logique) + Positioned( + bottom: 80.0, // Positionné au-dessus du bouton plein écran + right: 16.0, + child: _buildMapButton( + icon: Icons.my_location, + onPressed: () { + _getUserLocation(); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Construire les filtres pour les passages + Widget _buildFilters(ThemeData theme, bool isDesktop) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + // Filtre pour les passages effectués + _buildFilterChip( + label: AppKeys.typesPassages[1]?['titres'] as String? ?? + 'Effectués', + selected: _showEffectues, + color: Color(AppKeys.typesPassages[1]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showEffectues = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + + // Filtre pour les passages à finaliser + _buildFilterChip( + label: AppKeys.typesPassages[2]?['titres'] as String? ?? + 'À finaliser', + selected: _showAFinaliser, + color: Color(AppKeys.typesPassages[2]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showAFinaliser = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + + // Filtre pour les passages refusés + _buildFilterChip( + label: + AppKeys.typesPassages[3]?['titres'] as String? ?? 'Refusés', + selected: _showRefuses, + color: Color(AppKeys.typesPassages[3]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showRefuses = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + + // Filtre pour les dons + _buildFilterChip( + label: AppKeys.typesPassages[4]?['titres'] as String? ?? 'Dons', + selected: _showDons, + color: Color(AppKeys.typesPassages[4]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showDons = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + + // Filtre pour les lots + _buildFilterChip( + label: AppKeys.typesPassages[5]?['titres'] as String? ?? 'Lots', + selected: _showLots, + color: Color(AppKeys.typesPassages[5]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showLots = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + + // Filtre pour les maisons vides + _buildFilterChip( + label: AppKeys.typesPassages[6]?['titres'] as String? ?? + 'Maisons vides', + selected: _showMaisonsVides, + color: Color(AppKeys.typesPassages[6]?['couleur2'] as int), + onSelected: (selected) { + setState(() { + _showMaisonsVides = selected; + _loadPassages(); // Recharger les passages avec le nouveau filtre + _saveSettings(); // Sauvegarder les préférences + }); + }, + ), + ], + ), + ], + ), + ); + } + + // Construire un chip de filtre + Widget _buildFilterChip({ + required String label, + required bool selected, + required Color color, + required Function(bool) onSelected, + }) { + // Utiliser la couleur vive pour les boutons sélectionnés et une version plus terne pour les désélectionnés + final Color avatarColor = selected ? color : color.withOpacity(0.4); + final Color chipColor = + selected ? color.withOpacity(0.2) : Colors.grey.withOpacity(0.1); + + return FilterChip( + label: Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + color: selected ? Colors.black : Colors.black54, + ), + ), + selected: selected, + showCheckmark: false, + avatar: CircleAvatar( + backgroundColor: avatarColor, + radius: 10.0, + ), + backgroundColor: Colors.white, + selectedColor: chipColor, + side: BorderSide( + color: selected ? color : Colors.grey.withOpacity(0.3), + width: selected ? 1.5 : 1.0, + ), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + onSelected: onSelected, + ); + } + + // Construction d'un bouton de carte personnalisé + Widget _buildMapButton({ + required IconData icon, + required VoidCallback onPressed, + }) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: IconButton( + icon: Icon(icon, size: 20), + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: Colors.blue, + ), + ); + } + + // Construire les marqueurs pour les passages + List _buildPassageMarkers() { + return _passages.map((passage) { + return Marker( + point: passage['position'] as LatLng, + width: 14.0, + height: 14.0, + child: GestureDetector( + onTap: () { + _showPassageInfo(passage); + }, + child: Container( + decoration: BoxDecoration( + color: passage['color'] as Color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 1.0, + ), + ), + ), + ), + ); + }).toList(); + } + + // Construire les polygones pour les secteurs + List _buildSectorPolygons() { + return _sectors.map((sector) { + return Polygon( + points: sector['points'] as List, + color: (sector['color'] as Color).withOpacity(0.3), + borderColor: (sector['color'] as Color).withOpacity(1.0), + borderStrokeWidth: 2.0, + ); + }).toList(); + } + + // Méthode pour mettre à jour la position sur la carte + void _updateMapPosition(LatLng position, {double? zoom}) { + _mapController.move( + position, + zoom ?? _mapController.camera.zoom, + ); + + // Mettre à jour les variables d'état + setState(() { + _currentPosition = position; + if (zoom != null) { + _currentZoom = zoom; + } + }); + + // Sauvegarder les paramètres après mise à jour de la position + _saveSettings(); + } + + // Afficher les informations d'un passage lorsqu'on clique dessus + void _showPassageInfo(Map passage) { + final PassageModel passageModel = passage['model'] as PassageModel; + final int type = passageModel.fkType; + + // Construire l'adresse complète + final String adresse = + '${passageModel.numero}, ${passageModel.rueBis} ${passageModel.rue}'; + + // Informations sur l'étage, l'appartement et la résidence (si habitat = 2) + String? etageInfo; + String? apptInfo; + String? residenceInfo; + if (passageModel.fkHabitat == 2) { + if (passageModel.niveau.isNotEmpty) { + etageInfo = 'Etage ${passageModel.niveau}'; + } + if (passageModel.appt.isNotEmpty) { + apptInfo = 'appt. ${passageModel.appt}'; + } + if (passageModel.residence.isNotEmpty) { + residenceInfo = passageModel.residence; + } + } + + // Formater la date (uniquement si le type n'est pas 2) + String dateInfo = ''; + if (type != 2) { + dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}'; + } + + // Récupérer le nom du passage (si le type n'est pas 6 - Maison vide) + String? nomInfo; + if (type != 6 && passageModel.name.isNotEmpty) { + nomInfo = passageModel.name; + } + + // Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot) + Widget? reglementInfo; + if (type == 1 || type == 5) { + final int typeReglementId = passageModel.fkTypeReglement; + final String montant = passageModel.montant; + + // Récupérer les informations du type de règlement + if (AppKeys.typesReglements.containsKey(typeReglementId)) { + final Map typeReglement = + AppKeys.typesReglements[typeReglementId]!; + final String titre = typeReglement['titre'] as String; + final Color couleur = Color(typeReglement['couleur'] as int); + final IconData iconData = typeReglement['icon_data'] as IconData; + + reglementInfo = Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + Icon(iconData, color: couleur, size: 20), + const SizedBox(width: 8), + Text('$titre: $montant €', + style: + TextStyle(color: couleur, fontWeight: FontWeight.bold)), + ], + ), + ); + } + } + + // Afficher une bulle d'information + showDialog( + context: context, + builder: (context) => AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Adresse: $adresse'), + if (residenceInfo != null) ...[ + const SizedBox(height: 4), + Text(residenceInfo) + ], + if (etageInfo != null) ...[ + const SizedBox(height: 4), + Text(etageInfo) + ], + if (apptInfo != null) ...[ + const SizedBox(height: 4), + Text(apptInfo) + ], + if (dateInfo.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(dateInfo) + ], + if (nomInfo != null) ...[ + const SizedBox(height: 8), + Text('Nom: $nomInfo') + ], + if (reglementInfo != null) reglementInfo, + ], + ), + actionsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // Bouton d'édition + IconButton( + onPressed: () { + Navigator.of(context).pop(); + // Logique pour éditer le passage + debugPrint('Éditer le passage ${passageModel.id}'); + }, + icon: const Icon(Icons.edit), + color: Colors.blue, + tooltip: 'Modifier', + ), + + // Bouton de suppression + IconButton( + onPressed: () { + Navigator.of(context).pop(); + // Logique pour supprimer le passage + debugPrint('Supprimer le passage ${passageModel.id}'); + }, + icon: const Icon(Icons.delete), + color: Colors.red, + tooltip: 'Supprimer', + ), + ], + ), + + // Bouton de fermeture + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ], + ), + ); + } + + // Formater une date + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } +} diff --git a/flutt/lib/presentation/user/user_statistics_page.dart b/flutt/lib/presentation/user/user_statistics_page.dart new file mode 100644 index 00000000..dcb04490 --- /dev/null +++ b/flutt/lib/presentation/user/user_statistics_page.dart @@ -0,0 +1,581 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:fl_chart/fl_chart.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/presentation/widgets/charts/charts.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; + +class UserStatisticsPage extends StatefulWidget { + const UserStatisticsPage({super.key}); + + @override + State createState() => _UserStatisticsPageState(); +} + +class _UserStatisticsPageState extends State { + // Période sélectionnée + String _selectedPeriod = 'Semaine'; + + // Secteur sélectionné (0 = tous les secteurs) + int _selectedSectorId = 0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Statistiques', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + + // Filtres + _buildFilters(theme, isDesktop), + + const SizedBox(height: 24), + + // Graphiques + _buildCharts(theme), + + const SizedBox(height: 24), + + // Résumé par type de passage + _buildPassageTypeSummary(theme, isDesktop), + + const SizedBox(height: 24), + + // Résumé par type de règlement + _buildPaymentTypeSummary(theme, isDesktop), + ], + ), + ), + ), + ); + } + + // Construction des filtres + Widget _buildFilters(ThemeData theme, bool isDesktop) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filtres', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + // Sélection de la période + _buildFilterSection( + 'Période', + ['Jour', 'Semaine', 'Mois', 'Année'], + _selectedPeriod, + (value) { + setState(() { + _selectedPeriod = value; + }); + }, + theme, + ), + + // Sélection du secteur (si l'utilisateur a plusieurs secteurs) + _buildSectorSelector(context, theme), + + // Bouton d'application des filtres + ElevatedButton.icon( + onPressed: () { + // Actualiser les statistiques avec les filtres sélectionnés + setState(() { + // Dans une implémentation réelle, on chargerait ici les données + // filtrées par période et secteur + }); + }, + icon: const Icon(Icons.filter_list), + label: const Text('Appliquer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + // Construction du sélecteur de secteur + Widget _buildSectorSelector(BuildContext context, ThemeData theme) { + // Utiliser l'instance globale définie dans app.dart + + // Récupérer les secteurs de l'utilisateur + final sectors = userRepository.getUserSectors(); + + // Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur + if (sectors.length <= 1) { + return const SizedBox.shrink(); + } + + // Créer la liste des options avec "Tous" comme première option + final List> items = [ + const DropdownMenuItem( + value: 0, + child: Text('Tous les secteurs'), + ), + ]; + + // Ajouter les secteurs de l'utilisateur + for (final sector in sectors) { + items.add( + DropdownMenuItem( + value: sector.id, + child: Text(sector.libelle), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Secteur', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxWidth: 250), + child: DropdownButton( + value: _selectedSectorId, + isExpanded: true, + items: items, + onChanged: (value) { + if (value != null) { + setState(() { + _selectedSectorId = value; + }); + } + }, + hint: const Text('Sélectionner un secteur'), + ), + ), + ], + ); + } + + // Construction d'une section de filtre + Widget _buildFilterSection( + String title, + List options, + String selectedValue, + Function(String) onChanged, + ThemeData theme, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: options.map((option) { + return ButtonSegment( + value: option, + label: Text(option), + ); + }).toList(), + selected: {selectedValue}, + onSelectionChanged: (Set selection) { + onChanged(selection.first); + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.selected)) { + return AppTheme.secondaryColor; + } + return theme.colorScheme.surface; + }, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.selected)) { + return Colors.white; + } + return theme.colorScheme.onSurface; + }, + ), + ), + ), + ], + ); + } + + // Construction des graphiques + Widget _buildCharts(ThemeData theme) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Passages et règlements par $_selectedPeriod', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 300, + child: _buildActivityChart(theme), + ), + ], + ), + ), + ); + } + + // Construction du graphique d'activité + Widget _buildActivityChart(ThemeData theme) { + // Générer des données fictives pour les passages + final now = DateTime.now(); + final List> passageData = []; + + // Récupérer le secteur sélectionné (si applicable) + final String sectorLabel = _selectedSectorId == 0 + ? 'Tous les secteurs' + : userRepository.getSectorById(_selectedSectorId)?.libelle ?? + 'Secteur inconnu'; + + // Déterminer la plage de dates en fonction de la période sélectionnée + DateTime startDate; + int daysToGenerate; + + switch (_selectedPeriod) { + case 'Jour': + startDate = DateTime(now.year, now.month, now.day); + daysToGenerate = 1; + break; + case 'Semaine': + // Début de la semaine (lundi) + final weekday = now.weekday; + startDate = now.subtract(Duration(days: weekday - 1)); + daysToGenerate = 7; + break; + case 'Mois': + // Début du mois + startDate = DateTime(now.year, now.month, 1); + // Calculer le nombre de jours dans le mois + final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day; + daysToGenerate = lastDayOfMonth; + break; + case 'Année': + // Début de l'année + startDate = DateTime(now.year, 1, 1); + daysToGenerate = 365; + break; + default: + startDate = DateTime(now.year, now.month, now.day); + daysToGenerate = 7; + } + + // Générer des données pour la période sélectionnée + for (int i = 0; i < daysToGenerate; i++) { + final date = startDate.add(Duration(days: i)); + + // Générer des données pour chaque type de passage + for (int typeId = 1; typeId <= 6; typeId++) { + // Générer un nombre de passages basé sur le jour et le type + final count = (typeId == 1 || typeId == 2) + ? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2 + : (date.day % 4); // Moins pour les autres types + + if (count > 0) { + passageData.add({ + 'date': date.toIso8601String(), + 'type_passage': typeId, + 'nb': count, + }); + } + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Afficher le secteur sélectionné si ce n'est pas "Tous" + if (_selectedSectorId != 0) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + 'Secteur: $sectorLabel', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ActivityChart( + passageData: passageData, + periodType: _selectedPeriod, + height: 300, + ), + ], + ); + } + + // Construction du résumé par type de passage + Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) { + // Dans une implémentation réelle, ces données seraient filtrées par secteur + // en fonction de _selectedSectorId + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Répartition par type de passage', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + // Graphique circulaire + Expanded( + flex: isDesktop ? 1 : 2, + child: SizedBox( + height: 200, + child: PassagePieChart( + passagesByType: { + 1: 60, // Effectués + 2: 15, // À finaliser + 3: 10, // Refusés + 4: 8, // Dons + 5: 5, // Lots + 6: 2, // Maisons vides + }, + size: 140, + labelSize: 12, + showPercentage: true, + showIcons: false, // Désactiver les icônes + isDonut: true, // Activer le format donut + innerRadius: '50%' // Rayon interne du donut + ), + ), + ), + + // Légende + if (isDesktop) + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLegendItem( + 'Effectués', '60%', const Color(0xFF4CAF50)), + _buildLegendItem( + 'À finaliser', '15%', const Color(0xFFFF9800)), + _buildLegendItem( + 'Refusés', '10%', const Color(0xFFF44336)), + _buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)), + _buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)), + _buildLegendItem( + 'Maisons vides', '2%', const Color(0xFF9E9E9E)), + ], + ), + ), + ], + ), + if (!isDesktop) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)), + _buildLegendItem( + 'À finaliser', '15%', const Color(0xFFFF9800)), + _buildLegendItem('Refusés', '10%', const Color(0xFFF44336)), + _buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)), + _buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)), + _buildLegendItem( + 'Maisons vides', '2%', const Color(0xFF9E9E9E)), + ], + ), + ], + ), + ), + ); + } + + // Construction du résumé par type de règlement + Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) { + // Dans une implémentation réelle, ces données seraient filtrées par secteur + // en fonction de _selectedSectorId + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Répartition par type de règlement', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + // Graphique circulaire + Expanded( + flex: isDesktop ? 1 : 2, + child: SizedBox( + height: 200, + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: [ + _buildPieChartSection( + 'Espèces', 30, const Color(0xFF4CAF50), 0), + _buildPieChartSection( + 'Chèques', 45, const Color(0xFF2196F3), 1), + _buildPieChartSection( + 'CB', 25, const Color(0xFFF44336), 2), + ], + ), + ), + ), + ), + + // Légende + if (isDesktop) + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLegendItem( + 'Espèces', '30%', const Color(0xFF4CAF50)), + _buildLegendItem( + 'Chèques', '45%', const Color(0xFF2196F3)), + _buildLegendItem('CB', '25%', const Color(0xFFF44336)), + ], + ), + ), + ], + ), + if (!isDesktop) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)), + _buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)), + _buildLegendItem('CB', '25%', const Color(0xFFF44336)), + ], + ), + ], + ), + ), + ); + } + + // Construction d'une section de graphique circulaire + PieChartSectionData _buildPieChartSection( + String title, double value, Color color, int index) { + return PieChartSectionData( + color: color, + value: value, + title: '$value%', + radius: 60, + titleStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ); + } + + // Construction d'un élément de légende + Widget _buildLegendItem(String title, String value, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/widgets/charts/activity_chart.dart b/flutt/lib/presentation/widgets/charts/activity_chart.dart new file mode 100644 index 00000000..534fb046 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/activity_chart.dart @@ -0,0 +1,476 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/foundation.dart' show listEquals; +import 'package:intl/intl.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/services/passage_data_service.dart'; + +/// Widget de graphique d'activité affichant les passages +class ActivityChart extends StatefulWidget { + /// Liste des données de passage par date et type (si fournie directement) + /// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...] + final List>? passageData; + + /// Type de période (Jour, Semaine, Mois, Année) + final String periodType; + + /// Hauteur du graphique + final double height; + + /// Nombre de jours à afficher (par défaut 15) + final int daysToShow; + + /// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs) + final int? userId; + + /// Types de passages à exclure (par défaut [2] = "À finaliser") + final List excludePassageTypes; + + /// Indique si les données doivent être chargées depuis la Hive box + final bool loadFromHive; + + /// Callback appelé lorsque la période change + final Function(int days)? onPeriodChanged; + + /// Titre du graphique + final String title; + + /// Afficher les étiquettes de valeur + final bool showDataLabels; + + /// Largeur des colonnes (en pourcentage) + final double columnWidth; + + /// Espacement entre les colonnes (en pourcentage) + final double columnSpacing; + + /// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages) + final bool showAllPassages; + + const ActivityChart({ + super.key, + this.passageData, + this.periodType = 'Jour', + this.height = 350, + this.daysToShow = 15, + this.userId, + this.excludePassageTypes = const [2], + this.loadFromHive = false, + this.onPeriodChanged, + this.title = 'Dernière activité enregistrée sur 15 jours', + this.showDataLabels = true, + this.columnWidth = 0.8, + this.columnSpacing = 0.2, + this.showAllPassages = false, + }) : assert(loadFromHive || passageData != null, + 'Soit loadFromHive doit être true, soit passageData doit être fourni'); + + @override + State createState() => _ActivityChartState(); +} + +/// Classe pour stocker les données d'activité par date +class ActivityData { + final DateTime date; + final String dateStr; + final Map passagesByType; + final int totalPassages; + + ActivityData({ + required this.date, + required this.dateStr, + required this.passagesByType, + }) : totalPassages = + passagesByType.values.fold(0, (sum, count) => sum + count); +} + +class _ActivityChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + // Données pour les graphiques + List> _passageData = []; + List _chartData = []; + bool _isLoading = true; + bool _hasData = false; + bool _dataLoaded = false; + + // Période sélectionnée en jours + int _selectedDays = 15; + + // Contrôleur de zoom pour le graphique + late ZoomPanBehavior _zoomPanBehavior; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + ); + + // Initialiser la période sélectionnée avec la valeur par défaut du widget + _selectedDays = widget.daysToShow; + + // Initialiser le contrôleur de zoom + _zoomPanBehavior = ZoomPanBehavior( + enablePinching: true, + enableDoubleTapZooming: true, + enablePanning: true, + zoomMode: ZoomMode.x, + ); + + _loadData(); + _animationController.forward(); + } + + /// Trouve la date du passage le plus récent + DateTime _getMostRecentDate() { + final allDates = [ + ..._passageData.map((data) => DateTime.parse(data['date'] as String)), + ]; + if (allDates.isEmpty) { + return DateTime.now(); + } + return allDates.reduce((a, b) => a.isAfter(b) ? a : b); + } + + void _loadData() { + // Si les données ont déjà été chargées, ne pas les recharger + if (_dataLoaded) return; + + // Marquer comme chargé immédiatement pour éviter les appels multiples + _dataLoaded = true; + + setState(() { + _isLoading = true; + }); + + if (widget.loadFromHive) { + // Charger les données depuis Hive + WidgetsBinding.instance.addPostFrameCallback((_) { + // Éviter de recharger si le widget a été démonté entre-temps + if (!mounted) return; + + try { + // Utiliser les instances globales définies dans app.dart + + // Créer une instance du service de données + final passageDataService = PassageDataService( + passageRepository: passageRepository, + userRepository: userRepository, + ); + + // Utiliser le service pour charger les données + _passageData = passageDataService.loadPassageData( + daysToShow: _selectedDays, + excludePassageTypes: widget.excludePassageTypes, + userId: widget.userId, + showAllPassages: widget.showAllPassages, + ); + + _prepareChartData(); + + // Mettre à jour l'état une seule fois après avoir préparé les données + if (mounted) { + setState(() { + _isLoading = false; + _hasData = _chartData.isNotEmpty; + }); + } + } catch (e) { + // En cas d'erreur, réinitialiser l'état pour permettre une future tentative + if (mounted) { + setState(() { + _isLoading = false; + _hasData = false; + }); + } + } + }); + } else { + // Utiliser les données fournies directement + _passageData = widget.passageData ?? []; + _prepareChartData(); + + setState(() { + _isLoading = false; + _hasData = _chartData.isNotEmpty; + }); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(ActivityChart oldWidget) { + super.didUpdateWidget(oldWidget); + + // Vérifier si les propriétés importantes ont changé + final bool periodChanged = oldWidget.periodType != widget.periodType || + oldWidget.daysToShow != widget.daysToShow; + final bool dataSourceChanged = widget.loadFromHive + ? false + : oldWidget.passageData != widget.passageData; + final bool filteringChanged = oldWidget.userId != widget.userId || + !listEquals( + oldWidget.excludePassageTypes, widget.excludePassageTypes) || + oldWidget.showAllPassages != widget.showAllPassages; + + // Si des paramètres importants ont changé, recharger les données + if (periodChanged || dataSourceChanged || filteringChanged) { + _selectedDays = widget.daysToShow; + _dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement + _loadData(); + } + } + + // La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData + // pour éviter les appels multiples et les problèmes de cycle de vie + + /// Prépare les données pour le graphique + void _prepareChartData() { + try { + // Vérifier que les données sont disponibles + if (_passageData.isEmpty) { + _chartData = []; + return; + } + + // Obtenir toutes les dates uniques + final Set uniqueDatesSet = {}; + for (final data in _passageData) { + if (data.containsKey('date') && data['date'] != null) { + uniqueDatesSet.add(data['date'] as String); + } + } + + // Trier les dates + final List uniqueDates = uniqueDatesSet.toList(); + uniqueDates.sort(); + + // Créer les données pour chaque date + _chartData = []; + for (final dateStr in uniqueDates) { + final passagesByType = {}; + + // Initialiser tous les types de passage possibles + for (final typeId in AppKeys.typesPassages.keys) { + if (!widget.excludePassageTypes.contains(typeId)) { + passagesByType[typeId] = 0; + } + } + + // Remplir les données de passage + for (final data in _passageData) { + if (data.containsKey('date') && + data['date'] == dateStr && + data.containsKey('type_passage') && + data.containsKey('nb')) { + final typeId = data['type_passage'] as int; + if (!widget.excludePassageTypes.contains(typeId)) { + passagesByType[typeId] = data['nb'] as int; + } + } + } + + try { + // Convertir la date en objet DateTime + final dateParts = dateStr.split('-'); + if (dateParts.length == 3) { + final year = int.parse(dateParts[0]); + final month = int.parse(dateParts[1]); + final day = int.parse(dateParts[2]); + + final date = DateTime(year, month, day); + + // Ajouter les données à la liste + _chartData.add(ActivityData( + date: date, + dateStr: dateStr, + passagesByType: passagesByType, + )); + } + } catch (e) { + // Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs + } + } + + // Trier les données par date + _chartData.sort((a, b) => a.date.compareTo(b.date)); + } catch (e) { + // Erreur silencieuse pour éviter les logs excessifs + _chartData = []; + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return SizedBox( + height: widget.height, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (!_hasData || _chartData.isEmpty) { + return SizedBox( + height: widget.height, + child: const Center( + child: Text('Aucune donnée disponible'), + ), + ); + } + + // Préparer les données si nécessaire + if (_chartData.isEmpty) { + _prepareChartData(); + } + + return SizedBox( + height: widget.height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre (conservé) + if (widget.title.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, top: 16.0, bottom: 8.0), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + // Graphique (occupe maintenant plus d'espace) + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: SfCartesianChart( + plotAreaBorderWidth: 0, + legend: Legend( + isVisible: true, + position: LegendPosition.bottom, + overflowMode: LegendItemOverflowMode.wrap, + ), + primaryXAxis: DateTimeAxis( + dateFormat: DateFormat('dd/MM'), + intervalType: DateTimeIntervalType.days, + majorGridLines: const MajorGridLines(width: 0), + labelStyle: const TextStyle(fontSize: 10), + // Définir explicitement la plage de dates à afficher + minimum: _chartData.isNotEmpty ? _chartData.first.date : null, + maximum: _chartData.isNotEmpty ? _chartData.last.date : null, + // Assurer que tous les jours sont affichés + interval: 1, + axisLabelFormatter: (AxisLabelRenderDetails details) { + return ChartAxisLabel(details.text, details.textStyle); + }, + ), + primaryYAxis: NumericAxis( + labelStyle: const TextStyle(fontSize: 10), + axisLine: const AxisLine(width: 0), + majorTickLines: const MajorTickLines(size: 0), + majorGridLines: const MajorGridLines( + width: 0.5, + color: Colors.grey, + dashArray: [5, 5], // Motif de pointillés + ), + title: const AxisTitle( + text: 'Passages', + textStyle: TextStyle(fontSize: 10, color: Colors.grey), + ), + ), + series: _buildSeries(), + tooltipBehavior: TooltipBehavior(enable: true), + zoomPanBehavior: _zoomPanBehavior, + ), + ), + ), + ], + ), + ); + } + + /// Construit les séries de données pour le graphique + List> _buildSeries() { + final List> series = []; + + // Vérifier que les données sont disponibles + if (_chartData.isEmpty) { + return series; + } + + // Obtenir tous les types de passage (sauf ceux exclus) + final passageTypes = AppKeys.typesPassages.keys + .where((typeId) => !widget.excludePassageTypes.contains(typeId)) + .toList(); + + // Créer les séries pour les passages (colonnes empilées) + for (final typeId in passageTypes) { + // Vérifier que le type existe dans AppKeys + if (!AppKeys.typesPassages.containsKey(typeId)) { + continue; + } + final typeInfo = AppKeys.typesPassages[typeId]!; + + // Vérifier que les clés nécessaires existent + if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) { + continue; + } + + final typeColor = Color(typeInfo['couleur1'] as int); + final typeName = typeInfo['titre'] as String; + + // Calculer le total pour ce type pour déterminer s'il faut l'afficher + int totalForType = 0; + for (final data in _chartData) { + totalForType += data.passagesByType[typeId] ?? 0; + } + + // On peut décider de ne pas afficher les types sans données + final addZeroValueTypes = false; + + // Ajouter la série pour ce type + if (totalForType > 0 || addZeroValueTypes) { + series.add( + StackedColumnSeries( + name: typeName, + dataSource: _chartData, + xValueMapper: (ActivityData data, _) => data.date, + yValueMapper: (ActivityData data, _) { + final value = data.passagesByType.containsKey(typeId) + ? data.passagesByType[typeId]! + : 0; + return value; + }, + color: typeColor, + width: widget.columnWidth, + spacing: widget.columnSpacing, + dataLabelSettings: DataLabelSettings( + isVisible: widget.showDataLabels, + labelAlignment: ChartDataLabelAlignment.middle, + textStyle: const TextStyle(fontSize: 8, color: Colors.white), + ), + markerSettings: const MarkerSettings(isVisible: false), + animationDuration: 1500, + ), + ); + } + } + + return series; + } +} diff --git a/flutt/lib/presentation/widgets/charts/charts.dart b/flutt/lib/presentation/widgets/charts/charts.dart new file mode 100644 index 00000000..ca91b26e --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/charts.dart @@ -0,0 +1,11 @@ +/// Bibliothèque de widgets de graphiques pour l'application GeoSector +library geosector_charts; + +export 'payment_data.dart'; +export 'payment_pie_chart.dart'; +export 'payment_utils.dart'; +export 'passage_data.dart'; +export 'passage_utils.dart'; +export 'passage_pie_chart.dart'; +export 'activity_chart.dart'; +export 'combined_chart.dart'; diff --git a/flutt/lib/presentation/widgets/charts/combined_chart.dart b/flutt/lib/presentation/widgets/charts/combined_chart.dart new file mode 100644 index 00000000..89bfe599 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/combined_chart.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:geosector_app/presentation/widgets/charts/passage_data.dart'; +import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart'; +import 'package:intl/intl.dart'; + +/// Widget de graphique combiné pour afficher les passages et règlements +class CombinedChart extends StatelessWidget { + /// Liste des données de passage par type + final List> passageData; + + /// Liste des données de règlement par type + final List> paymentData; + + /// Type de période (Jour, Semaine, Mois, Année) + final String periodType; + + /// Hauteur du graphique + final double height; + + /// Largeur des barres + final double barWidth; + + /// Rayon des points sur les lignes + final double dotRadius; + + /// Épaisseur des lignes + final double lineWidth; + + /// Montant maximum pour l'axe Y des règlements + final double? maxYAmount; + + /// Nombre maximum pour l'axe Y des passages + final int? maxYCount; + + const CombinedChart({ + super.key, + required this.passageData, + required this.paymentData, + this.periodType = 'Jour', + this.height = 300, + this.barWidth = 16, + this.dotRadius = 4, + this.lineWidth = 3, + this.maxYAmount, + this.maxYCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // Convertir les données brutes en modèles structurés + final passagesByType = PassageUtils.getPassageDataByType(passageData); + final paymentsByType = PassageUtils.getPaymentDataByType(paymentData); + + // Extraire les dates uniques pour l'axe X + final List allDates = []; + for (final data in passageData) { + final DateTime date = data['date'] is DateTime + ? data['date'] + : DateTime.parse(data['date']); + if (!allDates.any((d) => + d.year == date.year && d.month == date.month && d.day == date.day)) { + allDates.add(date); + } + } + + // Trier les dates + allDates.sort((a, b) => a.compareTo(b)); + + // Calculer le maximum pour les axes Y + double maxAmount = 0; + for (final typeData in paymentsByType) { + for (final data in typeData) { + if (data.amount > maxAmount) { + maxAmount = data.amount; + } + } + } + + int maxCount = 0; + for (final typeData in passagesByType) { + for (final data in typeData) { + if (data.count > maxCount) { + maxCount = data.count; + } + } + } + + // Utiliser les maximums fournis ou calculés + final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble(); + final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil(); + + return SizedBox( + height: height, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: effectiveMaxYCount.toDouble(), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + tooltipPadding: const EdgeInsets.all(8), + tooltipMargin: 8, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final date = allDates[group.x.toInt()]; + final formattedDate = DateFormat('dd/MM').format(date); + + // Calculer le total des passages pour cette date + int totalPassages = 0; + for (final typeData in passagesByType) { + for (final data in typeData) { + if (data.date.year == date.year && + data.date.month == date.month && + data.date.day == date.day) { + totalPassages += data.count; + } + } + } + + return BarTooltipItem( + '$formattedDate: $totalPassages passages', + TextStyle( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value >= 0 && value < allDates.length) { + final date = allDates[value.toInt()]; + final formattedDate = + PassageUtils.formatDateForChart(date, periodType); + + return SideTitleWidget( + meta: meta, + space: 8, + child: Text( + formattedDate, + style: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontSize: 10, + ), + ), + ); + } + return const SizedBox(); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + return SideTitleWidget( + meta: meta, + space: 8, + child: Text( + value.toInt().toString(), + style: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontSize: 10, + ), + ), + ); + }, + reservedSize: 30, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + // Convertir la valeur de l'axe Y des passages à l'échelle des montants + final amountValue = + (value / effectiveMaxYCount) * effectiveMaxYAmount; + + return SideTitleWidget( + meta: meta, + space: 8, + child: Text( + '${amountValue.toInt()}€', + style: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontSize: 10, + ), + ), + ); + }, + reservedSize: 40, + ), + ), + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + getDrawingHorizontalLine: (value) { + return FlLine( + color: theme.dividerColor.withOpacity(0.2), + strokeWidth: 1, + ); + }, + drawVerticalLine: false, + ), + borderData: FlBorderData(show: false), + barGroups: _createBarGroups(allDates, passagesByType), + extraLinesData: ExtraLinesData( + horizontalLines: [], + verticalLines: [], + extraLinesOnTop: true, + ), + ), + swapAnimationDuration: const Duration(milliseconds: 250), + ), + ); + } + + /// Créer les groupes de barres pour les passages + List _createBarGroups( + List allDates, + List> passagesByType, + ) { + final List groups = []; + + for (int i = 0; i < allDates.length; i++) { + final date = allDates[i]; + + // Calculer le total des passages pour cette date + int totalPassages = 0; + for (final typeData in passagesByType) { + for (final data in typeData) { + if (data.date.year == date.year && + data.date.month == date.month && + data.date.day == date.day) { + totalPassages += data.count; + } + } + } + + // Créer un groupe de barres pour cette date + groups.add( + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: totalPassages.toDouble(), + color: Colors.blue.shade700, + width: barWidth, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6), + topRight: Radius.circular(6), + ), + ), + ], + ), + ); + } + + return groups; + } +} + +/// Widget de légende pour le graphique combiné +class CombinedChartLegend extends StatelessWidget { + const CombinedChartLegend({super.key}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _buildLegendItem('Passages', Colors.blue.shade700, isBar: true), + _buildLegendItem('Espèces', const Color(0xFF4CAF50)), + _buildLegendItem('Chèques', const Color(0xFF2196F3)), + _buildLegendItem('CB', const Color(0xFFF44336)), + ], + ); + } + + /// Créer un élément de légende + Widget _buildLegendItem(String label, Color color, {bool isBar = false}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + shape: isBar ? BoxShape.rectangle : BoxShape.circle, + borderRadius: isBar ? BorderRadius.circular(3) : null, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(fontSize: 12), + ), + ], + ); + } +} diff --git a/flutt/lib/presentation/widgets/charts/passage_data.dart b/flutt/lib/presentation/widgets/charts/passage_data.dart new file mode 100644 index 00000000..a4d429d7 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/passage_data.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +/// Modèle de données pour représenter un passage avec sa date, son type et son nombre +class PassageData { + /// Date du passage + final DateTime date; + + /// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.) + final int typeId; + + /// Nombre de passages + final int count; + + /// Couleur associée au type de passage + final Color color; + + /// Icône associée au type de passage (chemin vers le fichier d'icône) + final String iconPath; + + /// Titre du type de passage + final String title; + + const PassageData({ + required this.date, + required this.typeId, + required this.count, + required this.color, + required this.iconPath, + required this.title, + }); + + /// Crée une instance de PassageData à partir d'une date au format ISO 8601 + factory PassageData.fromIsoDate({ + required String isoDate, + required int typeId, + required int count, + required Color color, + required String iconPath, + required String title, + }) { + return PassageData( + date: DateTime.parse(isoDate), + typeId: typeId, + count: count, + color: color, + iconPath: iconPath, + title: title, + ); + } +} + +/// Modèle de données pour représenter un règlement avec sa date, son type et son montant +class PaymentAmountData { + /// Date du règlement + final DateTime date; + + /// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB) + final int typeId; + + /// Montant du règlement + final double amount; + + /// Couleur associée au type de règlement + final Color color; + + /// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData) + final dynamic iconData; + + /// Titre du type de règlement + final String title; + + /// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601 + factory PaymentAmountData.fromIsoDate({ + required String isoDate, + required int typeId, + required double amount, + required Color color, + required dynamic iconData, + required String title, + }) { + return PaymentAmountData( + date: DateTime.parse(isoDate), + typeId: typeId, + amount: amount, + color: color, + iconData: iconData, + title: title, + ); + } + + const PaymentAmountData({ + required this.date, + required this.typeId, + required this.amount, + required this.color, + required this.iconData, + required this.title, + }); +} diff --git a/flutt/lib/presentation/widgets/charts/passage_pie_chart.dart b/flutt/lib/presentation/widgets/charts/passage_pie_chart.dart new file mode 100644 index 00000000..18d65ae3 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/passage_pie_chart.dart @@ -0,0 +1,459 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/foundation.dart' show listEquals, mapEquals; +import 'package:intl/intl.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/repositories/passage_repository.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/core/services/passage_data_service.dart'; + +/// Modèle de données pour le graphique en camembert des passages +class PassageChartData { + /// Identifiant du type de passage + final int typeId; + + /// Nombre de passages de ce type + final int count; + + /// Titre du type de passage + final String title; + + /// Couleur associée au type de passage + final Color color; + + /// Icône associée au type de passage + final IconData icon; + + PassageChartData({ + required this.typeId, + required this.count, + required this.title, + required this.color, + required this.icon, + }); +} + +/// Widget de graphique en camembert pour représenter la répartition des passages par type +class PassagePieChart extends StatefulWidget { + /// Liste des données de passages par type sous forme de Map avec typeId et count + /// Si loadFromHive est true, ce paramètre est ignoré + final Map passagesByType; + + /// Taille du graphique + final double size; + + /// Taille des étiquettes + final double labelSize; + + /// Afficher les pourcentages + final bool showPercentage; + + /// Afficher les icônes + final bool showIcons; + + /// Afficher la légende + final bool showLegend; + + /// Format donut (anneau) + final bool isDonut; + + /// Rayon central pour le format donut (en pourcentage) + final String innerRadius; + + /// Charger les données depuis Hive + final bool loadFromHive; + + /// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true) + final int? userId; + + /// Types de passages à exclure (utilisé seulement si loadFromHive est true) + final List excludePassageTypes; + + /// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true) + final bool showAllPassages; + + const PassagePieChart({ + super.key, + this.passagesByType = const {}, + this.size = 300, + this.labelSize = 12, + this.showPercentage = true, + this.showIcons = true, + this.showLegend = true, + this.isDonut = false, + this.innerRadius = '40%', + this.loadFromHive = false, + this.userId, + this.excludePassageTypes = const [2], + this.showAllPassages = false, + }); + + @override + State createState() => _PassagePieChartState(); +} + +class _PassagePieChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + /// Données de passages par type + late Map _passagesByType; + + /// Variables pour la mise en cache et l'optimisation + bool _dataLoaded = false; + bool _isLoading = false; + List? _cachedChartData; + List? _cachedAnnotations; + + @override + void initState() { + super.initState(); + _passagesByType = widget.passagesByType; + + // Initialiser le contrôleur d'animation + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + ); + + _animationController.forward(); + + // Si nous n'utilisons pas Hive, préparer les données immédiatement + if (!widget.loadFromHive) { + _prepareChartData(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (widget.loadFromHive && !_dataLoaded && !_isLoading) { + _isLoading = true; // Prévenir les chargements multiples + _loadPassageDataFromHive(context); + } + } + + @override + void didUpdateWidget(PassagePieChart oldWidget) { + super.didUpdateWidget(oldWidget); + + // Vérifier si les propriétés importantes ont changé + final bool dataSourceChanged = widget.loadFromHive + ? false + : !mapEquals(oldWidget.passagesByType, widget.passagesByType); + final bool filteringChanged = oldWidget.userId != widget.userId || + !listEquals( + oldWidget.excludePassageTypes, widget.excludePassageTypes) || + oldWidget.showAllPassages != widget.showAllPassages; + final bool visualChanged = oldWidget.size != widget.size || + oldWidget.labelSize != widget.labelSize || + oldWidget.showPercentage != widget.showPercentage || + oldWidget.showIcons != widget.showIcons || + oldWidget.showLegend != widget.showLegend || + oldWidget.isDonut != widget.isDonut || + oldWidget.innerRadius != widget.innerRadius; + + // Si les paramètres de filtrage ou de source de données ont changé, recharger les données + if (dataSourceChanged || filteringChanged) { + _cachedChartData = null; + _cachedAnnotations = null; + + // Relancer l'animation si les données ont changé + _animationController.reset(); + _animationController.forward(); + + if (!widget.loadFromHive) { + _passagesByType = widget.passagesByType; + _prepareChartData(); + } else if (!_isLoading) { + _dataLoaded = false; + _isLoading = true; + _loadPassageDataFromHive(context); + } + } + // Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données + else if (visualChanged) { + _cachedAnnotations = null; + } + } + + /// Charge les données de passage depuis Hive en utilisant le service PassageDataService + void _loadPassageDataFromHive(BuildContext context) { + // Éviter les appels multiples + if (_isLoading && _dataLoaded) return; + + // Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie + WidgetsBinding.instance.addPostFrameCallback((_) { + // Vérifier si le widget est toujours monté + if (!mounted) return; + + try { + // Utiliser les instances globales définies dans app.dart + + // Créer une instance du service de données + final passageDataService = PassageDataService( + passageRepository: passageRepository, + userRepository: userRepository, + ); + + // Utiliser le service pour charger les données + final data = passageDataService.loadPassageDataForPieChart( + excludePassageTypes: widget.excludePassageTypes, + userId: widget.userId, + showAllPassages: widget.showAllPassages, + ); + + // Mettre à jour les données et les états + if (mounted) { + setState(() { + _passagesByType = data; + _dataLoaded = true; + _isLoading = false; + _cachedChartData = + null; // Forcer la régénération des données du graphique + _cachedAnnotations = null; + }); + + // Préparer les données du graphique + _prepareChartData(); + } + } catch (e) { + // Gérer les erreurs et réinitialiser l'état pour permettre une future tentative + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + }); + } + + /// Prépare les données pour le graphique en camembert avec mise en cache + List _prepareChartData() { + // Utiliser les données en cache si disponibles + if (_cachedChartData != null) { + return _cachedChartData!; + } + + final List chartData = []; + + // Créer les données du graphique + _passagesByType.forEach((typeId, count) { + // Vérifier que le type existe et que le compteur est positif + if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) { + final typeInfo = AppKeys.typesPassages[typeId]!; + chartData.add(PassageChartData( + typeId: typeId, + count: count, + title: typeInfo['titre'] as String, + color: Color(typeInfo['couleur2'] as int), + icon: typeInfo['icon_data'] as IconData, + )); + } + }); + + // Mettre en cache les données générées + _cachedChartData = chartData; + + return chartData; + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes + if (widget.loadFromHive && !_dataLoaded) { + return SizedBox( + width: widget.size, + height: widget.size, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + final chartData = _prepareChartData(); + + // Si aucune donnée, afficher un message + if (chartData.isEmpty) { + return SizedBox( + width: widget.size, + height: widget.size, + child: const Center( + child: Text('Aucune donnée disponible'), + ), + ); + } + + // Créer des animations pour différents aspects du graphique + final progressAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ); + + final explodeAnimation = CurvedAnimation( + parent: _animationController, + curve: Interval(0.7, 1.0, curve: Curves.elasticOut), + ); + + final opacityAnimation = CurvedAnimation( + parent: _animationController, + curve: Interval(0.1, 0.5, curve: Curves.easeIn), + ); + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return SizedBox( + width: widget.size, + height: widget.size, + child: SfCircularChart( + margin: EdgeInsets.zero, + legend: Legend( + isVisible: widget.showLegend, + position: LegendPosition.bottom, + overflowMode: LegendItemOverflowMode.wrap, + textStyle: TextStyle(fontSize: widget.labelSize), + ), + tooltipBehavior: TooltipBehavior(enable: true), + series: [ + widget.isDonut + ? DoughnutSeries( + dataSource: chartData, + xValueMapper: (PassageChartData data, _) => data.title, + yValueMapper: (PassageChartData data, _) => data.count, + pointColorMapper: (PassageChartData data, _) => + data.color, + enableTooltip: true, + dataLabelMapper: (PassageChartData data, _) { + if (widget.showPercentage) { + // Calculer le pourcentage avec une décimale + final total = chartData.fold( + 0, (sum, item) => sum + item.count); + final percentage = (data.count / total * 100); + return '${percentage.toStringAsFixed(1)}%'; + } else { + return data.title; + } + }, + dataLabelSettings: DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.outside, + textStyle: TextStyle(fontSize: widget.labelSize), + connectorLineSettings: const ConnectorLineSettings( + type: ConnectorType.curve, + length: '15%', + ), + ), + innerRadius: widget.innerRadius, + explode: true, + explodeIndex: 0, + explodeOffset: + '${(5 * explodeAnimation.value).toStringAsFixed(1)}%', + opacity: opacityAnimation.value, + animationDuration: + 0, // On désactive l'animation intégrée car nous utilisons notre propre animation + startAngle: 270, + endAngle: 270 + (360 * progressAnimation.value).toInt(), + ) + : PieSeries( + dataSource: chartData, + xValueMapper: (PassageChartData data, _) => data.title, + yValueMapper: (PassageChartData data, _) => data.count, + pointColorMapper: (PassageChartData data, _) => + data.color, + enableTooltip: true, + dataLabelMapper: (PassageChartData data, _) { + if (widget.showPercentage) { + // Calculer le pourcentage avec une décimale + final total = chartData.fold( + 0, (sum, item) => sum + item.count); + final percentage = (data.count / total * 100); + return '${percentage.toStringAsFixed(1)}%'; + } else { + return data.title; + } + }, + dataLabelSettings: DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.outside, + textStyle: TextStyle(fontSize: widget.labelSize), + connectorLineSettings: const ConnectorLineSettings( + type: ConnectorType.curve, + length: '15%', + ), + ), + explode: true, + explodeIndex: 0, + explodeOffset: + '${(5 * explodeAnimation.value).toStringAsFixed(1)}%', + opacity: opacityAnimation.value, + animationDuration: + 0, // On désactive l'animation intégrée car nous utilisons notre propre animation + startAngle: 270, + endAngle: 270 + (360 * progressAnimation.value).toInt(), + ), + ], + annotations: + widget.showIcons ? _buildIconAnnotations(chartData) : null, + ), + ); + }, + ); + } + + /// Crée les annotations d'icônes pour le graphique avec mise en cache + List _buildIconAnnotations( + List chartData) { + // Utiliser les annotations en cache si disponibles + if (_cachedAnnotations != null) { + return _cachedAnnotations!; + } + + final List annotations = []; + + // Calculer le total pour les pourcentages + int total = chartData.fold(0, (sum, item) => sum + item.count); + if (total == 0) return []; // Éviter la division par zéro + + // Position angulaire actuelle (en radians) + double currentAngle = 0; + + for (int i = 0; i < chartData.length; i++) { + final data = chartData[i]; + final percentage = data.count / total; + + // Calculer l'angle central de ce segment + final segmentAngle = percentage * 2 * 3.14159; + final midAngle = currentAngle + (segmentAngle / 2); + + // Ajouter une annotation pour l'icône + annotations.add( + CircularChartAnnotation( + widget: Icon( + data.icon, + color: Colors.white, + size: 16, + ), + radius: '50%', + angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés + ), + ); + + // Mettre à jour l'angle actuel + currentAngle += segmentAngle; + } + + // Mettre en cache les annotations générées + _cachedAnnotations = annotations; + + return annotations; + } +} diff --git a/flutt/lib/presentation/widgets/charts/passage_utils.dart b/flutt/lib/presentation/widgets/charts/passage_utils.dart new file mode 100644 index 00000000..a8665580 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/passage_utils.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/presentation/widgets/charts/passage_data.dart'; +import 'package:intl/intl.dart'; + +/// Utilitaires pour les passages et règlements +class PassageUtils { + /// Convertit les données de passage brutes en liste de PassageData + /// + /// [passageData] est une liste d'objets contenant date, type_passage et nb + static List> getPassageDataByType( + List> passageData) { + // Créer un Map pour stocker les données par type de passage + final Map> passagesByType = {}; + + // Initialiser les listes pour chaque type de passage + for (final entry in AppKeys.typesPassages.entries) { + passagesByType[entry.key] = []; + } + + // Grouper les passages par type + for (final data in passageData) { + final int typeId = data['type_passage']; + final int count = data['nb']; + + if (AppKeys.typesPassages.containsKey(typeId)) { + final typeData = AppKeys.typesPassages[typeId]!; + final Color color = Color(typeData['couleur1'] as int); + final String iconPath = typeData['icone'] as String; + final String title = typeData['titre'] as String; + + // Utiliser la méthode factory qui gère les dates au format ISO 8601 + if (data['date'] is String) { + passagesByType[typeId]!.add( + PassageData.fromIsoDate( + isoDate: data['date'], + typeId: typeId, + count: count, + color: color, + iconPath: iconPath, + title: title, + ), + ); + } else { + // Fallback pour les objets DateTime (pour compatibilité) + final DateTime date = data['date'] as DateTime; + passagesByType[typeId]!.add( + PassageData( + date: date, + typeId: typeId, + count: count, + color: color, + iconPath: iconPath, + title: title, + ), + ); + } + } + } + + // Convertir le Map en liste de listes + return passagesByType.values.toList(); + } + + /// Convertit les données de règlement brutes en liste de PaymentAmountData + /// + /// [paymentData] est une liste d'objets contenant date, type_reglement et montant + static List> getPaymentDataByType( + List> paymentData) { + // Créer un Map pour stocker les données par type de règlement + final Map> paymentsByType = {}; + + // Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement") + for (final entry in AppKeys.typesReglements.entries) { + if (entry.key > 0) { + // Ignorer le type 0 (Pas de règlement) + paymentsByType[entry.key] = []; + } + } + + // Grouper les règlements par type + for (final data in paymentData) { + final int typeId = data['type_reglement']; + final double amount = data['montant'] is double + ? data['montant'] + : double.parse(data['montant'].toString()); + + if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) { + final typeData = AppKeys.typesReglements[typeId]!; + final Color color = Color(typeData['couleur'] as int); + final dynamic iconData = _getIconForPaymentType(typeId); + final String title = typeData['titre'] as String; + + // Utiliser la méthode factory qui gère les dates au format ISO 8601 + if (data['date'] is String) { + paymentsByType[typeId]!.add( + PaymentAmountData.fromIsoDate( + isoDate: data['date'], + typeId: typeId, + amount: amount, + color: color, + iconData: iconData, + title: title, + ), + ); + } else { + // Fallback pour les objets DateTime (pour compatibilité) + final DateTime date = data['date'] as DateTime; + paymentsByType[typeId]!.add( + PaymentAmountData( + date: date, + typeId: typeId, + amount: amount, + color: color, + iconData: iconData, + title: title, + ), + ); + } + } + } + + // Convertir le Map en liste de listes + return paymentsByType.values.toList(); + } + + /// Génère des données de passage fictives pour les 14 derniers jours + static List> generateMockPassageData() { + final List> mockData = []; + final now = DateTime.now(); + + for (int i = 13; i >= 0; i--) { + final date = now.subtract(Duration(days: i)); + + // Ajouter des données pour chaque type de passage + for (int typeId = 1; typeId <= 6; typeId++) { + // Générer un nombre aléatoire de passages entre 0 et 5 + final count = (typeId == 1 || typeId == 2) + ? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2 + : (date.day % 3); // Moins pour les autres types + + if (count > 0) { + mockData.add({ + 'date': date, + 'type_passage': typeId, + 'nb': count, + }); + } + } + } + + return mockData; + } + + /// Génère des données de règlement fictives pour les 14 derniers jours + static List> generateMockPaymentData() { + final List> mockData = []; + final now = DateTime.now(); + + for (int i = 13; i >= 0; i--) { + final date = now.subtract(Duration(days: i)); + + // Ajouter des données pour chaque type de règlement + for (int typeId = 1; typeId <= 3; typeId++) { + // Générer un montant aléatoire + final amount = (typeId * 100.0) + (date.day * 10.0); + + mockData.add({ + 'date': date, + 'type_reglement': typeId, + 'montant': amount, + }); + } + } + + return mockData; + } + + /// Obtenir l'icône correspondant au type de règlement + /// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys + static IconData _getIconForPaymentType(int typeId) { + switch (typeId) { + case 1: // Espèces + return Icons.payments; + case 2: // Chèque + return Icons.money; + case 3: // CB + return Icons.credit_card; + default: + return Icons.euro; + } + } + + /// Formater une date pour l'affichage dans les graphiques + static String formatDateForChart(DateTime date, String periodType) { + switch (periodType.toLowerCase()) { + case 'jour': + return DateFormat('dd/MM').format(date); + case 'semaine': + // Calculer le numéro de la semaine dans l'année + final firstDayOfYear = DateTime(date.year, 1, 1); + final dayOfYear = date.difference(firstDayOfYear).inDays; + final weekNumber = + ((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil(); + return 'S$weekNumber'; + case 'mois': + return DateFormat('MMM').format(date); + case 'année': + return DateFormat('yyyy').format(date); + default: + return DateFormat('dd/MM').format(date); + } + } +} diff --git a/flutt/lib/presentation/widgets/charts/payment_data.dart b/flutt/lib/presentation/widgets/charts/payment_data.dart new file mode 100644 index 00000000..bbcf41f5 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/payment_data.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// Modèle de données pour représenter un type de règlement avec son montant +class PaymentData { + /// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB) + final int typeId; + + /// Montant du règlement + final double amount; + + /// Couleur associée au type de règlement + final Color color; + + /// Icône associée au type de règlement + final IconData icon; + + /// Titre du type de règlement + final String title; + + const PaymentData({ + required this.typeId, + required this.amount, + required this.color, + required this.icon, + required this.title, + }); +} diff --git a/flutt/lib/presentation/widgets/charts/payment_pie_chart.dart b/flutt/lib/presentation/widgets/charts/payment_pie_chart.dart new file mode 100644 index 00000000..951abe33 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/payment_pie_chart.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_data.dart'; +import 'dart:math' as math; + +/// Widget de graphique en camembert pour représenter la répartition des règlements +class PaymentPieChart extends StatefulWidget { + /// Liste des données de règlement à afficher dans le graphique + final List payments; + + /// Taille du graphique + final double size; + + /// Taille des étiquettes + final double labelSize; + + /// Afficher les pourcentages + final bool showPercentage; + + /// Afficher les icônes + final bool showIcons; + + /// Afficher la légende + final bool showLegend; + + /// Format donut (anneau) + final bool isDonut; + + /// Rayon central pour le format donut (en pourcentage) + final String innerRadius; + + /// Activer l'effet 3D + final bool enable3DEffect; + + /// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort) + final double effect3DIntensity; + + /// Activer l'effet d'explosion plus prononcé + final bool enableEnhancedExplode; + + /// Utiliser un dégradé pour simuler l'effet 3D + final bool useGradient; + + const PaymentPieChart({ + super.key, + required this.payments, + this.size = 300, + this.labelSize = 12, + this.showPercentage = true, + this.showIcons = true, + this.showLegend = true, + this.isDonut = false, + this.innerRadius = '40%', + this.enable3DEffect = false, + this.effect3DIntensity = 1.0, + this.enableEnhancedExplode = false, + this.useGradient = false, + }); + + @override + State createState() => _PaymentPieChartState(); +} + +class _PaymentPieChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + ); + + _animationController.forward(); + } + + @override + void didUpdateWidget(PaymentPieChart oldWidget) { + super.didUpdateWidget(oldWidget); + + // Relancer l'animation si les données ont changé + // Utiliser une comparaison plus stricte pour éviter des animations inutiles + bool shouldResetAnimation = false; + + if (oldWidget.payments.length != widget.payments.length) { + shouldResetAnimation = true; + } else { + // Comparer les éléments importants uniquement + for (int i = 0; i < oldWidget.payments.length; i++) { + if (i >= widget.payments.length) break; + if (oldWidget.payments[i].amount != widget.payments[i].amount || + oldWidget.payments[i].title != widget.payments[i].title) { + shouldResetAnimation = true; + break; + } + } + } + + if (shouldResetAnimation) { + _animationController.reset(); + _animationController.forward(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// Prépare les données pour le graphique en camembert + List _prepareChartData() { + // Filtrer les règlements avec un montant > 0 + return widget.payments.where((payment) => payment.amount > 0).toList(); + } + + @override + Widget build(BuildContext context) { + final chartData = _prepareChartData(); + + // Si aucune donnée, afficher un message + if (chartData.isEmpty) { + return SizedBox( + width: widget.size, + height: widget.size, + child: const Center( + child: Text('Aucune donnée disponible'), + ), + ); + } + + // Créer des animations pour différents aspects du graphique + final progressAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ); + + final explodeAnimation = CurvedAnimation( + parent: _animationController, + curve: Interval(0.7, 1.0, curve: Curves.elasticOut), + ); + + final opacityAnimation = CurvedAnimation( + parent: _animationController, + curve: Interval(0.1, 0.5, curve: Curves.easeIn), + ); + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return SizedBox( + width: widget.size, + height: widget.size, + child: SfCircularChart( + margin: EdgeInsets.zero, + legend: Legend( + isVisible: widget.showLegend, + position: LegendPosition.bottom, + overflowMode: LegendItemOverflowMode.wrap, + textStyle: TextStyle(fontSize: widget.labelSize), + ), + tooltipBehavior: TooltipBehavior(enable: true), + series: [ + widget.isDonut + ? DoughnutSeries( + dataSource: chartData, + xValueMapper: (PaymentData data, _) => data.title, + yValueMapper: (PaymentData data, _) => data.amount, + pointColorMapper: (PaymentData data, _) { + if (widget.enable3DEffect) { + // Utiliser un angle différent pour chaque segment pour simuler un effet 3D + final index = chartData.indexOf(data); + final angle = + (index / chartData.length) * 2 * math.pi; + return widget.useGradient + ? _createEnhanced3DColor(data.color, angle) + : _create3DColor( + data.color, widget.effect3DIntensity); + } + return data.color; + }, + // Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion + enableTooltip: true, + dataLabelMapper: (PaymentData data, _) { + if (widget.showPercentage) { + // Calculer le pourcentage avec une décimale + final total = chartData.fold( + 0.0, (sum, item) => sum + item.amount); + final percentage = (data.amount / total * 100); + return '${percentage.toStringAsFixed(1)}%'; + } else { + return data.title; + } + }, + dataLabelSettings: DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.outside, + textStyle: TextStyle(fontSize: widget.labelSize), + connectorLineSettings: const ConnectorLineSettings( + type: ConnectorType.curve, + length: '15%', + ), + ), + innerRadius: widget.innerRadius, + // Effet d'explosion plus prononcé pour donner du relief avec animation + explode: true, + explodeAll: widget.enableEnhancedExplode, + explodeIndex: widget.enableEnhancedExplode ? null : 0, + explodeOffset: widget.enableEnhancedExplode + ? widget.enable3DEffect + ? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%' + : '${(8 * explodeAnimation.value).toStringAsFixed(1)}%' + : '${(5 * explodeAnimation.value).toStringAsFixed(1)}%', + // Effet 3D via l'opacité et les couleurs avec animation + opacity: widget.enable3DEffect + ? 0.95 * opacityAnimation.value + : opacityAnimation.value, + // Animation progressive du graphique + animationDuration: + 0, // On désactive l'animation intégrée car nous utilisons notre propre animation + startAngle: 270, + endAngle: 270 + (360 * progressAnimation.value).toInt(), + ) + : PieSeries( + dataSource: chartData, + xValueMapper: (PaymentData data, _) => data.title, + yValueMapper: (PaymentData data, _) => data.amount, + pointColorMapper: (PaymentData data, _) { + if (widget.enable3DEffect) { + // Utiliser un angle différent pour chaque segment pour simuler un effet 3D + final index = chartData.indexOf(data); + final angle = + (index / chartData.length) * 2 * math.pi; + return widget.useGradient + ? _createEnhanced3DColor(data.color, angle) + : _create3DColor( + data.color, widget.effect3DIntensity); + } + return data.color; + }, + // Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion + enableTooltip: true, + dataLabelMapper: (PaymentData data, _) { + if (widget.showPercentage) { + // Calculer le pourcentage avec une décimale + final total = chartData.fold( + 0.0, (sum, item) => sum + item.amount); + final percentage = (data.amount / total * 100); + return '${percentage.toStringAsFixed(1)}%'; + } else { + return data.title; + } + }, + dataLabelSettings: DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.outside, + textStyle: TextStyle(fontSize: widget.labelSize), + connectorLineSettings: const ConnectorLineSettings( + type: ConnectorType.curve, + length: '15%', + ), + ), + // Effet d'explosion plus prononcé pour donner du relief avec animation + explode: true, + explodeAll: widget.enableEnhancedExplode, + explodeIndex: widget.enableEnhancedExplode ? null : 0, + explodeOffset: widget.enableEnhancedExplode + ? widget.enable3DEffect + ? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%' + : '${(8 * explodeAnimation.value).toStringAsFixed(1)}%' + : '${(5 * explodeAnimation.value).toStringAsFixed(1)}%', + // Effet 3D via l'opacité et les couleurs avec animation + opacity: widget.enable3DEffect + ? 0.95 * opacityAnimation.value + : opacityAnimation.value, + // Animation progressive du graphique + animationDuration: + 0, // On désactive l'animation intégrée car nous utilisons notre propre animation + startAngle: 270, + endAngle: 270 + (360 * progressAnimation.value).toInt(), + ), + ], + annotations: + widget.showIcons ? _buildIconAnnotations(chartData) : null, + // Paramètres pour améliorer l'effet 3D + palette: widget.enable3DEffect ? _create3DPalette(chartData) : null, + // Ajouter un effet de bordure pour renforcer l'effet 3D + borderWidth: widget.enable3DEffect ? 0.5 : 0, + // Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion + ), + ); + }, + ); + } + + /// Crée une couleur avec effet 3D en ajoutant des nuances + Color _create3DColor(Color baseColor, double intensity) { + // Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé + final hslColor = HSLColor.fromColor(baseColor); + + // Augmenter la luminosité pour simuler un éclairage + final adjustedLightness = + (hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0); + + // Augmenter légèrement la saturation pour des couleurs plus vives + final adjustedSaturation = + (hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0); + + return hslColor + .withLightness(adjustedLightness) + .withSaturation(adjustedSaturation) + .toColor(); + } + + /// Crée une palette de couleurs pour l'effet 3D + List _create3DPalette(List chartData) { + List palette = []; + + // Créer des variations de couleurs pour chaque segment + for (var i = 0; i < chartData.length; i++) { + var data = chartData[i]; + + // Calculer un angle pour chaque segment pour simuler un éclairage directionnel + final angle = (i / chartData.length) * 2 * math.pi; + + // Créer un effet d'ombre et de lumière en fonction de l'angle + final hslColor = HSLColor.fromColor(data.color); + + // Ajuster la luminosité en fonction de l'angle + final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle); + final adjustedLightness = (hslColor.lightness - + 0.1 * widget.effect3DIntensity + + lightAdjustment) + .clamp(0.0, 1.0); + + // Ajuster la saturation pour plus de profondeur + final adjustedSaturation = + (hslColor.saturation + 0.1 * widget.effect3DIntensity) + .clamp(0.0, 1.0); + + final enhancedColor = hslColor + .withLightness(adjustedLightness) + .withSaturation(adjustedSaturation) + .toColor(); + + palette.add(enhancedColor); + } + + return palette; + } + + /// Crée une couleur avec effet 3D plus avancé + Color _createEnhanced3DColor(Color baseColor, double angle) { + // Simuler un effet de lumière directionnel + final hslColor = HSLColor.fromColor(baseColor); + + // Ajuster la luminosité en fonction de l'angle pour simuler un éclairage + final adjustedLightness = hslColor.lightness + + (0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3); + + return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor(); + } + + /// Crée les annotations d'icônes pour le graphique + List _buildIconAnnotations( + List chartData) { + final List annotations = []; + + // Calculer le total pour les pourcentages + double total = chartData.fold(0.0, (sum, item) => sum + item.amount); + + // Position angulaire actuelle (en radians) + double currentAngle = 0; + + for (int i = 0; i < chartData.length; i++) { + final data = chartData[i]; + final percentage = data.amount / total; + + // Calculer l'angle central de ce segment + final segmentAngle = percentage * 2 * 3.14159; + final midAngle = currentAngle + (segmentAngle / 2); + + // Ajouter une annotation pour l'icône + annotations.add( + CircularChartAnnotation( + widget: Icon( + data.icon, + color: Colors.white, + size: 16, + ), + radius: '50%', + angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés + ), + ); + + // Mettre à jour l'angle actuel + currentAngle += segmentAngle; + } + + return annotations; + } +} diff --git a/flutt/lib/presentation/widgets/charts/payment_utils.dart b/flutt/lib/presentation/widgets/charts/payment_utils.dart new file mode 100644 index 00000000..a15e8c68 --- /dev/null +++ b/flutt/lib/presentation/widgets/charts/payment_utils.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/presentation/widgets/charts/payment_data.dart'; + +/// Utilitaires pour les paiements et règlements +class PaymentUtils { + /// Convertit les données de règlement depuis les constantes AppKeys + /// + /// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant + static List getPaymentDataFromAmounts( + Map paymentAmounts) { + final List paymentDataList = []; + + // Parcourir tous les types de règlements définis dans AppKeys + AppKeys.typesReglements.forEach((typeId, typeData) { + // Vérifier si nous avons un montant pour ce type de règlement + final double amount = paymentAmounts[typeId] ?? 0.0; + + // Créer un objet PaymentData pour ce type de règlement + final PaymentData paymentData = PaymentData( + typeId: typeId, + amount: amount, + color: Color(typeData['couleur'] as int), + icon: typeData['icon_data'] as IconData, + title: typeData['titre'] as String, + ); + + paymentDataList.add(paymentData); + }); + + return paymentDataList; + } +} diff --git a/flutt/lib/presentation/widgets/chat/chat_input.dart b/flutt/lib/presentation/widgets/chat/chat_input.dart new file mode 100644 index 00000000..e4f4172c --- /dev/null +++ b/flutt/lib/presentation/widgets/chat/chat_input.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +/// Widget pour la zone de saisie des messages +class ChatInput extends StatefulWidget { + final Function(String) onMessageSent; + + const ChatInput({ + Key? key, + required this.onMessageSent, + }) : super(key: key); + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + final TextEditingController _controller = TextEditingController(); + bool _isComposing = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.spacingM, + vertical: AppTheme.spacingS, + ), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // Bouton pour ajouter des pièces jointes + IconButton( + icon: const Icon(Icons.attach_file), + color: AppTheme.primaryColor, + onPressed: () { + // Afficher les options de pièces jointes + _showAttachmentOptions(context); + }, + ), + + // Champ de saisie du message + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: 'Écrivez votre message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.grey[100], + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + textCapitalization: TextCapitalization.sentences, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + onChanged: (text) { + setState(() { + _isComposing = text.isNotEmpty; + }); + }, + ), + ), + + // Bouton d'envoi + IconButton( + icon: Icon( + _isComposing ? Icons.send : Icons.mic, + color: _isComposing ? AppTheme.primaryColor : Colors.grey[600], + ), + onPressed: _isComposing + ? () { + final text = _controller.text.trim(); + if (text.isNotEmpty) { + widget.onMessageSent(text); + _controller.clear(); + setState(() { + _isComposing = false; + }); + } + } + : () { + // Activer la reconnaissance vocale + }, + ), + ], + ), + ); + } + + // Afficher les options de pièces jointes + void _showAttachmentOptions(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.symmetric( + vertical: AppTheme.spacingL, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Ajouter une pièce jointe', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: AppTheme.spacingL), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildAttachmentOption( + context, + Icons.photo, + 'Photo', + Colors.green, + () { + Navigator.pop(context); + // Sélectionner une photo + }, + ), + _buildAttachmentOption( + context, + Icons.camera_alt, + 'Caméra', + Colors.blue, + () { + Navigator.pop(context); + // Prendre une photo + }, + ), + _buildAttachmentOption( + context, + Icons.insert_drive_file, + 'Document', + Colors.orange, + () { + Navigator.pop(context); + // Sélectionner un document + }, + ), + _buildAttachmentOption( + context, + Icons.location_on, + 'Position', + Colors.red, + () { + Navigator.pop(context); + // Partager la position + }, + ), + ], + ), + const SizedBox(height: AppTheme.spacingL), + ], + ), + ), + ); + } + + // Construire une option de pièce jointe + Widget _buildAttachmentOption( + BuildContext context, + IconData icon, + String label, + Color color, + VoidCallback onTap, + ) { + return InkWell( + onTap: onTap, + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: color, + size: 28, + ), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: Colors.grey[800], + fontSize: 12, + ), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/widgets/chat/chat_messages.dart b/flutt/lib/presentation/widgets/chat/chat_messages.dart new file mode 100644 index 00000000..11ffceee --- /dev/null +++ b/flutt/lib/presentation/widgets/chat/chat_messages.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +/// Widget pour afficher les messages d'une conversation +class ChatMessages extends StatelessWidget { + final List> messages; + final int currentUserId; + final Function(Map) onReply; + + const ChatMessages({ + Key? key, + required this.messages, + required this.currentUserId, + required this.onReply, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return messages.isEmpty + ? const Center( + child: Text('Aucun message dans cette conversation'), + ) + : ListView.builder( + padding: const EdgeInsets.all(AppTheme.spacingM), + itemCount: messages.length, + reverse: + false, // Afficher les messages du plus ancien au plus récent + itemBuilder: (context, index) { + final message = messages[index]; + final isCurrentUser = message['senderId'] == currentUserId; + final hasReply = message['replyTo'] != null; + + return Padding( + padding: const EdgeInsets.only(bottom: AppTheme.spacingM), + child: Column( + crossAxisAlignment: isCurrentUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + // Afficher le message auquel on répond + if (hasReply) ...[ + Container( + margin: EdgeInsets.only( + left: isCurrentUser ? 0 : 40, + right: isCurrentUser ? 40 : 0, + bottom: 4, + ), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Réponse à ${message['replyTo']['senderName']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: AppTheme.primaryColor, + ), + ), + Text( + message['replyTo']['message'], + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + + // Message principal + Row( + mainAxisAlignment: isCurrentUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar (seulement pour les messages des autres) + if (!isCurrentUser) + CircleAvatar( + radius: 16, + backgroundColor: + AppTheme.primaryColor.withOpacity(0.2), + backgroundImage: message['avatar'] != null + ? AssetImage(message['avatar'] as String) + : null, + child: message['avatar'] == null + ? Text( + message['senderName'].isNotEmpty + ? message['senderName'][0].toUpperCase() + : '', + style: const TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ) + : null, + ), + + const SizedBox(width: 8), + + // Contenu du message + Flexible( + child: Column( + crossAxisAlignment: isCurrentUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + // Nom de l'expéditeur (seulement pour les messages des autres) + if (!isCurrentUser) + Padding( + padding: + const EdgeInsets.only(left: 4, bottom: 2), + child: Text( + message['senderName'], + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + + // Bulle de message + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isCurrentUser + ? AppTheme.primaryColor + : Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + message['message'], + style: TextStyle( + color: isCurrentUser + ? Colors.white + : Colors.black87, + ), + ), + ), + + // Heure et statut + Padding( + padding: const EdgeInsets.only(top: 4, left: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message['time']), + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 4), + if (isCurrentUser) + Icon( + message['isRead'] + ? Icons.done_all + : Icons.done, + size: 12, + color: message['isRead'] + ? Colors.blue + : Colors.grey[600], + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(width: 8), + + // Menu d'actions (seulement pour les messages des autres) + if (!isCurrentUser) + PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 16, + color: Colors.grey[600], + ), + padding: EdgeInsets.zero, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'reply', + child: Row( + children: [ + Icon(Icons.reply, size: 16), + SizedBox(width: 8), + Text('Répondre'), + ], + ), + ), + const PopupMenuItem( + value: 'copy', + child: Row( + children: [ + Icon(Icons.content_copy, size: 16), + SizedBox(width: 8), + Text('Copier'), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'reply') { + onReply(message); + } else if (value == 'copy') { + // Copier le message + } + }, + ), + ], + ), + ], + ), + ); + }, + ); + } + + // Formater l'heure du message + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/flutt/lib/presentation/widgets/chat/chat_sidebar.dart b/flutt/lib/presentation/widgets/chat/chat_sidebar.dart new file mode 100644 index 00000000..161904ab --- /dev/null +++ b/flutt/lib/presentation/widgets/chat/chat_sidebar.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +/// Widget pour afficher la barre latérale des contacts +class ChatSidebar extends StatelessWidget { + final List> teamContacts; + final List> clientContacts; + final bool isTeamChat; + final int selectedContactId; + final Function(int, String, bool) onContactSelected; + final Function(bool) onToggleGroup; + + const ChatSidebar({ + Key? key, + required this.teamContacts, + required this.clientContacts, + required this.isTeamChat, + required this.selectedContactId, + required this.onContactSelected, + required this.onToggleGroup, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // En-tête avec les onglets + Container( + padding: const EdgeInsets.all(AppTheme.spacingM), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: _buildTabButton( + context, + 'Équipe', + isTeamChat, + () => onToggleGroup(true), + ), + ), + const SizedBox(width: AppTheme.spacingS), + Expanded( + child: _buildTabButton( + context, + 'Clients', + !isTeamChat, + () => onToggleGroup(false), + ), + ), + ], + ), + ), + + // Liste des contacts + Expanded( + child: Container( + color: Colors.grey[100], + child: ListView( + padding: EdgeInsets.zero, + children: [ + // Afficher les contacts appropriés en fonction de l'onglet sélectionné + ...isTeamChat + ? teamContacts.map( + (contact) => _buildContactItem(context, contact, true)) + : clientContacts.map((contact) => + _buildContactItem(context, contact, false)), + ], + ), + ), + ), + ], + ); + } + + // Construire un bouton d'onglet + Widget _buildTabButton( + BuildContext context, + String label, + bool isSelected, + VoidCallback onPressed, + ) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? AppTheme.primaryColor : Colors.grey[200], + foregroundColor: isSelected ? Colors.white : Colors.black, + elevation: isSelected ? 2 : 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall), + ), + ), + child: Text(label), + ); + } + + // Construire un élément de contact + Widget _buildContactItem( + BuildContext context, + Map contact, + bool isTeam, + ) { + final bool isSelected = contact['id'] == selectedContactId; + final bool hasUnread = (contact['unread'] as int) > 0; + + return ListTile( + selected: isSelected, + selectedTileColor: Colors.blue.withOpacity(0.1), + leading: CircleAvatar( + backgroundColor: AppTheme.primaryColor.withOpacity(0.2), + backgroundImage: contact['avatar'] != null + ? AssetImage(contact['avatar'] as String) + : null, + child: contact['avatar'] == null + ? Text( + (contact['name'] as String).isNotEmpty + ? (contact['name'] as String)[0].toUpperCase() + : '', + style: const TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + title: Row( + children: [ + Expanded( + child: Text( + contact['name'] as String, + style: TextStyle( + fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (contact['online'] == true) + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + subtitle: Text( + contact['lastMessage'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal, + color: hasUnread ? Colors.black87 : Colors.grey[600], + ), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatTime(contact['time'] as DateTime), + style: TextStyle( + fontSize: 12, + color: hasUnread ? AppTheme.primaryColor : Colors.grey[500], + ), + ), + const SizedBox(height: 4), + if (hasUnread) + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.primaryColor, + shape: BoxShape.circle, + ), + child: Text( + (contact['unread'] as int).toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + onTap: () => onContactSelected( + contact['id'] as int, + contact['name'] as String, + isTeam, + ), + ); + } + + // Formater l'heure du dernier message + String _formatTime(DateTime time) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final messageDate = DateTime(time.year, time.month, time.day); + + if (messageDate == today) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } else if (messageDate == yesterday) { + return 'Hier'; + } else { + return '${time.day}/${time.month}'; + } + } +} diff --git a/flutt/lib/presentation/widgets/connectivity_indicator.dart b/flutt/lib/presentation/widgets/connectivity_indicator.dart new file mode 100644 index 00000000..2c39fd56 --- /dev/null +++ b/flutt/lib/presentation/widgets/connectivity_indicator.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/core/services/connectivity_service.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales + +/// Widget qui affiche l'état de la connexion Internet +class ConnectivityIndicator extends StatelessWidget { + /// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté + final bool showErrorMessage; + + /// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles) + final bool showConnectionType; + + /// Callback appelé lorsque l'état de la connexion change + final Function(bool isConnected)? onConnectivityChanged; + + const ConnectivityIndicator({ + super.key, + this.showErrorMessage = true, + this.showConnectionType = true, + this.onConnectivityChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // Utiliser l'instance globale de connectivityService définie dans app.dart + final isConnected = connectivityService.isConnected; + final connectionType = connectivityService.connectionType; + final connectionStatus = connectivityService.connectionStatus; + + // Appeler le callback si fourni, mais pas directement dans le build + // pour éviter les problèmes de rendu + WidgetsBinding.instance.addPostFrameCallback((_) { + if (onConnectivityChanged != null) { + onConnectivityChanged!(isConnected); + } + }); + + if (!isConnected && showErrorMessage) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: theme.colorScheme.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.wifi_off, + color: theme.colorScheme.error, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ); + } else if (isConnected && showConnectionType) { + // Obtenir la couleur et l'icône en fonction du type de connexion + final color = _getConnectionColor(connectionStatus, theme); + final icon = _getConnectionIcon(connectionStatus); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: color.withOpacity(0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: color, + size: 14, + ), + const SizedBox(width: 4), + Text( + connectionType, + style: theme.textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + // Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false + return const SizedBox.shrink(); + } + + /// Retourne l'icône correspondant au type de connexion + IconData _getConnectionIcon(List statusList) { + // Utiliser le premier type de connexion qui n'est pas 'none' + ConnectivityResult status = statusList.firstWhere( + (result) => result != ConnectivityResult.none, + orElse: () => ConnectivityResult.none); + + switch (status) { + case ConnectivityResult.wifi: + return Icons.wifi; + case ConnectivityResult.mobile: + return Icons.signal_cellular_alt; + case ConnectivityResult.ethernet: + return Icons.lan; + case ConnectivityResult.bluetooth: + return Icons.bluetooth; + case ConnectivityResult.vpn: + return Icons.vpn_key; + default: + return Icons.wifi_off; + } + } + + /// Retourne la couleur correspondant au type de connexion + Color _getConnectionColor( + List statusList, ThemeData theme) { + // Utiliser le premier type de connexion qui n'est pas 'none' + ConnectivityResult status = statusList.firstWhere( + (result) => result != ConnectivityResult.none, + orElse: () => ConnectivityResult.none); + + switch (status) { + case ConnectivityResult.wifi: + return Colors.green; + case ConnectivityResult.mobile: + return Colors.blue; + case ConnectivityResult.ethernet: + return Colors.purple; + case ConnectivityResult.bluetooth: + return Colors.indigo; + case ConnectivityResult.vpn: + return Colors.orange; + default: + return theme.colorScheme.error; + } + } +} diff --git a/flutt/lib/presentation/widgets/custom_button.dart b/flutt/lib/presentation/widgets/custom_button.dart new file mode 100644 index 00000000..b41da8fe --- /dev/null +++ b/flutt/lib/presentation/widgets/custom_button.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +class CustomButton extends StatelessWidget { + final VoidCallback? onPressed; + final String text; + final IconData? icon; + final bool isLoading; + final double? width; + final Color? backgroundColor; + final Color? textColor; + + const CustomButton({ + super.key, + required this.onPressed, + required this.text, + this.icon, + this.isLoading = false, + this.width, + this.backgroundColor, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: width, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor ?? theme.colorScheme.primary, + foregroundColor: textColor ?? Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + textColor ?? Colors.white, + ), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon), + const SizedBox(width: 8), + ], + Text( + text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/flutt/lib/presentation/widgets/custom_text_field.dart b/flutt/lib/presentation/widgets/custom_text_field.dart new file mode 100644 index 00000000..589de433 --- /dev/null +++ b/flutt/lib/presentation/widgets/custom_text_field.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String? hintText; + final IconData? prefixIcon; + final Widget? suffixIcon; + final bool obscureText; + final TextInputType keyboardType; + final String? Function(String?)? validator; + final List? inputFormatters; + final int? maxLines; + final int? minLines; + final bool readOnly; + final VoidCallback? onTap; + final Function(String)? onChanged; + final bool autofocus; + final FocusNode? focusNode; + final String? errorText; + final Color? fillColor; + final String? helperText; + final Function(String)? onFieldSubmitted; + + const CustomTextField({ + super.key, + required this.controller, + required this.label, + this.hintText, + this.prefixIcon, + this.suffixIcon, + this.obscureText = false, + this.keyboardType = TextInputType.text, + this.validator, + this.inputFormatters, + this.maxLines = 1, + this.minLines, + this.readOnly = false, + this.onTap, + this.onChanged, + this.autofocus = false, + this.focusNode, + this.errorText, + this.fillColor, + this.helperText, + this.onFieldSubmitted, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label.isNotEmpty) ...[ + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + ], + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + validator: validator, + inputFormatters: inputFormatters, + maxLines: maxLines, + minLines: minLines, + readOnly: readOnly, + onTap: onTap, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + autofocus: autofocus, + focusNode: focusNode, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onBackground, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onBackground.withOpacity(0.5), + ), + errorText: errorText, + helperText: helperText, + helperStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onBackground.withOpacity(0.6), + ), + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: theme.colorScheme.primary) + : null, + suffixIcon: suffixIcon, + fillColor: fillColor ?? theme.inputDecorationTheme.fillColor, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 2, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + ), + ], + ); + } +} diff --git a/flutt/lib/presentation/widgets/dashboard_app_bar.dart b/flutt/lib/presentation/widgets/dashboard_app_bar.dart new file mode 100644 index 00000000..cabe265a --- /dev/null +++ b/flutt/lib/presentation/widgets/dashboard_app_bar.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; +import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart'; +import 'package:go_router/go_router.dart'; + +/// AppBar personnalisée pour les tableaux de bord +class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Le titre principal de l'AppBar (généralement le nom de l'application) + final String title; + + /// Le titre de la page actuelle (optionnel) + final String? pageTitle; + + /// Actions supplémentaires à afficher dans l'AppBar + final List? additionalActions; + + /// Indique si le bouton "Nouveau passage" doit être affiché + final bool showNewPassageButton; + + /// Callback appelé lorsque le bouton "Nouveau passage" est pressé + final VoidCallback? onNewPassagePressed; + + /// Indique si l'utilisateur est un administrateur + final bool isAdmin; + + /// Callback appelé lorsque le bouton de déconnexion est pressé + final VoidCallback? onLogoutPressed; + + const DashboardAppBar({ + Key? key, + required this.title, + this.pageTitle, + this.additionalActions, + this.showNewPassageButton = true, + this.onNewPassagePressed, + this.isAdmin = false, + this.onLogoutPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AppBar( + title: _buildTitle(context), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + elevation: 4, + leading: _buildLogo(), + actions: _buildActions(context), + ); + } + + /// Construction du logo dans l'AppBar + Widget _buildLogo() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Image.asset( + 'assets/images/geosector-logo-80.png', + width: 40, + height: 40, + ), + ); + } + + /// Construction des actions de l'AppBar + List _buildActions(BuildContext context) { + final theme = Theme.of(context); + final List actions = []; + + // Ajouter l'indicateur de connectivité + actions.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), + child: const ConnectivityIndicator( + showErrorMessage: false, + showConnectionType: true, + ), + ), + ); + + // Ajouter les actions supplémentaires si elles existent + if (additionalActions != null && additionalActions!.isNotEmpty) { + actions.addAll(additionalActions!); + } else if (showNewPassageButton) { + // Ajouter le bouton "Nouveau passage" en haut à droite + actions.add( + TextButton.icon( + icon: const Icon(Icons.add_location_alt, color: Colors.white), + label: const Text('Nouveau passage', + style: TextStyle(color: Colors.white)), + onPressed: onNewPassagePressed, + style: TextButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + } + + // Ajouter le bouton de déconnexion + actions.add( + IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Déconnexion', + onPressed: onLogoutPressed ?? + () { + // Si aucun callback n'est fourni, utiliser le userRepository global + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Déconnexion'), + content: + const Text('Voulez-vous vraiment vous déconnecter ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + // Appeler la méthode logout du userRepository + // qui nettoie les hive boxes, lance la requête API logout + // et supprime user.sessionId + await userRepository.logout(); + + // Rediriger vers la landing page + if (context.mounted) { + // Utiliser go_router pour la navigation + context.go('/public'); + } + }, + child: const Text('Déconnexion'), + ), + ], + ), + ); + }, + ), + ); + + actions.add(const SizedBox(width: 8)); // Espacement à droite + + return actions; + } + + /// Construction du titre de l'AppBar + Widget _buildTitle(BuildContext context) { + // Si aucun titre de page n'est fourni, afficher simplement le titre principal + if (pageTitle == null) { + return Text(title); + } + + // Construire un titre composé en fonction du rôle de l'utilisateur + final String prefix = isAdmin ? 'Administration' : title; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(prefix), + const Text(' - '), + Text(pageTitle!), + ], + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/flutt/lib/presentation/widgets/dashboard_layout.dart b/flutt/lib/presentation/widgets/dashboard_layout.dart new file mode 100644 index 00000000..d04f286f --- /dev/null +++ b/flutt/lib/presentation/widgets/dashboard_layout.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart'; +import 'package:geosector_app/presentation/widgets/responsive_navigation.dart'; + +/// Layout commun pour les tableaux de bord utilisateur et administrateur +/// Combine DashboardAppBar et ResponsiveNavigation +class DashboardLayout extends StatelessWidget { + /// Le contenu principal à afficher + final Widget body; + + /// Le titre de la page + final String title; + + /// L'index de la page sélectionnée + final int selectedIndex; + + /// Callback appelé lorsqu'un élément de navigation est sélectionné + final Function(int) onDestinationSelected; + + /// Liste des destinations de navigation + final List destinations; + + /// Actions supplémentaires à afficher dans l'AppBar + final List? additionalActions; + + /// Indique si le bouton "Nouveau passage" doit être affiché + final bool showNewPassageButton; + + /// Callback appelé lorsque le bouton "Nouveau passage" est pressé + final VoidCallback? onNewPassagePressed; + + /// Widgets à afficher en bas de la sidebar + final List? sidebarBottomItems; + + /// Indique si l'utilisateur est un administrateur + final bool isAdmin; + + /// Callback appelé lorsque le bouton de déconnexion est pressé + final VoidCallback? onLogoutPressed; + + const DashboardLayout({ + Key? key, + required this.body, + required this.title, + required this.selectedIndex, + required this.onDestinationSelected, + required this.destinations, + this.additionalActions, + this.showNewPassageButton = true, + this.onNewPassagePressed, + this.sidebarBottomItems, + this.isAdmin = false, + this.onLogoutPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + try { + debugPrint('Building DashboardLayout'); + + // Vérifier que les destinations ne sont pas vides + if (destinations.isEmpty) { + debugPrint('ERREUR: destinations est vide dans DashboardLayout'); + return const Scaffold( + body: Center( + child: Text('Erreur: Aucune destination de navigation disponible'), + ), + ); + } + + // Vérifier que selectedIndex est valide + if (selectedIndex < 0 || selectedIndex >= destinations.length) { + debugPrint('ERREUR: selectedIndex invalide dans DashboardLayout'); + return Scaffold( + body: Center( + child: + Text('Erreur: Index de navigation invalide ($selectedIndex)'), + ), + ); + } + + return Scaffold( + appBar: DashboardAppBar( + title: title, + pageTitle: destinations[selectedIndex].label, + additionalActions: additionalActions, + showNewPassageButton: showNewPassageButton, + onNewPassagePressed: onNewPassagePressed, + isAdmin: isAdmin, + onLogoutPressed: onLogoutPressed, + ), + body: ResponsiveNavigation( + title: + title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence + body: body, + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + destinations: destinations, + // Ne pas afficher le bouton "Nouveau passage" dans la navigation car il est déjà dans l'AppBar + showNewPassageButton: false, + onNewPassagePressed: onNewPassagePressed, + sidebarBottomItems: sidebarBottomItems, + isAdmin: isAdmin, + // Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar + showAppBar: false, + ), + ); + } catch (e) { + debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e'); + // Afficher une interface de secours en cas d'erreur + return Scaffold( + appBar: AppBar( + title: Text('Erreur - $title'), + backgroundColor: Colors.red, + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 64), + const SizedBox(height: 16), + const Text( + 'Une erreur est survenue', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text('Détails: $e'), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + Navigator.of(context) + .pushNamedAndRemoveUntil('/', (route) => false); + }, + child: const Text('Retour à l\'accueil'), + ), + ], + ), + ), + ); + } + } +} diff --git a/flutt/lib/presentation/widgets/help_dialog.dart b/flutt/lib/presentation/widgets/help_dialog.dart new file mode 100644 index 00000000..7e75c674 --- /dev/null +++ b/flutt/lib/presentation/widgets/help_dialog.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +/// Widget d'aide commun pour toute l'application +/// Affiche une boîte de dialogue modale avec une aide contextuelle +/// basée sur la page courante +class HelpDialog extends StatelessWidget { + /// Nom de la page courante pour laquelle l'aide est demandée + final String currentPage; + + const HelpDialog({ + Key? key, + required this.currentPage, + }) : super(key: key); + + /// Affiche la boîte de dialogue d'aide + static void show(BuildContext context, String currentPage) { + showDialog( + context: context, + builder: (context) => HelpDialog(currentPage: currentPage), + ); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final theme = Theme.of(context); + + // Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau + final isDesktop = size.width > 900; + + // Calculer la largeur de la boîte de dialogue + // 90% de la largeur de l'écran pour les mobiles + // 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px) + final dialogWidth = isDesktop + ? size.width * 0.5 > 600 + ? 600.0 + : size.width * 0.5 + : size.width * 0.9; + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + // Définir la largeur de la boîte de dialogue + child: Container( + width: dialogWidth, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre de l'aide avec le nom de la page courante + Row( + children: [ + Icon( + Icons.help_outline, + color: theme.colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Aide - Page $currentPage', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 32), + // Contenu de l'aide (à personnaliser selon la page) + Text( + 'Contenu d\'aide pour la page "$currentPage".', + style: theme.textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'Cette section sera personnalisée avec des instructions spécifiques pour chaque page de l\'application.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + // Bouton pour fermer la boîte de dialogue + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text('Fermer'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutt/lib/presentation/widgets/loading_overlay.dart b/flutt/lib/presentation/widgets/loading_overlay.dart new file mode 100644 index 00000000..3938fb3f --- /dev/null +++ b/flutt/lib/presentation/widgets/loading_overlay.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +/// Widget d'overlay de chargement qui affiche un spinner centré avec un message optionnel +/// Utilisé pour les opérations longues comme la connexion, déconnexion et synchronisation +class LoadingOverlay extends StatelessWidget { + final String? message; + final Color backgroundColor; + final Color spinnerColor; + final Color textColor; + final double spinnerSize; + final double strokeWidth; + + const LoadingOverlay({ + Key? key, + this.message, + this.backgroundColor = Colors.black54, + this.spinnerColor = Colors.white, + this.textColor = Colors.white, + this.spinnerSize = 60.0, + this.strokeWidth = 5.0, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + color: backgroundColor, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: spinnerSize, + height: spinnerSize, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(spinnerColor), + strokeWidth: strokeWidth, + ), + ), + if (message != null) ...[ // Afficher le texte seulement si message n'est pas null + const SizedBox(height: 24), + Text( + message!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } + + /// Méthode statique pour afficher l'overlay de chargement + static Future show({ + required BuildContext context, + required Future future, + String? message, + double spinnerSize = 60.0, + double strokeWidth = 5.0, + }) async { + // Afficher l'overlay + final overlayEntry = OverlayEntry( + builder: (context) => LoadingOverlay( + message: message, + spinnerSize: spinnerSize, + strokeWidth: strokeWidth, + ), + ); + + Overlay.of(context).insert(overlayEntry); + + try { + // Attendre que le future se termine + final result = await future; + // Supprimer l'overlay + overlayEntry.remove(); + return result; + } catch (e) { + // En cas d'erreur, supprimer l'overlay et relancer l'erreur + overlayEntry.remove(); + rethrow; + } + } +} diff --git a/flutt/lib/presentation/widgets/mapbox_map.dart b/flutt/lib/presentation/widgets/mapbox_map.dart new file mode 100644 index 00000000..b7ea1b09 --- /dev/null +++ b/flutt/lib/presentation/widgets/mapbox_map.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +/// Widget de carte réutilisable utilisant Mapbox +/// +/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit +/// des fonctionnalités pour afficher des marqueurs, des polygones et des contrôles. +class MapboxMap extends StatefulWidget { + /// Position initiale de la carte + final LatLng initialPosition; + + /// Niveau de zoom initial + final double initialZoom; + + /// Liste des marqueurs à afficher + final List? markers; + + /// Liste des polygones à afficher + final List? polygons; + + /// Contrôleur de carte externe (optionnel) + final MapController? mapController; + + /// Callback appelé lorsque la carte est déplacée + final void Function(MapEvent)? onMapEvent; + + /// Afficher les boutons de contrôle (zoom, localisation) + final bool showControls; + + /// Style de la carte Mapbox (optionnel) + /// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12' + final String? mapStyle; + + const MapboxMap({ + super.key, + this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut + this.initialZoom = 13.0, + this.markers, + this.polygons, + this.mapController, + this.onMapEvent, + this.showControls = true, + this.mapStyle, + }); + + @override + State createState() => _MapboxMapState(); +} + +class _MapboxMapState extends State { + /// Contrôleur de carte interne + late final MapController _mapController; + + /// Niveau de zoom actuel + double _currentZoom = 13.0; + + @override + void initState() { + super.initState(); + _mapController = widget.mapController ?? MapController(); + _currentZoom = widget.initialZoom; + } + + @override + void dispose() { + // Ne pas disposer le contrôleur s'il a été fourni de l'extérieur + if (widget.mapController == null) { + _mapController.dispose(); + } + super.dispose(); + } + + /// Construit un bouton de contrôle de carte + Widget _buildMapButton({ + required IconData icon, + required VoidCallback onPressed, + }) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: IconButton( + icon: Icon(icon, size: 20), + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Déterminer l'URL du template de tuiles Mapbox + final String mapboxToken = AppKeys.mapboxApiKey; + final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11'; + final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken'; + + return Stack( + children: [ + // Carte principale + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: widget.initialPosition, + initialZoom: widget.initialZoom, + onMapEvent: (event) { + if (event is MapEventMove) { + setState(() { + // Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel + _currentZoom = _mapController.camera.zoom; + }); + } + + // Appeler le callback externe si fourni + if (widget.onMapEvent != null) { + widget.onMapEvent!(event); + } + }, + ), + children: [ + // Tuiles de la carte (Mapbox) + TileLayer( + urlTemplate: urlTemplate, + userAgentPackageName: 'app.geosector.fr', + maxNativeZoom: 19, + additionalOptions: { + 'accessToken': mapboxToken, + }, + ), + + // Polygones + if (widget.polygons != null && widget.polygons!.isNotEmpty) + PolygonLayer(polygons: widget.polygons!), + + // Marqueurs + if (widget.markers != null && widget.markers!.isNotEmpty) + MarkerLayer(markers: widget.markers!), + ], + ), + + // Boutons de contrôle + if (widget.showControls) + Positioned( + bottom: 16, + right: 16, + child: Column( + children: [ + // Bouton de zoom + + _buildMapButton( + icon: Icons.add, + onPressed: () { + _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom + 1, + ); + }, + ), + const SizedBox(height: 8), + + // Bouton de zoom - + _buildMapButton( + icon: Icons.remove, + onPressed: () { + _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom - 1, + ); + }, + ), + const SizedBox(height: 8), + + // Bouton de localisation + _buildMapButton( + icon: Icons.my_location, + onPressed: () { + _mapController.move( + widget.initialPosition, + 15, + ); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutt/lib/presentation/widgets/passages/passages_list_widget.dart b/flutt/lib/presentation/widgets/passages/passages_list_widget.dart new file mode 100644 index 00000000..4cd70485 --- /dev/null +++ b/flutt/lib/presentation/widgets/passages/passages_list_widget.dart @@ -0,0 +1,918 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:geosector_app/core/theme/app_theme.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; + +/// Un widget réutilisable pour afficher une liste de passages avec filtres +class PassagesListWidget extends StatefulWidget { + /// Liste des passages à afficher + final List> passages; + + /// Titre de la section (optionnel) + final String? title; + + /// Nombre maximum de passages à afficher (optionnel) + final int? maxPassages; + + /// Si vrai, les filtres seront affichés + final bool showFilters; + + /// Si vrai, la barre de recherche sera affichée + final bool showSearch; + + /// Si vrai, les boutons d'action (détails, modifier, etc.) seront affichés + final bool showActions; + + /// Callback appelé lorsqu'un passage est sélectionné + final Function(Map)? onPassageSelected; + + /// Callback appelé lorsqu'un passage est modifié + final Function(Map)? onPassageEdit; + + /// Callback appelé lorsqu'un reçu est demandé + final Function(Map)? onReceiptView; + + /// Callback appelé lorsque les détails sont demandés + final Function(Map)? onDetailsView; + + /// Filtres initiaux (optionnels) + final String? initialTypeFilter; + final String? initialPaymentFilter; + final String? initialSearchQuery; + + /// Filtres avancés (optionnels) + /// Liste des types de passages à exclure (ex: [2] pour exclure les passages "À finaliser") + final List? excludePassageTypes; + + /// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs) + final int? filterByUserId; + + /// ID du secteur pour filtrer les passages (null = tous les secteurs) + final int? filterBySectorId; + + /// Période de filtrage pour la date passedAt + final String? periodFilter; // 'last15', 'lastWeek', 'lastMonth', 'custom' + + /// Plage de dates personnalisée pour le filtrage (utilisé si periodFilter = 'custom') + final DateTimeRange? dateRange; + + const PassagesListWidget({ + super.key, + required this.passages, + this.title, + this.maxPassages, + this.showFilters = true, + this.showSearch = true, + this.showActions = true, + this.onPassageSelected, + this.onPassageEdit, + this.onReceiptView, + this.onDetailsView, + this.initialTypeFilter, + this.initialPaymentFilter, + this.initialSearchQuery, + this.excludePassageTypes, + this.filterByUserId, + this.filterBySectorId, + this.periodFilter, + this.dateRange, + }); + + @override + State createState() => _PassagesListWidgetState(); +} + +class _PassagesListWidgetState extends State { + // Filtres + late String _selectedTypeFilter; + late String _selectedPaymentFilter; + late String _searchQuery; + + // Contrôleur de recherche + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + // Initialiser les filtres + _selectedTypeFilter = widget.initialTypeFilter ?? 'Tous'; + _selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous'; + _searchQuery = widget.initialSearchQuery ?? ''; + _searchController.text = _searchQuery; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + // Liste filtrée avec gestion des erreurs + List> get _filteredPassages { + try { + var filtered = widget.passages.where((passage) { + try { + // Vérification que le passage est valide + if (passage == null) { + return false; + } + + // Exclure les types de passages spécifiés + if (widget.excludePassageTypes != null && + passage.containsKey('type') && + widget.excludePassageTypes!.contains(passage['type'])) { + return false; + } + + // Filtrer par utilisateur + if (widget.filterByUserId != null && + passage.containsKey('fkUser') && + passage['fkUser'] != widget.filterByUserId) { + return false; + } + + // Filtrer par secteur + if (widget.filterBySectorId != null && + passage.containsKey('fkSector') && + passage['fkSector'] != widget.filterBySectorId) { + return false; + } + + // Filtre par type + if (_selectedTypeFilter != 'Tous') { + try { + final typeEntries = AppKeys.typesPassages.entries.where( + (entry) => entry.value['titre'] == _selectedTypeFilter); + + if (typeEntries.isNotEmpty) { + final typeIndex = typeEntries.first.key; + if (!passage.containsKey('type') || + passage['type'] != typeIndex) { + return false; + } + } + } catch (e) { + debugPrint('Erreur de filtrage par type: $e'); + } + } + + // Filtre par type de règlement + if (_selectedPaymentFilter != 'Tous') { + try { + final paymentEntries = AppKeys.typesReglements.entries.where( + (entry) => entry.value['titre'] == _selectedPaymentFilter); + + if (paymentEntries.isNotEmpty) { + final paymentIndex = paymentEntries.first.key; + if (!passage.containsKey('payment') || + passage['payment'] != paymentIndex) { + return false; + } + } + } catch (e) { + debugPrint('Erreur de filtrage par type de règlement: $e'); + } + } + + // Filtre par recherche + if (_searchQuery.isNotEmpty) { + try { + final query = _searchQuery.toLowerCase(); + final address = passage.containsKey('address') + ? passage['address']?.toString().toLowerCase() ?? '' + : ''; + final name = passage.containsKey('name') + ? passage['name']?.toString().toLowerCase() ?? '' + : ''; + final notes = passage.containsKey('notes') + ? passage['notes']?.toString().toLowerCase() ?? '' + : ''; + + return address.contains(query) || + name.contains(query) || + notes.contains(query); + } catch (e) { + debugPrint('Erreur de filtrage par recherche: $e'); + return false; + } + } + + return true; + } catch (e) { + debugPrint('Erreur lors du filtrage d\'un passage: $e'); + return false; + } + }).toList(); + + // Trier les passages par date (les plus récents d'abord) + filtered.sort((a, b) { + if (a.containsKey('date') && b.containsKey('date')) { + final DateTime dateA = a['date'] as DateTime; + final DateTime dateB = b['date'] as DateTime; + return dateB.compareTo(dateA); // Ordre décroissant + } + return 0; + }); + + // Limiter le nombre de passages si maxPassages est défini + if (widget.maxPassages != null && filtered.length > widget.maxPassages!) { + filtered = filtered.sublist(0, widget.maxPassages!); + } + + return filtered; + } catch (e) { + debugPrint('Erreur critique dans _filteredPassages: $e'); + return []; + } + } + + // Vérifier si un passage appartient à l'utilisateur courant + bool _isPassageOwnedByCurrentUser(Map passage) { + // Utiliser directement le champ isOwnedByCurrentUser s'il existe + if (passage.containsKey('isOwnedByCurrentUser')) { + return passage['isOwnedByCurrentUser'] == true; + } + + // Sinon, vérifier si le passage appartient à l'utilisateur filtré + if (widget.filterByUserId != null && passage.containsKey('fkUser')) { + return passage['fkUser'].toString() == widget.filterByUserId.toString(); + } + + // Par défaut, considérer que le passage n'appartient pas à l'utilisateur courant + return false; + } + + // Widget pour construire la ligne d'informations du passage (date, nom, montant, règlement) + Widget _buildPassageInfoRow(Map passage, ThemeData theme, + DateFormat dateFormat, Map typeReglement) { + try { + final bool hasName = passage.containsKey('name') && + (passage['name'] as String?).toString().isNotEmpty; + final double amount = + passage.containsKey('amount') ? passage['amount'] as double : 0.0; + final bool hasValidAmount = amount > 0; + final bool isTypeEffectue = passage.containsKey('type') && + passage['type'] == 1; // Type 1 = Effectué + final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage); + + // Déterminer si nous sommes dans une page admin (pas de filterByUserId) + final bool isAdminPage = widget.filterByUserId == null; + + // Dans les pages admin, tous les passages sont affichés normalement + // Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement + final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser; + + // Définir des styles différents en fonction du propriétaire du passage et du type de page + final TextStyle? baseTextStyle = shouldGreyOut + ? theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.5)) + : theme.textTheme.bodyMedium; + + return Row( + children: [ + // Partie gauche: Date et informations + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date (toujours affichée) + Row( + children: [ + Icon( + Icons.calendar_today, + size: 16, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Text( + passage.containsKey('date') + ? dateFormat.format(passage['date'] as DateTime) + : 'Date non disponible', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + + const SizedBox(height: 4), + + // Ligne avec nom, montant et type de règlement + Row( + children: [ + // Nom (si connu) + if (hasName) + Flexible( + child: Text( + passage['name'] as String, + style: baseTextStyle, + overflow: TextOverflow.ellipsis, + ), + ), + + // Montant et type de règlement (si montant > 0) + if (hasValidAmount) ...[ + const SizedBox(width: 8), + Icon( + Icons.euro, + size: 16, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Text( + '${passage['amount']}€', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + // Type de règlement + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Color(typeReglement['couleur'] as int) + .withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + typeReglement['titre'] as String, + style: TextStyle( + color: Color(typeReglement['couleur'] as int), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), + ), + + // Partie droite: Boutons d'action + if (widget.showActions) ...[ + // Bouton Reçu (pour les passages de type 1 - Effectué) + // Dans la page admin, afficher pour tous les passages + // Dans la page user, uniquement pour les passages de l'utilisateur courant + if (isTypeEffectue && + widget.onReceiptView != null && + (isAdminPage || isOwnedByCurrentUser)) + IconButton( + icon: const Icon(Icons.picture_as_pdf, color: Colors.green), + tooltip: 'Reçu', + onPressed: () => widget.onReceiptView!(passage), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(8.0), + iconSize: 20, + ), + + // Bouton Modifier + // Dans la page admin, afficher pour tous les passages + // Dans la page user, uniquement pour les passages de l'utilisateur courant + if (widget.onPassageEdit != null && + (isAdminPage || isOwnedByCurrentUser)) + IconButton( + icon: const Icon(Icons.edit, color: Colors.blue), + tooltip: 'Modifier', + onPressed: () => widget.onPassageEdit!(passage), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(8.0), + iconSize: 20, + ), + ], + ], + ); + } catch (e) { + debugPrint( + 'Erreur lors de la construction de la ligne d\'informations du passage: $e'); + return const SizedBox(); + } + } + + // Construction d'une carte pour un passage + Widget _buildPassageCard( + Map passage, ThemeData theme, bool isDesktop) { + try { + // Vérification des données et valeurs par défaut + final int type = passage.containsKey('type') ? passage['type'] as int : 0; + final Map typePassage = + AppKeys.typesPassages[type] ?? AppKeys.typesPassages[1]!; + final int paymentType = + passage.containsKey('payment') ? passage['payment'] as int : 0; + final Map typeReglement = + AppKeys.typesReglements[paymentType] ?? AppKeys.typesReglements[0]!; + final DateFormat dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage); + + // Déterminer si nous sommes dans une page admin (pas de filterByUserId) + final bool isAdminPage = widget.filterByUserId == null; + + // Dans les pages admin, tous les passages sont affichés normalement + // Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement + final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser; + final bool isClickable = isAdminPage || isOwnedByCurrentUser; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + // Appliquer une couleur grisée uniquement dans les pages user et si le passage n'appartient pas à l'utilisateur courant + color: shouldGreyOut + ? theme.colorScheme.surface.withOpacity(0.7) + : theme.colorScheme.surface, + child: InkWell( + // Rendre le passage cliquable uniquement s'il appartient à l'utilisateur courant + // ou si nous sommes dans la page admin + onTap: isClickable && widget.onPassageSelected != null + ? () => widget.onPassageSelected!(passage) + : null, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icône du type de passage + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Color(typePassage['couleur1'] as int) + .withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + typePassage['icon_data'] as IconData, + color: Color(typePassage['couleur1'] as int), + ), + ), + const SizedBox(width: 10), + + // Informations principales + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + passage['address'] as String, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Color(typePassage['couleur1'] as int) + .withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + typePassage['titre'] as String, + style: TextStyle( + color: + Color(typePassage['couleur1'] as int), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + + // Utilisation du widget de ligne d'informations pour tous les types de passages + _buildPassageInfoRow( + passage, theme, dateFormat, typeReglement), + ], + ), + ), + ], + ), + + if (passage['notes'] != null && + passage['notes'].toString().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + 'Notes: ${passage['notes']}', + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + ), + + // Indicateur d'erreur (si présent) + if (passage['hasError'] == true) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Erreur détectée', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.red, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } catch (e) { + debugPrint('Erreur lors de la construction de la carte de passage: $e'); + return const SizedBox(); + } + } + + // Construction d'un filtre déroulant (version standard) + Widget _buildDropdownFilter( + String label, + String selectedValue, + List options, + Function(String) onChanged, + ThemeData theme, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12.0), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedValue, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + items: options.map((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (String? value) { + if (value != null) { + onChanged(value); + } + }, + ), + ), + ), + ], + ); + } + + // Construction d'un filtre déroulant (version compacte) + Widget _buildCompactDropdownFilter( + String label, + String selectedValue, + List options, + Function(String) onChanged, + ThemeData theme, + ) { + return Row( + children: [ + Text( + '$label:', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedValue, + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down), + items: options.map((String value) { + return DropdownMenuItem( + value: value, + child: Text( + value, + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (String? value) { + if (value != null) { + onChanged(value); + } + }, + ), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre (si fourni) + if (widget.title != null) + Padding( + padding: const EdgeInsets.all(10.0), + child: Text( + widget.title!, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + + // Filtres (si activés) + if (widget.showFilters) _buildFilters(theme, isDesktop), + + // Liste des passages dans une card de hauteur fixe avec défilement + Container( + height: 600, // Hauteur fixe de 600px + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: _filteredPassages.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: theme.colorScheme.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + 'Aucun passage trouvé', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + const SizedBox(height: 8), + Text( + 'Essayez de modifier vos filtres de recherche', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: _filteredPassages.length, + itemBuilder: (context, index) { + final passage = _filteredPassages[index]; + return _buildPassageCard(passage, theme, isDesktop); + }, + ), + ), + ], + ); + } + + // Construction du panneau de filtres + Widget _buildFilters(ThemeData theme, bool isDesktop) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + color: theme.colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isDesktop) + // Version compacte pour le web (desktop) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barre de recherche (si activée) + if (widget.showSearch) + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par adresse ou nom...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide( + color: theme.colorScheme.outline, + width: 1.0, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 14.0), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + ), + + // Filtre par type de passage + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: _buildCompactDropdownFilter( + 'Type', + _selectedTypeFilter, + [ + 'Tous', + ...AppKeys.typesPassages.values + .map((type) => type['titre'] as String) + ], + (value) { + setState(() { + _selectedTypeFilter = value; + }); + }, + theme, + ), + ), + ), + + // Filtre par type de règlement + Expanded( + child: _buildCompactDropdownFilter( + 'Règlement', + _selectedPaymentFilter, + [ + 'Tous', + ...AppKeys.typesReglements.values + .map((type) => type['titre'] as String) + ], + (value) { + setState(() { + _selectedPaymentFilter = value; + }); + }, + theme, + ), + ), + ], + ), + ) + else + // Version mobile (non-desktop) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barre de recherche (si activée) + if (widget.showSearch) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par adresse ou nom...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide( + color: theme.colorScheme.outline, + width: 1.0, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 14.0), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + + // Filtres + Row( + children: [ + // Filtre par type de passage + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _buildDropdownFilter( + 'Type', + _selectedTypeFilter, + [ + 'Tous', + ...AppKeys.typesPassages.values + .map((type) => type['titre'] as String) + ], + (value) { + setState(() { + _selectedTypeFilter = value; + }); + }, + theme, + ), + ), + ), + + // Filtre par type de règlement + Expanded( + child: _buildDropdownFilter( + 'Règlement', + _selectedPaymentFilter, + [ + 'Tous', + ...AppKeys.typesReglements.values + .map((type) => type['titre'] as String) + ], + (value) { + setState(() { + _selectedPaymentFilter = value; + }); + }, + theme, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/flutt/lib/presentation/widgets/profile_dialog.dart b/flutt/lib/presentation/widgets/profile_dialog.dart new file mode 100644 index 00000000..7d3d0b58 --- /dev/null +++ b/flutt/lib/presentation/widgets/profile_dialog.dart @@ -0,0 +1,356 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:geosector_app/core/repositories/user_repository.dart'; + +/// Widget de profil commun pour toute l'application +/// Affiche une boîte de dialogue modale avec un formulaire de mise à jour +/// des données utilisateur +class ProfileDialog extends StatefulWidget { + /// ID de l'utilisateur dont on veut afficher/modifier le profil + final String userId; + + const ProfileDialog({ + Key? key, + required this.userId, + }) : super(key: key); + + /// Affiche la boîte de dialogue de profil + static void show(BuildContext context, String userId) { + showDialog( + context: context, + builder: (context) => ProfileDialog(userId: userId), + ); + } + + @override + State createState() => _ProfileDialogState(); +} + +class _ProfileDialogState extends State { + /// Contrôleurs pour les champs du formulaire + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + + /// État de chargement + bool _isLoading = true; + + /// État d'erreur + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + /// Charge les données de l'utilisateur depuis l'API + Future _loadUserData() async { + try { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + // Utiliser l'instance globale définie dans app.dart + final user = userRepository.currentUser; + + // Si l'utilisateur est trouvé, remplir les champs du formulaire + if (user != null) { + _firstNameController.text = user.firstName ?? ''; + _lastNameController.text = user.name ?? ''; + _emailController.text = user.email ?? ''; + // Note: Utiliser la propriété appropriée pour le téléphone si elle existe + // ou laisser vide si elle n'existe pas + _phoneController.text = ''; // Champ laissé vide par défaut + } else { + _errorMessage = 'Utilisateur non trouvé'; + } + } catch (e) { + _errorMessage = 'Erreur lors du chargement des données: $e'; + } finally { + setState(() { + _isLoading = false; + }); + } + } + + /// Enregistre les modifications du profil + Future _saveProfile() async { + try { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + // Utiliser l'instance globale définie dans app.dart + + // Mettre à jour les données de l'utilisateur + // Note: Cette partie dépend de l'implémentation réelle du UserRepository + // et devrait être adaptée en fonction de l'API disponible + + // Récupérer l'utilisateur actuel + final user = userRepository.currentUser; + if (user != null) { + // Mettre à jour les propriétés de l'utilisateur + user.firstName = _firstNameController.text; + user.name = _lastNameController.text; + + // Sauvegarder les modifications + // Note: Utiliser la méthode appropriée du repository + // Exemple: userRepo.saveUser(user) ou userRepo.updateUser(user) + + // Pour l'instant, nous simulons une mise à jour réussie + // Cette partie devra être adaptée à l'API réelle + await Future.delayed(const Duration(milliseconds: 500)); + + // Fermer la boîte de dialogue + if (mounted) { + Navigator.of(context) + .pop(true); // Retourne true pour indiquer le succès + + // Afficher un message de succès + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Profil mis à jour avec succès'), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + } else { + throw Exception('Utilisateur non trouvé'); + } + } catch (e) { + setState(() { + _errorMessage = 'Erreur lors de la mise à jour du profil: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final theme = Theme.of(context); + + // Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau + final isDesktop = size.width > 900; + + // Calculer la largeur de la boîte de dialogue + // 90% de la largeur de l'écran pour les mobiles + // 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px) + final dialogWidth = isDesktop + ? size.width * 0.5 > 600 + ? 600.0 + : size.width * 0.5 + : size.width * 0.9; + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + // Définir la largeur de la boîte de dialogue + child: Container( + width: dialogWidth, + padding: const EdgeInsets.all(24), + child: _isLoading + ? const Center( + child: CircularProgressIndicator(), + ) + : _errorMessage != null + ? _buildErrorView() + : _buildProfileForm(), + ), + ); + } + + /// Construit la vue d'erreur + Widget _buildErrorView() { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Erreur', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? 'Une erreur inconnue est survenue', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: const Text('Fermer'), + ), + ], + ); + } + + /// Construit le formulaire de profil + Widget _buildProfileForm() { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre du formulaire + Row( + children: [ + Icon( + Icons.person, + color: theme.colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Mon compte', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 32), + + // Formulaire + Form( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Prénom + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: 'Prénom', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre prénom'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Nom + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: 'Nom', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre nom'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Email + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre email'; + } + if (!value.contains('@')) { + return 'Veuillez entrer un email valide'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Téléphone + TextFormField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Téléphone', + prefixIcon: Icon(Icons.phone_outlined), + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Boutons d'action + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Annuler', + style: TextStyle( + color: theme.colorScheme.error, + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _saveProfile, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text('Enregistrer'), + ), + ], + ), + ], + ); + } +} diff --git a/flutt/lib/presentation/widgets/responsive_navigation.dart b/flutt/lib/presentation/widgets/responsive_navigation.dart new file mode 100644 index 00000000..d08eaa1e --- /dev/null +++ b/flutt/lib/presentation/widgets/responsive_navigation.dart @@ -0,0 +1,687 @@ +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:flutter/material.dart'; +import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales +import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/core/repositories/user_repository.dart'; +import 'package:geosector_app/presentation/widgets/help_dialog.dart'; +import 'package:geosector_app/presentation/widgets/profile_dialog.dart'; + +/// Widget qui fournit une navigation responsive pour l'application. +/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile. +class ResponsiveNavigation extends StatefulWidget { + /// Le contenu principal à afficher + final Widget body; + + /// Le titre de la page + final String title; + + /// L'index de la page sélectionnée + final int selectedIndex; + + /// Callback appelé lorsqu'un élément de navigation est sélectionné + final Function(int) onDestinationSelected; + + /// Liste des destinations de navigation + final List destinations; + + /// Actions supplémentaires à afficher dans l'AppBar + final List? additionalActions; + + /// Indique si le bouton "Nouveau passage" doit être affiché + final bool showNewPassageButton; + + /// Callback appelé lorsque le bouton "Nouveau passage" est pressé + final VoidCallback? onNewPassagePressed; + + /// Clé de la boîte Hive pour sauvegarder les paramètres + final String settingsBoxKey; + + /// Clé pour sauvegarder l'état de la sidebar + final String sidebarStateKey; + + /// Widgets à afficher en bas de la sidebar + final List? sidebarBottomItems; + + /// Indique si l'utilisateur est un administrateur + final bool isAdmin; + + /// Indique si l'AppBar doit être affiché + final bool showAppBar; + + const ResponsiveNavigation({ + Key? key, + required this.body, + required this.title, + required this.selectedIndex, + required this.onDestinationSelected, + required this.destinations, + this.additionalActions, + this.showNewPassageButton = true, + this.onNewPassagePressed, + this.settingsBoxKey = AppKeys.settingsBoxName, + this.sidebarStateKey = 'isSidebarMinimized', + this.sidebarBottomItems, + this.isAdmin = false, + this.showAppBar = true, + }) : super(key: key); + + @override + State createState() => _ResponsiveNavigationState(); +} + +class _ResponsiveNavigationState extends State { + /// État de la barre latérale (minimisée ou non) + bool _isSidebarMinimized = false; + + /// Référence à la boîte Hive pour les paramètres + late Box _settingsBox; + + @override + void initState() { + super.initState(); + _initSettings(); + } + + /// Initialiser la boîte de paramètres et charger les préférences + Future _initSettings() async { + try { + // Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte + if (!Hive.isBoxOpen(widget.settingsBoxKey)) { + _settingsBox = await Hive.openBox(widget.settingsBoxKey); + } else { + _settingsBox = Hive.box(widget.settingsBoxKey); + } + + // Charger l'état de la barre latérale + final sidebarState = _settingsBox.get(widget.sidebarStateKey); + if (sidebarState != null && sidebarState is bool) { + setState(() { + _isSidebarMinimized = sidebarState; + }); + } + } catch (e) { + debugPrint('Erreur lors du chargement des paramètres: $e'); + } + } + + /// Sauvegarder l'état de la barre latérale + void _saveSettings() { + try { + // Sauvegarder l'état de la barre latérale + _settingsBox.put(widget.sidebarStateKey, _isSidebarMinimized); + } catch (e) { + debugPrint('Erreur lors de la sauvegarde des paramètres: $e'); + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 900; + + return Scaffold( + appBar: widget.showAppBar + ? AppBar( + title: Text(widget.title), + actions: _buildAppBarActions(context), + ) + : null, + body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(), + bottomNavigationBar: (isDesktop) ? null : _buildBottomNavigationBar(), + ); + } + + /// Construction du layout pour les écrans de bureau (web) + Widget _buildDesktopLayout() { + // Utiliser une couleur de fond différente selon le type d'utilisateur + final backgroundColor = widget.isAdmin + ? const Color(0xFFFFEBEE) // Fond rouge clair pour l'interface admin + : const Color( + 0xFFE8F5E9); // Fond vert clair pour l'interface utilisateur + + return Row( + children: [ + _buildSidebar(), + Expanded( + child: Container( + color: backgroundColor, + child: widget.body, + ), + ), + ], + ); + } + + /// Construction du layout pour les écrans mobiles + Widget _buildMobileLayout() { + // Utiliser une couleur de fond différente selon le type d'utilisateur + final backgroundColor = widget.isAdmin + ? const Color(0xFFFFEBEE) // Fond rouge clair pour l'interface admin + : const Color( + 0xFFE8F5E9); // Fond vert clair pour l'interface utilisateur + + return Container( + color: backgroundColor, + child: widget.body, + ); + } + + /// Construction des actions de l'AppBar + List _buildAppBarActions(BuildContext context) { + List actions = []; + + // Ajouter les actions supplémentaires si elles existent + if (widget.additionalActions != null && + widget.additionalActions!.isNotEmpty) { + actions.addAll(widget.additionalActions!); + } else if (widget.showNewPassageButton && widget.selectedIndex == 0) { + // Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil + actions.add( + TextButton.icon( + icon: const Icon(Icons.add_location_alt, color: Colors.white), + label: const Text('Nouveau passage', + style: TextStyle(color: Colors.white)), + onPressed: widget.onNewPassagePressed ?? + () { + // Fonction par défaut si onNewPassagePressed n'est pas fourni + _showPassageForm(context); + }, + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + actions.add(const SizedBox(width: 16)); // Espacement à droite + } + + return actions; + } + + /// Construction de la barre de navigation inférieure pour mobile + Widget _buildBottomNavigationBar() { + final theme = Theme.of(context); + + return NavigationBar( + selectedIndex: widget.selectedIndex, + onDestinationSelected: widget.onDestinationSelected, + backgroundColor: theme.colorScheme.surface, + elevation: 8, + destinations: widget.destinations, + ); + } + + /// Obtenir le nom complet de l'utilisateur (prénom + nom) + String _getFullUserName(BuildContext context) { + // Utiliser l'instance globale définie dans app.dart + final user = userRepository.currentUser; + + if (user == null) return 'Utilisateur'; + + String fullName = ''; + + // Ajouter le prénom si disponible + if (user.firstName != null && user.firstName!.isNotEmpty) { + fullName += user.firstName!; + } + + // Ajouter le nom + if (user.name != null && user.name!.isNotEmpty) { + // Ajouter un espace si le prénom est déjà présent + if (fullName.isNotEmpty) { + fullName += ' '; + } + fullName += user.name!; + } + + // Si aucun nom n'a été trouvé, utiliser 'Utilisateur' par défaut + return fullName.isEmpty ? 'Utilisateur' : fullName; + } + + /// Obtenir les initiales du prénom et du nom de l'utilisateur + String _getUserInitials(BuildContext context) { + // Utiliser l'instance globale définie dans app.dart + final user = userRepository.currentUser; + + if (user == null) return 'U'; + + String initials = ''; + + // Ajouter l'initiale du prénom si disponible + if (user.firstName != null && user.firstName!.isNotEmpty) { + initials += user.firstName!.substring(0, 1).toUpperCase(); + } + + // Ajouter l'initiale du nom + if (user.name != null && user.name!.isNotEmpty) { + initials += user.name!.substring(0, 1).toUpperCase(); + } + + // Si aucune initiale n'a été trouvée, utiliser 'U' par défaut + return initials.isEmpty ? 'U' : initials; + } + + /// Afficher le sectName entre parenthèses s'il existe + Widget _buildSectNameText(BuildContext context) { + final theme = Theme.of(context); + // Utiliser l'instance globale définie dans app.dart + final user = userRepository.currentUser; + + // Si l'utilisateur n'a pas de sectName ou s'il est vide, retourner un widget vide + if (user == null || user.sectName == null || user.sectName!.isEmpty) { + return const SizedBox.shrink(); + } + + // Sinon, afficher le sectName entre parenthèses + return Text( + '(${user.sectName})', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ); + } + + /// Construction de la barre latérale pour la version web + Widget _buildSidebar() { + final theme = Theme.of(context); + + return Card( + margin: EdgeInsets.zero, + elevation: 4, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + child: Container( + width: _isSidebarMinimized ? 70 : 250, + color: theme.colorScheme.surface, + child: Column( + children: [ + // Bouton pour minimiser/maximiser la barre latérale + Align( + alignment: _isSidebarMinimized + ? Alignment.center + : Alignment.centerRight, + child: Padding( + padding: + EdgeInsets.only(top: 8, right: _isSidebarMinimized ? 0 : 8), + child: IconButton( + icon: Icon(_isSidebarMinimized + ? Icons.chevron_right + : Icons.chevron_left), + onPressed: () { + setState(() { + _isSidebarMinimized = !_isSidebarMinimized; + _saveSettings(); // Sauvegarder l'état de la barre latérale + }); + }, + tooltip: _isSidebarMinimized ? 'Développer' : 'Réduire', + ), + ), + ), + const SizedBox(height: 8), + if (!_isSidebarMinimized) + CircleAvatar( + radius: 40, + backgroundColor: theme.colorScheme.primary, + child: Text( + _getUserInitials(context), + style: TextStyle( + fontSize: 28, + color: theme.colorScheme.onPrimary, + ), + ), + ), + const SizedBox(height: 8), + if (!_isSidebarMinimized) ...[ + Text( + _getFullUserName(context), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + // Afficher le sectName entre parenthèses s'il existe + _buildSectNameText(context), + Text( + userRepository.currentUser?.email ?? '', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 24), + ] else + const SizedBox(height: 8), + const Divider(), + + // Éléments de navigation + for (int i = 0; i < widget.destinations.length; i++) + _buildNavItem( + i, widget.destinations[i].label, widget.destinations[i].icon), + + const Spacer(), + const Divider(), + + // Éléments du bas de la sidebar + if (widget.sidebarBottomItems != null && !_isSidebarMinimized) + ...widget.sidebarBottomItems!, + + // Éléments par défaut du bas de la sidebar + if (!_isSidebarMinimized) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Paramètres', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + _SettingsItem( + icon: Icons.person, + title: 'Mon compte', + subtitle: null, + isSidebarMinimized: _isSidebarMinimized, + onTap: () { + // Afficher la boîte de dialogue de profil avec l'ID de l'utilisateur actuel + // Utiliser l'instance globale définie dans app.dart + final user = userRepository.currentUser; + if (user != null && user.id != null) { + // Convertir l'ID en chaîne de caractères si nécessaire + ProfileDialog.show(context, user.id!.toString()); + } else { + // Afficher un message d'erreur si l'utilisateur n'est pas trouvé + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Erreur: Utilisateur non trouvé'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + ), + if (widget.isAdmin && userRepository.currentUser?.role == 2) + _SettingsItem( + icon: Icons.people, + title: 'Amicale & membres', + isSidebarMinimized: _isSidebarMinimized, + onTap: () { + // Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres" + context.go('/admin'); + + // Sélectionner l'onglet "Amicale et membres" (index 5) + // Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord + // puisse le récupérer et sélectionner le bon onglet + final settingsBox = Hive.box(AppKeys.settingsBoxName); + settingsBox.put('adminSelectedPageIndex', 5); + }, + ), + const SizedBox(height: 16), + _SettingsItem( + icon: Icons.help_outline, + title: 'Aide', + isSidebarMinimized: _isSidebarMinimized, + onTap: () { + // Afficher la boîte de dialogue d'aide avec le titre de la page courante + HelpDialog.show(context, widget.title); + }, + ), + ], + ), + ), + ); + } + + /// Construction d'un élément de navigation pour la barre latérale + Widget _buildNavItem(int index, String title, Widget icon) { + final theme = Theme.of(context); + final isSelected = widget.selectedIndex == index; + final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null; + + // Remplacer certains titres si l'interface est de type "user" + String displayTitle = title; + if (!widget.isAdmin) { + if (title == "Accueil") { + displayTitle = "Tableau de bord"; + } else if (title == "Stats") { + displayTitle = "Statistiques"; + } + } + + if (_isSidebarMinimized) { + // Version minimisée - afficher uniquement l'icône + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Tooltip( + message: displayTitle, + child: InkWell( + onTap: () { + widget.onDestinationSelected(index); + }, + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: iconData != null + ? Icon( + iconData, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withOpacity(0.6), + size: 24, + ) + : icon, + ), + ), + ), + ); + } else { + // Version normale avec texte et icône + return ListTile( + leading: iconData != null + ? Icon( + iconData, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withOpacity(0.6), + ) + : icon, + title: Text( + displayTitle, + style: TextStyle( + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + tileColor: + isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null, + onTap: () { + widget.onDestinationSelected(index); + }, + ); + } + } + + /// Affiche le formulaire de passage + void _showPassageForm(BuildContext context) { + final theme = Theme.of(context); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + 'Nouveau passage', + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: InputDecoration( + labelText: 'Adresse', + prefixIcon: const Icon(Icons.location_on), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Type de passage', + prefixIcon: const Icon(Icons.category), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + items: const [ + DropdownMenuItem( + value: 1, + child: Text('Effectué'), + ), + DropdownMenuItem( + value: 2, + child: Text('À finaliser'), + ), + DropdownMenuItem( + value: 3, + child: Text('Refusé'), + ), + DropdownMenuItem( + value: 4, + child: Text('Don'), + ), + DropdownMenuItem( + value: 5, + child: Text('Lot'), + ), + DropdownMenuItem( + value: 6, + child: Text('Maison vide'), + ), + ], + onChanged: (value) {}, + ), + const SizedBox(height: 16), + TextField( + decoration: InputDecoration( + labelText: 'Commentaire', + prefixIcon: const Icon(Icons.comment), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + 'Annuler', + style: TextStyle( + color: theme.colorScheme.error, + ), + ), + ), + ElevatedButton( + onPressed: () { + // Enregistrer le passage + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Passage enregistré avec succès'), + backgroundColor: theme.colorScheme.primary, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: const Text('Enregistrer'), + ), + ], + ), + ); + } +} + +/// Widget pour les éléments de paramètres +class _SettingsItem extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback onTap; + final bool isSidebarMinimized; + + const _SettingsItem({ + required this.icon, + required this.title, + this.subtitle, + this.trailing, + required this.onTap, + required this.isSidebarMinimized, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (isSidebarMinimized) { + // Version minimisée - afficher uniquement l'icône + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Tooltip( + message: title, + child: InkWell( + onTap: onTap, + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: theme.colorScheme.primary, + size: 24, + ), + ), + ), + ), + ); + } else { + // Version normale avec texte et icône + return ListTile( + leading: Icon( + icon, + color: theme.colorScheme.primary, + ), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: trailing, + onTap: onTap, + ); + } + } +} diff --git a/flutt/lib/presentation/widgets/sector_distribution_card.dart b/flutt/lib/presentation/widgets/sector_distribution_card.dart new file mode 100644 index 00000000..bf4ccc48 --- /dev/null +++ b/flutt/lib/presentation/widgets/sector_distribution_card.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:geosector_app/core/data/models/passage_model.dart'; +import 'package:geosector_app/core/data/models/sector_model.dart'; +import 'package:geosector_app/core/constants/app_keys.dart'; +import 'package:geosector_app/shared/app_theme.dart'; + +class SectorDistributionCard extends StatefulWidget { + final String title; + final double? height; + final EdgeInsetsGeometry? padding; + + const SectorDistributionCard({ + Key? key, + this.title = 'Répartition par secteur', + this.height, + this.padding, + }) : super(key: key); + + @override + State createState() => _SectorDistributionCardState(); +} + +class _SectorDistributionCardState extends State { + List> sectorStats = []; + bool isLoading = true; + + @override + void initState() { + super.initState(); + _loadSectorData(); + } + + Future _loadSectorData() async { + setState(() { + isLoading = true; + }); + + try { + // S'assurer que les boîtes Hive sont ouvertes + if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) { + await Hive.openBox(AppKeys.sectorsBoxName); + } + + if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) { + await Hive.openBox(AppKeys.passagesBoxName); + } + + // Récupérer tous les secteurs + final sectorsBox = Hive.box(AppKeys.sectorsBoxName); + final List sectors = sectorsBox.values.toList(); + + // Récupérer tous les passages + final passagesBox = Hive.box(AppKeys.passagesBoxName); + final List passages = passagesBox.values.toList(); + + // Compter les passages par secteur (en excluant ceux où fkType==2) + final Map sectorCounts = {}; + + for (final passage in passages) { + // Exclure les passages où fkType==2 + if (passage.fkSector != null && passage.fkType != 2) { + sectorCounts[passage.fkSector!] = + (sectorCounts[passage.fkSector!] ?? 0) + 1; + } + } + + // Préparer les données pour l'affichage + List> stats = []; + for (final sector in sectors) { + final count = sectorCounts[sector.id] ?? 0; + if (count > 0) { + stats.add({ + 'name': sector.libelle, + 'count': count, + 'color': sector.color.isEmpty + ? 0xFF4B77BE + : int.tryParse(sector.color.replaceAll('#', '0xFF')) ?? + 0xFF4B77BE, + }); + } + } + + setState(() { + sectorStats = stats; + isLoading = false; + }); + } catch (e) { + debugPrint('Erreur lors du chargement des données de secteur: $e'); + setState(() { + isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + height: widget.height, + padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + if (isLoading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: _loadSectorData, + tooltip: 'Rafraîchir', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: AppTheme.spacingM), + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : sectorStats.isEmpty + ? const Center( + child: Text('Aucune donnée de secteur disponible')) + : ListView.builder( + itemCount: sectorStats.length, + itemBuilder: (context, index) { + final sector = sectorStats[index]; + return _buildSectorItem( + context, + sector['name'], + sector['count'], + Color(sector['color']), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildSectorItem( + BuildContext context, + String name, + int count, + Color color, + ) { + final totalCount = + sectorStats.fold(0, (sum, item) => sum + (item['count'] as int)); + final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0; + + return Padding( + padding: const EdgeInsets.only(bottom: AppTheme.spacingS), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + name, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$count (${percentage.toInt()}%)', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: percentage / 100, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(color), + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + ], + ), + ); + } +} diff --git a/flutt/lib/shared/app_theme.dart b/flutt/lib/shared/app_theme.dart new file mode 100644 index 00000000..e946de3e --- /dev/null +++ b/flutt/lib/shared/app_theme.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Couleurs principales + static const Color primaryColor = Color(0xFF4B77BE); + static const Color secondaryColor = Color(0xFF2C3E50); + static const Color accentColor = Color(0xFF3498DB); + static const Color backgroundColor = Color(0xFFF5F7FA); + static const Color cardColor = Colors.white; + + // Couleurs de texte + static const Color textPrimaryColor = Color(0xFF2C3E50); + static const Color textSecondaryColor = Color(0xFF7F8C8D); + static const Color textLightColor = Color(0xFFBDC3C7); + + // Couleurs des boutons + static const Color buttonPrimaryColor = Color(0xFF4B77BE); + static const Color buttonSecondaryColor = Color(0xFF95A5A6); + static const Color buttonSuccessColor = Color(0xFF2ECC71); + static const Color buttonDangerColor = Color(0xFFE74C3C); + static const Color buttonWarningColor = Color(0xFFF1C40F); + + // Couleurs des charts + static const List chartColors = [ + Color(0xFF4B77BE), + Color(0xFF2ECC71), + Color(0xFFE74C3C), + Color(0xFFF1C40F), + Color(0xFF9B59B6), + Color(0xFF1ABC9C), + Color(0xFFE67E22), + ]; + + // Ombres + static List cardShadow = [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + spreadRadius: 1, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ]; + + static List buttonShadow = [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ]; + + // Rayons des bordures + static const double borderRadiusSmall = 4.0; + static const double borderRadiusMedium = 8.0; + static const double borderRadiusLarge = 12.0; + + // Espacement + static const double spacingXS = 4.0; + static const double spacingS = 8.0; + static const double spacingM = 16.0; + static const double spacingL = 24.0; + static const double spacingXL = 32.0; + static const double spacingXXL = 48.0; + + // Thème light + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.light( + primary: primaryColor, + secondary: secondaryColor, + background: backgroundColor, + surface: cardColor, + ), + scaffoldBackgroundColor: backgroundColor, + appBarTheme: const AppBarTheme( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + ), + cardTheme: CardTheme( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: buttonPrimaryColor, + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric( + horizontal: spacingL, + vertical: spacingM, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: primaryColor, + side: const BorderSide(color: primaryColor), + padding: const EdgeInsets.symmetric( + horizontal: spacingL, + vertical: spacingM, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryColor, + padding: const EdgeInsets.symmetric( + horizontal: spacingM, + vertical: spacingS, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + borderSide: BorderSide(color: textLightColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + borderSide: BorderSide(color: textLightColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadiusMedium), + borderSide: BorderSide(color: primaryColor), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: spacingM, + vertical: spacingM, + ), + ), + textTheme: const TextTheme( + displayLarge: TextStyle(color: textPrimaryColor), + displayMedium: TextStyle(color: textPrimaryColor), + displaySmall: TextStyle(color: textPrimaryColor), + headlineLarge: TextStyle(color: textPrimaryColor), + headlineMedium: TextStyle(color: textPrimaryColor), + headlineSmall: TextStyle(color: textPrimaryColor), + titleLarge: TextStyle(color: textPrimaryColor), + titleMedium: TextStyle(color: textPrimaryColor), + titleSmall: TextStyle(color: textPrimaryColor), + bodyLarge: TextStyle(color: textPrimaryColor), + bodyMedium: TextStyle(color: textPrimaryColor), + bodySmall: TextStyle(color: textSecondaryColor), + labelLarge: TextStyle(color: textPrimaryColor), + labelMedium: TextStyle(color: textSecondaryColor), + labelSmall: TextStyle(color: textSecondaryColor), + ), + dividerTheme: const DividerThemeData( + color: Color(0xFFECF0F1), + thickness: 1, + space: spacingM, + ), + ); +} diff --git a/flutt/pubspec.yaml b/flutt/pubspec.yaml new file mode 100644 index 00000000..cf7b4dc6 --- /dev/null +++ b/flutt/pubspec.yaml @@ -0,0 +1,75 @@ +name: geosector_app +description: 'GEOSECTOR - Une application de gestion de distribution par secteurs géographiques' +publish_to: 'none' +version: 0.2.1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.6 + + # Navigation + go_router: ^14.8.1 + + # État et gestion des données + hive: ^2.2.3 + hive_flutter: ^1.1.0 + path_provider: ^2.1.1 + + # API & Réseau + dio: ^5.3.3 + connectivity_plus: ^6.1.3 + retry: ^3.1.2 + + # UI et animations + google_fonts: ^6.1.0 + flutter_svg: ^2.0.9 + + # Utilitaires + intl: ^0.20.2 + uuid: ^4.2.1 + fl_chart: ^0.70.2 + syncfusion_flutter_charts: ^29.1.35 + + # Cartes et géolocalisation + url_launcher: ^6.3.1 + flutter_map: ^8.1.1 + latlong2: ^0.9.1 + geolocator: ^13.0.4 + + # Chat et notifications + mqtt5_client: ^4.11.0 + flutter_local_notifications: ^19.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + hive_generator: ^2.0.1 + build_runner: ^2.4.6 + flutter_launcher_icons: ^0.13.1 + +flutter_launcher_icons: + android: 'launcher_icon' + ios: true + image_path: 'assets/images/geosector-logo.png' + web: + generate: true + image_path: 'assets/images/geosector-logo.png' + background_color: '#ffffff' + theme_color: '#ffffff' + windows: + generate: true + image_path: 'assets/images/geosector-logo.png' + icon_size: 48 + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/icons/ + - assets/animations/ diff --git a/flutt/test/widget_test.dart b/flutt/test/widget_test.dart new file mode 100644 index 00000000..417f33b4 --- /dev/null +++ b/flutt/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:geosector_app/app.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const GeoSectorApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/flutt/update_imports.sh b/flutt/update_imports.sh new file mode 100755 index 00000000..3079e01c --- /dev/null +++ b/flutt/update_imports.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Liste des fichiers à modifier +FILES=( + "lib/presentation/admin/admin_dashboard_page.dart" + "lib/presentation/user/user_dashboard_page.dart" + "lib/presentation/admin/admin_dashboard_home_page.dart" + "lib/presentation/admin/admin_statistics_page.dart" + "lib/presentation/admin/admin_history_page.dart" + "lib/presentation/user/user_dashboard_home_page.dart" + "lib/presentation/user/user_statistics_page.dart" + "lib/presentation/user/user_history_page.dart" + "lib/presentation/user/user_communication_page.dart" + "lib/core/widgets/dashboard_app_bar.dart" + "lib/presentation/widgets/responsive_navigation.dart" + "lib/presentation/widgets/charts/activity_chart.dart" + "lib/presentation/widgets/charts/passage_pie_chart.dart" + "lib/presentation/widgets/profile_dialog.dart" +) + +for file in "${FILES[@]}"; do + echo "Updating $file..." + + # Vérifier si le fichier existe + if [ ! -f "$file" ]; then + echo "File $file does not exist, skipping." + continue + fi + + # Remplacer l'import de provider par l'import de app.dart + sed -i '' -e '/import .package:provider\/provider.dart.;/d' "$file" + + # Ajouter l'import de app.dart s'il n'existe pas déjà + if ! grep -q "import 'package:geosector_app/app.dart';" "$file"; then + sed -i '' -e '1,/^import/s/^import/import '\''package:geosector_app\/app.dart'\''; \/\/ Pour accéder aux instances globales\nimport/' "$file" + fi + + echo "Updated $file" +done + +echo "All files updated!" diff --git a/flutt/web/favicon.png b/flutt/web/favicon.png new file mode 100644 index 00000000..6a461647 Binary files /dev/null and b/flutt/web/favicon.png differ diff --git a/flutt/web/icons/Icon-192.png b/flutt/web/icons/Icon-192.png new file mode 100644 index 00000000..a9bca263 Binary files /dev/null and b/flutt/web/icons/Icon-192.png differ diff --git a/flutt/web/icons/Icon-512.png b/flutt/web/icons/Icon-512.png new file mode 100644 index 00000000..d8a1289d Binary files /dev/null and b/flutt/web/icons/Icon-512.png differ diff --git a/flutt/web/icons/Icon-maskable-192.png b/flutt/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..a9bca263 Binary files /dev/null and b/flutt/web/icons/Icon-maskable-192.png differ diff --git a/flutt/web/icons/Icon-maskable-512.png b/flutt/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d8a1289d Binary files /dev/null and b/flutt/web/icons/Icon-maskable-512.png differ diff --git a/flutt/web/index.html b/flutt/web/index.html new file mode 100644 index 00000000..f86c9430 --- /dev/null +++ b/flutt/web/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + GEOSECTOR + + + + + + + diff --git a/flutt/web/manifest.json b/flutt/web/manifest.json new file mode 100644 index 00000000..f6745617 --- /dev/null +++ b/flutt/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "geosector_app", + "short_name": "geosector_app", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 00000000..bdef8201 --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..382941e0 --- /dev/null +++ b/web/README.md @@ -0,0 +1,47 @@ +# Svelte + Vite + +This template should help get you started developing with Svelte in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + +This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `checkJs` in the JS template?** + +It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```js +// store.js +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..feaf27cc --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Svelte + + +
+ + + diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 00000000..5696a2de --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..a45dda8a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2858 @@ +{ + "name": "geosector", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "geosector", + "version": "0.0.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "autoprefixer": "^10.4.20", + "svelte": "^5.23.1", + "tailwindcss": "^3.4.9", + "vite": "^6.3.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", + "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", + "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", + "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", + "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", + "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", + "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", + "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", + "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", + "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", + "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", + "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", + "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", + "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", + "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", + "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", + "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", + "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", + "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.0.3.tgz", + "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.0", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.15", + "vitefu": "^1.0.4" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.144", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.144.tgz", + "integrity": "sha512-eJIaMRKeAzxfBSxtjYnoIAw/tdD6VIH6tHBZepZnAbE3Gyqqs5mGN87DvcldPUbVkIljTK8pY0CMcUljP64lfQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.3", + "@esbuild/android-arm": "0.25.3", + "@esbuild/android-arm64": "0.25.3", + "@esbuild/android-x64": "0.25.3", + "@esbuild/darwin-arm64": "0.25.3", + "@esbuild/darwin-x64": "0.25.3", + "@esbuild/freebsd-arm64": "0.25.3", + "@esbuild/freebsd-x64": "0.25.3", + "@esbuild/linux-arm": "0.25.3", + "@esbuild/linux-arm64": "0.25.3", + "@esbuild/linux-ia32": "0.25.3", + "@esbuild/linux-loong64": "0.25.3", + "@esbuild/linux-mips64el": "0.25.3", + "@esbuild/linux-ppc64": "0.25.3", + "@esbuild/linux-riscv64": "0.25.3", + "@esbuild/linux-s390x": "0.25.3", + "@esbuild/linux-x64": "0.25.3", + "@esbuild/netbsd-arm64": "0.25.3", + "@esbuild/netbsd-x64": "0.25.3", + "@esbuild/openbsd-arm64": "0.25.3", + "@esbuild/openbsd-x64": "0.25.3", + "@esbuild/sunos-x64": "0.25.3", + "@esbuild/win32-arm64": "0.25.3", + "@esbuild/win32-ia32": "0.25.3", + "@esbuild/win32-x64": "0.25.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", + "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.1", + "@rollup/rollup-android-arm64": "4.40.1", + "@rollup/rollup-darwin-arm64": "4.40.1", + "@rollup/rollup-darwin-x64": "4.40.1", + "@rollup/rollup-freebsd-arm64": "4.40.1", + "@rollup/rollup-freebsd-x64": "4.40.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", + "@rollup/rollup-linux-arm-musleabihf": "4.40.1", + "@rollup/rollup-linux-arm64-gnu": "4.40.1", + "@rollup/rollup-linux-arm64-musl": "4.40.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-musl": "4.40.1", + "@rollup/rollup-linux-s390x-gnu": "4.40.1", + "@rollup/rollup-linux-x64-gnu": "4.40.1", + "@rollup/rollup-linux-x64-musl": "4.40.1", + "@rollup/rollup-win32-arm64-msvc": "4.40.1", + "@rollup/rollup-win32-ia32-msvc": "4.40.1", + "@rollup/rollup-win32-x64-msvc": "4.40.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.28.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.28.2.tgz", + "integrity": "sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^1.4.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", + "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", + "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..872c25aa --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "geosector", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "autoprefixer": "^10.4.20", + "svelte": "^5.23.1", + "tailwindcss": "^3.4.9", + "vite": "^6.3.1" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..ba807304 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 00000000..40d6d62d --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,47 @@ + + +
+ +

Vite + Svelte

+ +
+ +
+ +

+ Check out SvelteKit, the official Svelte app framework powered by Vite! +

+ +

+ Click on the Vite and Svelte logos to learn more +

+
+ + + diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 00000000..8625b61c --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,83 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/src/assets/svelte.svg b/web/src/assets/svelte.svg new file mode 100644 index 00000000..c5e08481 --- /dev/null +++ b/web/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/Counter.svelte b/web/src/lib/Counter.svelte new file mode 100644 index 00000000..770c9226 --- /dev/null +++ b/web/src/lib/Counter.svelte @@ -0,0 +1,10 @@ + + + diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 00000000..458c7a8a --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app'), +}) + +export default app diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 00000000..4078e747 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 00000000..b0683fd2 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 00000000..3c0cdf09 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./src/**/*.{html,js,svelte,ts}"], + + theme: { + extend: {} + }, + + plugins: [] +}; diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 00000000..d32eba1d --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [svelte()], +})