update UX and fixes code issues
f837ddc parent: 2e074e1 modified
README.md +114 -41 | @@ -4,15 +4,24 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc | ||
| 4 | 4 | |
| 5 | 5 | ## Fonctionnalités |
| 6 | 6 | |
| 7 | -- **Gestion des flux RSS** : Ajout, modification et suppression de flux podcast | |
| 8 | -- **Téléchargement automatique** : Synchronisation et téléchargement des nouveaux épisodes | |
| 9 | -- **Dossiers personnalisés** : Chaque flux peut avoir son propre dossier de téléchargement | |
| 10 | -- **Interface graphique** : Application native avec egui (OpenGL) | |
| 11 | -- **Persistance** : Sauvegarde des flux configurés dans un fichier JSON | |
| 7 | +- **Gestion des flux RSS** : ajout, modification, suppression (avec confirmation) de flux podcast, activation/désactivation, tags de catégorisation | |
| 8 | +- **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression | |
| 9 | +- **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation | |
| 10 | +- **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet | |
| 11 | +- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés | |
| 12 | +- **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats | |
| 13 | +- **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours) | |
| 14 | +- **Playlists** : regroupement des flux avec description | |
| 15 | +- **Export / Import** : sauvegarde et restauration des flux au format JSON | |
| 16 | +- **Historique** : journal des opérations (synchronisations, téléchargements, ajouts/suppressions, import/export) avec filtres par type et statut, persisté entre les sessions | |
| 17 | +- **Statistiques** : nombre de flux, épisodes téléchargés, dates de dernière activité, tableau par flux | |
| 18 | +- **Thèmes sombre et clair** : palette centralisée, choix persisté entre les sessions | |
| 19 | +- **Notifications** : messages flottants non intrusifs (coin haut droit) avec bouton de fermeture | |
| 20 | +- **Menu et raccourcis clavier** : barre de menus complète et raccourcis globaux | |
| 12 | 21 | |
| 13 | 22 | ## Prérequis |
| 14 | 23 | |
| 15 | -- [Rust](https://www.rust-lang.org/) 1.70 ou supérieur | |
| 24 | +- [Rust](https://www.rust-lang.org/) 1.92 ou supérieur (exigence MSRV d'egui 0.35) | |
| 16 | 25 | - Git (optionnel, pour cloner le dépôt) |
| 17 | 26 | |
| 18 | 27 | ## Installation |
| @@ -45,37 +54,64 @@ Ou exécuter directement le binaire compilé : | ||
| 45 | 54 | |
| 46 | 55 | ### Interface |
| 47 | 56 | |
| 48 | -L'application propose une interface simple avec : | |
| 57 | +L'application s'organise autour d'une barre de menus, d'un écran principal (recherche, filtres, actions, liste des flux et des playlists), d'une barre de statut et de notifications flottantes. | |
| 49 | 58 | |
| 50 | -- **Écran d'accueil** : | |
| 51 | - - Bouton "Ajouter un flux" pour ajouter un nouveau flux RSS | |
| 52 | - - Bouton "Synchroniser les flux" pour télécharger les nouveaux épisodes | |
| 53 | - - Liste des flux configurés avec options d'édition et suppression | |
| 59 | +- **Écran principal** : | |
| 60 | + - Recherche instantanée (nom, URL, tags) et filtres par date | |
| 61 | + - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer » | |
| 62 | + - Cartes de flux : statut activé/désactivé, dernière vérification, nombre d'épisodes téléchargés, dernier épisode récupéré, indicateur de synchronisation en cours | |
| 63 | + - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation) | |
| 54 | 64 | |
| 55 | -- **Ajout d'un flux** : | |
| 56 | - - **Nom du podcast** : Nom pour identifier le flux | |
| 57 | - - **URL du flux RSS** : URL du flux RSS du podcast | |
| 58 | - - **Dossier de téléchargement** : Chemin absolu vers le dossier de destination | |
| 65 | +- **Vue détaillée d'un flux** : | |
| 66 | + - Liste des épisodes du plus récent au plus ancien, avec date, taille et durée | |
| 67 | + - Téléchargement individuel d'un épisode, état « téléchargé » détecté sur le disque | |
| 68 | + - Filtre par titre et bouton d'actualisation | |
| 59 | 69 | |
| 60 | -- **Édition d'un flux** : | |
| 61 | - - Modification du nom, URL et dossier de téléchargement | |
| 62 | - - Activation/désactivation du flux | |
| 70 | +- **Barre de menus** : | |
| 71 | + - **Fichier** : ajouter un flux, importer, exporter, quitter | |
| 72 | + - **Actions** : synchroniser tous les flux, ouvrir les fenêtres Statistiques et Historique | |
| 73 | + - **Affichage** : bascule thème sombre/clair | |
| 74 | + | |
| 75 | +### Raccourcis clavier | |
| 76 | + | |
| 77 | +| Raccourci | Action | | |
| 78 | +|---|---| | |
| 79 | +| `Ctrl+N` | Ajouter un flux | | |
| 80 | +| `F5` | Synchroniser tous les flux | | |
| 81 | +| `Ctrl+F` | Focus sur la recherche | | |
| 82 | +| `Entrée` | Valider un dialogue (ajout/édition de flux) | | |
| 83 | +| `Échap` | Fermer un dialogue ou la vue détaillée | | |
| 63 | 84 | |
| 64 | 85 | ## Configuration |
| 65 | 86 | |
| 66 | -Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé dans : | |
| 67 | -- **Windows** : `%APPDATA%\rss-podcast-dl-egui\feeds.json` | |
| 68 | -- **Linux/macOS** : `~/.config/rss-podcast-dl-egui/feeds.json` | |
| 87 | +Les fichiers de configuration sont stockés dans un répertoire dédié : | |
| 88 | + | |
| 89 | +- **Windows** : `%APPDATA%\rss-podcast-dl\` | |
| 90 | +- **Linux/macOS** : `~/.config/rss-podcast-dl/` | |
| 91 | + | |
| 92 | +| Fichier | Contenu | | |
| 93 | +|---|---| | |
| 94 | +| `config.json` | Flux configurés | | |
| 95 | +| `config_playlists.json` | Playlists | | |
| 96 | +| `settings.json` | Préférences (thème) | | |
| 97 | +| `history.json` | Historique des opérations | | |
| 69 | 98 | |
| 70 | 99 | ### Format du fichier de configuration |
| 71 | 100 | |
| 72 | 101 | ```json |
| 73 | 102 | [ |
| 74 | 103 | { |
| 104 | + "id": "9d2c8a4e-1b3f-4c5d-9e6f-7a8b9c0d1e2f", | |
| 75 | 105 | "url": "https://exemple.com/podcast.rss", |
| 76 | 106 | "name": "Nom du Podcast", |
| 77 | - "download_path": "/chemin/vers/dossier", | |
| 78 | - "enabled": true | |
| 107 | + "download_path": "C:\\Users\\moi\\Musique\\podcasts", | |
| 108 | + "last_checked": "2026-09-29T10:00:00Z", | |
| 109 | + "last_episode_date": null, | |
| 110 | + "enabled": true, | |
| 111 | + "tags": ["Tech", "Français"], | |
| 112 | + "downloaded_episodes_count": 12, | |
| 113 | + "last_downloaded_episode_name": "Épisode 42", | |
| 114 | + "last_downloaded_episode_date": "2026-09-28T08:00:00Z" | |
| 79 | 115 | } |
| 80 | 116 | ] |
| 81 | 117 | ``` |
| @@ -86,39 +122,63 @@ Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé d | ||
| 86 | 122 | . |
| 87 | 123 | ├── src/ |
| 88 | 124 | │ ├── app.rs # Application principale egui |
| 125 | +│ ├── main.rs # Point d'entrée | |
| 89 | 126 | │ ├── config/ |
| 90 | 127 | │ │ ├── app_state.rs # État global de l'application |
| 91 | -│ │ ├── feed.rs # Structure Feed et méthodes | |
| 128 | +│ │ ├── backup.rs # Sauvegardes de la configuration | |
| 92 | 129 | │ │ ├── episode.rs # Structure Episode |
| 130 | +│ │ ├── feed.rs # Structure Feed et méthodes | |
| 131 | +│ │ ├── history.rs # Historique des opérations | |
| 93 | 132 | │ │ ├── mod.rs # Exports du module config |
| 94 | -│ │ └── storage.rs # Sauvegarde/chargement des flux | |
| 95 | -│ ├── main.rs # Point d'entrée | |
| 133 | +│ │ ├── playlist.rs # Structure Playlist | |
| 134 | +│ │ ├── search.rs # Requêtes de recherche | |
| 135 | +│ │ ├── settings.rs # Persistance des préférences (thème) | |
| 136 | +│ │ ├── statistics.rs # Statistiques et formatage | |
| 137 | +│ │ ├── storage.rs # Sauvegarde/chargement JSON | |
| 138 | +│ │ └── sync_task.rs # Tâches de synchronisation asynchrones | |
| 96 | 139 | │ ├── rss/ |
| 97 | -│ │ ├── downloader.rs # Téléchargement des épisodes | |
| 140 | +│ │ ├── async_sync.rs # Synchronisation asynchrone des flux | |
| 141 | +│ │ ├── audio_metadata.rs # Tags ID3 | |
| 142 | +│ │ ├── downloader.rs # Téléchargement des épisodes (atomique) | |
| 98 | 143 | │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS |
| 99 | 144 | │ │ ├── mod.rs |
| 100 | -│ │ ├── parser.rs # Parsing du flux RSS | |
| 101 | -│ │ └── sync.rs # Synchronisation des flux | |
| 145 | +│ │ └── parser.rs # Récupération et parsing du flux RSS | |
| 102 | 146 | │ └── ui/ |
| 103 | -│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux | |
| 104 | -│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux | |
| 105 | -│ ├── feed_list.rs # Liste des flux | |
| 106 | -│ ├── home.rs # Écran d'accueil | |
| 107 | -│ └── mod.rs | |
| 147 | +│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux (modal) | |
| 148 | +│ ├── confirm_dialog.rs # Confirmations de suppression (modales) | |
| 149 | +│ ├── export_import.rs # Export/Import JSON (modaux) | |
| 150 | +│ ├── feed_detail.rs # Vue détaillée : épisodes d'un flux | |
| 151 | +│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux (modal) | |
| 152 | +│ ├── feed_list.rs # Liste des flux | |
| 153 | +│ ├── filter_panel.rs # Filtres par date | |
| 154 | +│ ├── history_panel.rs # Fenêtre Historique | |
| 155 | +│ ├── home.rs # Écran principal | |
| 156 | +│ ├── menu_bar.rs # Barre de menus et raccourcis clavier | |
| 157 | +│ ├── mod.rs # Module UI + helper de tronquage | |
| 158 | +│ ├── notifications.rs # Notifications flottantes | |
| 159 | +│ ├── playlist_list.rs # Liste des playlists et dialogues | |
| 160 | +│ ├── search_bar.rs # Barre de recherche | |
| 161 | +│ ├── stats_panel.rs # Fenêtre Statistiques | |
| 162 | +│ ├── status_bar.rs # Barre de statut | |
| 163 | +│ ├── sync_status.rs # Indicateur de synchronisation | |
| 164 | +│ └── theme.rs # Palette de couleurs centralisée | |
| 108 | 165 | ├── spec/ |
| 109 | 166 | │ ├── init-plan.md # Plan initial du projet |
| 110 | 167 | │ └── init-tasks.md # Tâches détaillées |
| 168 | +├── assets/ | |
| 111 | 169 | └── Cargo.toml |
| 112 | 170 | ``` |
| 113 | 171 | |
| 114 | 172 | ## Technologies utilisées |
| 115 | 173 | |
| 116 | -- **Langage** : Rust | |
| 117 | -- **Framework UI** : [egui](https://github.com/emilk/egui) - Immediate mode GUI | |
| 174 | +- **Langage** : Rust (edition 2021) | |
| 175 | +- **Framework UI** : [egui](https://github.com/emilk/egui) 0.35 / [eframe](https://github.com/emilk/egui) - Immediate mode GUI | |
| 118 | 176 | - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate |
| 119 | 177 | - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json) |
| 120 | 178 | - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger) |
| 121 | 179 | - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking) |
| 180 | +- **Tags audio** : [id3](https://crates.io/crates/id3) | |
| 181 | +- **Dialogues natifs** : [rfd](https://crates.io/crates/rfd) (sélecteurs de fichiers et de dossiers) | |
| 122 | 182 | |
| 123 | 183 | ## Dépendances |
| 124 | 184 | |
| @@ -128,6 +188,13 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | ||
| 128 | 188 | |
| 129 | 189 | - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`) |
| 130 | 190 | |
| 191 | +## Tests | |
| 192 | + | |
| 193 | +```bash | |
| 194 | +cargo test # Exécute la suite de tests (modèles, stockage, parsing, extraction…) | |
| 195 | +cargo clippy # Lint sans avertissement | |
| 196 | +``` | |
| 197 | + | |
| 131 | 198 | ## Roadmap |
| 132 | 199 | |
| 133 | 200 | - [x] Phase 1 : Structure de base et configuration |
| @@ -137,11 +204,17 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | ||
| 137 | 204 | - [x] Phase 5 : Synchronisation |
| 138 | 205 | - [x] Phase 6 : Intégration et tests |
| 139 | 206 | - [x] Phase 7 : Finalisation |
| 140 | -- [x] Phase 8 : Fonctionnalités Avancées | |
| 141 | -- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité | |
| 207 | +- [x] Phase 8 : Fonctionnalités avancées (playlists, export/import, thèmes) | |
| 208 | +- [x] Phase 9 : Fonctionnalités de productivité (recherche, historique, statistiques) | |
| 209 | +- [x] Phase 10 : Confort d'utilisation (menu, raccourcis, vue détaillée par épisode, | |
| 210 | + confirmations, notifications flottantes, thème persisté) | |
| 142 | 211 | |
| 143 | 212 | ### Fonctionnalités futures |
| 144 | -Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%. | |
| 213 | + | |
| 214 | +- Synchronisation planifiée (automatique en arrière-plan) | |
| 215 | +- Lecteur audio intégré | |
| 216 | +- Support OPML pour l'import/export | |
| 217 | +- Taille/position de fenêtre persistées | |
| 145 | 218 | |
| 146 | 219 | ## Contribution |
| 147 | 220 | |
| @@ -149,8 +222,8 @@ Les contributions sont les bienvenues ! Ouvrez une issue ou soumettez une pull r | ||
| 149 | 222 | |
| 150 | 223 | ## Licence |
| 151 | 224 | |
| 152 | -Ce projet est sous licence MIT. Voir [LICENSE](LICENSE) pour plus de détails. | |
| 225 | +Ce projet est sous licence MIT (voir l'en-tête `license` du [Cargo.toml](Cargo.toml)). | |
| 153 | 226 | |
| 154 | 227 | --- |
| 155 | 228 | |
| 156 | -Développé avec Rust et egui. | |
| \ No newline at end of file | ||
| 229 | +Développé avec Rust et egui. | |
| @@ -4,15 +4,24 @@ Un outil en Rust avec interface egui pour télécharger automatiquement des podc | |||
| 4 | 4 | ||
| 5 | ## Fonctionnalités | 5 | ## Fonctionnalités |
| 6 | 6 | ||
| 7 | -- **Gestion des flux RSS** : Ajout, modification et suppression de flux podcast | 7 | +- **Gestion des flux RSS** : ajout, modification, suppression (avec confirmation) de flux podcast, activation/désactivation, tags de catégorisation |
| 8 | -- **Téléchargement automatique** : Synchronisation et téléchargement des nouveaux épisodes | 8 | +- **Synchronisation asynchrone** : synchronisation globale ou flux par flux, sans bloquer l'interface, avec indicateur de progression |
| 9 | -- **Dossiers personnalisés** : Chaque flux peut avoir son propre dossier de téléchargement | 9 | +- **Vue détaillée par flux** : liste des épisodes avec dates, tailles et durées, téléchargement individuel, filtre par titre, actualisation |
| 10 | -- **Interface graphique** : Application native avec egui (OpenGL) | 10 | +- **Téléchargement atomique** : écriture via un fichier `.part` puis renommage — un téléchargement interrompu n'est jamais confondu avec un épisode complet |
| 11 | -- **Persistance** : Sauvegarde des flux configurés dans un fichier JSON | 11 | +- **Tags ID3** : titre, artiste et album écrits dans les fichiers téléchargés |
| 12 | +- **Recherche** : filtrage instantané des flux par nom, URL ou tags, avec compteur de résultats | ||
| 13 | +- **Filtres par date** : périodes personnalisées ou prédéfinies (7/30 derniers jours) | ||
| 14 | +- **Playlists** : regroupement des flux avec description | ||
| 15 | +- **Export / Import** : sauvegarde et restauration des flux au format JSON | ||
| 16 | +- **Historique** : journal des opérations (synchronisations, téléchargements, ajouts/suppressions, import/export) avec filtres par type et statut, persisté entre les sessions | ||
| 17 | +- **Statistiques** : nombre de flux, épisodes téléchargés, dates de dernière activité, tableau par flux | ||
| 18 | +- **Thèmes sombre et clair** : palette centralisée, choix persisté entre les sessions | ||
| 19 | +- **Notifications** : messages flottants non intrusifs (coin haut droit) avec bouton de fermeture | ||
| 20 | +- **Menu et raccourcis clavier** : barre de menus complète et raccourcis globaux | ||
| 12 | 21 | ||
| 13 | ## Prérequis | 22 | ## Prérequis |
| 14 | 23 | ||
| 15 | -- [Rust](https://www.rust-lang.org/) 1.70 ou supérieur | 24 | +- [Rust](https://www.rust-lang.org/) 1.92 ou supérieur (exigence MSRV d'egui 0.35) |
| 16 | - Git (optionnel, pour cloner le dépôt) | 25 | - Git (optionnel, pour cloner le dépôt) |
| 17 | 26 | ||
| 18 | ## Installation | 27 | ## Installation |
| @@ -45,37 +54,64 @@ Ou exécuter directement le binaire compilé : | |||
| 45 | 54 | ||
| 46 | ### Interface | 55 | ### Interface |
| 47 | 56 | ||
| 48 | -L'application propose une interface simple avec : | 57 | +L'application s'organise autour d'une barre de menus, d'un écran principal (recherche, filtres, actions, liste des flux et des playlists), d'une barre de statut et de notifications flottantes. |
| 49 | 58 | ||
| 50 | -- **Écran d'accueil** : | 59 | +- **Écran principal** : |
| 51 | - - Bouton "Ajouter un flux" pour ajouter un nouveau flux RSS | 60 | + - Recherche instantanée (nom, URL, tags) et filtres par date |
| 52 | - - Bouton "Synchroniser les flux" pour télécharger les nouveaux épisodes | 61 | + - Boutons « Ajouter un flux », « Synchroniser tous », « Exporter », « Importer » |
| 53 | - - Liste des flux configurés avec options d'édition et suppression | 62 | + - Cartes de flux : statut activé/désactivé, dernière vérification, nombre d'épisodes téléchargés, dernier épisode récupéré, indicateur de synchronisation en cours |
| 63 | + - Actions par flux : vue détaillée (📖), synchroniser (🔄), éditer (✏️), supprimer (🗑️ avec confirmation) | ||
| 54 | 64 | ||
| 55 | -- **Ajout d'un flux** : | 65 | +- **Vue détaillée d'un flux** : |
| 56 | - - **Nom du podcast** : Nom pour identifier le flux | 66 | + - Liste des épisodes du plus récent au plus ancien, avec date, taille et durée |
| 57 | - - **URL du flux RSS** : URL du flux RSS du podcast | 67 | + - Téléchargement individuel d'un épisode, état « téléchargé » détecté sur le disque |
| 58 | - - **Dossier de téléchargement** : Chemin absolu vers le dossier de destination | 68 | + - Filtre par titre et bouton d'actualisation |
| 59 | 69 | ||
| 60 | -- **Édition d'un flux** : | 70 | +- **Barre de menus** : |
| 61 | - - Modification du nom, URL et dossier de téléchargement | 71 | + - **Fichier** : ajouter un flux, importer, exporter, quitter |
| 62 | - - Activation/désactivation du flux | 72 | + - **Actions** : synchroniser tous les flux, ouvrir les fenêtres Statistiques et Historique |
| 73 | + - **Affichage** : bascule thème sombre/clair | ||
| 74 | + | ||
| 75 | +### Raccourcis clavier | ||
| 76 | + | ||
| 77 | +| Raccourci | Action | | ||
| 78 | +|---|---| | ||
| 79 | +| `Ctrl+N` | Ajouter un flux | | ||
| 80 | +| `F5` | Synchroniser tous les flux | | ||
| 81 | +| `Ctrl+F` | Focus sur la recherche | | ||
| 82 | +| `Entrée` | Valider un dialogue (ajout/édition de flux) | | ||
| 83 | +| `Échap` | Fermer un dialogue ou la vue détaillée | | ||
| 63 | 84 | ||
| 64 | ## Configuration | 85 | ## Configuration |
| 65 | 86 | ||
| 66 | -Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé dans : | 87 | +Les fichiers de configuration sont stockés dans un répertoire dédié : |
| 67 | -- **Windows** : `%APPDATA%\rss-podcast-dl-egui\feeds.json` | 88 | + |
| 68 | -- **Linux/macOS** : `~/.config/rss-podcast-dl-egui/feeds.json` | 89 | +- **Windows** : `%APPDATA%\rss-podcast-dl\` |
| 90 | +- **Linux/macOS** : `~/.config/rss-podcast-dl/` | ||
| 91 | + | ||
| 92 | +| Fichier | Contenu | | ||
| 93 | +|---|---| | ||
| 94 | +| `config.json` | Flux configurés | | ||
| 95 | +| `config_playlists.json` | Playlists | | ||
| 96 | +| `settings.json` | Préférences (thème) | | ||
| 97 | +| `history.json` | Historique des opérations | | ||
| 69 | 98 | ||
| 70 | ### Format du fichier de configuration | 99 | ### Format du fichier de configuration |
| 71 | 100 | ||
| 72 | ```json | 101 | ```json |
| 73 | [ | 102 | [ |
| 74 | { | 103 | { |
| 104 | + "id": "9d2c8a4e-1b3f-4c5d-9e6f-7a8b9c0d1e2f", | ||
| 75 | "url": "https://exemple.com/podcast.rss", | 105 | "url": "https://exemple.com/podcast.rss", |
| 76 | "name": "Nom du Podcast", | 106 | "name": "Nom du Podcast", |
| 77 | - "download_path": "/chemin/vers/dossier", | 107 | + "download_path": "C:\\Users\\moi\\Musique\\podcasts", |
| 78 | - "enabled": true | 108 | + "last_checked": "2026-09-29T10:00:00Z", |
| 109 | + "last_episode_date": null, | ||
| 110 | + "enabled": true, | ||
| 111 | + "tags": ["Tech", "Français"], | ||
| 112 | + "downloaded_episodes_count": 12, | ||
| 113 | + "last_downloaded_episode_name": "Épisode 42", | ||
| 114 | + "last_downloaded_episode_date": "2026-09-28T08:00:00Z" | ||
| 79 | } | 115 | } |
| 80 | ] | 116 | ] |
| 81 | ``` | 117 | ``` |
| @@ -86,39 +122,63 @@ Les flux sont automatiquement sauvegardés dans un fichier `feeds.json` situé d | |||
| 86 | . | 122 | . |
| 87 | ├── src/ | 123 | ├── src/ |
| 88 | │ ├── app.rs # Application principale egui | 124 | │ ├── app.rs # Application principale egui |
| 125 | +│ ├── main.rs # Point d'entrée | ||
| 89 | │ ├── config/ | 126 | │ ├── config/ |
| 90 | │ │ ├── app_state.rs # État global de l'application | 127 | │ │ ├── app_state.rs # État global de l'application |
| 91 | -│ │ ├── feed.rs # Structure Feed et méthodes | 128 | +│ │ ├── backup.rs # Sauvegardes de la configuration |
| 92 | │ │ ├── episode.rs # Structure Episode | 129 | │ │ ├── episode.rs # Structure Episode |
| 130 | +│ │ ├── feed.rs # Structure Feed et méthodes | ||
| 131 | +│ │ ├── history.rs # Historique des opérations | ||
| 93 | │ │ ├── mod.rs # Exports du module config | 132 | │ │ ├── mod.rs # Exports du module config |
| 94 | -│ │ └── storage.rs # Sauvegarde/chargement des flux | 133 | +│ │ ├── playlist.rs # Structure Playlist |
| 95 | -│ ├── main.rs # Point d'entrée | 134 | +│ │ ├── search.rs # Requêtes de recherche |
| 135 | +│ │ ├── settings.rs # Persistance des préférences (thème) | ||
| 136 | +│ │ ├── statistics.rs # Statistiques et formatage | ||
| 137 | +│ │ ├── storage.rs # Sauvegarde/chargement JSON | ||
| 138 | +│ │ └── sync_task.rs # Tâches de synchronisation asynchrones | ||
| 96 | │ ├── rss/ | 139 | │ ├── rss/ |
| 97 | -│ │ ├── downloader.rs # Téléchargement des épisodes | 140 | +│ │ ├── async_sync.rs # Synchronisation asynchrone des flux |
| 141 | +│ │ ├── audio_metadata.rs # Tags ID3 | ||
| 142 | +│ │ ├── downloader.rs # Téléchargement des épisodes (atomique) | ||
| 98 | │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS | 143 | │ │ ├── episode_extractor.rs # Extraction des épisodes depuis le RSS |
| 99 | │ │ ├── mod.rs | 144 | │ │ ├── mod.rs |
| 100 | -│ │ ├── parser.rs # Parsing du flux RSS | 145 | +│ │ └── parser.rs # Récupération et parsing du flux RSS |
| 101 | -│ │ └── sync.rs # Synchronisation des flux | ||
| 102 | │ └── ui/ | 146 | │ └── ui/ |
| 103 | -│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux | 147 | +│ ├── add_feed_dialog.rs # Dialogue d'ajout de flux (modal) |
| 104 | -│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux | 148 | +│ ├── confirm_dialog.rs # Confirmations de suppression (modales) |
| 105 | -│ ├── feed_list.rs # Liste des flux | 149 | +│ ├── export_import.rs # Export/Import JSON (modaux) |
| 106 | -│ ├── home.rs # Écran d'accueil | 150 | +│ ├── feed_detail.rs # Vue détaillée : épisodes d'un flux |
| 107 | -│ └── mod.rs | 151 | +│ ├── feed_edit_dialog.rs # Dialogue d'édition de flux (modal) |
| 152 | +│ ├── feed_list.rs # Liste des flux | ||
| 153 | +│ ├── filter_panel.rs # Filtres par date | ||
| 154 | +│ ├── history_panel.rs # Fenêtre Historique | ||
| 155 | +│ ├── home.rs # Écran principal | ||
| 156 | +│ ├── menu_bar.rs # Barre de menus et raccourcis clavier | ||
| 157 | +│ ├── mod.rs # Module UI + helper de tronquage | ||
| 158 | +│ ├── notifications.rs # Notifications flottantes | ||
| 159 | +│ ├── playlist_list.rs # Liste des playlists et dialogues | ||
| 160 | +│ ├── search_bar.rs # Barre de recherche | ||
| 161 | +│ ├── stats_panel.rs # Fenêtre Statistiques | ||
| 162 | +│ ├── status_bar.rs # Barre de statut | ||
| 163 | +│ ├── sync_status.rs # Indicateur de synchronisation | ||
| 164 | +│ └── theme.rs # Palette de couleurs centralisée | ||
| 108 | ├── spec/ | 165 | ├── spec/ |
| 109 | │ ├── init-plan.md # Plan initial du projet | 166 | │ ├── init-plan.md # Plan initial du projet |
| 110 | │ └── init-tasks.md # Tâches détaillées | 167 | │ └── init-tasks.md # Tâches détaillées |
| 168 | +├── assets/ | ||
| 111 | └── Cargo.toml | 169 | └── Cargo.toml |
| 112 | ``` | 170 | ``` |
| 113 | 171 | ||
| 114 | ## Technologies utilisées | 172 | ## Technologies utilisées |
| 115 | 173 | ||
| 116 | -- **Langage** : Rust | 174 | +- **Langage** : Rust (edition 2021) |
| 117 | -- **Framework UI** : [egui](https://github.com/emilk/egui) - Immediate mode GUI | 175 | +- **Framework UI** : [egui](https://github.com/emilk/egui) 0.35 / [eframe](https://github.com/emilk/egui) - Immediate mode GUI |
| 118 | - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate | 176 | - **Parsing RSS** : [rss](https://crates.io/crates/rss) crate |
| 119 | - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json) | 177 | - **Sérialisation** : [serde](https://crates.io/crates/serde) avec [serde_json](https://crates.io/crates/serde_json) |
| 120 | - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger) | 178 | - **Logging** : [log](https://crates.io/crates/log) avec [env_logger](https://crates.io/crates/env_logger) |
| 121 | - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking) | 179 | - **Requêtes HTTP** : [reqwest](https://crates.io/crates/reqwest) (avec support blocking) |
| 180 | +- **Tags audio** : [id3](https://crates.io/crates/id3) | ||
| 181 | +- **Dialogues natifs** : [rfd](https://crates.io/crates/rfd) (sélecteurs de fichiers et de dossiers) | ||
| 122 | 182 | ||
| 123 | ## Dépendances | 183 | ## Dépendances |
| 124 | 184 | ||
| @@ -128,6 +188,13 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | |||
| 128 | 188 | ||
| 129 | - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`) | 189 | - `RUST_LOG` : Niveau de logging (ex: `RUST_LOG=info`) |
| 130 | 190 | ||
| 191 | +## Tests | ||
| 192 | + | ||
| 193 | +```bash | ||
| 194 | +cargo test # Exécute la suite de tests (modèles, stockage, parsing, extraction…) | ||
| 195 | +cargo clippy # Lint sans avertissement | ||
| 196 | +``` | ||
| 197 | + | ||
| 131 | ## Roadmap | 198 | ## Roadmap |
| 132 | 199 | ||
| 133 | - [x] Phase 1 : Structure de base et configuration | 200 | - [x] Phase 1 : Structure de base et configuration |
| @@ -137,11 +204,17 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | |||
| 137 | - [x] Phase 5 : Synchronisation | 204 | - [x] Phase 5 : Synchronisation |
| 138 | - [x] Phase 6 : Intégration et tests | 205 | - [x] Phase 6 : Intégration et tests |
| 139 | - [x] Phase 7 : Finalisation | 206 | - [x] Phase 7 : Finalisation |
| 140 | -- [x] Phase 8 : Fonctionnalités Avancées | 207 | +- [x] Phase 8 : Fonctionnalités avancées (playlists, export/import, thèmes) |
| 141 | -- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité | 208 | +- [x] Phase 9 : Fonctionnalités de productivité (recherche, historique, statistiques) |
| 209 | +- [x] Phase 10 : Confort d'utilisation (menu, raccourcis, vue détaillée par épisode, | ||
| 210 | + confirmations, notifications flottantes, thème persisté) | ||
| 142 | 211 | ||
| 143 | ### Fonctionnalités futures | 212 | ### Fonctionnalités futures |
| 144 | -Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%. | 213 | + |
| 214 | +- Synchronisation planifiée (automatique en arrière-plan) | ||
| 215 | +- Lecteur audio intégré | ||
| 216 | +- Support OPML pour l'import/export | ||
| 217 | +- Taille/position de fenêtre persistées | ||
| 145 | 218 | ||
| 146 | ## Contribution | 219 | ## Contribution |
| 147 | 220 | ||
| @@ -149,8 +222,8 @@ Les contributions sont les bienvenues ! Ouvrez une issue ou soumettez une pull r | |||
| 149 | 222 | ||
| 150 | ## Licence | 223 | ## Licence |
| 151 | 224 | ||
| 152 | -Ce projet est sous licence MIT. Voir [LICENSE](LICENSE) pour plus de détails. | 225 | +Ce projet est sous licence MIT (voir l'en-tête `license` du [Cargo.toml](Cargo.toml)). |
| 153 | 226 | ||
| 154 | --- | 227 | --- |
| 155 | 228 | ||
| 156 | -Développé avec Rust et egui. | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 229 | +Développé avec Rust et egui. | ||
modified
src/app.rs +49 -117 | @@ -2,9 +2,8 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module contains the main egui application and state management. |
| 4 | 4 | |
| 5 | -use crate::config::AppState; | |
| 6 | 5 | use crate::config::storage::load_feeds_default; |
| 7 | -use crate::config::Theme; | |
| 6 | +use crate::config::AppState; | |
| 8 | 7 | use eframe::egui; |
| 9 | 8 | use log::{error, info}; |
| 10 | 9 | |
| @@ -13,7 +12,6 @@ use log::{error, info}; | ||
| 13 | 12 | pub struct RssPodcastApp { |
| 14 | 13 | state: AppState, |
| 15 | 14 | title: String, |
| 16 | - clear_color: egui::Color32, | |
| 17 | 15 | } |
| 18 | 16 | |
| 19 | 17 | impl RssPodcastApp { |
| @@ -21,7 +19,6 @@ impl RssPodcastApp { | ||
| 21 | 19 | Self { |
| 22 | 20 | state, |
| 23 | 21 | title: "RSS Podcast Downloader".to_string(), |
| 24 | - clear_color: egui::Color32::from_rgb(30, 30, 30), | |
| 25 | 22 | } |
| 26 | 23 | } |
| 27 | 24 | } |
| @@ -35,133 +32,45 @@ impl Default for RssPodcastApp { | ||
| 35 | 32 | |
| 36 | 33 | impl eframe::App for RssPodcastApp { |
| 37 | 34 | fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { |
| 38 | - let ctx = ui.ctx(); | |
| 35 | + let ctx = ui.ctx().clone(); | |
| 39 | 36 | ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone())); |
| 40 | 37 | |
| 41 | - // Appliquer un thème personnalisé moderne | |
| 42 | - self.apply_custom_theme(ctx, self.state.get_theme()); | |
| 38 | + // Appliquer le thème centralisé et récupérer la palette active | |
| 39 | + let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme()); | |
| 43 | 40 | |
| 44 | - // Get theme-specific colors | |
| 45 | - let notification_bg = match self.state.get_theme() { | |
| 46 | - Theme::Dark => egui::Color32::from_rgb(40, 42, 54), | |
| 47 | - Theme::Light => egui::Color32::from_rgb(220, 230, 245), | |
| 48 | - }; | |
| 41 | + // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap) | |
| 42 | + crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx); | |
| 49 | 43 | |
| 50 | - // Draw notifications panel at the top | |
| 51 | - egui::Panel::top("notifications_panel") | |
| 52 | - .frame(egui::Frame { | |
| 53 | - fill: notification_bg, | |
| 54 | - corner_radius: 6.0.into(), | |
| 55 | - ..Default::default() | |
| 56 | - }) | |
| 44 | + // Barre de menus en haut | |
| 45 | + egui::Panel::top("menu_bar") | |
| 57 | 46 | .show(ui, |ui| { |
| 58 | - ui.vertical_centered(|ui| { | |
| 59 | - self.state.notification_manager.draw(ui); | |
| 60 | - }); | |
| 47 | + crate::ui::menu_bar::draw_menu(&mut self.state, ui); | |
| 61 | 48 | }); |
| 62 | 49 | |
| 50 | + // Zone principale défilante + barre de statut fixe en bas | |
| 63 | 51 | egui::CentralPanel::default() |
| 64 | 52 | .frame(egui::Frame { |
| 65 | - fill: self.clear_color, | |
| 53 | + fill: colors.window_bg, | |
| 66 | 54 | inner_margin: egui::Margin::symmetric(12, 8), |
| 67 | 55 | ..Default::default() |
| 68 | 56 | }) |
| 69 | 57 | .show(ui, |ui| { |
| 70 | 58 | ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0); |
| 71 | - ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16); | |
| 72 | - crate::ui::home::draw_home(&mut self.state, ui); | |
| 59 | + egui::ScrollArea::vertical() | |
| 60 | + .auto_shrink([false, false]) | |
| 61 | + .show(ui, |ui| { | |
| 62 | + ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16); | |
| 63 | + crate::ui::home::draw_home(&mut self.state, ui); | |
| 64 | + }); | |
| 73 | 65 | ui.separator(); |
| 74 | 66 | crate::ui::status_bar::draw_status_bar(&mut self.state, ui); |
| 75 | 67 | }); |
| 76 | - } | |
| 77 | -} | |
| 78 | 68 | |
| 79 | -impl RssPodcastApp { | |
| 80 | - /// Applique un thème personnalisé moderne et agréable | |
| 81 | - fn apply_custom_theme(&mut self, ctx: &egui::Context, theme: Theme) { | |
| 82 | - // Convert our Theme to egui::Theme | |
| 83 | - let egui_theme = match theme { | |
| 84 | - Theme::Dark => egui::Theme::Dark, | |
| 85 | - Theme::Light => egui::Theme::Light, | |
| 86 | - }; | |
| 87 | - let mut style = (*ctx.style_of(egui_theme)).clone(); | |
| 88 | - | |
| 89 | - match theme { | |
| 90 | - Theme::Dark => { | |
| 91 | - // Thème sombre moderne inspiré de Nord/Dracula | |
| 92 | - style.visuals.dark_mode = true; | |
| 93 | - | |
| 94 | - // Couleurs principales pour le thème sombre | |
| 95 | - let bg_primary = egui::Color32::from_rgb(36, 39, 58); | |
| 96 | - let bg_secondary = egui::Color32::from_rgb(46, 52, 72); | |
| 97 | - let bg_tertiary = egui::Color32::from_rgb(56, 62, 80); | |
| 98 | - let accent = egui::Color32::from_rgb(130, 170, 250); | |
| 99 | - let border = egui::Color32::from_rgb(80, 88, 106); | |
| 100 | - | |
| 101 | - // Arrière-plan | |
| 102 | - style.visuals.widgets.noninteractive.bg_fill = bg_primary; | |
| 103 | - style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border); | |
| 104 | - | |
| 105 | - // Boutons normaux | |
| 106 | - style.visuals.widgets.inactive.bg_fill = bg_secondary; | |
| 107 | - style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border); | |
| 108 | - | |
| 109 | - // Boutons hover | |
| 110 | - style.visuals.widgets.hovered.bg_fill = bg_tertiary; | |
| 111 | - | |
| 112 | - // Boutons actifs | |
| 113 | - style.visuals.widgets.active.bg_fill = accent; | |
| 114 | - style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); | |
| 115 | - | |
| 116 | - // Couleur des liens | |
| 117 | - style.visuals.hyperlink_color = accent; | |
| 118 | - | |
| 119 | - // Couleur de la fenêtre | |
| 120 | - self.clear_color = egui::Color32::from_rgb(30, 30, 30); | |
| 121 | - } | |
| 122 | - Theme::Light => { | |
| 123 | - // Thème clair moderne | |
| 124 | - style.visuals.dark_mode = false; | |
| 125 | - | |
| 126 | - // Couleurs principales pour le thème clair | |
| 127 | - let bg_primary = egui::Color32::from_rgb(240, 245, 252); | |
| 128 | - let bg_secondary = egui::Color32::from_rgb(220, 230, 245); | |
| 129 | - let bg_tertiary = egui::Color32::from_rgb(200, 220, 240); | |
| 130 | - let accent = egui::Color32::from_rgb(100, 140, 220); | |
| 131 | - let border = egui::Color32::from_rgb(180, 195, 210); | |
| 132 | - | |
| 133 | - // Arrière-plan | |
| 134 | - style.visuals.widgets.noninteractive.bg_fill = bg_primary; | |
| 135 | - style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border); | |
| 136 | - | |
| 137 | - // Boutons normaux | |
| 138 | - style.visuals.widgets.inactive.bg_fill = bg_secondary; | |
| 139 | - style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border); | |
| 140 | - | |
| 141 | - // Boutons hover | |
| 142 | - style.visuals.widgets.hovered.bg_fill = bg_tertiary; | |
| 143 | - | |
| 144 | - // Boutons actifs | |
| 145 | - style.visuals.widgets.active.bg_fill = accent; | |
| 146 | - style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); | |
| 147 | - | |
| 148 | - // Couleur des liens | |
| 149 | - style.visuals.hyperlink_color = accent; | |
| 150 | - | |
| 151 | - // Couleur de la fenêtre | |
| 152 | - self.clear_color = egui::Color32::from_rgb(248, 250, 255); | |
| 153 | - } | |
| 154 | - } | |
| 155 | - | |
| 156 | - // Espacement amélioré (commun aux deux thèmes) | |
| 157 | - style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0); | |
| 158 | - style.spacing.button_padding = egui::Vec2::new(12.0, 6.0); | |
| 159 | - | |
| 160 | - // Taille des icônes | |
| 161 | - style.spacing.icon_width = 20.0; | |
| 162 | - style.spacing.icon_spacing = 8.0; | |
| 163 | - | |
| 164 | - ctx.set_style_of(egui_theme, style); | |
| 69 | + // Notifications flottantes (overlay, coin haut droit) | |
| 70 | + self.state.notification_manager.draw_overlay(&ctx); | |
| 71 | + | |
| 72 | + // Dialogues de confirmation (suppressions) | |
| 73 | + crate::ui::confirm_dialog::draw_confirmations(&mut self.state, &ctx); | |
| 165 | 74 | } |
| 166 | 75 | } |
| 167 | 76 | |
| @@ -170,9 +79,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | ||
| 170 | 79 | |
| 171 | 80 | let mut options = eframe::NativeOptions::default(); |
| 172 | 81 | options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0)); |
| 173 | - | |
| 82 | + | |
| 174 | 83 | let mut state = AppState::with_default_config(); |
| 175 | - | |
| 84 | + | |
| 176 | 85 | match load_feeds_default() { |
| 177 | 86 | Ok(feeds) => { |
| 178 | 87 | state.feeds = feeds; |
| @@ -187,15 +96,38 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | ||
| 187 | 96 | match crate::config::storage::load_playlists_default() { |
| 188 | 97 | Ok(playlists) => { |
| 189 | 98 | state.playlists = playlists; |
| 190 | - info!("Loaded {} playlists from configuration", state.playlists.len()); | |
| 99 | + info!( | |
| 100 | + "Loaded {} playlists from configuration", | |
| 101 | + state.playlists.len() | |
| 102 | + ); | |
| 191 | 103 | } |
| 192 | 104 | Err(e) => { |
| 193 | 105 | info!("No existing playlists found or error loading: {}", e); |
| 194 | 106 | } |
| 195 | 107 | } |
| 196 | 108 | |
| 109 | + // Restore the persisted theme (settings.json) | |
| 110 | + if let Some(theme) = crate::config::settings::load_theme() { | |
| 111 | + state.theme = theme; | |
| 112 | + info!("Restored persisted theme"); | |
| 113 | + } | |
| 114 | + | |
| 115 | + // Load the operation history and persist new entries automatically | |
| 116 | + let history_path = crate::config::settings::get_history_path(); | |
| 117 | + if history_path.exists() { | |
| 118 | + if let Err(e) = state.history_manager.load_from_file(&history_path) { | |
| 119 | + log::warn!("Failed to load history: {}", e); | |
| 120 | + } else { | |
| 121 | + info!( | |
| 122 | + "Loaded {} history entries", | |
| 123 | + state.history_manager.len() | |
| 124 | + ); | |
| 125 | + } | |
| 126 | + } | |
| 127 | + state.history_manager.set_save_path(history_path); | |
| 128 | + | |
| 197 | 129 | let app = RssPodcastApp::new(state); |
| 198 | - | |
| 130 | + | |
| 199 | 131 | if let Err(e) = eframe::run_native( |
| 200 | 132 | "RSS Podcast Downloader", |
| 201 | 133 | options, |
| @@ -2,9 +2,8 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module contains the main egui application and state management. | 3 | //! This module contains the main egui application and state management. |
| 4 | 4 | ||
| 5 | -use crate::config::AppState; | ||
| 6 | use crate::config::storage::load_feeds_default; | 5 | use crate::config::storage::load_feeds_default; |
| 7 | -use crate::config::Theme; | 6 | +use crate::config::AppState; |
| 8 | use eframe::egui; | 7 | use eframe::egui; |
| 9 | use log::{error, info}; | 8 | use log::{error, info}; |
| 10 | 9 | ||
| @@ -13,7 +12,6 @@ use log::{error, info}; | |||
| 13 | pub struct RssPodcastApp { | 12 | pub struct RssPodcastApp { |
| 14 | state: AppState, | 13 | state: AppState, |
| 15 | title: String, | 14 | title: String, |
| 16 | - clear_color: egui::Color32, | ||
| 17 | } | 15 | } |
| 18 | 16 | ||
| 19 | impl RssPodcastApp { | 17 | impl RssPodcastApp { |
| @@ -21,7 +19,6 @@ impl RssPodcastApp { | |||
| 21 | Self { | 19 | Self { |
| 22 | state, | 20 | state, |
| 23 | title: "RSS Podcast Downloader".to_string(), | 21 | title: "RSS Podcast Downloader".to_string(), |
| 24 | - clear_color: egui::Color32::from_rgb(30, 30, 30), | ||
| 25 | } | 22 | } |
| 26 | } | 23 | } |
| 27 | } | 24 | } |
| @@ -35,133 +32,45 @@ impl Default for RssPodcastApp { | |||
| 35 | 32 | ||
| 36 | impl eframe::App for RssPodcastApp { | 33 | impl eframe::App for RssPodcastApp { |
| 37 | fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { | 34 | fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { |
| 38 | - let ctx = ui.ctx(); | 35 | + let ctx = ui.ctx().clone(); |
| 39 | ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone())); | 36 | ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.title.clone())); |
| 40 | 37 | ||
| 41 | - // Appliquer un thème personnalisé moderne | 38 | + // Appliquer le thème centralisé et récupérer la palette active |
| 42 | - self.apply_custom_theme(ctx, self.state.get_theme()); | 39 | + let colors = crate::ui::theme::apply_custom_theme(&ctx, self.state.get_theme()); |
| 43 | 40 | ||
| 44 | - // Get theme-specific colors | 41 | + // Raccourcis clavier globaux (Ctrl+N, F5, Ctrl+F, Échap) |
| 45 | - let notification_bg = match self.state.get_theme() { | 42 | + crate::ui::menu_bar::handle_shortcuts(&mut self.state, &ctx); |
| 46 | - Theme::Dark => egui::Color32::from_rgb(40, 42, 54), | ||
| 47 | - Theme::Light => egui::Color32::from_rgb(220, 230, 245), | ||
| 48 | - }; | ||
| 49 | 43 | ||
| 50 | - // Draw notifications panel at the top | 44 | + // Barre de menus en haut |
| 51 | - egui::Panel::top("notifications_panel") | 45 | + egui::Panel::top("menu_bar") |
| 52 | - .frame(egui::Frame { | ||
| 53 | - fill: notification_bg, | ||
| 54 | - corner_radius: 6.0.into(), | ||
| 55 | - ..Default::default() | ||
| 56 | - }) | ||
| 57 | .show(ui, |ui| { | 46 | .show(ui, |ui| { |
| 58 | - ui.vertical_centered(|ui| { | 47 | + crate::ui::menu_bar::draw_menu(&mut self.state, ui); |
| 59 | - self.state.notification_manager.draw(ui); | ||
| 60 | - }); | ||
| 61 | }); | 48 | }); |
| 62 | 49 | ||
| 50 | + // Zone principale défilante + barre de statut fixe en bas | ||
| 63 | egui::CentralPanel::default() | 51 | egui::CentralPanel::default() |
| 64 | .frame(egui::Frame { | 52 | .frame(egui::Frame { |
| 65 | - fill: self.clear_color, | 53 | + fill: colors.window_bg, |
| 66 | inner_margin: egui::Margin::symmetric(12, 8), | 54 | inner_margin: egui::Margin::symmetric(12, 8), |
| 67 | ..Default::default() | 55 | ..Default::default() |
| 68 | }) | 56 | }) |
| 69 | .show(ui, |ui| { | 57 | .show(ui, |ui| { |
| 70 | ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0); | 58 | ui.spacing_mut().item_spacing = egui::Vec2::new(12.0, 12.0); |
| 71 | - ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16); | 59 | + egui::ScrollArea::vertical() |
| 72 | - crate::ui::home::draw_home(&mut self.state, ui); | 60 | + .auto_shrink([false, false]) |
| 61 | + .show(ui, |ui| { | ||
| 62 | + ui.spacing_mut().window_margin = egui::Margin::symmetric(16, 16); | ||
| 63 | + crate::ui::home::draw_home(&mut self.state, ui); | ||
| 64 | + }); | ||
| 73 | ui.separator(); | 65 | ui.separator(); |
| 74 | crate::ui::status_bar::draw_status_bar(&mut self.state, ui); | 66 | crate::ui::status_bar::draw_status_bar(&mut self.state, ui); |
| 75 | }); | 67 | }); |
| 76 | - } | ||
| 77 | -} | ||
| 78 | 68 | ||
| 79 | -impl RssPodcastApp { | 69 | + // Notifications flottantes (overlay, coin haut droit) |
| 80 | - /// Applique un thème personnalisé moderne et agréable | 70 | + self.state.notification_manager.draw_overlay(&ctx); |
| 81 | - fn apply_custom_theme(&mut self, ctx: &egui::Context, theme: Theme) { | 71 | + |
| 82 | - // Convert our Theme to egui::Theme | 72 | + // Dialogues de confirmation (suppressions) |
| 83 | - let egui_theme = match theme { | 73 | + crate::ui::confirm_dialog::draw_confirmations(&mut self.state, &ctx); |
| 84 | - Theme::Dark => egui::Theme::Dark, | ||
| 85 | - Theme::Light => egui::Theme::Light, | ||
| 86 | - }; | ||
| 87 | - let mut style = (*ctx.style_of(egui_theme)).clone(); | ||
| 88 | - | ||
| 89 | - match theme { | ||
| 90 | - Theme::Dark => { | ||
| 91 | - // Thème sombre moderne inspiré de Nord/Dracula | ||
| 92 | - style.visuals.dark_mode = true; | ||
| 93 | - | ||
| 94 | - // Couleurs principales pour le thème sombre | ||
| 95 | - let bg_primary = egui::Color32::from_rgb(36, 39, 58); | ||
| 96 | - let bg_secondary = egui::Color32::from_rgb(46, 52, 72); | ||
| 97 | - let bg_tertiary = egui::Color32::from_rgb(56, 62, 80); | ||
| 98 | - let accent = egui::Color32::from_rgb(130, 170, 250); | ||
| 99 | - let border = egui::Color32::from_rgb(80, 88, 106); | ||
| 100 | - | ||
| 101 | - // Arrière-plan | ||
| 102 | - style.visuals.widgets.noninteractive.bg_fill = bg_primary; | ||
| 103 | - style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border); | ||
| 104 | - | ||
| 105 | - // Boutons normaux | ||
| 106 | - style.visuals.widgets.inactive.bg_fill = bg_secondary; | ||
| 107 | - style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border); | ||
| 108 | - | ||
| 109 | - // Boutons hover | ||
| 110 | - style.visuals.widgets.hovered.bg_fill = bg_tertiary; | ||
| 111 | - | ||
| 112 | - // Boutons actifs | ||
| 113 | - style.visuals.widgets.active.bg_fill = accent; | ||
| 114 | - style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); | ||
| 115 | - | ||
| 116 | - // Couleur des liens | ||
| 117 | - style.visuals.hyperlink_color = accent; | ||
| 118 | - | ||
| 119 | - // Couleur de la fenêtre | ||
| 120 | - self.clear_color = egui::Color32::from_rgb(30, 30, 30); | ||
| 121 | - } | ||
| 122 | - Theme::Light => { | ||
| 123 | - // Thème clair moderne | ||
| 124 | - style.visuals.dark_mode = false; | ||
| 125 | - | ||
| 126 | - // Couleurs principales pour le thème clair | ||
| 127 | - let bg_primary = egui::Color32::from_rgb(240, 245, 252); | ||
| 128 | - let bg_secondary = egui::Color32::from_rgb(220, 230, 245); | ||
| 129 | - let bg_tertiary = egui::Color32::from_rgb(200, 220, 240); | ||
| 130 | - let accent = egui::Color32::from_rgb(100, 140, 220); | ||
| 131 | - let border = egui::Color32::from_rgb(180, 195, 210); | ||
| 132 | - | ||
| 133 | - // Arrière-plan | ||
| 134 | - style.visuals.widgets.noninteractive.bg_fill = bg_primary; | ||
| 135 | - style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border); | ||
| 136 | - | ||
| 137 | - // Boutons normaux | ||
| 138 | - style.visuals.widgets.inactive.bg_fill = bg_secondary; | ||
| 139 | - style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border); | ||
| 140 | - | ||
| 141 | - // Boutons hover | ||
| 142 | - style.visuals.widgets.hovered.bg_fill = bg_tertiary; | ||
| 143 | - | ||
| 144 | - // Boutons actifs | ||
| 145 | - style.visuals.widgets.active.bg_fill = accent; | ||
| 146 | - style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); | ||
| 147 | - | ||
| 148 | - // Couleur des liens | ||
| 149 | - style.visuals.hyperlink_color = accent; | ||
| 150 | - | ||
| 151 | - // Couleur de la fenêtre | ||
| 152 | - self.clear_color = egui::Color32::from_rgb(248, 250, 255); | ||
| 153 | - } | ||
| 154 | - } | ||
| 155 | - | ||
| 156 | - // Espacement amélioré (commun aux deux thèmes) | ||
| 157 | - style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0); | ||
| 158 | - style.spacing.button_padding = egui::Vec2::new(12.0, 6.0); | ||
| 159 | - | ||
| 160 | - // Taille des icônes | ||
| 161 | - style.spacing.icon_width = 20.0; | ||
| 162 | - style.spacing.icon_spacing = 8.0; | ||
| 163 | - | ||
| 164 | - ctx.set_style_of(egui_theme, style); | ||
| 165 | } | 74 | } |
| 166 | } | 75 | } |
| 167 | 76 | ||
| @@ -170,9 +79,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | |||
| 170 | 79 | ||
| 171 | let mut options = eframe::NativeOptions::default(); | 80 | let mut options = eframe::NativeOptions::default(); |
| 172 | options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0)); | 81 | options.viewport.inner_size = Some(egui::Vec2::new(1000.0, 600.0)); |
| 173 | - | 82 | + |
| 174 | let mut state = AppState::with_default_config(); | 83 | let mut state = AppState::with_default_config(); |
| 175 | - | 84 | + |
| 176 | match load_feeds_default() { | 85 | match load_feeds_default() { |
| 177 | Ok(feeds) => { | 86 | Ok(feeds) => { |
| 178 | state.feeds = feeds; | 87 | state.feeds = feeds; |
| @@ -187,15 +96,38 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | |||
| 187 | match crate::config::storage::load_playlists_default() { | 96 | match crate::config::storage::load_playlists_default() { |
| 188 | Ok(playlists) => { | 97 | Ok(playlists) => { |
| 189 | state.playlists = playlists; | 98 | state.playlists = playlists; |
| 190 | - info!("Loaded {} playlists from configuration", state.playlists.len()); | 99 | + info!( |
| 100 | + "Loaded {} playlists from configuration", | ||
| 101 | + state.playlists.len() | ||
| 102 | + ); | ||
| 191 | } | 103 | } |
| 192 | Err(e) => { | 104 | Err(e) => { |
| 193 | info!("No existing playlists found or error loading: {}", e); | 105 | info!("No existing playlists found or error loading: {}", e); |
| 194 | } | 106 | } |
| 195 | } | 107 | } |
| 196 | 108 | ||
| 109 | + // Restore the persisted theme (settings.json) | ||
| 110 | + if let Some(theme) = crate::config::settings::load_theme() { | ||
| 111 | + state.theme = theme; | ||
| 112 | + info!("Restored persisted theme"); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + // Load the operation history and persist new entries automatically | ||
| 116 | + let history_path = crate::config::settings::get_history_path(); | ||
| 117 | + if history_path.exists() { | ||
| 118 | + if let Err(e) = state.history_manager.load_from_file(&history_path) { | ||
| 119 | + log::warn!("Failed to load history: {}", e); | ||
| 120 | + } else { | ||
| 121 | + info!( | ||
| 122 | + "Loaded {} history entries", | ||
| 123 | + state.history_manager.len() | ||
| 124 | + ); | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + state.history_manager.set_save_path(history_path); | ||
| 128 | + | ||
| 197 | let app = RssPodcastApp::new(state); | 129 | let app = RssPodcastApp::new(state); |
| 198 | - | 130 | + |
| 199 | if let Err(e) = eframe::run_native( | 131 | if let Err(e) = eframe::run_native( |
| 200 | "RSS Podcast Downloader", | 132 | "RSS Podcast Downloader", |
| 201 | options, | 133 | options, |
modified
src/config/app_state.rs +165 -44 | @@ -2,15 +2,16 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module defines the main application state structure. |
| 4 | 4 | |
| 5 | +use crate::config::episode::Episode; | |
| 5 | 6 | use crate::config::feed::Feed; |
| 6 | -use crate::config::history::HistoryManager; | |
| 7 | +use crate::config::history::{HistoryEntry, HistoryManager, OperationType}; | |
| 7 | 8 | use crate::config::playlist::Playlist; |
| 8 | 9 | use crate::config::search::SearchQuery; |
| 9 | -use crate::config::statistics::AppStatistics; | |
| 10 | 10 | use crate::config::sync_task::SyncTaskManager; |
| 11 | 11 | use crate::ui::notifications::NotificationManager; |
| 12 | 12 | use chrono::{DateTime, Utc}; |
| 13 | 13 | use serde::{Deserialize, Serialize}; |
| 14 | +use std::collections::HashSet; | |
| 14 | 15 | |
| 15 | 16 | /// Theme options for the application |
| 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| @@ -90,8 +91,6 @@ pub struct AppState { | ||
| 90 | 91 | pub filter_date_from: Option<DateTime<Utc>>, |
| 91 | 92 | /// Filter: date to (inclusive) |
| 92 | 93 | pub filter_date_to: Option<DateTime<Utc>>, |
| 93 | - /// Filter: episode number | |
| 94 | - pub filter_episode_number: String, | |
| 95 | 94 | /// Whether to show the date picker for 'from' date |
| 96 | 95 | pub show_date_picker_from: bool, |
| 97 | 96 | /// Whether to show the date picker for 'to' date |
| @@ -102,24 +101,38 @@ pub struct AppState { | ||
| 102 | 101 | pub show_import_dialog: bool, |
| 103 | 102 | /// Last export/import error message |
| 104 | 103 | pub export_import_error: Option<String>, |
| 105 | - // Phase 9: Search functionality | |
| 106 | - /// Current search query | |
| 107 | - /// Not yet wired into the UI (see ui/search_bar.rs) | |
| 108 | - #[allow(dead_code)] | |
| 104 | + /// Current search query (toolbar search bar) | |
| 109 | 105 | pub search_query: SearchQuery, |
| 110 | - /// Whether search panel is visible | |
| 111 | - #[allow(dead_code)] | |
| 112 | - pub show_search: bool, | |
| 113 | - // Phase 9: Statistics | |
| 114 | - /// Application statistics | |
| 115 | - /// Not yet wired into the UI (see ui/stats_panel.rs) | |
| 116 | - #[allow(dead_code)] | |
| 117 | - pub statistics: AppStatistics, | |
| 118 | - // Phase 9: History | |
| 106 | + /// Request to give focus to the search bar (Ctrl+F) | |
| 107 | + pub focus_search: bool, | |
| 119 | 108 | /// History manager for tracking operations |
| 120 | - /// Not yet wired into the UI (see ui/history_panel.rs) | |
| 121 | - #[allow(dead_code)] | |
| 122 | 109 | pub history_manager: HistoryManager, |
| 110 | + /// History window: filter by operation type | |
| 111 | + pub history_filter_type: Option<OperationType>, | |
| 112 | + /// History window: filter by status | |
| 113 | + pub history_filter_status: | |
| 114 | + Option<crate::config::history::OperationStatus>, | |
| 115 | + /// Whether the statistics window is visible | |
| 116 | + pub show_stats_window: bool, | |
| 117 | + /// Whether the history window is visible | |
| 118 | + pub show_history_window: bool, | |
| 119 | + /// Feed pending deletion confirmation (feed id) | |
| 120 | + pub confirm_delete_feed_id: Option<String>, | |
| 121 | + /// Playlist pending deletion confirmation (playlist id) | |
| 122 | + pub confirm_delete_playlist_id: Option<String>, | |
| 123 | + // Feed detail view | |
| 124 | + /// ID of the feed shown in the detail view | |
| 125 | + pub detail_feed_id: Option<String>, | |
| 126 | + /// Episodes of the feed shown in the detail view | |
| 127 | + pub detail_episodes: Vec<Episode>, | |
| 128 | + /// Whether episodes are currently being loaded in the detail view | |
| 129 | + pub detail_loading: bool, | |
| 130 | + /// Error message when loading episodes failed | |
| 131 | + pub detail_error: Option<String>, | |
| 132 | + /// Episode filter (detail view) | |
| 133 | + pub detail_episode_filter: String, | |
| 134 | + /// IDs of episodes currently being downloaded individually | |
| 135 | + pub downloading_episode_ids: HashSet<String>, | |
| 123 | 136 | /// Manager for async synchronization tasks |
| 124 | 137 | pub sync_task_manager: SyncTaskManager, |
| 125 | 138 | } |
| @@ -152,17 +165,26 @@ impl AppState { | ||
| 152 | 165 | playlists: Vec::new(), |
| 153 | 166 | filter_date_from: None, |
| 154 | 167 | filter_date_to: None, |
| 155 | - filter_episode_number: String::new(), | |
| 156 | 168 | show_date_picker_from: false, |
| 157 | 169 | show_date_picker_to: false, |
| 158 | 170 | show_export_dialog: false, |
| 159 | 171 | show_import_dialog: false, |
| 160 | 172 | export_import_error: None, |
| 161 | - // Phase 9 fields | |
| 162 | 173 | search_query: SearchQuery::default(), |
| 163 | - show_search: false, | |
| 164 | - statistics: AppStatistics::new(), | |
| 174 | + focus_search: false, | |
| 165 | 175 | history_manager: HistoryManager::new(), |
| 176 | + history_filter_type: None, | |
| 177 | + history_filter_status: None, | |
| 178 | + show_stats_window: false, | |
| 179 | + show_history_window: false, | |
| 180 | + confirm_delete_feed_id: None, | |
| 181 | + confirm_delete_playlist_id: None, | |
| 182 | + detail_feed_id: None, | |
| 183 | + detail_episodes: Vec::new(), | |
| 184 | + detail_loading: false, | |
| 185 | + detail_error: None, | |
| 186 | + detail_episode_filter: String::new(), | |
| 187 | + downloading_episode_ids: HashSet::new(), | |
| 166 | 188 | theme: Theme::default(), |
| 167 | 189 | sync_task_manager: SyncTaskManager::new(), |
| 168 | 190 | } |
| @@ -175,15 +197,90 @@ impl AppState { | ||
| 175 | 197 | |
| 176 | 198 | /// Adds a new feed to the application state |
| 177 | 199 | pub fn add_feed(&mut self, feed: Feed) { |
| 200 | + self.record_history( | |
| 201 | + OperationType::AddFeed, | |
| 202 | + format!("Flux ajouté : {}", feed.name), | |
| 203 | + ); | |
| 178 | 204 | self.feeds.push(feed); |
| 179 | 205 | self.notification_manager.success("Flux ajouté avec succès"); |
| 180 | 206 | } |
| 181 | 207 | |
| 208 | + /// Deletes a feed by id, returning the removed feed if found | |
| 209 | + pub fn delete_feed_by_id(&mut self, feed_id: &str) -> Option<Feed> { | |
| 210 | + let index = self.feeds.iter().position(|f| f.id == feed_id)?; | |
| 211 | + let feed = self.feeds.remove(index); | |
| 212 | + if self.current_feed == Some(index) { | |
| 213 | + self.select_feed(None); | |
| 214 | + } | |
| 215 | + Some(feed) | |
| 216 | + } | |
| 217 | + | |
| 182 | 218 | /// Selects a feed by index |
| 183 | 219 | pub fn select_feed(&mut self, index: Option<usize>) { |
| 184 | 220 | self.current_feed = index; |
| 185 | 221 | } |
| 186 | 222 | |
| 223 | + /// Opens the detail view for a feed and starts loading its episodes | |
| 224 | + pub fn open_feed_detail(&mut self, feed_id: String) { | |
| 225 | + let Some(feed) = self.feeds.iter().find(|f| f.id == feed_id).cloned() else { | |
| 226 | + return; | |
| 227 | + }; | |
| 228 | + self.detail_feed_id = Some(feed.id.clone()); | |
| 229 | + self.detail_episodes.clear(); | |
| 230 | + self.detail_error = None; | |
| 231 | + self.detail_episode_filter.clear(); | |
| 232 | + self.detail_loading = true; | |
| 233 | + self.sync_task_manager.start_load_episodes(feed); | |
| 234 | + } | |
| 235 | + | |
| 236 | + /// Closes the feed detail view | |
| 237 | + pub fn close_feed_detail(&mut self) { | |
| 238 | + self.detail_feed_id = None; | |
| 239 | + self.detail_episodes.clear(); | |
| 240 | + self.detail_error = None; | |
| 241 | + self.detail_episode_filter.clear(); | |
| 242 | + self.detail_loading = false; | |
| 243 | + } | |
| 244 | + | |
| 245 | + /// Starts an asynchronous synchronization of all feeds | |
| 246 | + pub fn start_sync_all_feeds(&mut self) { | |
| 247 | + if !self.has_feeds() { | |
| 248 | + self.notification_manager.info("Aucun flux à synchroniser"); | |
| 249 | + return; | |
| 250 | + } | |
| 251 | + | |
| 252 | + log::info!("Starting asynchronous synchronization of all feeds"); | |
| 253 | + | |
| 254 | + let feeds_to_sync = self.feeds.clone(); | |
| 255 | + self.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | |
| 256 | + self.record_history( | |
| 257 | + OperationType::SyncAll, | |
| 258 | + "Synchronisation de tous les flux lancée".to_string(), | |
| 259 | + ); | |
| 260 | + | |
| 261 | + self.notification_manager | |
| 262 | + .info("Synchronisation de tous les flux en cours..."); | |
| 263 | + } | |
| 264 | + | |
| 265 | + /// Records a successful operation in the history | |
| 266 | + pub fn record_history(&mut self, operation_type: OperationType, description: impl Into<String>) { | |
| 267 | + let mut entry = HistoryEntry::new(operation_type, description.into()); | |
| 268 | + entry.mark_success(); | |
| 269 | + self.history_manager.add_entry(entry); | |
| 270 | + } | |
| 271 | + | |
| 272 | + /// Records a failed operation in the history | |
| 273 | + pub fn record_history_failure( | |
| 274 | + &mut self, | |
| 275 | + operation_type: OperationType, | |
| 276 | + description: impl Into<String>, | |
| 277 | + error: impl Into<String>, | |
| 278 | + ) { | |
| 279 | + let mut entry = HistoryEntry::new(operation_type, description.into()); | |
| 280 | + entry.mark_failed(error.into()); | |
| 281 | + self.history_manager.add_entry(entry); | |
| 282 | + } | |
| 283 | + | |
| 187 | 284 | /// Returns the number of feeds |
| 188 | 285 | pub fn feed_count(&self) -> usize { |
| 189 | 286 | self.feeds.len() |
| @@ -235,15 +332,12 @@ impl AppState { | ||
| 235 | 332 | self.notification_manager.success("Playlist ajoutée"); |
| 236 | 333 | } |
| 237 | 334 | |
| 238 | - /// Removes a playlist by index | |
| 239 | - pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> { | |
| 240 | - if index < self.playlists.len() { | |
| 241 | - let playlist = self.playlists.remove(index); | |
| 242 | - self.notification_manager.success("Playlist supprimée"); | |
| 243 | - Some(playlist) | |
| 244 | - } else { | |
| 245 | - None | |
| 246 | - } | |
| 335 | + /// Removes a playlist by id, returning the removed playlist if found | |
| 336 | + pub fn remove_playlist_by_id(&mut self, playlist_id: &str) -> Option<Playlist> { | |
| 337 | + let index = self.playlists.iter().position(|p| p.id == playlist_id)?; | |
| 338 | + let playlist = self.playlists.remove(index); | |
| 339 | + self.notification_manager.success("Playlist supprimée"); | |
| 340 | + Some(playlist) | |
| 247 | 341 | } |
| 248 | 342 | |
| 249 | 343 | /// Clears the add playlist form fields |
| @@ -263,7 +357,6 @@ impl AppState { | ||
| 263 | 357 | pub fn clear_filters(&mut self) { |
| 264 | 358 | self.filter_date_from = None; |
| 265 | 359 | self.filter_date_to = None; |
| 266 | - self.filter_episode_number.clear(); | |
| 267 | 360 | self.notification_manager.info("Filtres effacés"); |
| 268 | 361 | } |
| 269 | 362 | |
| @@ -279,14 +372,18 @@ impl AppState { | ||
| 279 | 372 | self.export_import_error = None; |
| 280 | 373 | } |
| 281 | 374 | |
| 282 | - /// Toggles the theme between light and dark | |
| 375 | + /// Toggles the theme between light and dark and persists the choice | |
| 283 | 376 | pub fn toggle_theme(&mut self) { |
| 284 | 377 | self.theme.toggle(); |
| 285 | 378 | let theme_name = match self.theme { |
| 286 | 379 | Theme::Dark => "sombre", |
| 287 | 380 | Theme::Light => "clair", |
| 288 | 381 | }; |
| 289 | - self.notification_manager.info(format!("Thème basculé vers: {}", theme_name)); | |
| 382 | + if let Err(e) = crate::config::settings::save_theme(self.theme) { | |
| 383 | + log::error!("Failed to save theme: {}", e); | |
| 384 | + } | |
| 385 | + self.notification_manager | |
| 386 | + .info(format!("Thème basculé vers: {}", theme_name)); | |
| 290 | 387 | } |
| 291 | 388 | |
| 292 | 389 | /// Gets the current theme |
| @@ -311,21 +408,45 @@ mod tests { | ||
| 311 | 408 | fn test_add_feed() { |
| 312 | 409 | let mut app_state = AppState::new(); |
| 313 | 410 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 314 | - | |
| 411 | + | |
| 315 | 412 | app_state.add_feed(feed); |
| 316 | - | |
| 413 | + | |
| 317 | 414 | assert_eq!(app_state.feeds.len(), 1); |
| 318 | 415 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); |
| 319 | 416 | } |
| 320 | 417 | |
| 418 | + #[test] | |
| 419 | + fn test_delete_feed_by_id() { | |
| 420 | + let mut app_state = AppState::new(); | |
| 421 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | |
| 422 | + let feed_id = feed.id.clone(); | |
| 423 | + app_state.add_feed(feed); | |
| 424 | + | |
| 425 | + assert!(app_state.delete_feed_by_id(&feed_id).is_some()); | |
| 426 | + assert!(app_state.feeds.is_empty()); | |
| 427 | + assert!(app_state.delete_feed_by_id(&feed_id).is_none()); | |
| 428 | + } | |
| 429 | + | |
| 430 | + #[test] | |
| 431 | + fn test_remove_playlist_by_id() { | |
| 432 | + let mut app_state = AppState::new(); | |
| 433 | + let playlist = Playlist::new("Ma playlist"); | |
| 434 | + let playlist_id = playlist.id.clone(); | |
| 435 | + app_state.add_playlist(playlist); | |
| 436 | + | |
| 437 | + assert!(app_state.remove_playlist_by_id(&playlist_id).is_some()); | |
| 438 | + assert!(app_state.playlists.is_empty()); | |
| 439 | + assert!(app_state.remove_playlist_by_id(&playlist_id).is_none()); | |
| 440 | + } | |
| 441 | + | |
| 321 | 442 | #[test] |
| 322 | 443 | fn test_select_feed() { |
| 323 | 444 | let mut app_state = AppState::new(); |
| 324 | 445 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 325 | - | |
| 446 | + | |
| 326 | 447 | app_state.add_feed(feed); |
| 327 | 448 | app_state.select_feed(Some(0)); |
| 328 | - | |
| 449 | + | |
| 329 | 450 | assert_eq!(app_state.current_feed, Some(0)); |
| 330 | 451 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); |
| 331 | 452 | } |
| @@ -334,10 +455,10 @@ mod tests { | ||
| 334 | 455 | fn test_feed_count() { |
| 335 | 456 | let mut app_state = AppState::new(); |
| 336 | 457 | assert_eq!(app_state.feed_count(), 0); |
| 337 | - | |
| 458 | + | |
| 338 | 459 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); |
| 339 | 460 | assert_eq!(app_state.feed_count(), 1); |
| 340 | - | |
| 461 | + | |
| 341 | 462 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); |
| 342 | 463 | assert_eq!(app_state.feed_count(), 2); |
| 343 | 464 | } |
| @@ -345,13 +466,13 @@ mod tests { | ||
| 345 | 466 | #[test] |
| 346 | 467 | fn test_enabled_feed_count() { |
| 347 | 468 | let mut app_state = AppState::new(); |
| 348 | - | |
| 469 | + | |
| 349 | 470 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled |
| 350 | 471 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled |
| 351 | 472 | app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled |
| 352 | - | |
| 473 | + | |
| 353 | 474 | app_state.feeds[2].enabled = false; |
| 354 | - | |
| 475 | + | |
| 355 | 476 | assert_eq!(app_state.enabled_feed_count(), 2); |
| 356 | 477 | } |
| 357 | 478 | } |
| @@ -2,15 +2,16 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module defines the main application state structure. | 3 | //! This module defines the main application state structure. |
| 4 | 4 | ||
| 5 | +use crate::config::episode::Episode; | ||
| 5 | use crate::config::feed::Feed; | 6 | use crate::config::feed::Feed; |
| 6 | -use crate::config::history::HistoryManager; | 7 | +use crate::config::history::{HistoryEntry, HistoryManager, OperationType}; |
| 7 | use crate::config::playlist::Playlist; | 8 | use crate::config::playlist::Playlist; |
| 8 | use crate::config::search::SearchQuery; | 9 | use crate::config::search::SearchQuery; |
| 9 | -use crate::config::statistics::AppStatistics; | ||
| 10 | use crate::config::sync_task::SyncTaskManager; | 10 | use crate::config::sync_task::SyncTaskManager; |
| 11 | use crate::ui::notifications::NotificationManager; | 11 | use crate::ui::notifications::NotificationManager; |
| 12 | use chrono::{DateTime, Utc}; | 12 | use chrono::{DateTime, Utc}; |
| 13 | use serde::{Deserialize, Serialize}; | 13 | use serde::{Deserialize, Serialize}; |
| 14 | +use std::collections::HashSet; | ||
| 14 | 15 | ||
| 15 | /// Theme options for the application | 16 | /// Theme options for the application |
| 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| @@ -90,8 +91,6 @@ pub struct AppState { | |||
| 90 | pub filter_date_from: Option<DateTime<Utc>>, | 91 | pub filter_date_from: Option<DateTime<Utc>>, |
| 91 | /// Filter: date to (inclusive) | 92 | /// Filter: date to (inclusive) |
| 92 | pub filter_date_to: Option<DateTime<Utc>>, | 93 | pub filter_date_to: Option<DateTime<Utc>>, |
| 93 | - /// Filter: episode number | ||
| 94 | - pub filter_episode_number: String, | ||
| 95 | /// Whether to show the date picker for 'from' date | 94 | /// Whether to show the date picker for 'from' date |
| 96 | pub show_date_picker_from: bool, | 95 | pub show_date_picker_from: bool, |
| 97 | /// Whether to show the date picker for 'to' date | 96 | /// Whether to show the date picker for 'to' date |
| @@ -102,24 +101,38 @@ pub struct AppState { | |||
| 102 | pub show_import_dialog: bool, | 101 | pub show_import_dialog: bool, |
| 103 | /// Last export/import error message | 102 | /// Last export/import error message |
| 104 | pub export_import_error: Option<String>, | 103 | pub export_import_error: Option<String>, |
| 105 | - // Phase 9: Search functionality | 104 | + /// Current search query (toolbar search bar) |
| 106 | - /// Current search query | ||
| 107 | - /// Not yet wired into the UI (see ui/search_bar.rs) | ||
| 108 | - #[allow(dead_code)] | ||
| 109 | pub search_query: SearchQuery, | 105 | pub search_query: SearchQuery, |
| 110 | - /// Whether search panel is visible | 106 | + /// Request to give focus to the search bar (Ctrl+F) |
| 111 | - #[allow(dead_code)] | 107 | + pub focus_search: bool, |
| 112 | - pub show_search: bool, | ||
| 113 | - // Phase 9: Statistics | ||
| 114 | - /// Application statistics | ||
| 115 | - /// Not yet wired into the UI (see ui/stats_panel.rs) | ||
| 116 | - #[allow(dead_code)] | ||
| 117 | - pub statistics: AppStatistics, | ||
| 118 | - // Phase 9: History | ||
| 119 | /// History manager for tracking operations | 108 | /// History manager for tracking operations |
| 120 | - /// Not yet wired into the UI (see ui/history_panel.rs) | ||
| 121 | - #[allow(dead_code)] | ||
| 122 | pub history_manager: HistoryManager, | 109 | pub history_manager: HistoryManager, |
| 110 | + /// History window: filter by operation type | ||
| 111 | + pub history_filter_type: Option<OperationType>, | ||
| 112 | + /// History window: filter by status | ||
| 113 | + pub history_filter_status: | ||
| 114 | + Option<crate::config::history::OperationStatus>, | ||
| 115 | + /// Whether the statistics window is visible | ||
| 116 | + pub show_stats_window: bool, | ||
| 117 | + /// Whether the history window is visible | ||
| 118 | + pub show_history_window: bool, | ||
| 119 | + /// Feed pending deletion confirmation (feed id) | ||
| 120 | + pub confirm_delete_feed_id: Option<String>, | ||
| 121 | + /// Playlist pending deletion confirmation (playlist id) | ||
| 122 | + pub confirm_delete_playlist_id: Option<String>, | ||
| 123 | + // Feed detail view | ||
| 124 | + /// ID of the feed shown in the detail view | ||
| 125 | + pub detail_feed_id: Option<String>, | ||
| 126 | + /// Episodes of the feed shown in the detail view | ||
| 127 | + pub detail_episodes: Vec<Episode>, | ||
| 128 | + /// Whether episodes are currently being loaded in the detail view | ||
| 129 | + pub detail_loading: bool, | ||
| 130 | + /// Error message when loading episodes failed | ||
| 131 | + pub detail_error: Option<String>, | ||
| 132 | + /// Episode filter (detail view) | ||
| 133 | + pub detail_episode_filter: String, | ||
| 134 | + /// IDs of episodes currently being downloaded individually | ||
| 135 | + pub downloading_episode_ids: HashSet<String>, | ||
| 123 | /// Manager for async synchronization tasks | 136 | /// Manager for async synchronization tasks |
| 124 | pub sync_task_manager: SyncTaskManager, | 137 | pub sync_task_manager: SyncTaskManager, |
| 125 | } | 138 | } |
| @@ -152,17 +165,26 @@ impl AppState { | |||
| 152 | playlists: Vec::new(), | 165 | playlists: Vec::new(), |
| 153 | filter_date_from: None, | 166 | filter_date_from: None, |
| 154 | filter_date_to: None, | 167 | filter_date_to: None, |
| 155 | - filter_episode_number: String::new(), | ||
| 156 | show_date_picker_from: false, | 168 | show_date_picker_from: false, |
| 157 | show_date_picker_to: false, | 169 | show_date_picker_to: false, |
| 158 | show_export_dialog: false, | 170 | show_export_dialog: false, |
| 159 | show_import_dialog: false, | 171 | show_import_dialog: false, |
| 160 | export_import_error: None, | 172 | export_import_error: None, |
| 161 | - // Phase 9 fields | ||
| 162 | search_query: SearchQuery::default(), | 173 | search_query: SearchQuery::default(), |
| 163 | - show_search: false, | 174 | + focus_search: false, |
| 164 | - statistics: AppStatistics::new(), | ||
| 165 | history_manager: HistoryManager::new(), | 175 | history_manager: HistoryManager::new(), |
| 176 | + history_filter_type: None, | ||
| 177 | + history_filter_status: None, | ||
| 178 | + show_stats_window: false, | ||
| 179 | + show_history_window: false, | ||
| 180 | + confirm_delete_feed_id: None, | ||
| 181 | + confirm_delete_playlist_id: None, | ||
| 182 | + detail_feed_id: None, | ||
| 183 | + detail_episodes: Vec::new(), | ||
| 184 | + detail_loading: false, | ||
| 185 | + detail_error: None, | ||
| 186 | + detail_episode_filter: String::new(), | ||
| 187 | + downloading_episode_ids: HashSet::new(), | ||
| 166 | theme: Theme::default(), | 188 | theme: Theme::default(), |
| 167 | sync_task_manager: SyncTaskManager::new(), | 189 | sync_task_manager: SyncTaskManager::new(), |
| 168 | } | 190 | } |
| @@ -175,15 +197,90 @@ impl AppState { | |||
| 175 | 197 | ||
| 176 | /// Adds a new feed to the application state | 198 | /// Adds a new feed to the application state |
| 177 | pub fn add_feed(&mut self, feed: Feed) { | 199 | pub fn add_feed(&mut self, feed: Feed) { |
| 200 | + self.record_history( | ||
| 201 | + OperationType::AddFeed, | ||
| 202 | + format!("Flux ajouté : {}", feed.name), | ||
| 203 | + ); | ||
| 178 | self.feeds.push(feed); | 204 | self.feeds.push(feed); |
| 179 | self.notification_manager.success("Flux ajouté avec succès"); | 205 | self.notification_manager.success("Flux ajouté avec succès"); |
| 180 | } | 206 | } |
| 181 | 207 | ||
| 208 | + /// Deletes a feed by id, returning the removed feed if found | ||
| 209 | + pub fn delete_feed_by_id(&mut self, feed_id: &str) -> Option<Feed> { | ||
| 210 | + let index = self.feeds.iter().position(|f| f.id == feed_id)?; | ||
| 211 | + let feed = self.feeds.remove(index); | ||
| 212 | + if self.current_feed == Some(index) { | ||
| 213 | + self.select_feed(None); | ||
| 214 | + } | ||
| 215 | + Some(feed) | ||
| 216 | + } | ||
| 217 | + | ||
| 182 | /// Selects a feed by index | 218 | /// Selects a feed by index |
| 183 | pub fn select_feed(&mut self, index: Option<usize>) { | 219 | pub fn select_feed(&mut self, index: Option<usize>) { |
| 184 | self.current_feed = index; | 220 | self.current_feed = index; |
| 185 | } | 221 | } |
| 186 | 222 | ||
| 223 | + /// Opens the detail view for a feed and starts loading its episodes | ||
| 224 | + pub fn open_feed_detail(&mut self, feed_id: String) { | ||
| 225 | + let Some(feed) = self.feeds.iter().find(|f| f.id == feed_id).cloned() else { | ||
| 226 | + return; | ||
| 227 | + }; | ||
| 228 | + self.detail_feed_id = Some(feed.id.clone()); | ||
| 229 | + self.detail_episodes.clear(); | ||
| 230 | + self.detail_error = None; | ||
| 231 | + self.detail_episode_filter.clear(); | ||
| 232 | + self.detail_loading = true; | ||
| 233 | + self.sync_task_manager.start_load_episodes(feed); | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + /// Closes the feed detail view | ||
| 237 | + pub fn close_feed_detail(&mut self) { | ||
| 238 | + self.detail_feed_id = None; | ||
| 239 | + self.detail_episodes.clear(); | ||
| 240 | + self.detail_error = None; | ||
| 241 | + self.detail_episode_filter.clear(); | ||
| 242 | + self.detail_loading = false; | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + /// Starts an asynchronous synchronization of all feeds | ||
| 246 | + pub fn start_sync_all_feeds(&mut self) { | ||
| 247 | + if !self.has_feeds() { | ||
| 248 | + self.notification_manager.info("Aucun flux à synchroniser"); | ||
| 249 | + return; | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + log::info!("Starting asynchronous synchronization of all feeds"); | ||
| 253 | + | ||
| 254 | + let feeds_to_sync = self.feeds.clone(); | ||
| 255 | + self.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | ||
| 256 | + self.record_history( | ||
| 257 | + OperationType::SyncAll, | ||
| 258 | + "Synchronisation de tous les flux lancée".to_string(), | ||
| 259 | + ); | ||
| 260 | + | ||
| 261 | + self.notification_manager | ||
| 262 | + .info("Synchronisation de tous les flux en cours..."); | ||
| 263 | + } | ||
| 264 | + | ||
| 265 | + /// Records a successful operation in the history | ||
| 266 | + pub fn record_history(&mut self, operation_type: OperationType, description: impl Into<String>) { | ||
| 267 | + let mut entry = HistoryEntry::new(operation_type, description.into()); | ||
| 268 | + entry.mark_success(); | ||
| 269 | + self.history_manager.add_entry(entry); | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + /// Records a failed operation in the history | ||
| 273 | + pub fn record_history_failure( | ||
| 274 | + &mut self, | ||
| 275 | + operation_type: OperationType, | ||
| 276 | + description: impl Into<String>, | ||
| 277 | + error: impl Into<String>, | ||
| 278 | + ) { | ||
| 279 | + let mut entry = HistoryEntry::new(operation_type, description.into()); | ||
| 280 | + entry.mark_failed(error.into()); | ||
| 281 | + self.history_manager.add_entry(entry); | ||
| 282 | + } | ||
| 283 | + | ||
| 187 | /// Returns the number of feeds | 284 | /// Returns the number of feeds |
| 188 | pub fn feed_count(&self) -> usize { | 285 | pub fn feed_count(&self) -> usize { |
| 189 | self.feeds.len() | 286 | self.feeds.len() |
| @@ -235,15 +332,12 @@ impl AppState { | |||
| 235 | self.notification_manager.success("Playlist ajoutée"); | 332 | self.notification_manager.success("Playlist ajoutée"); |
| 236 | } | 333 | } |
| 237 | 334 | ||
| 238 | - /// Removes a playlist by index | 335 | + /// Removes a playlist by id, returning the removed playlist if found |
| 239 | - pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> { | 336 | + pub fn remove_playlist_by_id(&mut self, playlist_id: &str) -> Option<Playlist> { |
| 240 | - if index < self.playlists.len() { | 337 | + let index = self.playlists.iter().position(|p| p.id == playlist_id)?; |
| 241 | - let playlist = self.playlists.remove(index); | 338 | + let playlist = self.playlists.remove(index); |
| 242 | - self.notification_manager.success("Playlist supprimée"); | 339 | + self.notification_manager.success("Playlist supprimée"); |
| 243 | - Some(playlist) | 340 | + Some(playlist) |
| 244 | - } else { | ||
| 245 | - None | ||
| 246 | - } | ||
| 247 | } | 341 | } |
| 248 | 342 | ||
| 249 | /// Clears the add playlist form fields | 343 | /// Clears the add playlist form fields |
| @@ -263,7 +357,6 @@ impl AppState { | |||
| 263 | pub fn clear_filters(&mut self) { | 357 | pub fn clear_filters(&mut self) { |
| 264 | self.filter_date_from = None; | 358 | self.filter_date_from = None; |
| 265 | self.filter_date_to = None; | 359 | self.filter_date_to = None; |
| 266 | - self.filter_episode_number.clear(); | ||
| 267 | self.notification_manager.info("Filtres effacés"); | 360 | self.notification_manager.info("Filtres effacés"); |
| 268 | } | 361 | } |
| 269 | 362 | ||
| @@ -279,14 +372,18 @@ impl AppState { | |||
| 279 | self.export_import_error = None; | 372 | self.export_import_error = None; |
| 280 | } | 373 | } |
| 281 | 374 | ||
| 282 | - /// Toggles the theme between light and dark | 375 | + /// Toggles the theme between light and dark and persists the choice |
| 283 | pub fn toggle_theme(&mut self) { | 376 | pub fn toggle_theme(&mut self) { |
| 284 | self.theme.toggle(); | 377 | self.theme.toggle(); |
| 285 | let theme_name = match self.theme { | 378 | let theme_name = match self.theme { |
| 286 | Theme::Dark => "sombre", | 379 | Theme::Dark => "sombre", |
| 287 | Theme::Light => "clair", | 380 | Theme::Light => "clair", |
| 288 | }; | 381 | }; |
| 289 | - self.notification_manager.info(format!("Thème basculé vers: {}", theme_name)); | 382 | + if let Err(e) = crate::config::settings::save_theme(self.theme) { |
| 383 | + log::error!("Failed to save theme: {}", e); | ||
| 384 | + } | ||
| 385 | + self.notification_manager | ||
| 386 | + .info(format!("Thème basculé vers: {}", theme_name)); | ||
| 290 | } | 387 | } |
| 291 | 388 | ||
| 292 | /// Gets the current theme | 389 | /// Gets the current theme |
| @@ -311,21 +408,45 @@ mod tests { | |||
| 311 | fn test_add_feed() { | 408 | fn test_add_feed() { |
| 312 | let mut app_state = AppState::new(); | 409 | let mut app_state = AppState::new(); |
| 313 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | 410 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 314 | - | 411 | + |
| 315 | app_state.add_feed(feed); | 412 | app_state.add_feed(feed); |
| 316 | - | 413 | + |
| 317 | assert_eq!(app_state.feeds.len(), 1); | 414 | assert_eq!(app_state.feeds.len(), 1); |
| 318 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); | 415 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); |
| 319 | } | 416 | } |
| 320 | 417 | ||
| 418 | + #[test] | ||
| 419 | + fn test_delete_feed_by_id() { | ||
| 420 | + let mut app_state = AppState::new(); | ||
| 421 | + let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | ||
| 422 | + let feed_id = feed.id.clone(); | ||
| 423 | + app_state.add_feed(feed); | ||
| 424 | + | ||
| 425 | + assert!(app_state.delete_feed_by_id(&feed_id).is_some()); | ||
| 426 | + assert!(app_state.feeds.is_empty()); | ||
| 427 | + assert!(app_state.delete_feed_by_id(&feed_id).is_none()); | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + #[test] | ||
| 431 | + fn test_remove_playlist_by_id() { | ||
| 432 | + let mut app_state = AppState::new(); | ||
| 433 | + let playlist = Playlist::new("Ma playlist"); | ||
| 434 | + let playlist_id = playlist.id.clone(); | ||
| 435 | + app_state.add_playlist(playlist); | ||
| 436 | + | ||
| 437 | + assert!(app_state.remove_playlist_by_id(&playlist_id).is_some()); | ||
| 438 | + assert!(app_state.playlists.is_empty()); | ||
| 439 | + assert!(app_state.remove_playlist_by_id(&playlist_id).is_none()); | ||
| 440 | + } | ||
| 441 | + | ||
| 321 | #[test] | 442 | #[test] |
| 322 | fn test_select_feed() { | 443 | fn test_select_feed() { |
| 323 | let mut app_state = AppState::new(); | 444 | let mut app_state = AppState::new(); |
| 324 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); | 445 | let feed = Feed::new("https://example.com/rss", "Test Podcast", "/downloads"); |
| 325 | - | 446 | + |
| 326 | app_state.add_feed(feed); | 447 | app_state.add_feed(feed); |
| 327 | app_state.select_feed(Some(0)); | 448 | app_state.select_feed(Some(0)); |
| 328 | - | 449 | + |
| 329 | assert_eq!(app_state.current_feed, Some(0)); | 450 | assert_eq!(app_state.current_feed, Some(0)); |
| 330 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); | 451 | assert_eq!(app_state.feeds[0].name, "Test Podcast"); |
| 331 | } | 452 | } |
| @@ -334,10 +455,10 @@ mod tests { | |||
| 334 | fn test_feed_count() { | 455 | fn test_feed_count() { |
| 335 | let mut app_state = AppState::new(); | 456 | let mut app_state = AppState::new(); |
| 336 | assert_eq!(app_state.feed_count(), 0); | 457 | assert_eq!(app_state.feed_count(), 0); |
| 337 | - | 458 | + |
| 338 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); | 459 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); |
| 339 | assert_eq!(app_state.feed_count(), 1); | 460 | assert_eq!(app_state.feed_count(), 1); |
| 340 | - | 461 | + |
| 341 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); | 462 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); |
| 342 | assert_eq!(app_state.feed_count(), 2); | 463 | assert_eq!(app_state.feed_count(), 2); |
| 343 | } | 464 | } |
| @@ -345,13 +466,13 @@ mod tests { | |||
| 345 | #[test] | 466 | #[test] |
| 346 | fn test_enabled_feed_count() { | 467 | fn test_enabled_feed_count() { |
| 347 | let mut app_state = AppState::new(); | 468 | let mut app_state = AppState::new(); |
| 348 | - | 469 | + |
| 349 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled | 470 | app_state.add_feed(Feed::new("url1", "Podcast 1", "path1")); // enabled |
| 350 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled | 471 | app_state.add_feed(Feed::new("url2", "Podcast 2", "path2")); // enabled |
| 351 | app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled | 472 | app_state.add_feed(Feed::new("url3", "Podcast 3", "path3")); // will be disabled |
| 352 | - | 473 | + |
| 353 | app_state.feeds[2].enabled = false; | 474 | app_state.feeds[2].enabled = false; |
| 354 | - | 475 | + |
| 355 | assert_eq!(app_state.enabled_feed_count(), 2); | 476 | assert_eq!(app_state.enabled_feed_count(), 2); |
| 356 | } | 477 | } |
| 357 | } | 478 | } |
modified
src/config/mod.rs +1 -0 | @@ -9,6 +9,7 @@ pub mod feed; | ||
| 9 | 9 | pub mod history; |
| 10 | 10 | pub mod playlist; |
| 11 | 11 | pub mod search; |
| 12 | +pub mod settings; | |
| 12 | 13 | pub mod statistics; |
| 13 | 14 | pub mod storage; |
| 14 | 15 | pub mod sync_task; |
| @@ -9,6 +9,7 @@ pub mod feed; | |||
| 9 | pub mod history; | 9 | pub mod history; |
| 10 | pub mod playlist; | 10 | pub mod playlist; |
| 11 | pub mod search; | 11 | pub mod search; |
| 12 | +pub mod settings; | ||
| 12 | pub mod statistics; | 13 | pub mod statistics; |
| 13 | pub mod storage; | 14 | pub mod storage; |
| 14 | pub mod sync_task; | 15 | pub mod sync_task; |
modified
src/config/search.rs +1 -1 | @@ -57,7 +57,7 @@ impl SearchQuery { | ||
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | /// Vérifier si le texte contient la requête |
| 60 | - fn contains_query(&self, text: &str) -> bool { | |
| 60 | + pub fn contains_query(&self, text: &str) -> bool { | |
| 61 | 61 | if self.is_empty() { |
| 62 | 62 | return true; |
| 63 | 63 | } |
| @@ -57,7 +57,7 @@ impl SearchQuery { | |||
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | /// Vérifier si le texte contient la requête | 59 | /// Vérifier si le texte contient la requête |
| 60 | - fn contains_query(&self, text: &str) -> bool { | 60 | + pub fn contains_query(&self, text: &str) -> bool { |
| 61 | if self.is_empty() { | 61 | if self.is_empty() { |
| 62 | return true; | 62 | return true; |
| 63 | } | 63 | } |
added
src/config/settings.rs +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +//! Settings module - Application settings persistence | |
| 2 | +//! | |
| 3 | +//! Persists user preferences (theme, etc.) to a JSON settings file. | |
| 4 | + | |
| 5 | +use crate::config::app_state::Theme; | |
| 6 | +use log::{info, warn}; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use std::fs; | |
| 9 | +use std::path::PathBuf; | |
| 10 | +use thiserror::Error; | |
| 11 | + | |
| 12 | +/// Error type for settings operations | |
| 13 | +#[derive(Debug, Error)] | |
| 14 | +pub enum SettingsError { | |
| 15 | + #[error("Failed to write settings file: {0}")] | |
| 16 | + WriteError(String), | |
| 17 | + #[error("Failed to serialize settings: {0}")] | |
| 18 | + SerializeError(String), | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Application settings persisted across sessions | |
| 22 | +#[derive(Debug, Default, Serialize, Deserialize)] | |
| 23 | +pub struct Settings { | |
| 24 | + /// UI theme (dark or light) | |
| 25 | + pub theme: Theme, | |
| 26 | +} | |
| 27 | + | |
| 28 | +/// Returns the path of the settings file in the configuration directory | |
| 29 | +pub fn get_settings_path() -> PathBuf { | |
| 30 | + crate::config::storage::get_config_dir().join("settings.json") | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Saves the current settings (theme) to the default settings file | |
| 34 | +pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | |
| 35 | + let settings = Settings { theme }; | |
| 36 | + let path = get_settings_path(); | |
| 37 | + | |
| 38 | + if let Some(parent) = path.parent() { | |
| 39 | + if !parent.exists() { | |
| 40 | + fs::create_dir_all(parent).map_err(|e| { | |
| 41 | + SettingsError::WriteError(format!("{}: {}", parent.display(), e)) | |
| 42 | + })?; | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + let json = serde_json::to_string_pretty(&settings) | |
| 47 | + .map_err(|e| SettingsError::SerializeError(e.to_string()))?; | |
| 48 | + fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?; | |
| 49 | + | |
| 50 | + info!("Saved theme settings to {}", path.display()); | |
| 51 | + Ok(()) | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Loads the persisted theme, if a settings file exists and is valid | |
| 55 | +pub fn load_theme() -> Option<Theme> { | |
| 56 | + let path = get_settings_path(); | |
| 57 | + if !path.exists() { | |
| 58 | + return None; | |
| 59 | + } | |
| 60 | + | |
| 61 | + let content = match fs::read_to_string(&path) { | |
| 62 | + Ok(content) => content, | |
| 63 | + Err(e) => { | |
| 64 | + warn!("Failed to read settings file {}: {}", path.display(), e); | |
| 65 | + return None; | |
| 66 | + } | |
| 67 | + }; | |
| 68 | + | |
| 69 | + match serde_json::from_str::<Settings>(&content) { | |
| 70 | + Ok(settings) => Some(settings.theme), | |
| 71 | + Err(e) => { | |
| 72 | + warn!("Failed to parse settings file {}: {}", path.display(), e); | |
| 73 | + None | |
| 74 | + } | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// Returns the path of the history file in the configuration directory | |
| 79 | +pub fn get_history_path() -> PathBuf { | |
| 80 | + crate::config::storage::get_config_dir().join("history.json") | |
| 81 | +} | |
| 82 | + | |
| 83 | +#[cfg(test)] | |
| 84 | +mod tests { | |
| 85 | + use super::*; | |
| 86 | + | |
| 87 | + #[test] | |
| 88 | + fn test_settings_default() { | |
| 89 | + let settings = Settings::default(); | |
| 90 | + assert_eq!(settings.theme, Theme::Dark); | |
| 91 | + } | |
| 92 | + | |
| 93 | + #[test] | |
| 94 | + fn test_settings_roundtrip() { | |
| 95 | + let settings = Settings { | |
| 96 | + theme: Theme::Light, | |
| 97 | + }; | |
| 98 | + let json = serde_json::to_string(&settings).unwrap(); | |
| 99 | + let parsed: Settings = serde_json::from_str(&json).unwrap(); | |
| 100 | + assert_eq!(parsed.theme, Theme::Light); | |
| 101 | + } | |
| 102 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +//! Settings module - Application settings persistence | ||
| 2 | +//! | ||
| 3 | +//! Persists user preferences (theme, etc.) to a JSON settings file. | ||
| 4 | + | ||
| 5 | +use crate::config::app_state::Theme; | ||
| 6 | +use log::{info, warn}; | ||
| 7 | +use serde::{Deserialize, Serialize}; | ||
| 8 | +use std::fs; | ||
| 9 | +use std::path::PathBuf; | ||
| 10 | +use thiserror::Error; | ||
| 11 | + | ||
| 12 | +/// Error type for settings operations | ||
| 13 | +#[derive(Debug, Error)] | ||
| 14 | +pub enum SettingsError { | ||
| 15 | + #[error("Failed to write settings file: {0}")] | ||
| 16 | + WriteError(String), | ||
| 17 | + #[error("Failed to serialize settings: {0}")] | ||
| 18 | + SerializeError(String), | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +/// Application settings persisted across sessions | ||
| 22 | +#[derive(Debug, Default, Serialize, Deserialize)] | ||
| 23 | +pub struct Settings { | ||
| 24 | + /// UI theme (dark or light) | ||
| 25 | + pub theme: Theme, | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +/// Returns the path of the settings file in the configuration directory | ||
| 29 | +pub fn get_settings_path() -> PathBuf { | ||
| 30 | + crate::config::storage::get_config_dir().join("settings.json") | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/// Saves the current settings (theme) to the default settings file | ||
| 34 | +pub fn save_theme(theme: Theme) -> Result<(), SettingsError> { | ||
| 35 | + let settings = Settings { theme }; | ||
| 36 | + let path = get_settings_path(); | ||
| 37 | + | ||
| 38 | + if let Some(parent) = path.parent() { | ||
| 39 | + if !parent.exists() { | ||
| 40 | + fs::create_dir_all(parent).map_err(|e| { | ||
| 41 | + SettingsError::WriteError(format!("{}: {}", parent.display(), e)) | ||
| 42 | + })?; | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + let json = serde_json::to_string_pretty(&settings) | ||
| 47 | + .map_err(|e| SettingsError::SerializeError(e.to_string()))?; | ||
| 48 | + fs::write(&path, json).map_err(|e| SettingsError::WriteError(e.to_string()))?; | ||
| 49 | + | ||
| 50 | + info!("Saved theme settings to {}", path.display()); | ||
| 51 | + Ok(()) | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +/// Loads the persisted theme, if a settings file exists and is valid | ||
| 55 | +pub fn load_theme() -> Option<Theme> { | ||
| 56 | + let path = get_settings_path(); | ||
| 57 | + if !path.exists() { | ||
| 58 | + return None; | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + let content = match fs::read_to_string(&path) { | ||
| 62 | + Ok(content) => content, | ||
| 63 | + Err(e) => { | ||
| 64 | + warn!("Failed to read settings file {}: {}", path.display(), e); | ||
| 65 | + return None; | ||
| 66 | + } | ||
| 67 | + }; | ||
| 68 | + | ||
| 69 | + match serde_json::from_str::<Settings>(&content) { | ||
| 70 | + Ok(settings) => Some(settings.theme), | ||
| 71 | + Err(e) => { | ||
| 72 | + warn!("Failed to parse settings file {}: {}", path.display(), e); | ||
| 73 | + None | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +/// Returns the path of the history file in the configuration directory | ||
| 79 | +pub fn get_history_path() -> PathBuf { | ||
| 80 | + crate::config::storage::get_config_dir().join("history.json") | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +#[cfg(test)] | ||
| 84 | +mod tests { | ||
| 85 | + use super::*; | ||
| 86 | + | ||
| 87 | + #[test] | ||
| 88 | + fn test_settings_default() { | ||
| 89 | + let settings = Settings::default(); | ||
| 90 | + assert_eq!(settings.theme, Theme::Dark); | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + #[test] | ||
| 94 | + fn test_settings_roundtrip() { | ||
| 95 | + let settings = Settings { | ||
| 96 | + theme: Theme::Light, | ||
| 97 | + }; | ||
| 98 | + let json = serde_json::to_string(&settings).unwrap(); | ||
| 99 | + let parsed: Settings = serde_json::from_str(&json).unwrap(); | ||
| 100 | + assert_eq!(parsed.theme, Theme::Light); | ||
| 101 | + } | ||
| 102 | +} | ||
modified
src/config/sync_task.rs +121 -34 | @@ -35,6 +35,23 @@ pub enum SyncMessage { | ||
| 35 | 35 | episode_title: String, |
| 36 | 36 | error: String, |
| 37 | 37 | }, |
| 38 | + /// Episodes of a feed were loaded (detail view) | |
| 39 | + EpisodesLoaded { | |
| 40 | + feed_id: String, | |
| 41 | + episodes: Vec<crate::config::episode::Episode>, | |
| 42 | + }, | |
| 43 | + /// Episode list load failed (detail view) | |
| 44 | + EpisodesLoadFailed { | |
| 45 | + feed_id: String, | |
| 46 | + error: String, | |
| 47 | + }, | |
| 48 | + /// Individual episode download completed | |
| 49 | + EpisodeDownloadCompleted { | |
| 50 | + feed_id: String, | |
| 51 | + episode_id: String, | |
| 52 | + episode_title: String, | |
| 53 | + size: Option<u64>, | |
| 54 | + }, | |
| 38 | 55 | } |
| 39 | 56 | |
| 40 | 57 | /// Status of a feed synchronization |
| @@ -92,10 +109,12 @@ impl SyncStatus { | ||
| 92 | 109 | /// Update feed sync status |
| 93 | 110 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { |
| 94 | 111 | self.feed_sync_status.insert(feed_id, status); |
| 95 | - | |
| 112 | + | |
| 96 | 113 | // Update overall progress |
| 97 | 114 | if self.global_sync_in_progress && self.total_feeds > 0 { |
| 98 | - let completed = self.feed_sync_status.values() | |
| 115 | + let completed = self | |
| 116 | + .feed_sync_status | |
| 117 | + .values() | |
| 99 | 118 | .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })) |
| 100 | 119 | .count(); |
| 101 | 120 | self.completed_feeds = completed; |
| @@ -107,17 +126,17 @@ impl SyncStatus { | ||
| 107 | 126 | pub fn get_overall_progress(&self) -> f32 { |
| 108 | 127 | self.overall_progress |
| 109 | 128 | } |
| 110 | - | |
| 129 | + | |
| 111 | 130 | /// Check if there are any sync activities (in progress or recently completed) |
| 112 | 131 | /// Used to determine if we should show sync-related UI elements |
| 113 | 132 | pub fn has_sync_activity(&self) -> bool { |
| 114 | - self.global_sync_in_progress || | |
| 115 | - !self.feed_sync_status.is_empty() | |
| 133 | + self.global_sync_in_progress || !self.feed_sync_status.is_empty() | |
| 116 | 134 | } |
| 117 | 135 | |
| 118 | 136 | /// Get syncing feed count |
| 119 | 137 | pub fn syncing_feed_count(&self) -> usize { |
| 120 | - self.feed_sync_status.values() | |
| 138 | + self.feed_sync_status | |
| 139 | + .values() | |
| 121 | 140 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) |
| 122 | 141 | .count() |
| 123 | 142 | } |
| @@ -148,15 +167,15 @@ impl SyncTaskManager { | ||
| 148 | 167 | /// Process incoming messages and update status |
| 149 | 168 | pub fn process_messages(&mut self) -> Vec<SyncMessage> { |
| 150 | 169 | let mut messages = Vec::new(); |
| 151 | - | |
| 170 | + | |
| 152 | 171 | let receiver = self.receiver.clone(); |
| 153 | 172 | let receiver_guard = receiver.lock().unwrap(); |
| 154 | - | |
| 173 | + | |
| 155 | 174 | while let Ok(msg) = receiver_guard.try_recv() { |
| 156 | 175 | self.handle_message(&msg); |
| 157 | 176 | messages.push(msg); |
| 158 | 177 | } |
| 159 | - | |
| 178 | + | |
| 160 | 179 | messages |
| 161 | 180 | } |
| 162 | 181 | |
| @@ -164,17 +183,13 @@ impl SyncTaskManager { | ||
| 164 | 183 | fn handle_message(&mut self, message: &SyncMessage) { |
| 165 | 184 | match message { |
| 166 | 185 | SyncMessage::SyncStarted { feed_id } => { |
| 167 | - self.status.update_feed_status( | |
| 168 | - feed_id.clone(), | |
| 169 | - FeedSyncStatus::InProgress, | |
| 170 | - ); | |
| 186 | + self.status | |
| 187 | + .update_feed_status(feed_id.clone(), FeedSyncStatus::InProgress); | |
| 171 | 188 | } |
| 172 | 189 | SyncMessage::SyncCompleted { feed_id, .. } => { |
| 173 | - self.status.update_feed_status( | |
| 174 | - feed_id.clone(), | |
| 175 | - FeedSyncStatus::Completed, | |
| 176 | - ); | |
| 177 | - | |
| 190 | + self.status | |
| 191 | + .update_feed_status(feed_id.clone(), FeedSyncStatus::Completed); | |
| 192 | + | |
| 178 | 193 | // Check if all feeds are completed |
| 179 | 194 | if self.status.global_sync_in_progress { |
| 180 | 195 | let all_completed = self.status.feed_sync_status.values().all(|s| { |
| @@ -190,9 +205,9 @@ impl SyncTaskManager { | ||
| 190 | 205 | feed_id.clone(), |
| 191 | 206 | FeedSyncStatus::Failed { |
| 192 | 207 | error: error.clone(), |
| 193 | - } | |
| 208 | + }, | |
| 194 | 209 | ); |
| 195 | - | |
| 210 | + | |
| 196 | 211 | // Check if all feeds are completed (including failed ones) |
| 197 | 212 | if self.status.global_sync_in_progress { |
| 198 | 213 | let all_completed = self.status.feed_sync_status.values().all(|s| { |
| @@ -203,8 +218,11 @@ impl SyncTaskManager { | ||
| 203 | 218 | } |
| 204 | 219 | } |
| 205 | 220 | } |
| 206 | - SyncMessage::EpisodeDownloadFailed { .. } => { | |
| 207 | - // Displayed by the caller (home.rs), no status update needed | |
| 221 | + SyncMessage::EpisodeDownloadFailed { .. } | |
| 222 | + | SyncMessage::EpisodesLoaded { .. } | |
| 223 | + | SyncMessage::EpisodesLoadFailed { .. } | |
| 224 | + | SyncMessage::EpisodeDownloadCompleted { .. } => { | |
| 225 | + // Consumed directly by the caller (home.rs), no status update needed | |
| 208 | 226 | } |
| 209 | 227 | } |
| 210 | 228 | } |
| @@ -212,9 +230,9 @@ impl SyncTaskManager { | ||
| 212 | 230 | /// Start syncing all feeds asynchronously |
| 213 | 231 | pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { |
| 214 | 232 | self.status.start_global_sync(feeds.len()); |
| 215 | - | |
| 233 | + | |
| 216 | 234 | let sender = self.sender.clone(); |
| 217 | - | |
| 235 | + | |
| 218 | 236 | thread::spawn(move || { |
| 219 | 237 | crate::rss::async_sync::sync_all_feeds_async(feeds, sender); |
| 220 | 238 | }); |
| @@ -224,15 +242,79 @@ impl SyncTaskManager { | ||
| 224 | 242 | pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { |
| 225 | 243 | let sender = self.sender.clone(); |
| 226 | 244 | let feed_id = feed.id.clone(); |
| 227 | - | |
| 245 | + | |
| 228 | 246 | // Update status |
| 229 | - self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress); | |
| 230 | - | |
| 247 | + self.status | |
| 248 | + .update_feed_status(feed_id, FeedSyncStatus::InProgress); | |
| 249 | + | |
| 231 | 250 | thread::spawn(move || { |
| 232 | 251 | crate::rss::async_sync::sync_feed_async(feed, sender); |
| 233 | 252 | }); |
| 234 | 253 | } |
| 235 | - | |
| 254 | + | |
| 255 | + /// Loads the episodes of a feed asynchronously (detail view) | |
| 256 | + pub fn start_load_episodes(&mut self, feed: crate::config::feed::Feed) { | |
| 257 | + let sender = self.sender.clone(); | |
| 258 | + let feed_id = feed.id.clone(); | |
| 259 | + | |
| 260 | + thread::spawn(move || { | |
| 261 | + let result = (|| -> Result<Vec<crate::config::episode::Episode>, String> { | |
| 262 | + let content = | |
| 263 | + crate::rss::parser::fetch_feed(&feed.url).map_err(|e| e.to_string())?; | |
| 264 | + let channel = crate::rss::parser::parse_rss_content(&content) | |
| 265 | + .map_err(|e| e.to_string())?; | |
| 266 | + crate::rss::episode_extractor::extract_episodes(&channel).map_err(|e| e.to_string()) | |
| 267 | + })(); | |
| 268 | + | |
| 269 | + match result { | |
| 270 | + Ok(episodes) => { | |
| 271 | + let _ = sender.send(SyncMessage::EpisodesLoaded { | |
| 272 | + feed_id, | |
| 273 | + episodes, | |
| 274 | + }); | |
| 275 | + } | |
| 276 | + Err(error) => { | |
| 277 | + let _ = sender.send(SyncMessage::EpisodesLoadFailed { feed_id, error }); | |
| 278 | + } | |
| 279 | + } | |
| 280 | + }); | |
| 281 | + } | |
| 282 | + | |
| 283 | + /// Downloads a single episode asynchronously (detail view) | |
| 284 | + pub fn start_download_episode( | |
| 285 | + &mut self, | |
| 286 | + feed: crate::config::feed::Feed, | |
| 287 | + episode: crate::config::episode::Episode, | |
| 288 | + ) { | |
| 289 | + let sender = self.sender.clone(); | |
| 290 | + let feed_id = feed.id.clone(); | |
| 291 | + | |
| 292 | + thread::spawn(move || { | |
| 293 | + match crate::rss::downloader::download_episode( | |
| 294 | + &episode, | |
| 295 | + &feed.download_path, | |
| 296 | + Box::new(|_| {}), | |
| 297 | + ) { | |
| 298 | + Ok(path) => { | |
| 299 | + let size = std::fs::metadata(&path).map(|m| m.len()).ok(); | |
| 300 | + let _ = sender.send(SyncMessage::EpisodeDownloadCompleted { | |
| 301 | + feed_id, | |
| 302 | + episode_id: episode.id.clone(), | |
| 303 | + episode_title: episode.title.clone(), | |
| 304 | + size, | |
| 305 | + }); | |
| 306 | + } | |
| 307 | + Err(e) => { | |
| 308 | + let _ = sender.send(SyncMessage::EpisodeDownloadFailed { | |
| 309 | + feed_id, | |
| 310 | + episode_title: episode.title.clone(), | |
| 311 | + error: e.to_string(), | |
| 312 | + }); | |
| 313 | + } | |
| 314 | + } | |
| 315 | + }); | |
| 316 | + } | |
| 317 | + | |
| 236 | 318 | /// Clears completed and failed sync statuses |
| 237 | 319 | pub fn clear_completed_sync_status(&mut self) { |
| 238 | 320 | self.status.feed_sync_status.retain(|_, status| { |
| @@ -264,7 +346,7 @@ mod tests { | ||
| 264 | 346 | fn test_sync_status_start_global_sync() { |
| 265 | 347 | let mut status = SyncStatus::new(); |
| 266 | 348 | status.start_global_sync(5); |
| 267 | - | |
| 349 | + | |
| 268 | 350 | assert!(status.global_sync_in_progress); |
| 269 | 351 | assert_eq!(status.total_feeds, 5); |
| 270 | 352 | assert_eq!(status.completed_feeds, 0); |
| @@ -275,17 +357,22 @@ mod tests { | ||
| 275 | 357 | fn test_sync_status_overall_progress() { |
| 276 | 358 | let mut status = SyncStatus::new(); |
| 277 | 359 | status.start_global_sync(3); |
| 278 | - | |
| 360 | + | |
| 279 | 361 | // Add completed feeds |
| 280 | 362 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); |
| 281 | 363 | status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed); |
| 282 | - | |
| 364 | + | |
| 283 | 365 | assert_eq!(status.completed_feeds, 2); |
| 284 | 366 | assert!((status.get_overall_progress() - 0.666).abs() < 0.01); |
| 285 | - | |
| 367 | + | |
| 286 | 368 | // Add failed feed |
| 287 | - status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() }); | |
| 288 | - | |
| 369 | + status.update_feed_status( | |
| 370 | + "feed-3".to_string(), | |
| 371 | + FeedSyncStatus::Failed { | |
| 372 | + error: "Error".to_string(), | |
| 373 | + }, | |
| 374 | + ); | |
| 375 | + | |
| 289 | 376 | assert_eq!(status.completed_feeds, 3); |
| 290 | 377 | assert_eq!(status.get_overall_progress(), 1.0); |
| 291 | 378 | } |
| @@ -35,6 +35,23 @@ pub enum SyncMessage { | |||
| 35 | episode_title: String, | 35 | episode_title: String, |
| 36 | error: String, | 36 | error: String, |
| 37 | }, | 37 | }, |
| 38 | + /// Episodes of a feed were loaded (detail view) | ||
| 39 | + EpisodesLoaded { | ||
| 40 | + feed_id: String, | ||
| 41 | + episodes: Vec<crate::config::episode::Episode>, | ||
| 42 | + }, | ||
| 43 | + /// Episode list load failed (detail view) | ||
| 44 | + EpisodesLoadFailed { | ||
| 45 | + feed_id: String, | ||
| 46 | + error: String, | ||
| 47 | + }, | ||
| 48 | + /// Individual episode download completed | ||
| 49 | + EpisodeDownloadCompleted { | ||
| 50 | + feed_id: String, | ||
| 51 | + episode_id: String, | ||
| 52 | + episode_title: String, | ||
| 53 | + size: Option<u64>, | ||
| 54 | + }, | ||
| 38 | } | 55 | } |
| 39 | 56 | ||
| 40 | /// Status of a feed synchronization | 57 | /// Status of a feed synchronization |
| @@ -92,10 +109,12 @@ impl SyncStatus { | |||
| 92 | /// Update feed sync status | 109 | /// Update feed sync status |
| 93 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { | 110 | pub fn update_feed_status(&mut self, feed_id: String, status: FeedSyncStatus) { |
| 94 | self.feed_sync_status.insert(feed_id, status); | 111 | self.feed_sync_status.insert(feed_id, status); |
| 95 | - | 112 | + |
| 96 | // Update overall progress | 113 | // Update overall progress |
| 97 | if self.global_sync_in_progress && self.total_feeds > 0 { | 114 | if self.global_sync_in_progress && self.total_feeds > 0 { |
| 98 | - let completed = self.feed_sync_status.values() | 115 | + let completed = self |
| 116 | + .feed_sync_status | ||
| 117 | + .values() | ||
| 99 | .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })) | 118 | .filter(|s| matches!(s, FeedSyncStatus::Completed | FeedSyncStatus::Failed { .. })) |
| 100 | .count(); | 119 | .count(); |
| 101 | self.completed_feeds = completed; | 120 | self.completed_feeds = completed; |
| @@ -107,17 +126,17 @@ impl SyncStatus { | |||
| 107 | pub fn get_overall_progress(&self) -> f32 { | 126 | pub fn get_overall_progress(&self) -> f32 { |
| 108 | self.overall_progress | 127 | self.overall_progress |
| 109 | } | 128 | } |
| 110 | - | 129 | + |
| 111 | /// Check if there are any sync activities (in progress or recently completed) | 130 | /// Check if there are any sync activities (in progress or recently completed) |
| 112 | /// Used to determine if we should show sync-related UI elements | 131 | /// Used to determine if we should show sync-related UI elements |
| 113 | pub fn has_sync_activity(&self) -> bool { | 132 | pub fn has_sync_activity(&self) -> bool { |
| 114 | - self.global_sync_in_progress || | 133 | + self.global_sync_in_progress || !self.feed_sync_status.is_empty() |
| 115 | - !self.feed_sync_status.is_empty() | ||
| 116 | } | 134 | } |
| 117 | 135 | ||
| 118 | /// Get syncing feed count | 136 | /// Get syncing feed count |
| 119 | pub fn syncing_feed_count(&self) -> usize { | 137 | pub fn syncing_feed_count(&self) -> usize { |
| 120 | - self.feed_sync_status.values() | 138 | + self.feed_sync_status |
| 139 | + .values() | ||
| 121 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) | 140 | .filter(|s| matches!(s, FeedSyncStatus::InProgress)) |
| 122 | .count() | 141 | .count() |
| 123 | } | 142 | } |
| @@ -148,15 +167,15 @@ impl SyncTaskManager { | |||
| 148 | /// Process incoming messages and update status | 167 | /// Process incoming messages and update status |
| 149 | pub fn process_messages(&mut self) -> Vec<SyncMessage> { | 168 | pub fn process_messages(&mut self) -> Vec<SyncMessage> { |
| 150 | let mut messages = Vec::new(); | 169 | let mut messages = Vec::new(); |
| 151 | - | 170 | + |
| 152 | let receiver = self.receiver.clone(); | 171 | let receiver = self.receiver.clone(); |
| 153 | let receiver_guard = receiver.lock().unwrap(); | 172 | let receiver_guard = receiver.lock().unwrap(); |
| 154 | - | 173 | + |
| 155 | while let Ok(msg) = receiver_guard.try_recv() { | 174 | while let Ok(msg) = receiver_guard.try_recv() { |
| 156 | self.handle_message(&msg); | 175 | self.handle_message(&msg); |
| 157 | messages.push(msg); | 176 | messages.push(msg); |
| 158 | } | 177 | } |
| 159 | - | 178 | + |
| 160 | messages | 179 | messages |
| 161 | } | 180 | } |
| 162 | 181 | ||
| @@ -164,17 +183,13 @@ impl SyncTaskManager { | |||
| 164 | fn handle_message(&mut self, message: &SyncMessage) { | 183 | fn handle_message(&mut self, message: &SyncMessage) { |
| 165 | match message { | 184 | match message { |
| 166 | SyncMessage::SyncStarted { feed_id } => { | 185 | SyncMessage::SyncStarted { feed_id } => { |
| 167 | - self.status.update_feed_status( | 186 | + self.status |
| 168 | - feed_id.clone(), | 187 | + .update_feed_status(feed_id.clone(), FeedSyncStatus::InProgress); |
| 169 | - FeedSyncStatus::InProgress, | ||
| 170 | - ); | ||
| 171 | } | 188 | } |
| 172 | SyncMessage::SyncCompleted { feed_id, .. } => { | 189 | SyncMessage::SyncCompleted { feed_id, .. } => { |
| 173 | - self.status.update_feed_status( | 190 | + self.status |
| 174 | - feed_id.clone(), | 191 | + .update_feed_status(feed_id.clone(), FeedSyncStatus::Completed); |
| 175 | - FeedSyncStatus::Completed, | 192 | + |
| 176 | - ); | ||
| 177 | - | ||
| 178 | // Check if all feeds are completed | 193 | // Check if all feeds are completed |
| 179 | if self.status.global_sync_in_progress { | 194 | if self.status.global_sync_in_progress { |
| 180 | let all_completed = self.status.feed_sync_status.values().all(|s| { | 195 | let all_completed = self.status.feed_sync_status.values().all(|s| { |
| @@ -190,9 +205,9 @@ impl SyncTaskManager { | |||
| 190 | feed_id.clone(), | 205 | feed_id.clone(), |
| 191 | FeedSyncStatus::Failed { | 206 | FeedSyncStatus::Failed { |
| 192 | error: error.clone(), | 207 | error: error.clone(), |
| 193 | - } | 208 | + }, |
| 194 | ); | 209 | ); |
| 195 | - | 210 | + |
| 196 | // Check if all feeds are completed (including failed ones) | 211 | // Check if all feeds are completed (including failed ones) |
| 197 | if self.status.global_sync_in_progress { | 212 | if self.status.global_sync_in_progress { |
| 198 | let all_completed = self.status.feed_sync_status.values().all(|s| { | 213 | let all_completed = self.status.feed_sync_status.values().all(|s| { |
| @@ -203,8 +218,11 @@ impl SyncTaskManager { | |||
| 203 | } | 218 | } |
| 204 | } | 219 | } |
| 205 | } | 220 | } |
| 206 | - SyncMessage::EpisodeDownloadFailed { .. } => { | 221 | + SyncMessage::EpisodeDownloadFailed { .. } |
| 207 | - // Displayed by the caller (home.rs), no status update needed | 222 | + | SyncMessage::EpisodesLoaded { .. } |
| 223 | + | SyncMessage::EpisodesLoadFailed { .. } | ||
| 224 | + | SyncMessage::EpisodeDownloadCompleted { .. } => { | ||
| 225 | + // Consumed directly by the caller (home.rs), no status update needed | ||
| 208 | } | 226 | } |
| 209 | } | 227 | } |
| 210 | } | 228 | } |
| @@ -212,9 +230,9 @@ impl SyncTaskManager { | |||
| 212 | /// Start syncing all feeds asynchronously | 230 | /// Start syncing all feeds asynchronously |
| 213 | pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { | 231 | pub fn start_sync_all_feeds(&mut self, feeds: Vec<crate::config::feed::Feed>) { |
| 214 | self.status.start_global_sync(feeds.len()); | 232 | self.status.start_global_sync(feeds.len()); |
| 215 | - | 233 | + |
| 216 | let sender = self.sender.clone(); | 234 | let sender = self.sender.clone(); |
| 217 | - | 235 | + |
| 218 | thread::spawn(move || { | 236 | thread::spawn(move || { |
| 219 | crate::rss::async_sync::sync_all_feeds_async(feeds, sender); | 237 | crate::rss::async_sync::sync_all_feeds_async(feeds, sender); |
| 220 | }); | 238 | }); |
| @@ -224,15 +242,79 @@ impl SyncTaskManager { | |||
| 224 | pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { | 242 | pub fn start_sync_feed(&mut self, feed: crate::config::feed::Feed) { |
| 225 | let sender = self.sender.clone(); | 243 | let sender = self.sender.clone(); |
| 226 | let feed_id = feed.id.clone(); | 244 | let feed_id = feed.id.clone(); |
| 227 | - | 245 | + |
| 228 | // Update status | 246 | // Update status |
| 229 | - self.status.update_feed_status(feed_id, FeedSyncStatus::InProgress); | 247 | + self.status |
| 230 | - | 248 | + .update_feed_status(feed_id, FeedSyncStatus::InProgress); |
| 249 | + | ||
| 231 | thread::spawn(move || { | 250 | thread::spawn(move || { |
| 232 | crate::rss::async_sync::sync_feed_async(feed, sender); | 251 | crate::rss::async_sync::sync_feed_async(feed, sender); |
| 233 | }); | 252 | }); |
| 234 | } | 253 | } |
| 235 | - | 254 | + |
| 255 | + /// Loads the episodes of a feed asynchronously (detail view) | ||
| 256 | + pub fn start_load_episodes(&mut self, feed: crate::config::feed::Feed) { | ||
| 257 | + let sender = self.sender.clone(); | ||
| 258 | + let feed_id = feed.id.clone(); | ||
| 259 | + | ||
| 260 | + thread::spawn(move || { | ||
| 261 | + let result = (|| -> Result<Vec<crate::config::episode::Episode>, String> { | ||
| 262 | + let content = | ||
| 263 | + crate::rss::parser::fetch_feed(&feed.url).map_err(|e| e.to_string())?; | ||
| 264 | + let channel = crate::rss::parser::parse_rss_content(&content) | ||
| 265 | + .map_err(|e| e.to_string())?; | ||
| 266 | + crate::rss::episode_extractor::extract_episodes(&channel).map_err(|e| e.to_string()) | ||
| 267 | + })(); | ||
| 268 | + | ||
| 269 | + match result { | ||
| 270 | + Ok(episodes) => { | ||
| 271 | + let _ = sender.send(SyncMessage::EpisodesLoaded { | ||
| 272 | + feed_id, | ||
| 273 | + episodes, | ||
| 274 | + }); | ||
| 275 | + } | ||
| 276 | + Err(error) => { | ||
| 277 | + let _ = sender.send(SyncMessage::EpisodesLoadFailed { feed_id, error }); | ||
| 278 | + } | ||
| 279 | + } | ||
| 280 | + }); | ||
| 281 | + } | ||
| 282 | + | ||
| 283 | + /// Downloads a single episode asynchronously (detail view) | ||
| 284 | + pub fn start_download_episode( | ||
| 285 | + &mut self, | ||
| 286 | + feed: crate::config::feed::Feed, | ||
| 287 | + episode: crate::config::episode::Episode, | ||
| 288 | + ) { | ||
| 289 | + let sender = self.sender.clone(); | ||
| 290 | + let feed_id = feed.id.clone(); | ||
| 291 | + | ||
| 292 | + thread::spawn(move || { | ||
| 293 | + match crate::rss::downloader::download_episode( | ||
| 294 | + &episode, | ||
| 295 | + &feed.download_path, | ||
| 296 | + Box::new(|_| {}), | ||
| 297 | + ) { | ||
| 298 | + Ok(path) => { | ||
| 299 | + let size = std::fs::metadata(&path).map(|m| m.len()).ok(); | ||
| 300 | + let _ = sender.send(SyncMessage::EpisodeDownloadCompleted { | ||
| 301 | + feed_id, | ||
| 302 | + episode_id: episode.id.clone(), | ||
| 303 | + episode_title: episode.title.clone(), | ||
| 304 | + size, | ||
| 305 | + }); | ||
| 306 | + } | ||
| 307 | + Err(e) => { | ||
| 308 | + let _ = sender.send(SyncMessage::EpisodeDownloadFailed { | ||
| 309 | + feed_id, | ||
| 310 | + episode_title: episode.title.clone(), | ||
| 311 | + error: e.to_string(), | ||
| 312 | + }); | ||
| 313 | + } | ||
| 314 | + } | ||
| 315 | + }); | ||
| 316 | + } | ||
| 317 | + | ||
| 236 | /// Clears completed and failed sync statuses | 318 | /// Clears completed and failed sync statuses |
| 237 | pub fn clear_completed_sync_status(&mut self) { | 319 | pub fn clear_completed_sync_status(&mut self) { |
| 238 | self.status.feed_sync_status.retain(|_, status| { | 320 | self.status.feed_sync_status.retain(|_, status| { |
| @@ -264,7 +346,7 @@ mod tests { | |||
| 264 | fn test_sync_status_start_global_sync() { | 346 | fn test_sync_status_start_global_sync() { |
| 265 | let mut status = SyncStatus::new(); | 347 | let mut status = SyncStatus::new(); |
| 266 | status.start_global_sync(5); | 348 | status.start_global_sync(5); |
| 267 | - | 349 | + |
| 268 | assert!(status.global_sync_in_progress); | 350 | assert!(status.global_sync_in_progress); |
| 269 | assert_eq!(status.total_feeds, 5); | 351 | assert_eq!(status.total_feeds, 5); |
| 270 | assert_eq!(status.completed_feeds, 0); | 352 | assert_eq!(status.completed_feeds, 0); |
| @@ -275,17 +357,22 @@ mod tests { | |||
| 275 | fn test_sync_status_overall_progress() { | 357 | fn test_sync_status_overall_progress() { |
| 276 | let mut status = SyncStatus::new(); | 358 | let mut status = SyncStatus::new(); |
| 277 | status.start_global_sync(3); | 359 | status.start_global_sync(3); |
| 278 | - | 360 | + |
| 279 | // Add completed feeds | 361 | // Add completed feeds |
| 280 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); | 362 | status.update_feed_status("feed-1".to_string(), FeedSyncStatus::Completed); |
| 281 | status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed); | 363 | status.update_feed_status("feed-2".to_string(), FeedSyncStatus::Completed); |
| 282 | - | 364 | + |
| 283 | assert_eq!(status.completed_feeds, 2); | 365 | assert_eq!(status.completed_feeds, 2); |
| 284 | assert!((status.get_overall_progress() - 0.666).abs() < 0.01); | 366 | assert!((status.get_overall_progress() - 0.666).abs() < 0.01); |
| 285 | - | 367 | + |
| 286 | // Add failed feed | 368 | // Add failed feed |
| 287 | - status.update_feed_status("feed-3".to_string(), FeedSyncStatus::Failed { error: "Error".to_string() }); | 369 | + status.update_feed_status( |
| 288 | - | 370 | + "feed-3".to_string(), |
| 371 | + FeedSyncStatus::Failed { | ||
| 372 | + error: "Error".to_string(), | ||
| 373 | + }, | ||
| 374 | + ); | ||
| 375 | + | ||
| 289 | assert_eq!(status.completed_feeds, 3); | 376 | assert_eq!(status.completed_feeds, 3); |
| 290 | assert_eq!(status.get_overall_progress(), 1.0); | 377 | assert_eq!(status.get_overall_progress(), 1.0); |
| 291 | } | 378 | } |
modified
src/rss/async_sync.rs +47 -2 | @@ -93,8 +93,8 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 93 | 93 | info!("Successfully downloaded episode: {}", episode.title); |
| 94 | 94 | downloaded_count += 1; |
| 95 | 95 | |
| 96 | - // Update feed download statistics | |
| 97 | - feed_mut.downloaded_episodes_count += 1; | |
| 96 | + // Track the most recent download (the counter itself is | |
| 97 | + // recomputed from the files on disk below) | |
| 98 | 98 | feed_mut.last_downloaded_episode_name = Some(episode.title.clone()); |
| 99 | 99 | feed_mut.last_downloaded_episode_date = Some(Utc::now()); |
| 100 | 100 | } |
| @@ -109,6 +109,14 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | ||
| 109 | 109 | } |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | + // Recompute the downloaded episode count from the files actually | |
| 113 | + // present on disk: the displayed counter must reflect the current | |
| 114 | + // state, not a cumulative total that drifts over time. | |
| 115 | + feed_mut.downloaded_episodes_count = count_downloaded_episodes( | |
| 116 | + &episodes, | |
| 117 | + std::path::Path::new(&feed_mut.download_path), | |
| 118 | + ); | |
| 119 | + | |
| 112 | 120 | // Update feed metadata |
| 113 | 121 | feed_mut.last_checked = Some(Utc::now()); |
| 114 | 122 | if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) { |
| @@ -173,6 +181,14 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool { | ||
| 173 | 181 | !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path)) |
| 174 | 182 | } |
| 175 | 183 | |
| 184 | +/// Counts the episodes of a feed that are actually present on disk | |
| 185 | +fn count_downloaded_episodes(episodes: &[Episode], download_path: &std::path::Path) -> usize { | |
| 186 | + episodes | |
| 187 | + .iter() | |
| 188 | + .filter(|e| !crate::rss::downloader::needs_download(e, download_path)) | |
| 189 | + .count() | |
| 190 | +} | |
| 191 | + | |
| 176 | 192 | #[cfg(test)] |
| 177 | 193 | mod tests { |
| 178 | 194 | use super::*; |
| @@ -206,4 +222,33 @@ mod tests { | ||
| 206 | 222 | feed = Feed::new("https://example.com/rss", "Test", ""); |
| 207 | 223 | assert!(validate_feed(&feed).is_err()); |
| 208 | 224 | } |
| 225 | + | |
| 226 | + #[test] | |
| 227 | + fn test_count_downloaded_episodes() { | |
| 228 | + let temp_dir = tempfile::tempdir().unwrap(); | |
| 229 | + let download_path = temp_dir.path(); | |
| 230 | + | |
| 231 | + let episodes: Vec<Episode> = (0..3) | |
| 232 | + .map(|i| { | |
| 233 | + Episode::new( | |
| 234 | + format!("ep-{}", i), | |
| 235 | + format!("Episode {}", i), | |
| 236 | + format!("https://example.com/episode{}.mp3", i), | |
| 237 | + Utc::now(), | |
| 238 | + ) | |
| 239 | + }) | |
| 240 | + .collect(); | |
| 241 | + | |
| 242 | + // Nothing on disk yet | |
| 243 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 0); | |
| 244 | + | |
| 245 | + // Two files present: counted; the third is not | |
| 246 | + std::fs::write(download_path.join(&episodes[0].audio_filename), "a").unwrap(); | |
| 247 | + std::fs::write(download_path.join(&episodes[1].audio_filename), "b").unwrap(); | |
| 248 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 2); | |
| 249 | + | |
| 250 | + // An empty (incomplete) file must not be counted | |
| 251 | + std::fs::write(download_path.join(&episodes[2].audio_filename), "").unwrap(); | |
| 252 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 2); | |
| 253 | + } | |
| 209 | 254 | } |
| @@ -93,8 +93,8 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 93 | info!("Successfully downloaded episode: {}", episode.title); | 93 | info!("Successfully downloaded episode: {}", episode.title); |
| 94 | downloaded_count += 1; | 94 | downloaded_count += 1; |
| 95 | 95 | ||
| 96 | - // Update feed download statistics | 96 | + // Track the most recent download (the counter itself is |
| 97 | - feed_mut.downloaded_episodes_count += 1; | 97 | + // recomputed from the files on disk below) |
| 98 | feed_mut.last_downloaded_episode_name = Some(episode.title.clone()); | 98 | feed_mut.last_downloaded_episode_name = Some(episode.title.clone()); |
| 99 | feed_mut.last_downloaded_episode_date = Some(Utc::now()); | 99 | feed_mut.last_downloaded_episode_date = Some(Utc::now()); |
| 100 | } | 100 | } |
| @@ -109,6 +109,14 @@ pub fn sync_feed_async(feed: Feed, sender: Sender<SyncMessage>) { | |||
| 109 | } | 109 | } |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | + // Recompute the downloaded episode count from the files actually | ||
| 113 | + // present on disk: the displayed counter must reflect the current | ||
| 114 | + // state, not a cumulative total that drifts over time. | ||
| 115 | + feed_mut.downloaded_episodes_count = count_downloaded_episodes( | ||
| 116 | + &episodes, | ||
| 117 | + std::path::Path::new(&feed_mut.download_path), | ||
| 118 | + ); | ||
| 119 | + | ||
| 112 | // Update feed metadata | 120 | // Update feed metadata |
| 113 | feed_mut.last_checked = Some(Utc::now()); | 121 | feed_mut.last_checked = Some(Utc::now()); |
| 114 | if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) { | 122 | if let Some(latest) = episodes.iter().max_by_key(|e| e.pub_date) { |
| @@ -173,6 +181,14 @@ fn is_episode_downloaded(feed: &Feed, episode: &Episode) -> bool { | |||
| 173 | !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path)) | 181 | !crate::rss::downloader::needs_download(episode, std::path::Path::new(&feed.download_path)) |
| 174 | } | 182 | } |
| 175 | 183 | ||
| 184 | +/// Counts the episodes of a feed that are actually present on disk | ||
| 185 | +fn count_downloaded_episodes(episodes: &[Episode], download_path: &std::path::Path) -> usize { | ||
| 186 | + episodes | ||
| 187 | + .iter() | ||
| 188 | + .filter(|e| !crate::rss::downloader::needs_download(e, download_path)) | ||
| 189 | + .count() | ||
| 190 | +} | ||
| 191 | + | ||
| 176 | #[cfg(test)] | 192 | #[cfg(test)] |
| 177 | mod tests { | 193 | mod tests { |
| 178 | use super::*; | 194 | use super::*; |
| @@ -206,4 +222,33 @@ mod tests { | |||
| 206 | feed = Feed::new("https://example.com/rss", "Test", ""); | 222 | feed = Feed::new("https://example.com/rss", "Test", ""); |
| 207 | assert!(validate_feed(&feed).is_err()); | 223 | assert!(validate_feed(&feed).is_err()); |
| 208 | } | 224 | } |
| 225 | + | ||
| 226 | + #[test] | ||
| 227 | + fn test_count_downloaded_episodes() { | ||
| 228 | + let temp_dir = tempfile::tempdir().unwrap(); | ||
| 229 | + let download_path = temp_dir.path(); | ||
| 230 | + | ||
| 231 | + let episodes: Vec<Episode> = (0..3) | ||
| 232 | + .map(|i| { | ||
| 233 | + Episode::new( | ||
| 234 | + format!("ep-{}", i), | ||
| 235 | + format!("Episode {}", i), | ||
| 236 | + format!("https://example.com/episode{}.mp3", i), | ||
| 237 | + Utc::now(), | ||
| 238 | + ) | ||
| 239 | + }) | ||
| 240 | + .collect(); | ||
| 241 | + | ||
| 242 | + // Nothing on disk yet | ||
| 243 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 0); | ||
| 244 | + | ||
| 245 | + // Two files present: counted; the third is not | ||
| 246 | + std::fs::write(download_path.join(&episodes[0].audio_filename), "a").unwrap(); | ||
| 247 | + std::fs::write(download_path.join(&episodes[1].audio_filename), "b").unwrap(); | ||
| 248 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 2); | ||
| 249 | + | ||
| 250 | + // An empty (incomplete) file must not be counted | ||
| 251 | + std::fs::write(download_path.join(&episodes[2].audio_filename), "").unwrap(); | ||
| 252 | + assert_eq!(count_downloaded_episodes(&episodes, download_path), 2); | ||
| 253 | + } | ||
| 209 | } | 254 | } |
modified
src/rss/downloader.rs +6 -6 | @@ -207,17 +207,17 @@ pub fn download_episode( | ||
| 207 | 207 | |
| 208 | 208 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; |
| 209 | 209 | |
| 210 | - // Ensure ID3 tags are present after downloading | |
| 211 | - ensure_id3_tags( | |
| 210 | + // Ensure ID3 tags are present after downloading. A tag failure must not | |
| 211 | + // fail the whole download: the audio file is already complete on disk | |
| 212 | + // and would otherwise never be counted as downloaded. | |
| 213 | + if let Err(e) = ensure_id3_tags( | |
| 212 | 214 | &save_path, |
| 213 | 215 | &episode.title, |
| 214 | 216 | episode.artist.as_deref(), |
| 215 | 217 | episode.album.as_deref(), |
| 216 | - ).map_err(|e| { | |
| 217 | - // Log the error but don't fail the download | |
| 218 | + ) { | |
| 218 | 219 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); |
| 219 | - DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e)) | |
| 220 | - })?; | |
| 220 | + } | |
| 221 | 221 | |
| 222 | 222 | Ok(save_path) |
| 223 | 223 | } |
| @@ -207,17 +207,17 @@ pub fn download_episode( | |||
| 207 | 207 | ||
| 208 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; | 208 | download_file(&episode.audio_url, &save_path, Some(progress_callback))?; |
| 209 | 209 | ||
| 210 | - // Ensure ID3 tags are present after downloading | 210 | + // Ensure ID3 tags are present after downloading. A tag failure must not |
| 211 | - ensure_id3_tags( | 211 | + // fail the whole download: the audio file is already complete on disk |
| 212 | + // and would otherwise never be counted as downloaded. | ||
| 213 | + if let Err(e) = ensure_id3_tags( | ||
| 212 | &save_path, | 214 | &save_path, |
| 213 | &episode.title, | 215 | &episode.title, |
| 214 | episode.artist.as_deref(), | 216 | episode.artist.as_deref(), |
| 215 | episode.album.as_deref(), | 217 | episode.album.as_deref(), |
| 216 | - ).map_err(|e| { | 218 | + ) { |
| 217 | - // Log the error but don't fail the download | ||
| 218 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); | 219 | warn!("Failed to write ID3 tags for {}: {}", save_path.display(), e); |
| 219 | - DownloadError::NetworkError(format!("ID3 tag writing failed: {}", e)) | 220 | + } |
| 220 | - })?; | ||
| 221 | 221 | ||
| 222 | Ok(save_path) | 222 | Ok(save_path) |
| 223 | } | 223 | } |
modified
src/ui/add_feed_dialog.rs +86 -45 | @@ -6,55 +6,96 @@ use crate::config::{AppState, Feed}; | ||
| 6 | 6 | use egui::Ui; |
| 7 | 7 | use std::path::PathBuf; |
| 8 | 8 | |
| 9 | -/// Draws the add feed dialog | |
| 9 | +/// Draws the add feed dialog (modal) | |
| 10 | 10 | pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | 11 | if !state.show_add_feed_dialog { |
| 12 | 12 | return; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | - egui::Window::new("Ajouter un flux RSS") | |
| 16 | - .resizable(false) | |
| 17 | - .show(ui.ctx(), |ui| { | |
| 18 | - ui.vertical(|ui| { | |
| 19 | - ui.label("Nouveau flux"); | |
| 20 | - ui.separator(); | |
| 21 | - | |
| 22 | - ui.label("Nom du podcast"); | |
| 23 | - ui.text_edit_singleline(&mut state.new_feed_name); | |
| 24 | - | |
| 25 | - ui.label("URL du flux RSS"); | |
| 26 | - ui.text_edit_singleline(&mut state.new_feed_url); | |
| 27 | - | |
| 28 | - ui.label("Dossier de téléchargement"); | |
| 29 | - ui.text_edit_singleline(&mut state.new_feed_download_path); | |
| 30 | - | |
| 31 | - ui.separator(); | |
| 32 | - | |
| 33 | - // Validation | |
| 34 | - let valid = !state.new_feed_url.is_empty() && !state.new_feed_download_path.is_empty(); | |
| 35 | - | |
| 36 | - ui.horizontal(|ui| { | |
| 37 | - if valid && ui.button("Ajouter").clicked() { | |
| 38 | - log::info!("Adding new feed: {} - {}", state.new_feed_name, state.new_feed_url); | |
| 39 | - let feed = Feed::new( | |
| 40 | - state.new_feed_url.clone(), | |
| 41 | - state.new_feed_name.clone(), | |
| 42 | - PathBuf::from(state.new_feed_download_path.clone()) | |
| 43 | - ); | |
| 44 | - state.add_feed(feed); | |
| 45 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 46 | - log::info!("Feed added successfully"); | |
| 47 | - state.clear_new_feed_form(); | |
| 48 | - state.show_add_feed_dialog = false; | |
| 49 | - } else { | |
| 50 | - ui.label("Veuillez remplir tous les champs"); | |
| 51 | - } | |
| 52 | - | |
| 53 | - if ui.button("Annuler").clicked() { | |
| 54 | - state.clear_new_feed_form(); | |
| 55 | - state.show_add_feed_dialog = false; | |
| 56 | - } | |
| 57 | - }); | |
| 58 | - }); | |
| 15 | + let ctx = ui.ctx().clone(); | |
| 16 | + let modal = egui::Modal::new(egui::Id::new("add_feed_dialog")); | |
| 17 | + let response = modal.show(&ctx, |ui| { | |
| 18 | + ui.set_min_width(420.0); | |
| 19 | + ui.heading("Ajouter un flux RSS"); | |
| 20 | + ui.separator(); | |
| 21 | + | |
| 22 | + ui.label("Nom du podcast"); | |
| 23 | + let name_response = ui.text_edit_singleline(&mut state.new_feed_name); | |
| 24 | + | |
| 25 | + ui.label("URL du flux RSS"); | |
| 26 | + let url_response = ui.text_edit_singleline(&mut state.new_feed_url); | |
| 27 | + | |
| 28 | + ui.label("Dossier de téléchargement"); | |
| 29 | + let download_width = (ui.available_width() - 110.0).max(180.0); | |
| 30 | + ui.horizontal(|ui| { | |
| 31 | + ui.add( | |
| 32 | + egui::TextEdit::singleline(&mut state.new_feed_download_path) | |
| 33 | + .desired_width(download_width) | |
| 34 | + .hint_text("ex: C:\\Users\\moi\\Musique\\podcasts"), | |
| 35 | + ); | |
| 36 | + if ui.button("Parcourir…").clicked() { | |
| 37 | + if let Some(path) = rfd::FileDialog::new() | |
| 38 | + .set_title("Choisir le dossier de téléchargement") | |
| 39 | + .pick_folder() | |
| 40 | + { | |
| 41 | + state.new_feed_download_path = path.to_string_lossy().to_string(); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + }); | |
| 45 | + | |
| 46 | + ui.separator(); | |
| 47 | + | |
| 48 | + // Validation : URL et dossier obligatoires | |
| 49 | + let valid = !state.new_feed_url.trim().is_empty() | |
| 50 | + && !state.new_feed_download_path.trim().is_empty(); | |
| 51 | + | |
| 52 | + let mut submitted = false; | |
| 53 | + ui.horizontal(|ui| { | |
| 54 | + if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() { | |
| 55 | + submitted = true; | |
| 56 | + } | |
| 57 | + if ui.button("Annuler").clicked() { | |
| 58 | + state.clear_new_feed_form(); | |
| 59 | + state.show_add_feed_dialog = false; | |
| 60 | + } | |
| 61 | + }); | |
| 62 | + if !valid { | |
| 63 | + ui.label( | |
| 64 | + egui::RichText::new("L'URL du flux et le dossier de téléchargement sont requis.") | |
| 65 | + .weak() | |
| 66 | + .small(), | |
| 67 | + ); | |
| 68 | + } | |
| 69 | + | |
| 70 | + // Entrée pour valider depuis le champ nom ou URL | |
| 71 | + let enter_pressed = ui.input(|i| { | |
| 72 | + i.key_pressed(egui::Key::Enter) && (name_response.lost_focus() || url_response.lost_focus()) | |
| 59 | 73 | }); |
| 74 | + if enter_pressed && valid { | |
| 75 | + submitted = true; | |
| 76 | + } | |
| 77 | + | |
| 78 | + submitted | |
| 79 | + }); | |
| 80 | + | |
| 81 | + if response.inner { | |
| 82 | + log::info!( | |
| 83 | + "Adding new feed: {} - {}", | |
| 84 | + state.new_feed_name, | |
| 85 | + state.new_feed_url | |
| 86 | + ); | |
| 87 | + let feed = Feed::new( | |
| 88 | + state.new_feed_url.trim().to_string(), | |
| 89 | + state.new_feed_name.trim().to_string(), | |
| 90 | + PathBuf::from(state.new_feed_download_path.trim()), | |
| 91 | + ); | |
| 92 | + state.add_feed(feed); | |
| 93 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 94 | + log::info!("Feed added successfully"); | |
| 95 | + state.clear_new_feed_form(); | |
| 96 | + state.show_add_feed_dialog = false; | |
| 97 | + } else if response.should_close() { | |
| 98 | + state.clear_new_feed_form(); | |
| 99 | + state.show_add_feed_dialog = false; | |
| 100 | + } | |
| 60 | 101 | } |
| @@ -6,55 +6,96 @@ use crate::config::{AppState, Feed}; | |||
| 6 | use egui::Ui; | 6 | use egui::Ui; |
| 7 | use std::path::PathBuf; | 7 | use std::path::PathBuf; |
| 8 | 8 | ||
| 9 | -/// Draws the add feed dialog | 9 | +/// Draws the add feed dialog (modal) |
| 10 | pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_add_feed_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | if !state.show_add_feed_dialog { | 11 | if !state.show_add_feed_dialog { |
| 12 | return; | 12 | return; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | - egui::Window::new("Ajouter un flux RSS") | 15 | + let ctx = ui.ctx().clone(); |
| 16 | - .resizable(false) | 16 | + let modal = egui::Modal::new(egui::Id::new("add_feed_dialog")); |
| 17 | - .show(ui.ctx(), |ui| { | 17 | + let response = modal.show(&ctx, |ui| { |
| 18 | - ui.vertical(|ui| { | 18 | + ui.set_min_width(420.0); |
| 19 | - ui.label("Nouveau flux"); | 19 | + ui.heading("Ajouter un flux RSS"); |
| 20 | - ui.separator(); | 20 | + ui.separator(); |
| 21 | - | 21 | + |
| 22 | - ui.label("Nom du podcast"); | 22 | + ui.label("Nom du podcast"); |
| 23 | - ui.text_edit_singleline(&mut state.new_feed_name); | 23 | + let name_response = ui.text_edit_singleline(&mut state.new_feed_name); |
| 24 | - | 24 | + |
| 25 | - ui.label("URL du flux RSS"); | 25 | + ui.label("URL du flux RSS"); |
| 26 | - ui.text_edit_singleline(&mut state.new_feed_url); | 26 | + let url_response = ui.text_edit_singleline(&mut state.new_feed_url); |
| 27 | - | 27 | + |
| 28 | - ui.label("Dossier de téléchargement"); | 28 | + ui.label("Dossier de téléchargement"); |
| 29 | - ui.text_edit_singleline(&mut state.new_feed_download_path); | 29 | + let download_width = (ui.available_width() - 110.0).max(180.0); |
| 30 | - | 30 | + ui.horizontal(|ui| { |
| 31 | - ui.separator(); | 31 | + ui.add( |
| 32 | - | 32 | + egui::TextEdit::singleline(&mut state.new_feed_download_path) |
| 33 | - // Validation | 33 | + .desired_width(download_width) |
| 34 | - let valid = !state.new_feed_url.is_empty() && !state.new_feed_download_path.is_empty(); | 34 | + .hint_text("ex: C:\\Users\\moi\\Musique\\podcasts"), |
| 35 | - | 35 | + ); |
| 36 | - ui.horizontal(|ui| { | 36 | + if ui.button("Parcourir…").clicked() { |
| 37 | - if valid && ui.button("Ajouter").clicked() { | 37 | + if let Some(path) = rfd::FileDialog::new() |
| 38 | - log::info!("Adding new feed: {} - {}", state.new_feed_name, state.new_feed_url); | 38 | + .set_title("Choisir le dossier de téléchargement") |
| 39 | - let feed = Feed::new( | 39 | + .pick_folder() |
| 40 | - state.new_feed_url.clone(), | 40 | + { |
| 41 | - state.new_feed_name.clone(), | 41 | + state.new_feed_download_path = path.to_string_lossy().to_string(); |
| 42 | - PathBuf::from(state.new_feed_download_path.clone()) | 42 | + } |
| 43 | - ); | 43 | + } |
| 44 | - state.add_feed(feed); | 44 | + }); |
| 45 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | 45 | + |
| 46 | - log::info!("Feed added successfully"); | 46 | + ui.separator(); |
| 47 | - state.clear_new_feed_form(); | 47 | + |
| 48 | - state.show_add_feed_dialog = false; | 48 | + // Validation : URL et dossier obligatoires |
| 49 | - } else { | 49 | + let valid = !state.new_feed_url.trim().is_empty() |
| 50 | - ui.label("Veuillez remplir tous les champs"); | 50 | + && !state.new_feed_download_path.trim().is_empty(); |
| 51 | - } | 51 | + |
| 52 | - | 52 | + let mut submitted = false; |
| 53 | - if ui.button("Annuler").clicked() { | 53 | + ui.horizontal(|ui| { |
| 54 | - state.clear_new_feed_form(); | 54 | + if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() { |
| 55 | - state.show_add_feed_dialog = false; | 55 | + submitted = true; |
| 56 | - } | 56 | + } |
| 57 | - }); | 57 | + if ui.button("Annuler").clicked() { |
| 58 | - }); | 58 | + state.clear_new_feed_form(); |
| 59 | + state.show_add_feed_dialog = false; | ||
| 60 | + } | ||
| 61 | + }); | ||
| 62 | + if !valid { | ||
| 63 | + ui.label( | ||
| 64 | + egui::RichText::new("L'URL du flux et le dossier de téléchargement sont requis.") | ||
| 65 | + .weak() | ||
| 66 | + .small(), | ||
| 67 | + ); | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + // Entrée pour valider depuis le champ nom ou URL | ||
| 71 | + let enter_pressed = ui.input(|i| { | ||
| 72 | + i.key_pressed(egui::Key::Enter) && (name_response.lost_focus() || url_response.lost_focus()) | ||
| 59 | }); | 73 | }); |
| 74 | + if enter_pressed && valid { | ||
| 75 | + submitted = true; | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + submitted | ||
| 79 | + }); | ||
| 80 | + | ||
| 81 | + if response.inner { | ||
| 82 | + log::info!( | ||
| 83 | + "Adding new feed: {} - {}", | ||
| 84 | + state.new_feed_name, | ||
| 85 | + state.new_feed_url | ||
| 86 | + ); | ||
| 87 | + let feed = Feed::new( | ||
| 88 | + state.new_feed_url.trim().to_string(), | ||
| 89 | + state.new_feed_name.trim().to_string(), | ||
| 90 | + PathBuf::from(state.new_feed_download_path.trim()), | ||
| 91 | + ); | ||
| 92 | + state.add_feed(feed); | ||
| 93 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 94 | + log::info!("Feed added successfully"); | ||
| 95 | + state.clear_new_feed_form(); | ||
| 96 | + state.show_add_feed_dialog = false; | ||
| 97 | + } else if response.should_close() { | ||
| 98 | + state.clear_new_feed_form(); | ||
| 99 | + state.show_add_feed_dialog = false; | ||
| 100 | + } | ||
| 60 | } | 101 | } |
added
src/ui/confirm_dialog.rs +135 -0 | new file mode 100644 | ||
| @@ -0,0 +1,135 @@ | ||
| 1 | +//! Dialogues de confirmation pour les actions destructrices | |
| 2 | +//! | |
| 3 | +//! Affiche des fenêtres modales avant toute suppression de flux ou de playlist. | |
| 4 | + | |
| 5 | +use crate::config::AppState; | |
| 6 | +use egui::{Context, Ui}; | |
| 7 | + | |
| 8 | +/// Action choisie par l'utilisateur dans un dialogue de confirmation | |
| 9 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 10 | +enum ConfirmAction { | |
| 11 | + None, | |
| 12 | + Confirm, | |
| 13 | + Cancel, | |
| 14 | +} | |
| 15 | + | |
| 16 | +/// Affiche les dialogues de confirmation en attente | |
| 17 | +pub fn draw_confirmations(state: &mut AppState, ctx: &Context) { | |
| 18 | + draw_delete_feed_confirmation(state, ctx); | |
| 19 | + draw_delete_playlist_confirmation(state, ctx); | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// Confirmation avant suppression d'un flux | |
| 23 | +fn draw_delete_feed_confirmation(state: &mut AppState, ctx: &Context) { | |
| 24 | + let feed_id = match state.confirm_delete_feed_id.clone() { | |
| 25 | + Some(id) => id, | |
| 26 | + None => return, | |
| 27 | + }; | |
| 28 | + | |
| 29 | + // Le flux a pu être supprimé entre-temps : abandonner la confirmation | |
| 30 | + let Some(feed_name) = state.feeds.iter().find(|f| f.id == feed_id).map(|f| f.name.clone()) else { | |
| 31 | + state.confirm_delete_feed_id = None; | |
| 32 | + return; | |
| 33 | + }; | |
| 34 | + | |
| 35 | + let modal = egui::Modal::new(egui::Id::new("confirm_delete_feed")); | |
| 36 | + let response = modal.show(ctx, |ui| { | |
| 37 | + draw_confirm_content( | |
| 38 | + ui, | |
| 39 | + "Supprimer le flux", | |
| 40 | + &format!("Voulez-vous vraiment supprimer le flux \"{}\" ?", feed_name), | |
| 41 | + "Supprimer", | |
| 42 | + ) | |
| 43 | + }); | |
| 44 | + | |
| 45 | + match response.inner { | |
| 46 | + ConfirmAction::Confirm => { | |
| 47 | + state.confirm_delete_feed_id = None; | |
| 48 | + if let Some(feed) = state.delete_feed_by_id(&feed_id) { | |
| 49 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 50 | + state.record_history( | |
| 51 | + crate::config::history::OperationType::RemoveFeed, | |
| 52 | + format!("Flux supprimé : {}", feed.name), | |
| 53 | + ); | |
| 54 | + state.notification_manager.success("Flux supprimé"); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + ConfirmAction::Cancel => { | |
| 58 | + state.confirm_delete_feed_id = None; | |
| 59 | + } | |
| 60 | + ConfirmAction::None => { | |
| 61 | + if response.should_close() { | |
| 62 | + state.confirm_delete_feed_id = None; | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Confirmation avant suppression d'une playlist | |
| 69 | +fn draw_delete_playlist_confirmation(state: &mut AppState, ctx: &Context) { | |
| 70 | + let playlist_id = match state.confirm_delete_playlist_id.clone() { | |
| 71 | + Some(id) => id, | |
| 72 | + None => return, | |
| 73 | + }; | |
| 74 | + | |
| 75 | + let Some(playlist_name) = state | |
| 76 | + .playlists | |
| 77 | + .iter() | |
| 78 | + .find(|p| p.id == playlist_id) | |
| 79 | + .map(|p| p.name.clone()) | |
| 80 | + else { | |
| 81 | + state.confirm_delete_playlist_id = None; | |
| 82 | + return; | |
| 83 | + }; | |
| 84 | + | |
| 85 | + let modal = egui::Modal::new(egui::Id::new("confirm_delete_playlist")); | |
| 86 | + let response = modal.show(ctx, |ui| { | |
| 87 | + draw_confirm_content( | |
| 88 | + ui, | |
| 89 | + "Supprimer la playlist", | |
| 90 | + &format!( | |
| 91 | + "Voulez-vous vraiment supprimer la playlist \"{}\" ?", | |
| 92 | + playlist_name | |
| 93 | + ), | |
| 94 | + "Supprimer", | |
| 95 | + ) | |
| 96 | + }); | |
| 97 | + | |
| 98 | + match response.inner { | |
| 99 | + ConfirmAction::Confirm => { | |
| 100 | + state.confirm_delete_playlist_id = None; | |
| 101 | + if let Some(playlist) = state.remove_playlist_by_id(&playlist_id) { | |
| 102 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 103 | + log::info!("Deleted playlist: {}", playlist.name); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + ConfirmAction::Cancel => { | |
| 107 | + state.confirm_delete_playlist_id = None; | |
| 108 | + } | |
| 109 | + ConfirmAction::None => { | |
| 110 | + if response.should_close() { | |
| 111 | + state.confirm_delete_playlist_id = None; | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +/// Contenu commun d'un dialogue de confirmation | |
| 118 | +fn draw_confirm_content(ui: &mut Ui, title: &str, message: &str, confirm_label: &str) -> ConfirmAction { | |
| 119 | + ui.set_min_width(340.0); | |
| 120 | + ui.heading(title); | |
| 121 | + ui.add_space(8.0); | |
| 122 | + ui.label(message); | |
| 123 | + ui.add_space(12.0); | |
| 124 | + | |
| 125 | + let mut action = ConfirmAction::None; | |
| 126 | + ui.horizontal(|ui| { | |
| 127 | + if ui.button(confirm_label).clicked() { | |
| 128 | + action = ConfirmAction::Confirm; | |
| 129 | + } | |
| 130 | + if ui.button("Annuler").clicked() { | |
| 131 | + action = ConfirmAction::Cancel; | |
| 132 | + } | |
| 133 | + }); | |
| 134 | + action | |
| 135 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,135 @@ | |||
| 1 | +//! Dialogues de confirmation pour les actions destructrices | ||
| 2 | +//! | ||
| 3 | +//! Affiche des fenêtres modales avant toute suppression de flux ou de playlist. | ||
| 4 | + | ||
| 5 | +use crate::config::AppState; | ||
| 6 | +use egui::{Context, Ui}; | ||
| 7 | + | ||
| 8 | +/// Action choisie par l'utilisateur dans un dialogue de confirmation | ||
| 9 | +#[derive(Debug, Clone, Copy, PartialEq)] | ||
| 10 | +enum ConfirmAction { | ||
| 11 | + None, | ||
| 12 | + Confirm, | ||
| 13 | + Cancel, | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +/// Affiche les dialogues de confirmation en attente | ||
| 17 | +pub fn draw_confirmations(state: &mut AppState, ctx: &Context) { | ||
| 18 | + draw_delete_feed_confirmation(state, ctx); | ||
| 19 | + draw_delete_playlist_confirmation(state, ctx); | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +/// Confirmation avant suppression d'un flux | ||
| 23 | +fn draw_delete_feed_confirmation(state: &mut AppState, ctx: &Context) { | ||
| 24 | + let feed_id = match state.confirm_delete_feed_id.clone() { | ||
| 25 | + Some(id) => id, | ||
| 26 | + None => return, | ||
| 27 | + }; | ||
| 28 | + | ||
| 29 | + // Le flux a pu être supprimé entre-temps : abandonner la confirmation | ||
| 30 | + let Some(feed_name) = state.feeds.iter().find(|f| f.id == feed_id).map(|f| f.name.clone()) else { | ||
| 31 | + state.confirm_delete_feed_id = None; | ||
| 32 | + return; | ||
| 33 | + }; | ||
| 34 | + | ||
| 35 | + let modal = egui::Modal::new(egui::Id::new("confirm_delete_feed")); | ||
| 36 | + let response = modal.show(ctx, |ui| { | ||
| 37 | + draw_confirm_content( | ||
| 38 | + ui, | ||
| 39 | + "Supprimer le flux", | ||
| 40 | + &format!("Voulez-vous vraiment supprimer le flux \"{}\" ?", feed_name), | ||
| 41 | + "Supprimer", | ||
| 42 | + ) | ||
| 43 | + }); | ||
| 44 | + | ||
| 45 | + match response.inner { | ||
| 46 | + ConfirmAction::Confirm => { | ||
| 47 | + state.confirm_delete_feed_id = None; | ||
| 48 | + if let Some(feed) = state.delete_feed_by_id(&feed_id) { | ||
| 49 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 50 | + state.record_history( | ||
| 51 | + crate::config::history::OperationType::RemoveFeed, | ||
| 52 | + format!("Flux supprimé : {}", feed.name), | ||
| 53 | + ); | ||
| 54 | + state.notification_manager.success("Flux supprimé"); | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + ConfirmAction::Cancel => { | ||
| 58 | + state.confirm_delete_feed_id = None; | ||
| 59 | + } | ||
| 60 | + ConfirmAction::None => { | ||
| 61 | + if response.should_close() { | ||
| 62 | + state.confirm_delete_feed_id = None; | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +/// Confirmation avant suppression d'une playlist | ||
| 69 | +fn draw_delete_playlist_confirmation(state: &mut AppState, ctx: &Context) { | ||
| 70 | + let playlist_id = match state.confirm_delete_playlist_id.clone() { | ||
| 71 | + Some(id) => id, | ||
| 72 | + None => return, | ||
| 73 | + }; | ||
| 74 | + | ||
| 75 | + let Some(playlist_name) = state | ||
| 76 | + .playlists | ||
| 77 | + .iter() | ||
| 78 | + .find(|p| p.id == playlist_id) | ||
| 79 | + .map(|p| p.name.clone()) | ||
| 80 | + else { | ||
| 81 | + state.confirm_delete_playlist_id = None; | ||
| 82 | + return; | ||
| 83 | + }; | ||
| 84 | + | ||
| 85 | + let modal = egui::Modal::new(egui::Id::new("confirm_delete_playlist")); | ||
| 86 | + let response = modal.show(ctx, |ui| { | ||
| 87 | + draw_confirm_content( | ||
| 88 | + ui, | ||
| 89 | + "Supprimer la playlist", | ||
| 90 | + &format!( | ||
| 91 | + "Voulez-vous vraiment supprimer la playlist \"{}\" ?", | ||
| 92 | + playlist_name | ||
| 93 | + ), | ||
| 94 | + "Supprimer", | ||
| 95 | + ) | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + match response.inner { | ||
| 99 | + ConfirmAction::Confirm => { | ||
| 100 | + state.confirm_delete_playlist_id = None; | ||
| 101 | + if let Some(playlist) = state.remove_playlist_by_id(&playlist_id) { | ||
| 102 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 103 | + log::info!("Deleted playlist: {}", playlist.name); | ||
| 104 | + } | ||
| 105 | + } | ||
| 106 | + ConfirmAction::Cancel => { | ||
| 107 | + state.confirm_delete_playlist_id = None; | ||
| 108 | + } | ||
| 109 | + ConfirmAction::None => { | ||
| 110 | + if response.should_close() { | ||
| 111 | + state.confirm_delete_playlist_id = None; | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +/// Contenu commun d'un dialogue de confirmation | ||
| 118 | +fn draw_confirm_content(ui: &mut Ui, title: &str, message: &str, confirm_label: &str) -> ConfirmAction { | ||
| 119 | + ui.set_min_width(340.0); | ||
| 120 | + ui.heading(title); | ||
| 121 | + ui.add_space(8.0); | ||
| 122 | + ui.label(message); | ||
| 123 | + ui.add_space(12.0); | ||
| 124 | + | ||
| 125 | + let mut action = ConfirmAction::None; | ||
| 126 | + ui.horizontal(|ui| { | ||
| 127 | + if ui.button(confirm_label).clicked() { | ||
| 128 | + action = ConfirmAction::Confirm; | ||
| 129 | + } | ||
| 130 | + if ui.button("Annuler").clicked() { | ||
| 131 | + action = ConfirmAction::Cancel; | ||
| 132 | + } | ||
| 133 | + }); | ||
| 134 | + action | ||
| 135 | +} | ||
modified
src/ui/export_import.rs +102 -68 | @@ -6,45 +6,54 @@ use crate::config::AppState; | ||
| 6 | 6 | use egui::{Color32, Ui}; |
| 7 | 7 | use std::path::PathBuf; |
| 8 | 8 | |
| 9 | -/// Draws the export dialog | |
| 9 | +/// Draws the export dialog (modal) | |
| 10 | 10 | pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | 11 | if !state.show_export_dialog { |
| 12 | 12 | return; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | - egui::Window::new("Exporter les flux") | |
| 16 | - .resizable(false) | |
| 17 | - .show(ui.ctx(), |ui| { | |
| 18 | - ui.vertical(|ui| { | |
| 19 | - ui.label("Exporter les flux vers un fichier JSON"); | |
| 20 | - ui.label(format!("Nombre de flux à exporter: {}", state.feeds.len())); | |
| 21 | - ui.separator(); | |
| 22 | - | |
| 23 | - ui.horizontal(|ui| { | |
| 24 | - if ui.button("Choisir un fichier").clicked() { | |
| 25 | - if let Some(path) = rfd::FileDialog::new() | |
| 26 | - .add_filter("JSON Files", &["json"]) | |
| 27 | - .add_filter("All Files", &["*"]) | |
| 28 | - .set_title("Exporter les flux") | |
| 29 | - .save_file() | |
| 30 | - { | |
| 31 | - export_feeds_to_path(state, path); | |
| 32 | - } | |
| 33 | - } | |
| 34 | - | |
| 35 | - if ui.button("Annuler").clicked() { | |
| 36 | - state.show_export_dialog = false; | |
| 37 | - state.clear_export_import_error(); | |
| 38 | - } | |
| 39 | - }); | |
| 40 | - | |
| 41 | - // Show error if any | |
| 42 | - if let Some(ref error) = state.export_import_error { | |
| 43 | - ui.separator(); | |
| 44 | - ui.colored_label(Color32::RED, error); | |
| 15 | + let ctx = ui.ctx().clone(); | |
| 16 | + let modal = egui::Modal::new(egui::Id::new("export_dialog")); | |
| 17 | + let response = modal.show(&ctx, |ui| { | |
| 18 | + ui.set_min_width(360.0); | |
| 19 | + ui.heading("Exporter les flux"); | |
| 20 | + ui.separator(); | |
| 21 | + ui.label("Exporter les flux vers un fichier JSON"); | |
| 22 | + ui.label(format!("Nombre de flux à exporter : {}", state.feeds.len())); | |
| 23 | + ui.separator(); | |
| 24 | + | |
| 25 | + let mut close = false; | |
| 26 | + ui.horizontal(|ui| { | |
| 27 | + if ui.button("Choisir un fichier").clicked() { | |
| 28 | + if let Some(path) = rfd::FileDialog::new() | |
| 29 | + .add_filter("JSON Files", &["json"]) | |
| 30 | + .add_filter("All Files", &["*"]) | |
| 31 | + .set_title("Exporter les flux") | |
| 32 | + .save_file() | |
| 33 | + { | |
| 34 | + export_feeds_to_path(state, path); | |
| 35 | + close = true; | |
| 45 | 36 | } |
| 46 | - }); | |
| 37 | + } | |
| 38 | + | |
| 39 | + if ui.button("Annuler").clicked() { | |
| 40 | + close = true; | |
| 41 | + } | |
| 47 | 42 | }); |
| 43 | + | |
| 44 | + // Show error if any | |
| 45 | + if let Some(ref error) = state.export_import_error { | |
| 46 | + ui.separator(); | |
| 47 | + ui.colored_label(Color32::RED, error); | |
| 48 | + } | |
| 49 | + | |
| 50 | + close | |
| 51 | + }); | |
| 52 | + | |
| 53 | + if response.inner || response.should_close() { | |
| 54 | + state.show_export_dialog = false; | |
| 55 | + state.clear_export_import_error(); | |
| 56 | + } | |
| 48 | 57 | } |
| 49 | 58 | |
| 50 | 59 | /// Exports feeds to the specified path |
| @@ -52,7 +61,13 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) { | ||
| 52 | 61 | match crate::config::storage::export_feeds_pretty(&state.feeds, &path) { |
| 53 | 62 | Ok(_) => { |
| 54 | 63 | log::info!("Successfully exported feeds to {}", path.display()); |
| 55 | - state.notification_manager.success(format!("Export réussi: {}", path.display())); | |
| 64 | + state.record_history( | |
| 65 | + crate::config::history::OperationType::ExportFeeds, | |
| 66 | + format!("Export de {} flux vers {}", state.feeds.len(), path.display()), | |
| 67 | + ); | |
| 68 | + state | |
| 69 | + .notification_manager | |
| 70 | + .success(format!("Export réussi: {}", path.display())); | |
| 56 | 71 | state.show_export_dialog = false; |
| 57 | 72 | state.clear_export_import_error(); |
| 58 | 73 | } |
| @@ -63,44 +78,53 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) { | ||
| 63 | 78 | } |
| 64 | 79 | } |
| 65 | 80 | |
| 66 | -/// Draws the import dialog | |
| 81 | +/// Draws the import dialog (modal) | |
| 67 | 82 | pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) { |
| 68 | 83 | if !state.show_import_dialog { |
| 69 | 84 | return; |
| 70 | 85 | } |
| 71 | 86 | |
| 72 | - egui::Window::new("Importer les flux") | |
| 73 | - .resizable(false) | |
| 74 | - .show(ui.ctx(), |ui| { | |
| 75 | - ui.vertical(|ui| { | |
| 76 | - ui.label("Importer les flux depuis un fichier JSON"); | |
| 77 | - ui.separator(); | |
| 78 | - | |
| 79 | - ui.horizontal(|ui| { | |
| 80 | - if ui.button("Choisir un fichier").clicked() { | |
| 81 | - if let Some(path) = rfd::FileDialog::new() | |
| 82 | - .add_filter("JSON Files", &["json"]) | |
| 83 | - .add_filter("All Files", &["*"]) | |
| 84 | - .set_title("Importer les flux") | |
| 85 | - .pick_file() | |
| 86 | - { | |
| 87 | - import_feeds_from_path(state, path); | |
| 88 | - } | |
| 89 | - } | |
| 90 | - | |
| 91 | - if ui.button("Annuler").clicked() { | |
| 92 | - state.show_import_dialog = false; | |
| 93 | - state.clear_export_import_error(); | |
| 94 | - } | |
| 95 | - }); | |
| 96 | - | |
| 97 | - // Show error if any | |
| 98 | - if let Some(ref error) = state.export_import_error { | |
| 99 | - ui.separator(); | |
| 100 | - ui.colored_label(Color32::RED, error); | |
| 87 | + let ctx = ui.ctx().clone(); | |
| 88 | + let modal = egui::Modal::new(egui::Id::new("import_dialog")); | |
| 89 | + let response = modal.show(&ctx, |ui| { | |
| 90 | + ui.set_min_width(360.0); | |
| 91 | + ui.heading("Importer des flux"); | |
| 92 | + ui.separator(); | |
| 93 | + ui.label("Importer les flux depuis un fichier JSON"); | |
| 94 | + ui.separator(); | |
| 95 | + | |
| 96 | + let mut close = false; | |
| 97 | + ui.horizontal(|ui| { | |
| 98 | + if ui.button("Choisir un fichier").clicked() { | |
| 99 | + if let Some(path) = rfd::FileDialog::new() | |
| 100 | + .add_filter("JSON Files", &["json"]) | |
| 101 | + .add_filter("All Files", &["*"]) | |
| 102 | + .set_title("Importer les flux") | |
| 103 | + .pick_file() | |
| 104 | + { | |
| 105 | + import_feeds_from_path(state, path); | |
| 106 | + close = true; | |
| 101 | 107 | } |
| 102 | - }); | |
| 108 | + } | |
| 109 | + | |
| 110 | + if ui.button("Annuler").clicked() { | |
| 111 | + close = true; | |
| 112 | + } | |
| 103 | 113 | }); |
| 114 | + | |
| 115 | + // Show error if any | |
| 116 | + if let Some(ref error) = state.export_import_error { | |
| 117 | + ui.separator(); | |
| 118 | + ui.colored_label(Color32::RED, error); | |
| 119 | + } | |
| 120 | + | |
| 121 | + close | |
| 122 | + }); | |
| 123 | + | |
| 124 | + if response.inner || response.should_close() { | |
| 125 | + state.show_import_dialog = false; | |
| 126 | + state.clear_export_import_error(); | |
| 127 | + } | |
| 104 | 128 | } |
| 105 | 129 | |
| 106 | 130 | /// Imports feeds from the specified path |
| @@ -113,18 +137,28 @@ fn import_feeds_from_path(state: &mut AppState, path: PathBuf) { | ||
| 113 | 137 | feed.id = uuid::Uuid::new_v4().to_string(); |
| 114 | 138 | } |
| 115 | 139 | } |
| 116 | - | |
| 140 | + | |
| 117 | 141 | // Append imported feeds to existing feeds |
| 118 | 142 | let count = imported_feeds.len(); |
| 119 | 143 | state.feeds.extend(imported_feeds); |
| 120 | - | |
| 144 | + | |
| 121 | 145 | // Save the updated feeds |
| 122 | 146 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { |
| 123 | 147 | log::error!("Failed to save imported feeds: {}", e); |
| 124 | 148 | state.set_export_import_error(format!("Erreur de sauvegarde: {}", e)); |
| 125 | 149 | } else { |
| 126 | - log::info!("Successfully imported {} feeds from {}", count, path.display()); | |
| 127 | - state.notification_manager.success(format!("Import réussi: {} flux ajoutés", count)); | |
| 150 | + log::info!( | |
| 151 | + "Successfully imported {} feeds from {}", | |
| 152 | + count, | |
| 153 | + path.display() | |
| 154 | + ); | |
| 155 | + state.record_history( | |
| 156 | + crate::config::history::OperationType::ImportFeeds, | |
| 157 | + format!("Import de {} flux depuis {}", count, path.display()), | |
| 158 | + ); | |
| 159 | + state | |
| 160 | + .notification_manager | |
| 161 | + .success(format!("Import réussi: {} flux ajoutés", count)); | |
| 128 | 162 | state.show_import_dialog = false; |
| 129 | 163 | state.clear_export_import_error(); |
| 130 | 164 | } |
| @@ -6,45 +6,54 @@ use crate::config::AppState; | |||
| 6 | use egui::{Color32, Ui}; | 6 | use egui::{Color32, Ui}; |
| 7 | use std::path::PathBuf; | 7 | use std::path::PathBuf; |
| 8 | 8 | ||
| 9 | -/// Draws the export dialog | 9 | +/// Draws the export dialog (modal) |
| 10 | pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_export_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | if !state.show_export_dialog { | 11 | if !state.show_export_dialog { |
| 12 | return; | 12 | return; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | - egui::Window::new("Exporter les flux") | 15 | + let ctx = ui.ctx().clone(); |
| 16 | - .resizable(false) | 16 | + let modal = egui::Modal::new(egui::Id::new("export_dialog")); |
| 17 | - .show(ui.ctx(), |ui| { | 17 | + let response = modal.show(&ctx, |ui| { |
| 18 | - ui.vertical(|ui| { | 18 | + ui.set_min_width(360.0); |
| 19 | - ui.label("Exporter les flux vers un fichier JSON"); | 19 | + ui.heading("Exporter les flux"); |
| 20 | - ui.label(format!("Nombre de flux à exporter: {}", state.feeds.len())); | 20 | + ui.separator(); |
| 21 | - ui.separator(); | 21 | + ui.label("Exporter les flux vers un fichier JSON"); |
| 22 | - | 22 | + ui.label(format!("Nombre de flux à exporter : {}", state.feeds.len())); |
| 23 | - ui.horizontal(|ui| { | 23 | + ui.separator(); |
| 24 | - if ui.button("Choisir un fichier").clicked() { | 24 | + |
| 25 | - if let Some(path) = rfd::FileDialog::new() | 25 | + let mut close = false; |
| 26 | - .add_filter("JSON Files", &["json"]) | 26 | + ui.horizontal(|ui| { |
| 27 | - .add_filter("All Files", &["*"]) | 27 | + if ui.button("Choisir un fichier").clicked() { |
| 28 | - .set_title("Exporter les flux") | 28 | + if let Some(path) = rfd::FileDialog::new() |
| 29 | - .save_file() | 29 | + .add_filter("JSON Files", &["json"]) |
| 30 | - { | 30 | + .add_filter("All Files", &["*"]) |
| 31 | - export_feeds_to_path(state, path); | 31 | + .set_title("Exporter les flux") |
| 32 | - } | 32 | + .save_file() |
| 33 | - } | 33 | + { |
| 34 | - | 34 | + export_feeds_to_path(state, path); |
| 35 | - if ui.button("Annuler").clicked() { | 35 | + close = true; |
| 36 | - state.show_export_dialog = false; | ||
| 37 | - state.clear_export_import_error(); | ||
| 38 | - } | ||
| 39 | - }); | ||
| 40 | - | ||
| 41 | - // Show error if any | ||
| 42 | - if let Some(ref error) = state.export_import_error { | ||
| 43 | - ui.separator(); | ||
| 44 | - ui.colored_label(Color32::RED, error); | ||
| 45 | } | 36 | } |
| 46 | - }); | 37 | + } |
| 38 | + | ||
| 39 | + if ui.button("Annuler").clicked() { | ||
| 40 | + close = true; | ||
| 41 | + } | ||
| 47 | }); | 42 | }); |
| 43 | + | ||
| 44 | + // Show error if any | ||
| 45 | + if let Some(ref error) = state.export_import_error { | ||
| 46 | + ui.separator(); | ||
| 47 | + ui.colored_label(Color32::RED, error); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + close | ||
| 51 | + }); | ||
| 52 | + | ||
| 53 | + if response.inner || response.should_close() { | ||
| 54 | + state.show_export_dialog = false; | ||
| 55 | + state.clear_export_import_error(); | ||
| 56 | + } | ||
| 48 | } | 57 | } |
| 49 | 58 | ||
| 50 | /// Exports feeds to the specified path | 59 | /// Exports feeds to the specified path |
| @@ -52,7 +61,13 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) { | |||
| 52 | match crate::config::storage::export_feeds_pretty(&state.feeds, &path) { | 61 | match crate::config::storage::export_feeds_pretty(&state.feeds, &path) { |
| 53 | Ok(_) => { | 62 | Ok(_) => { |
| 54 | log::info!("Successfully exported feeds to {}", path.display()); | 63 | log::info!("Successfully exported feeds to {}", path.display()); |
| 55 | - state.notification_manager.success(format!("Export réussi: {}", path.display())); | 64 | + state.record_history( |
| 65 | + crate::config::history::OperationType::ExportFeeds, | ||
| 66 | + format!("Export de {} flux vers {}", state.feeds.len(), path.display()), | ||
| 67 | + ); | ||
| 68 | + state | ||
| 69 | + .notification_manager | ||
| 70 | + .success(format!("Export réussi: {}", path.display())); | ||
| 56 | state.show_export_dialog = false; | 71 | state.show_export_dialog = false; |
| 57 | state.clear_export_import_error(); | 72 | state.clear_export_import_error(); |
| 58 | } | 73 | } |
| @@ -63,44 +78,53 @@ fn export_feeds_to_path(state: &mut AppState, path: PathBuf) { | |||
| 63 | } | 78 | } |
| 64 | } | 79 | } |
| 65 | 80 | ||
| 66 | -/// Draws the import dialog | 81 | +/// Draws the import dialog (modal) |
| 67 | pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) { | 82 | pub fn draw_import_dialog(state: &mut AppState, ui: &mut Ui) { |
| 68 | if !state.show_import_dialog { | 83 | if !state.show_import_dialog { |
| 69 | return; | 84 | return; |
| 70 | } | 85 | } |
| 71 | 86 | ||
| 72 | - egui::Window::new("Importer les flux") | 87 | + let ctx = ui.ctx().clone(); |
| 73 | - .resizable(false) | 88 | + let modal = egui::Modal::new(egui::Id::new("import_dialog")); |
| 74 | - .show(ui.ctx(), |ui| { | 89 | + let response = modal.show(&ctx, |ui| { |
| 75 | - ui.vertical(|ui| { | 90 | + ui.set_min_width(360.0); |
| 76 | - ui.label("Importer les flux depuis un fichier JSON"); | 91 | + ui.heading("Importer des flux"); |
| 77 | - ui.separator(); | 92 | + ui.separator(); |
| 78 | - | 93 | + ui.label("Importer les flux depuis un fichier JSON"); |
| 79 | - ui.horizontal(|ui| { | 94 | + ui.separator(); |
| 80 | - if ui.button("Choisir un fichier").clicked() { | 95 | + |
| 81 | - if let Some(path) = rfd::FileDialog::new() | 96 | + let mut close = false; |
| 82 | - .add_filter("JSON Files", &["json"]) | 97 | + ui.horizontal(|ui| { |
| 83 | - .add_filter("All Files", &["*"]) | 98 | + if ui.button("Choisir un fichier").clicked() { |
| 84 | - .set_title("Importer les flux") | 99 | + if let Some(path) = rfd::FileDialog::new() |
| 85 | - .pick_file() | 100 | + .add_filter("JSON Files", &["json"]) |
| 86 | - { | 101 | + .add_filter("All Files", &["*"]) |
| 87 | - import_feeds_from_path(state, path); | 102 | + .set_title("Importer les flux") |
| 88 | - } | 103 | + .pick_file() |
| 89 | - } | 104 | + { |
| 90 | - | 105 | + import_feeds_from_path(state, path); |
| 91 | - if ui.button("Annuler").clicked() { | 106 | + close = true; |
| 92 | - state.show_import_dialog = false; | ||
| 93 | - state.clear_export_import_error(); | ||
| 94 | - } | ||
| 95 | - }); | ||
| 96 | - | ||
| 97 | - // Show error if any | ||
| 98 | - if let Some(ref error) = state.export_import_error { | ||
| 99 | - ui.separator(); | ||
| 100 | - ui.colored_label(Color32::RED, error); | ||
| 101 | } | 107 | } |
| 102 | - }); | 108 | + } |
| 109 | + | ||
| 110 | + if ui.button("Annuler").clicked() { | ||
| 111 | + close = true; | ||
| 112 | + } | ||
| 103 | }); | 113 | }); |
| 114 | + | ||
| 115 | + // Show error if any | ||
| 116 | + if let Some(ref error) = state.export_import_error { | ||
| 117 | + ui.separator(); | ||
| 118 | + ui.colored_label(Color32::RED, error); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + close | ||
| 122 | + }); | ||
| 123 | + | ||
| 124 | + if response.inner || response.should_close() { | ||
| 125 | + state.show_import_dialog = false; | ||
| 126 | + state.clear_export_import_error(); | ||
| 127 | + } | ||
| 104 | } | 128 | } |
| 105 | 129 | ||
| 106 | /// Imports feeds from the specified path | 130 | /// Imports feeds from the specified path |
| @@ -113,18 +137,28 @@ fn import_feeds_from_path(state: &mut AppState, path: PathBuf) { | |||
| 113 | feed.id = uuid::Uuid::new_v4().to_string(); | 137 | feed.id = uuid::Uuid::new_v4().to_string(); |
| 114 | } | 138 | } |
| 115 | } | 139 | } |
| 116 | - | 140 | + |
| 117 | // Append imported feeds to existing feeds | 141 | // Append imported feeds to existing feeds |
| 118 | let count = imported_feeds.len(); | 142 | let count = imported_feeds.len(); |
| 119 | state.feeds.extend(imported_feeds); | 143 | state.feeds.extend(imported_feeds); |
| 120 | - | 144 | + |
| 121 | // Save the updated feeds | 145 | // Save the updated feeds |
| 122 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { | 146 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { |
| 123 | log::error!("Failed to save imported feeds: {}", e); | 147 | log::error!("Failed to save imported feeds: {}", e); |
| 124 | state.set_export_import_error(format!("Erreur de sauvegarde: {}", e)); | 148 | state.set_export_import_error(format!("Erreur de sauvegarde: {}", e)); |
| 125 | } else { | 149 | } else { |
| 126 | - log::info!("Successfully imported {} feeds from {}", count, path.display()); | 150 | + log::info!( |
| 127 | - state.notification_manager.success(format!("Import réussi: {} flux ajoutés", count)); | 151 | + "Successfully imported {} feeds from {}", |
| 152 | + count, | ||
| 153 | + path.display() | ||
| 154 | + ); | ||
| 155 | + state.record_history( | ||
| 156 | + crate::config::history::OperationType::ImportFeeds, | ||
| 157 | + format!("Import de {} flux depuis {}", count, path.display()), | ||
| 158 | + ); | ||
| 159 | + state | ||
| 160 | + .notification_manager | ||
| 161 | + .success(format!("Import réussi: {} flux ajoutés", count)); | ||
| 128 | state.show_import_dialog = false; | 162 | state.show_import_dialog = false; |
| 129 | state.clear_export_import_error(); | 163 | state.clear_export_import_error(); |
| 130 | } | 164 | } |
added
src/ui/feed_detail.rs +230 -0 | new file mode 100644 | ||
| @@ -0,0 +1,230 @@ | ||
| 1 | +//! Feed detail view UI component | |
| 2 | +//! | |
| 3 | +//! Affiche les épisodes d'un flux avec téléchargement individuel, | |
| 4 | +//! actualisation et filtrage par titre. | |
| 5 | + | |
| 6 | +use crate::config::AppState; | |
| 7 | +use crate::config::statistics::{format_bytes, format_duration}; | |
| 8 | +use crate::ui::theme::ThemeColors; | |
| 9 | +use crate::ui::truncate; | |
| 10 | +use egui::{Align, Layout, RichText, Ui}; | |
| 11 | + | |
| 12 | +/// Draws the detail view of a feed (episode list) | |
| 13 | +pub fn draw_feed_detail(state: &mut AppState, ui: &mut Ui) { | |
| 14 | + let Some(feed_id) = state.detail_feed_id.clone() else { | |
| 15 | + return; | |
| 16 | + }; | |
| 17 | + | |
| 18 | + let Some(feed_index) = state.feeds.iter().position(|f| f.id == feed_id) else { | |
| 19 | + // Le flux a été supprimé : fermer la vue détaillée | |
| 20 | + state.close_feed_detail(); | |
| 21 | + return; | |
| 22 | + }; | |
| 23 | + | |
| 24 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 25 | + let feed_name = state.feeds[feed_index].name.clone(); | |
| 26 | + let feed_url = state.feeds[feed_index].url.clone(); | |
| 27 | + let feed_enabled = state.feeds[feed_index].enabled; | |
| 28 | + | |
| 29 | + // En-tête : retour + nom + URL | |
| 30 | + let mut go_back = false; | |
| 31 | + ui.horizontal(|ui| { | |
| 32 | + if ui | |
| 33 | + .button("⬅ Retour") | |
| 34 | + .on_hover_text("Retour à la liste des flux (Échap)") | |
| 35 | + .clicked() | |
| 36 | + { | |
| 37 | + go_back = true; | |
| 38 | + } | |
| 39 | + ui.label( | |
| 40 | + RichText::new(&feed_name) | |
| 41 | + .size(18.0) | |
| 42 | + .strong() | |
| 43 | + .color(colors.text_primary), | |
| 44 | + ); | |
| 45 | + if !feed_enabled { | |
| 46 | + ui.label(RichText::new("(désactivé)").size(12.0).color(colors.warning)); | |
| 47 | + } | |
| 48 | + }); | |
| 49 | + if go_back { | |
| 50 | + state.close_feed_detail(); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + ui.label( | |
| 54 | + RichText::new(format!("🔗 {}", feed_url)) | |
| 55 | + .size(12.0) | |
| 56 | + .color(colors.text_secondary), | |
| 57 | + ); | |
| 58 | + | |
| 59 | + ui.separator(); | |
| 60 | + | |
| 61 | + // Barre d'outils : actualiser + filtre + compteurs | |
| 62 | + let downloaded_count = state | |
| 63 | + .detail_episodes | |
| 64 | + .iter() | |
| 65 | + .filter(|e| e.downloaded) | |
| 66 | + .count(); | |
| 67 | + let total_count = state.detail_episodes.len(); | |
| 68 | + let mut refresh = false; | |
| 69 | + ui.horizontal(|ui| { | |
| 70 | + if ui.button("🔄 Actualiser").clicked() { | |
| 71 | + refresh = true; | |
| 72 | + } | |
| 73 | + ui.add( | |
| 74 | + egui::TextEdit::singleline(&mut state.detail_episode_filter) | |
| 75 | + .hint_text("Filtrer les épisodes…") | |
| 76 | + .desired_width(200.0), | |
| 77 | + ); | |
| 78 | + if !state.detail_episode_filter.is_empty() && ui.button("✕").clicked() { | |
| 79 | + state.detail_episode_filter.clear(); | |
| 80 | + } | |
| 81 | + ui.label( | |
| 82 | + RichText::new(format!( | |
| 83 | + "{} épisodes, {} téléchargés", | |
| 84 | + total_count, downloaded_count | |
| 85 | + )) | |
| 86 | + .color(colors.text_secondary), | |
| 87 | + ); | |
| 88 | + }); | |
| 89 | + if refresh { | |
| 90 | + state.open_feed_detail(feed_id); | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + | |
| 94 | + ui.add_space(4.0); | |
| 95 | + | |
| 96 | + // Chargement en cours | |
| 97 | + if state.detail_loading { | |
| 98 | + ui.horizontal(|ui| { | |
| 99 | + ui.spinner(); | |
| 100 | + ui.label("Chargement des épisodes…"); | |
| 101 | + }); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + | |
| 105 | + // Erreur de chargement | |
| 106 | + if let Some(error) = state.detail_error.clone() { | |
| 107 | + ui.colored_label(colors.error, format!("Erreur lors du chargement des épisodes : {}", error)); | |
| 108 | + if ui.button("Réessayer").clicked() { | |
| 109 | + state.open_feed_detail(feed_id); | |
| 110 | + } | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + | |
| 114 | + if state.detail_episodes.is_empty() { | |
| 115 | + ui.label(RichText::new("Aucun épisode trouvé dans ce flux.").color(colors.text_dim)); | |
| 116 | + return; | |
| 117 | + } | |
| 118 | + | |
| 119 | + // Indices des épisodes correspondant au filtre, triés du plus récent au plus ancien | |
| 120 | + let filter = state.detail_episode_filter.to_lowercase(); | |
| 121 | + let mut indices: Vec<usize> = state | |
| 122 | + .detail_episodes | |
| 123 | + .iter() | |
| 124 | + .enumerate() | |
| 125 | + .filter(|(_, e)| e.title.to_lowercase().contains(&filter)) | |
| 126 | + .map(|(i, _)| i) | |
| 127 | + .collect(); | |
| 128 | + indices.sort_by_key(|&i| std::cmp::Reverse(state.detail_episodes[i].pub_date)); | |
| 129 | + | |
| 130 | + if indices.is_empty() { | |
| 131 | + ui.label(RichText::new("Aucun épisode correspondant au filtre.").color(colors.text_dim)); | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + | |
| 135 | + for index in indices { | |
| 136 | + draw_episode_item(state, ui, feed_index, index, &colors); | |
| 137 | + } | |
| 138 | +} | |
| 139 | + | |
| 140 | +/// Draws a single episode row | |
| 141 | +fn draw_episode_item( | |
| 142 | + state: &mut AppState, | |
| 143 | + ui: &mut Ui, | |
| 144 | + feed_index: usize, | |
| 145 | + episode_index: usize, | |
| 146 | + colors: &ThemeColors, | |
| 147 | +) { | |
| 148 | + let episode = &state.detail_episodes[episode_index]; | |
| 149 | + let episode_id = episode.id.clone(); | |
| 150 | + let episode_title = episode.title.clone(); | |
| 151 | + let episode_downloaded = episode.downloaded; | |
| 152 | + let is_downloading = state.downloading_episode_ids.contains(&episode_id); | |
| 153 | + | |
| 154 | + egui::Frame { | |
| 155 | + fill: if episode_downloaded { | |
| 156 | + colors.bg_tertiary | |
| 157 | + } else { | |
| 158 | + colors.bg_secondary | |
| 159 | + }, | |
| 160 | + stroke: egui::Stroke::new(1.0, colors.border), | |
| 161 | + corner_radius: 6.0.into(), | |
| 162 | + inner_margin: egui::Margin::symmetric(12, 6), | |
| 163 | + ..Default::default() | |
| 164 | + } | |
| 165 | + .show(ui, |ui| { | |
| 166 | + ui.horizontal(|ui| { | |
| 167 | + // Titre + métadonnées | |
| 168 | + ui.vertical(|ui| { | |
| 169 | + let title_label = ui.label( | |
| 170 | + RichText::new(truncate(&episode_title, 70)) | |
| 171 | + .size(14.0) | |
| 172 | + .strong() | |
| 173 | + .color(colors.text_primary), | |
| 174 | + ); | |
| 175 | + if let Some(description) = &episode.description { | |
| 176 | + title_label.on_hover_text(truncate(description, 300)); | |
| 177 | + } | |
| 178 | + | |
| 179 | + ui.horizontal(|ui| { | |
| 180 | + ui.label( | |
| 181 | + RichText::new(episode.pub_date.format("%d/%m/%Y").to_string()) | |
| 182 | + .size(11.0) | |
| 183 | + .color(colors.text_secondary), | |
| 184 | + ); | |
| 185 | + if let Some(size) = episode.file_size { | |
| 186 | + ui.label( | |
| 187 | + RichText::new(format_bytes(size)) | |
| 188 | + .size(11.0) | |
| 189 | + .color(colors.text_secondary), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + if let Some(duration) = episode.duration_seconds { | |
| 193 | + ui.label( | |
| 194 | + RichText::new(format_duration(duration)) | |
| 195 | + .size(11.0) | |
| 196 | + .color(colors.text_secondary), | |
| 197 | + ); | |
| 198 | + } | |
| 199 | + }); | |
| 200 | + }); | |
| 201 | + | |
| 202 | + // Actions | |
| 203 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | |
| 204 | + if episode_downloaded { | |
| 205 | + ui.label( | |
| 206 | + RichText::new("✅ Téléchargé") | |
| 207 | + .size(12.0) | |
| 208 | + .color(colors.success), | |
| 209 | + ); | |
| 210 | + } else if is_downloading { | |
| 211 | + ui.label(RichText::new("⏳ Téléchargement…").size(12.0).color(colors.accent)); | |
| 212 | + } else if ui | |
| 213 | + .button(RichText::new("⬇ Télécharger").size(12.0)) | |
| 214 | + .on_hover_text(format!("Télécharger \"{}\"", truncate(&episode_title, 40))) | |
| 215 | + .clicked() | |
| 216 | + { | |
| 217 | + let feed = state.feeds[feed_index].clone(); | |
| 218 | + let episode = state.detail_episodes[episode_index].clone(); | |
| 219 | + state.downloading_episode_ids.insert(episode.id.clone()); | |
| 220 | + state | |
| 221 | + .notification_manager | |
| 222 | + .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40))); | |
| 223 | + state.sync_task_manager.start_download_episode(feed, episode); | |
| 224 | + } | |
| 225 | + }); | |
| 226 | + }); | |
| 227 | + }); | |
| 228 | + | |
| 229 | + ui.add_space(4.0); | |
| 230 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,230 @@ | |||
| 1 | +//! Feed detail view UI component | ||
| 2 | +//! | ||
| 3 | +//! Affiche les épisodes d'un flux avec téléchargement individuel, | ||
| 4 | +//! actualisation et filtrage par titre. | ||
| 5 | + | ||
| 6 | +use crate::config::AppState; | ||
| 7 | +use crate::config::statistics::{format_bytes, format_duration}; | ||
| 8 | +use crate::ui::theme::ThemeColors; | ||
| 9 | +use crate::ui::truncate; | ||
| 10 | +use egui::{Align, Layout, RichText, Ui}; | ||
| 11 | + | ||
| 12 | +/// Draws the detail view of a feed (episode list) | ||
| 13 | +pub fn draw_feed_detail(state: &mut AppState, ui: &mut Ui) { | ||
| 14 | + let Some(feed_id) = state.detail_feed_id.clone() else { | ||
| 15 | + return; | ||
| 16 | + }; | ||
| 17 | + | ||
| 18 | + let Some(feed_index) = state.feeds.iter().position(|f| f.id == feed_id) else { | ||
| 19 | + // Le flux a été supprimé : fermer la vue détaillée | ||
| 20 | + state.close_feed_detail(); | ||
| 21 | + return; | ||
| 22 | + }; | ||
| 23 | + | ||
| 24 | + let colors = ThemeColors::for_theme(state.get_theme()); | ||
| 25 | + let feed_name = state.feeds[feed_index].name.clone(); | ||
| 26 | + let feed_url = state.feeds[feed_index].url.clone(); | ||
| 27 | + let feed_enabled = state.feeds[feed_index].enabled; | ||
| 28 | + | ||
| 29 | + // En-tête : retour + nom + URL | ||
| 30 | + let mut go_back = false; | ||
| 31 | + ui.horizontal(|ui| { | ||
| 32 | + if ui | ||
| 33 | + .button("⬅ Retour") | ||
| 34 | + .on_hover_text("Retour à la liste des flux (Échap)") | ||
| 35 | + .clicked() | ||
| 36 | + { | ||
| 37 | + go_back = true; | ||
| 38 | + } | ||
| 39 | + ui.label( | ||
| 40 | + RichText::new(&feed_name) | ||
| 41 | + .size(18.0) | ||
| 42 | + .strong() | ||
| 43 | + .color(colors.text_primary), | ||
| 44 | + ); | ||
| 45 | + if !feed_enabled { | ||
| 46 | + ui.label(RichText::new("(désactivé)").size(12.0).color(colors.warning)); | ||
| 47 | + } | ||
| 48 | + }); | ||
| 49 | + if go_back { | ||
| 50 | + state.close_feed_detail(); | ||
| 51 | + return; | ||
| 52 | + } | ||
| 53 | + ui.label( | ||
| 54 | + RichText::new(format!("🔗 {}", feed_url)) | ||
| 55 | + .size(12.0) | ||
| 56 | + .color(colors.text_secondary), | ||
| 57 | + ); | ||
| 58 | + | ||
| 59 | + ui.separator(); | ||
| 60 | + | ||
| 61 | + // Barre d'outils : actualiser + filtre + compteurs | ||
| 62 | + let downloaded_count = state | ||
| 63 | + .detail_episodes | ||
| 64 | + .iter() | ||
| 65 | + .filter(|e| e.downloaded) | ||
| 66 | + .count(); | ||
| 67 | + let total_count = state.detail_episodes.len(); | ||
| 68 | + let mut refresh = false; | ||
| 69 | + ui.horizontal(|ui| { | ||
| 70 | + if ui.button("🔄 Actualiser").clicked() { | ||
| 71 | + refresh = true; | ||
| 72 | + } | ||
| 73 | + ui.add( | ||
| 74 | + egui::TextEdit::singleline(&mut state.detail_episode_filter) | ||
| 75 | + .hint_text("Filtrer les épisodes…") | ||
| 76 | + .desired_width(200.0), | ||
| 77 | + ); | ||
| 78 | + if !state.detail_episode_filter.is_empty() && ui.button("✕").clicked() { | ||
| 79 | + state.detail_episode_filter.clear(); | ||
| 80 | + } | ||
| 81 | + ui.label( | ||
| 82 | + RichText::new(format!( | ||
| 83 | + "{} épisodes, {} téléchargés", | ||
| 84 | + total_count, downloaded_count | ||
| 85 | + )) | ||
| 86 | + .color(colors.text_secondary), | ||
| 87 | + ); | ||
| 88 | + }); | ||
| 89 | + if refresh { | ||
| 90 | + state.open_feed_detail(feed_id); | ||
| 91 | + return; | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + ui.add_space(4.0); | ||
| 95 | + | ||
| 96 | + // Chargement en cours | ||
| 97 | + if state.detail_loading { | ||
| 98 | + ui.horizontal(|ui| { | ||
| 99 | + ui.spinner(); | ||
| 100 | + ui.label("Chargement des épisodes…"); | ||
| 101 | + }); | ||
| 102 | + return; | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + // Erreur de chargement | ||
| 106 | + if let Some(error) = state.detail_error.clone() { | ||
| 107 | + ui.colored_label(colors.error, format!("Erreur lors du chargement des épisodes : {}", error)); | ||
| 108 | + if ui.button("Réessayer").clicked() { | ||
| 109 | + state.open_feed_detail(feed_id); | ||
| 110 | + } | ||
| 111 | + return; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + if state.detail_episodes.is_empty() { | ||
| 115 | + ui.label(RichText::new("Aucun épisode trouvé dans ce flux.").color(colors.text_dim)); | ||
| 116 | + return; | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + // Indices des épisodes correspondant au filtre, triés du plus récent au plus ancien | ||
| 120 | + let filter = state.detail_episode_filter.to_lowercase(); | ||
| 121 | + let mut indices: Vec<usize> = state | ||
| 122 | + .detail_episodes | ||
| 123 | + .iter() | ||
| 124 | + .enumerate() | ||
| 125 | + .filter(|(_, e)| e.title.to_lowercase().contains(&filter)) | ||
| 126 | + .map(|(i, _)| i) | ||
| 127 | + .collect(); | ||
| 128 | + indices.sort_by_key(|&i| std::cmp::Reverse(state.detail_episodes[i].pub_date)); | ||
| 129 | + | ||
| 130 | + if indices.is_empty() { | ||
| 131 | + ui.label(RichText::new("Aucun épisode correspondant au filtre.").color(colors.text_dim)); | ||
| 132 | + return; | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + for index in indices { | ||
| 136 | + draw_episode_item(state, ui, feed_index, index, &colors); | ||
| 137 | + } | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +/// Draws a single episode row | ||
| 141 | +fn draw_episode_item( | ||
| 142 | + state: &mut AppState, | ||
| 143 | + ui: &mut Ui, | ||
| 144 | + feed_index: usize, | ||
| 145 | + episode_index: usize, | ||
| 146 | + colors: &ThemeColors, | ||
| 147 | +) { | ||
| 148 | + let episode = &state.detail_episodes[episode_index]; | ||
| 149 | + let episode_id = episode.id.clone(); | ||
| 150 | + let episode_title = episode.title.clone(); | ||
| 151 | + let episode_downloaded = episode.downloaded; | ||
| 152 | + let is_downloading = state.downloading_episode_ids.contains(&episode_id); | ||
| 153 | + | ||
| 154 | + egui::Frame { | ||
| 155 | + fill: if episode_downloaded { | ||
| 156 | + colors.bg_tertiary | ||
| 157 | + } else { | ||
| 158 | + colors.bg_secondary | ||
| 159 | + }, | ||
| 160 | + stroke: egui::Stroke::new(1.0, colors.border), | ||
| 161 | + corner_radius: 6.0.into(), | ||
| 162 | + inner_margin: egui::Margin::symmetric(12, 6), | ||
| 163 | + ..Default::default() | ||
| 164 | + } | ||
| 165 | + .show(ui, |ui| { | ||
| 166 | + ui.horizontal(|ui| { | ||
| 167 | + // Titre + métadonnées | ||
| 168 | + ui.vertical(|ui| { | ||
| 169 | + let title_label = ui.label( | ||
| 170 | + RichText::new(truncate(&episode_title, 70)) | ||
| 171 | + .size(14.0) | ||
| 172 | + .strong() | ||
| 173 | + .color(colors.text_primary), | ||
| 174 | + ); | ||
| 175 | + if let Some(description) = &episode.description { | ||
| 176 | + title_label.on_hover_text(truncate(description, 300)); | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + ui.horizontal(|ui| { | ||
| 180 | + ui.label( | ||
| 181 | + RichText::new(episode.pub_date.format("%d/%m/%Y").to_string()) | ||
| 182 | + .size(11.0) | ||
| 183 | + .color(colors.text_secondary), | ||
| 184 | + ); | ||
| 185 | + if let Some(size) = episode.file_size { | ||
| 186 | + ui.label( | ||
| 187 | + RichText::new(format_bytes(size)) | ||
| 188 | + .size(11.0) | ||
| 189 | + .color(colors.text_secondary), | ||
| 190 | + ); | ||
| 191 | + } | ||
| 192 | + if let Some(duration) = episode.duration_seconds { | ||
| 193 | + ui.label( | ||
| 194 | + RichText::new(format_duration(duration)) | ||
| 195 | + .size(11.0) | ||
| 196 | + .color(colors.text_secondary), | ||
| 197 | + ); | ||
| 198 | + } | ||
| 199 | + }); | ||
| 200 | + }); | ||
| 201 | + | ||
| 202 | + // Actions | ||
| 203 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 204 | + if episode_downloaded { | ||
| 205 | + ui.label( | ||
| 206 | + RichText::new("✅ Téléchargé") | ||
| 207 | + .size(12.0) | ||
| 208 | + .color(colors.success), | ||
| 209 | + ); | ||
| 210 | + } else if is_downloading { | ||
| 211 | + ui.label(RichText::new("⏳ Téléchargement…").size(12.0).color(colors.accent)); | ||
| 212 | + } else if ui | ||
| 213 | + .button(RichText::new("⬇ Télécharger").size(12.0)) | ||
| 214 | + .on_hover_text(format!("Télécharger \"{}\"", truncate(&episode_title, 40))) | ||
| 215 | + .clicked() | ||
| 216 | + { | ||
| 217 | + let feed = state.feeds[feed_index].clone(); | ||
| 218 | + let episode = state.detail_episodes[episode_index].clone(); | ||
| 219 | + state.downloading_episode_ids.insert(episode.id.clone()); | ||
| 220 | + state | ||
| 221 | + .notification_manager | ||
| 222 | + .info(format!("Téléchargement démarré : {}", truncate(&episode_title, 40))); | ||
| 223 | + state.sync_task_manager.start_download_episode(feed, episode); | ||
| 224 | + } | ||
| 225 | + }); | ||
| 226 | + }); | ||
| 227 | + }); | ||
| 228 | + | ||
| 229 | + ui.add_space(4.0); | ||
| 230 | +} | ||
modified
src/ui/feed_edit_dialog.rs +100 -53 | @@ -6,7 +6,7 @@ use crate::config::AppState; | ||
| 6 | 6 | use egui::Ui; |
| 7 | 7 | use std::path::PathBuf; |
| 8 | 8 | |
| 9 | -/// Draws the edit feed dialog | |
| 9 | +/// Draws the edit feed dialog (modal) | |
| 10 | 10 | pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | 11 | let editing_index = match state.editing_feed_index { |
| 12 | 12 | Some(idx) => idx, |
| @@ -15,6 +15,7 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 15 | 15 | |
| 16 | 16 | if editing_index >= state.feeds.len() { |
| 17 | 17 | state.clear_edit_feed_form(); |
| 18 | + state.show_edit_feed_dialog = false; | |
| 18 | 19 | return; |
| 19 | 20 | } |
| 20 | 21 | |
| @@ -23,57 +24,103 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 23 | 24 | state.load_edit_feed_form(editing_index); |
| 24 | 25 | } |
| 25 | 26 | |
| 26 | - egui::Window::new("Éditer le flux") | |
| 27 | - .resizable(false) | |
| 28 | - .show(ui.ctx(), |ui| { | |
| 29 | - ui.vertical(|ui| { | |
| 30 | - ui.label(format!("Éditer: {}", state.edit_feed_name)); | |
| 31 | - ui.separator(); | |
| 32 | - | |
| 33 | - ui.label("Nom du podcast"); | |
| 34 | - ui.text_edit_singleline(&mut state.edit_feed_name); | |
| 35 | - | |
| 36 | - ui.label("URL du flux RSS"); | |
| 37 | - ui.text_edit_singleline(&mut state.edit_feed_url); | |
| 38 | - | |
| 39 | - ui.label("Dossier de téléchargement"); | |
| 40 | - ui.text_edit_singleline(&mut state.edit_feed_download_path); | |
| 41 | - | |
| 42 | - ui.checkbox(&mut state.edit_feed_enabled, "Activé"); | |
| 43 | - | |
| 44 | - ui.label("Tags (séparés par des virgules)"); | |
| 45 | - ui.text_edit_singleline(&mut state.edit_feed_tags); | |
| 46 | - ui.label("Exemple: Tech,Podcast,Français"); | |
| 47 | - | |
| 48 | - ui.separator(); | |
| 49 | - | |
| 50 | - ui.horizontal(|ui| { | |
| 51 | - if ui.button("Sauvegarder").clicked() { | |
| 52 | - log::info!("Updating feed at index {}", editing_index); | |
| 53 | - state.feeds[editing_index].name = state.edit_feed_name.clone(); | |
| 54 | - state.feeds[editing_index].url = state.edit_feed_url.clone(); | |
| 55 | - state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone()); | |
| 56 | - state.feeds[editing_index].enabled = state.edit_feed_enabled; | |
| 57 | - | |
| 58 | - // Parse tags from comma-separated string | |
| 59 | - state.feeds[editing_index].tags = state.edit_feed_tags | |
| 60 | - .split(',') | |
| 61 | - .map(|s| s.trim().to_string()) | |
| 62 | - .filter(|s| !s.is_empty()) | |
| 63 | - .collect(); | |
| 64 | - | |
| 65 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 66 | - log::info!("Feed updated successfully"); | |
| 67 | - state.notification_manager.success("Flux modifié"); | |
| 68 | - state.clear_edit_feed_form(); | |
| 69 | - state.show_edit_feed_dialog = false; | |
| 70 | - } | |
| 71 | - | |
| 72 | - if ui.button("Annuler").clicked() { | |
| 73 | - state.clear_edit_feed_form(); | |
| 74 | - state.show_edit_feed_dialog = false; | |
| 75 | - } | |
| 76 | - }); | |
| 77 | - }); | |
| 27 | + let ctx = ui.ctx().clone(); | |
| 28 | + let modal = egui::Modal::new(egui::Id::new("edit_feed_dialog")); | |
| 29 | + let response = modal.show(&ctx, |ui| { | |
| 30 | + ui.set_min_width(420.0); | |
| 31 | + ui.heading(format!("Éditer : {}", state.feeds[editing_index].name)); | |
| 32 | + ui.separator(); | |
| 33 | + | |
| 34 | + ui.label("Nom du podcast"); | |
| 35 | + ui.text_edit_singleline(&mut state.edit_feed_name); | |
| 36 | + | |
| 37 | + ui.label("URL du flux RSS"); | |
| 38 | + let url_response = ui.text_edit_singleline(&mut state.edit_feed_url); | |
| 39 | + | |
| 40 | + ui.label("Dossier de téléchargement"); | |
| 41 | + let download_width = (ui.available_width() - 110.0).max(180.0); | |
| 42 | + ui.horizontal(|ui| { | |
| 43 | + ui.add( | |
| 44 | + egui::TextEdit::singleline(&mut state.edit_feed_download_path) | |
| 45 | + .desired_width(download_width), | |
| 46 | + ); | |
| 47 | + if ui.button("Parcourir…").clicked() { | |
| 48 | + if let Some(path) = rfd::FileDialog::new() | |
| 49 | + .set_title("Choisir le dossier de téléchargement") | |
| 50 | + .pick_folder() | |
| 51 | + { | |
| 52 | + state.edit_feed_download_path = path.to_string_lossy().to_string(); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + }); | |
| 56 | + | |
| 57 | + ui.checkbox(&mut state.edit_feed_enabled, "Activé"); | |
| 58 | + | |
| 59 | + ui.label("Tags (séparés par des virgules)"); | |
| 60 | + ui.text_edit_singleline(&mut state.edit_feed_tags); | |
| 61 | + ui.label( | |
| 62 | + egui::RichText::new("Exemple : Tech, Podcast, Français").weak().small(), | |
| 63 | + ); | |
| 64 | + | |
| 65 | + ui.separator(); | |
| 66 | + | |
| 67 | + let valid = | |
| 68 | + !state.edit_feed_name.trim().is_empty() && !state.edit_feed_url.trim().is_empty(); | |
| 69 | + | |
| 70 | + let mut submitted = false; | |
| 71 | + ui.horizontal(|ui| { | |
| 72 | + if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() { | |
| 73 | + submitted = true; | |
| 74 | + } | |
| 75 | + if ui.button("Annuler").clicked() { | |
| 76 | + state.clear_edit_feed_form(); | |
| 77 | + state.show_edit_feed_dialog = false; | |
| 78 | + } | |
| 78 | 79 | }); |
| 80 | + if !valid { | |
| 81 | + ui.label( | |
| 82 | + egui::RichText::new("Le nom et l'URL du flux sont requis.") | |
| 83 | + .weak() | |
| 84 | + .small(), | |
| 85 | + ); | |
| 86 | + } | |
| 87 | + | |
| 88 | + // Entrée pour valider depuis le champ URL | |
| 89 | + if url_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && valid { | |
| 90 | + submitted = true; | |
| 91 | + } | |
| 92 | + | |
| 93 | + submitted | |
| 94 | + }); | |
| 95 | + | |
| 96 | + if response.inner { | |
| 97 | + log::info!("Updating feed at index {}", editing_index); | |
| 98 | + let feed = &mut state.feeds[editing_index]; | |
| 99 | + feed.name = state.edit_feed_name.trim().to_string(); | |
| 100 | + feed.url = state.edit_feed_url.trim().to_string(); | |
| 101 | + feed.download_path = PathBuf::from(state.edit_feed_download_path.trim()); | |
| 102 | + feed.enabled = state.edit_feed_enabled; | |
| 103 | + | |
| 104 | + // Parse tags from comma-separated string | |
| 105 | + feed.tags = state | |
| 106 | + .edit_feed_tags | |
| 107 | + .split(',') | |
| 108 | + .map(|s| s.trim().to_string()) | |
| 109 | + .filter(|s| !s.is_empty()) | |
| 110 | + .collect(); | |
| 111 | + | |
| 112 | + let feed_name = feed.name.clone(); | |
| 113 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 114 | + log::info!("Feed updated successfully"); | |
| 115 | + state.record_history( | |
| 116 | + crate::config::history::OperationType::UpdateFeed, | |
| 117 | + format!("Flux modifié : {}", feed_name), | |
| 118 | + ); | |
| 119 | + state.notification_manager.success("Flux modifié"); | |
| 120 | + state.clear_edit_feed_form(); | |
| 121 | + state.show_edit_feed_dialog = false; | |
| 122 | + } else if response.should_close() { | |
| 123 | + state.clear_edit_feed_form(); | |
| 124 | + state.show_edit_feed_dialog = false; | |
| 125 | + } | |
| 79 | 126 | } |
| @@ -6,7 +6,7 @@ use crate::config::AppState; | |||
| 6 | use egui::Ui; | 6 | use egui::Ui; |
| 7 | use std::path::PathBuf; | 7 | use std::path::PathBuf; |
| 8 | 8 | ||
| 9 | -/// Draws the edit feed dialog | 9 | +/// Draws the edit feed dialog (modal) |
| 10 | pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { |
| 11 | let editing_index = match state.editing_feed_index { | 11 | let editing_index = match state.editing_feed_index { |
| 12 | Some(idx) => idx, | 12 | Some(idx) => idx, |
| @@ -15,6 +15,7 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | |||
| 15 | 15 | ||
| 16 | if editing_index >= state.feeds.len() { | 16 | if editing_index >= state.feeds.len() { |
| 17 | state.clear_edit_feed_form(); | 17 | state.clear_edit_feed_form(); |
| 18 | + state.show_edit_feed_dialog = false; | ||
| 18 | return; | 19 | return; |
| 19 | } | 20 | } |
| 20 | 21 | ||
| @@ -23,57 +24,103 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | |||
| 23 | state.load_edit_feed_form(editing_index); | 24 | state.load_edit_feed_form(editing_index); |
| 24 | } | 25 | } |
| 25 | 26 | ||
| 26 | - egui::Window::new("Éditer le flux") | 27 | + let ctx = ui.ctx().clone(); |
| 27 | - .resizable(false) | 28 | + let modal = egui::Modal::new(egui::Id::new("edit_feed_dialog")); |
| 28 | - .show(ui.ctx(), |ui| { | 29 | + let response = modal.show(&ctx, |ui| { |
| 29 | - ui.vertical(|ui| { | 30 | + ui.set_min_width(420.0); |
| 30 | - ui.label(format!("Éditer: {}", state.edit_feed_name)); | 31 | + ui.heading(format!("Éditer : {}", state.feeds[editing_index].name)); |
| 31 | - ui.separator(); | 32 | + ui.separator(); |
| 32 | - | 33 | + |
| 33 | - ui.label("Nom du podcast"); | 34 | + ui.label("Nom du podcast"); |
| 34 | - ui.text_edit_singleline(&mut state.edit_feed_name); | 35 | + ui.text_edit_singleline(&mut state.edit_feed_name); |
| 35 | - | 36 | + |
| 36 | - ui.label("URL du flux RSS"); | 37 | + ui.label("URL du flux RSS"); |
| 37 | - ui.text_edit_singleline(&mut state.edit_feed_url); | 38 | + let url_response = ui.text_edit_singleline(&mut state.edit_feed_url); |
| 38 | - | 39 | + |
| 39 | - ui.label("Dossier de téléchargement"); | 40 | + ui.label("Dossier de téléchargement"); |
| 40 | - ui.text_edit_singleline(&mut state.edit_feed_download_path); | 41 | + let download_width = (ui.available_width() - 110.0).max(180.0); |
| 41 | - | 42 | + ui.horizontal(|ui| { |
| 42 | - ui.checkbox(&mut state.edit_feed_enabled, "Activé"); | 43 | + ui.add( |
| 43 | - | 44 | + egui::TextEdit::singleline(&mut state.edit_feed_download_path) |
| 44 | - ui.label("Tags (séparés par des virgules)"); | 45 | + .desired_width(download_width), |
| 45 | - ui.text_edit_singleline(&mut state.edit_feed_tags); | 46 | + ); |
| 46 | - ui.label("Exemple: Tech,Podcast,Français"); | 47 | + if ui.button("Parcourir…").clicked() { |
| 47 | - | 48 | + if let Some(path) = rfd::FileDialog::new() |
| 48 | - ui.separator(); | 49 | + .set_title("Choisir le dossier de téléchargement") |
| 49 | - | 50 | + .pick_folder() |
| 50 | - ui.horizontal(|ui| { | 51 | + { |
| 51 | - if ui.button("Sauvegarder").clicked() { | 52 | + state.edit_feed_download_path = path.to_string_lossy().to_string(); |
| 52 | - log::info!("Updating feed at index {}", editing_index); | 53 | + } |
| 53 | - state.feeds[editing_index].name = state.edit_feed_name.clone(); | 54 | + } |
| 54 | - state.feeds[editing_index].url = state.edit_feed_url.clone(); | 55 | + }); |
| 55 | - state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone()); | 56 | + |
| 56 | - state.feeds[editing_index].enabled = state.edit_feed_enabled; | 57 | + ui.checkbox(&mut state.edit_feed_enabled, "Activé"); |
| 57 | - | 58 | + |
| 58 | - // Parse tags from comma-separated string | 59 | + ui.label("Tags (séparés par des virgules)"); |
| 59 | - state.feeds[editing_index].tags = state.edit_feed_tags | 60 | + ui.text_edit_singleline(&mut state.edit_feed_tags); |
| 60 | - .split(',') | 61 | + ui.label( |
| 61 | - .map(|s| s.trim().to_string()) | 62 | + egui::RichText::new("Exemple : Tech, Podcast, Français").weak().small(), |
| 62 | - .filter(|s| !s.is_empty()) | 63 | + ); |
| 63 | - .collect(); | 64 | + |
| 64 | - | 65 | + ui.separator(); |
| 65 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | 66 | + |
| 66 | - log::info!("Feed updated successfully"); | 67 | + let valid = |
| 67 | - state.notification_manager.success("Flux modifié"); | 68 | + !state.edit_feed_name.trim().is_empty() && !state.edit_feed_url.trim().is_empty(); |
| 68 | - state.clear_edit_feed_form(); | 69 | + |
| 69 | - state.show_edit_feed_dialog = false; | 70 | + let mut submitted = false; |
| 70 | - } | 71 | + ui.horizontal(|ui| { |
| 71 | - | 72 | + if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() { |
| 72 | - if ui.button("Annuler").clicked() { | 73 | + submitted = true; |
| 73 | - state.clear_edit_feed_form(); | 74 | + } |
| 74 | - state.show_edit_feed_dialog = false; | 75 | + if ui.button("Annuler").clicked() { |
| 75 | - } | 76 | + state.clear_edit_feed_form(); |
| 76 | - }); | 77 | + state.show_edit_feed_dialog = false; |
| 77 | - }); | 78 | + } |
| 78 | }); | 79 | }); |
| 80 | + if !valid { | ||
| 81 | + ui.label( | ||
| 82 | + egui::RichText::new("Le nom et l'URL du flux sont requis.") | ||
| 83 | + .weak() | ||
| 84 | + .small(), | ||
| 85 | + ); | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + // Entrée pour valider depuis le champ URL | ||
| 89 | + if url_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && valid { | ||
| 90 | + submitted = true; | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + submitted | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + if response.inner { | ||
| 97 | + log::info!("Updating feed at index {}", editing_index); | ||
| 98 | + let feed = &mut state.feeds[editing_index]; | ||
| 99 | + feed.name = state.edit_feed_name.trim().to_string(); | ||
| 100 | + feed.url = state.edit_feed_url.trim().to_string(); | ||
| 101 | + feed.download_path = PathBuf::from(state.edit_feed_download_path.trim()); | ||
| 102 | + feed.enabled = state.edit_feed_enabled; | ||
| 103 | + | ||
| 104 | + // Parse tags from comma-separated string | ||
| 105 | + feed.tags = state | ||
| 106 | + .edit_feed_tags | ||
| 107 | + .split(',') | ||
| 108 | + .map(|s| s.trim().to_string()) | ||
| 109 | + .filter(|s| !s.is_empty()) | ||
| 110 | + .collect(); | ||
| 111 | + | ||
| 112 | + let feed_name = feed.name.clone(); | ||
| 113 | + super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 114 | + log::info!("Feed updated successfully"); | ||
| 115 | + state.record_history( | ||
| 116 | + crate::config::history::OperationType::UpdateFeed, | ||
| 117 | + format!("Flux modifié : {}", feed_name), | ||
| 118 | + ); | ||
| 119 | + state.notification_manager.success("Flux modifié"); | ||
| 120 | + state.clear_edit_feed_form(); | ||
| 121 | + state.show_edit_feed_dialog = false; | ||
| 122 | + } else if response.should_close() { | ||
| 123 | + state.clear_edit_feed_form(); | ||
| 124 | + state.show_edit_feed_dialog = false; | ||
| 125 | + } | ||
| 79 | } | 126 | } |
modified
src/ui/feed_list.rs +165 -154 | @@ -2,29 +2,45 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module contains the feed list display and interaction logic. |
| 4 | 4 | |
| 5 | -use crate::config::{AppState, Theme}; | |
| 6 | -use egui::{Align, Color32, Layout, RichText, ScrollArea, Ui}; | |
| 5 | +use crate::config::AppState; | |
| 6 | +use crate::ui::theme::ThemeColors; | |
| 7 | +use egui::{Align, Layout, RichText, Ui}; | |
| 7 | 8 | |
| 8 | 9 | /// Draws the feed list |
| 9 | 10 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { |
| 10 | 11 | // Apply filters |
| 11 | 12 | let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); |
| 12 | - | |
| 13 | + | |
| 14 | + let searching = !state.search_query.is_empty(); | |
| 15 | + if searching { | |
| 16 | + ui.label( | |
| 17 | + RichText::new(format!( | |
| 18 | + "{} flux trouvé(s) pour \"{}\"", | |
| 19 | + filtered_indices.len(), | |
| 20 | + state.search_query.query.trim() | |
| 21 | + )) | |
| 22 | + .weak(), | |
| 23 | + ); | |
| 24 | + } | |
| 25 | + | |
| 13 | 26 | if filtered_indices.is_empty() { |
| 14 | - ui.label("Aucun flux correspondant aux filtres."); | |
| 27 | + if searching { | |
| 28 | + ui.label("Aucun flux correspondant à la recherche."); | |
| 29 | + } else { | |
| 30 | + ui.label("Aucun flux correspondant aux filtres."); | |
| 31 | + } | |
| 15 | 32 | return; |
| 16 | 33 | } |
| 17 | 34 | |
| 18 | - ScrollArea::vertical().show(ui, |ui| { | |
| 19 | - for &index in &filtered_indices { | |
| 20 | - draw_feed_item(state, ui, index); | |
| 21 | - } | |
| 22 | - }); | |
| 35 | + for &index in &filtered_indices { | |
| 36 | + draw_feed_item(state, ui, index); | |
| 37 | + } | |
| 23 | 38 | } |
| 24 | 39 | |
| 25 | -/// Draws a single feed item with improved UI | |
| 40 | +/// Draws a single feed item | |
| 26 | 41 | fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { |
| 27 | 42 | let feed = &state.feeds[index]; |
| 43 | + let feed_id = feed.id.clone(); | |
| 28 | 44 | let feed_name = feed.name.clone(); |
| 29 | 45 | let feed_url = feed.url.clone(); |
| 30 | 46 | let feed_path = feed.download_path.to_string_lossy().to_string(); |
| @@ -34,160 +50,195 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 34 | 50 | let feed_downloaded_count = feed.downloaded_episodes_count; |
| 35 | 51 | let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone(); |
| 36 | 52 | let feed_last_downloaded_date = feed.last_downloaded_episode_date; |
| 37 | - let is_selected = state.current_feed == Some(index); | |
| 38 | - let theme = state.get_theme(); | |
| 39 | - | |
| 40 | - // Theme-aware colors | |
| 41 | - let (text_primary, text_secondary, text_dim, | |
| 42 | - success_color, warning_color, info_color, | |
| 43 | - bg_selected, bg_normal, stroke_selected, stroke_normal) = | |
| 44 | - get_theme_colors(&theme, is_selected); | |
| 45 | - | |
| 46 | - let bg_color = if is_selected { bg_selected } else { bg_normal }; | |
| 47 | - let stroke_color = if is_selected { stroke_selected } else { stroke_normal }; | |
| 53 | + let feed_sync_status = state | |
| 54 | + .sync_task_manager | |
| 55 | + .status | |
| 56 | + .feed_sync_status | |
| 57 | + .get(&feed_id) | |
| 58 | + .cloned(); | |
| 59 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 48 | 60 | |
| 49 | 61 | // Frame avec style amélioré |
| 50 | 62 | egui::Frame { |
| 51 | - fill: bg_color, | |
| 52 | - stroke: egui::Stroke::new(1.0, stroke_color), | |
| 63 | + fill: colors.bg_secondary, | |
| 64 | + stroke: egui::Stroke::new(1.0, colors.border), | |
| 53 | 65 | corner_radius: 6.0.into(), |
| 54 | 66 | inner_margin: egui::Margin::symmetric(12, 8), |
| 55 | 67 | ..Default::default() |
| 56 | 68 | } |
| 57 | 69 | .show(ui, |ui| { |
| 58 | 70 | ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0); |
| 59 | - | |
| 71 | + | |
| 60 | 72 | ui.horizontal(|ui| { |
| 61 | 73 | // Section info principale |
| 62 | 74 | ui.vertical(|ui| { |
| 63 | 75 | // Nom du flux (plus gros et en gras) |
| 64 | - ui.label(RichText::new(&feed_name) | |
| 65 | - .size(16.0) | |
| 66 | - .strong() | |
| 67 | - .color(text_primary)); | |
| 68 | - | |
| 76 | + ui.label( | |
| 77 | + RichText::new(&feed_name) | |
| 78 | + .size(16.0) | |
| 79 | + .strong() | |
| 80 | + .color(colors.text_primary), | |
| 81 | + ); | |
| 82 | + | |
| 69 | 83 | // URL (plus petite) |
| 70 | - ui.label(RichText::new(format!("🔗 {}", feed_url)) | |
| 71 | - .size(12.0) | |
| 72 | - .color(text_secondary)); | |
| 73 | - | |
| 84 | + ui.label( | |
| 85 | + RichText::new(format!("🔗 {}", feed_url)) | |
| 86 | + .size(12.0) | |
| 87 | + .color(colors.text_secondary), | |
| 88 | + ); | |
| 89 | + | |
| 74 | 90 | // Dossier |
| 75 | - ui.label(RichText::new(format!("📁 {}", feed_path)) | |
| 76 | - .size(12.0) | |
| 77 | - .color(text_secondary)); | |
| 78 | - | |
| 91 | + ui.label( | |
| 92 | + RichText::new(format!("📁 {}", feed_path)) | |
| 93 | + .size(12.0) | |
| 94 | + .color(colors.text_secondary), | |
| 95 | + ); | |
| 96 | + | |
| 79 | 97 | // Tags |
| 80 | 98 | if !feed_tags.is_empty() { |
| 81 | 99 | ui.horizontal_wrapped(|ui| { |
| 82 | - ui.label(RichText::new("🏷️ ").size(12.0).color(text_dim)); | |
| 100 | + ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim)); | |
| 83 | 101 | for tag in &feed_tags { |
| 84 | - ui.label(RichText::new(format!("#{} ", tag)) | |
| 85 | - .size(11.0) | |
| 86 | - .color(info_color)); | |
| 102 | + ui.label( | |
| 103 | + RichText::new(format!("#{} ", tag)) | |
| 104 | + .size(11.0) | |
| 105 | + .color(colors.accent), | |
| 106 | + ); | |
| 87 | 107 | } |
| 88 | 108 | }); |
| 89 | 109 | } |
| 90 | 110 | }); |
| 91 | - | |
| 111 | + | |
| 92 | 112 | // Section statut et statistiques |
| 93 | 113 | ui.vertical(|ui| { |
| 94 | 114 | ui.add_space(4.0); |
| 95 | - | |
| 115 | + | |
| 96 | 116 | // Statut Activé/Désactivé |
| 97 | - let status_text = if feed_enabled { | |
| 98 | - RichText::new("✅ Activé").color(success_color) | |
| 117 | + let status_text = if feed_enabled { | |
| 118 | + RichText::new("✅ Activé").color(colors.success) | |
| 99 | 119 | } else { |
| 100 | - RichText::new("❌ Désactivé").color(warning_color) | |
| 120 | + RichText::new("❌ Désactivé").color(colors.warning) | |
| 101 | 121 | }; |
| 102 | 122 | ui.label(status_text.size(12.0)); |
| 103 | - | |
| 104 | - // Dernière vérification | |
| 105 | - if let Some(date) = feed_last_checked { | |
| 106 | - ui.label(RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M"))) | |
| 107 | - .size(11.0) | |
| 108 | - .color(text_secondary)); | |
| 109 | - } else { | |
| 110 | - ui.label(RichText::new("🔍 Jamais vérifié").size(11.0).color(text_dim)); | |
| 123 | + | |
| 124 | + // Statut de synchronisation de ce flux | |
| 125 | + match feed_sync_status { | |
| 126 | + Some(crate::config::sync_task::FeedSyncStatus::InProgress) => { | |
| 127 | + ui.label(RichText::new("⏳ Synchronisation…").size(11.0).color(colors.accent)); | |
| 128 | + } | |
| 129 | + Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => { | |
| 130 | + ui.label( | |
| 131 | + RichText::new("⚠️ Échec de synchro") | |
| 132 | + .size(11.0) | |
| 133 | + .color(colors.error), | |
| 134 | + ) | |
| 135 | + .on_hover_text(&error); | |
| 136 | + } | |
| 137 | + _ => { | |
| 138 | + // Dernière vérification | |
| 139 | + if let Some(date) = feed_last_checked { | |
| 140 | + ui.label( | |
| 141 | + RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M"))) | |
| 142 | + .size(11.0) | |
| 143 | + .color(colors.text_secondary), | |
| 144 | + ); | |
| 145 | + } else { | |
| 146 | + ui.label( | |
| 147 | + RichText::new("🔍 Jamais vérifié") | |
| 148 | + .size(11.0) | |
| 149 | + .color(colors.text_dim), | |
| 150 | + ); | |
| 151 | + } | |
| 152 | + } | |
| 111 | 153 | } |
| 112 | - | |
| 154 | + | |
| 113 | 155 | // Séparateur visuel |
| 114 | 156 | ui.add_space(6.0); |
| 115 | 157 | ui.separator(); |
| 116 | 158 | ui.add_space(4.0); |
| 117 | - | |
| 159 | + | |
| 118 | 160 | // Section Statistiques de téléchargement (mise en évidence) |
| 119 | - ui.label(RichText::new("📊 Téléchargements").size(11.0).strong().color(info_color)) | |
| 120 | - .on_hover_text("Nombre et dernier épisode téléchargé"); | |
| 121 | - | |
| 161 | + ui.label( | |
| 162 | + RichText::new("📊 Téléchargements") | |
| 163 | + .size(11.0) | |
| 164 | + .strong() | |
| 165 | + .color(colors.accent), | |
| 166 | + ) | |
| 167 | + .on_hover_text("Nombre et dernier épisode téléchargé"); | |
| 168 | + | |
| 122 | 169 | // Nombre de téléchargements |
| 123 | 170 | if feed_downloaded_count > 0 { |
| 124 | - ui.label(RichText::new(format!("📥 {} épisodes", feed_downloaded_count)) | |
| 125 | - .size(11.0) | |
| 126 | - .color(success_color)); | |
| 171 | + ui.label( | |
| 172 | + RichText::new(format!("📥 {} épisodes", feed_downloaded_count)) | |
| 173 | + .size(11.0) | |
| 174 | + .color(colors.success), | |
| 175 | + ); | |
| 127 | 176 | } else { |
| 128 | - ui.label(RichText::new("📥 Aucun téléchargement").size(11.0).color(text_dim)); | |
| 177 | + ui.label( | |
| 178 | + RichText::new("📥 Aucun téléchargement") | |
| 179 | + .size(11.0) | |
| 180 | + .color(colors.text_dim), | |
| 181 | + ); | |
| 129 | 182 | } |
| 130 | - | |
| 183 | + | |
| 131 | 184 | // Dernier épisode téléchargé |
| 132 | 185 | if let Some(last_name) = feed_last_downloaded_name { |
| 133 | - if let Some(last_date) = feed_last_downloaded_date { | |
| 134 | - // Tronquer le nom si trop long | |
| 135 | - let display_name = if last_name.len() > 25 { | |
| 136 | - format!("{}...", &last_name[..22]) | |
| 137 | - } else { | |
| 138 | - last_name | |
| 139 | - }; | |
| 140 | - ui.label(RichText::new(format!("🎵 {}", display_name)) | |
| 141 | - .size(11.0) | |
| 142 | - .color(info_color)); | |
| 143 | - ui.label(RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M"))) | |
| 144 | - .size(10.0) | |
| 145 | - .color(text_dim)); | |
| 146 | - } else { | |
| 147 | - let display_name = if last_name.len() > 25 { | |
| 148 | - format!("{}...", &last_name[..22]) | |
| 149 | - } else { | |
| 150 | - last_name | |
| 151 | - }; | |
| 152 | - ui.label(RichText::new(format!("🎵 {}", display_name)) | |
| 186 | + let display_name = crate::ui::truncate(&last_name, 25); | |
| 187 | + ui.label( | |
| 188 | + RichText::new(format!("🎵 {}", display_name)) | |
| 153 | 189 | .size(11.0) |
| 154 | - .color(info_color)); | |
| 190 | + .color(colors.accent), | |
| 191 | + ); | |
| 192 | + if let Some(last_date) = feed_last_downloaded_date { | |
| 193 | + ui.label( | |
| 194 | + RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M"))) | |
| 195 | + .size(10.0) | |
| 196 | + .color(colors.text_dim), | |
| 197 | + ); | |
| 155 | 198 | } |
| 156 | 199 | } |
| 157 | 200 | }); |
| 158 | - | |
| 201 | + | |
| 159 | 202 | // Section actions (à droite) |
| 160 | 203 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 161 | 204 | ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); |
| 162 | - | |
| 205 | + | |
| 206 | + // Bouton Détails | |
| 207 | + if ui | |
| 208 | + .button(RichText::new("📖").size(18.0)) | |
| 209 | + .on_hover_text("Voir les épisodes de ce flux") | |
| 210 | + .clicked() | |
| 211 | + { | |
| 212 | + state.open_feed_detail(feed_id.clone()); | |
| 213 | + } | |
| 214 | + | |
| 163 | 215 | // Bouton Synchroniser |
| 164 | - if ui.button(RichText::new("🔄").size(18.0)) | |
| 216 | + if ui | |
| 217 | + .button(RichText::new("🔄").size(18.0)) | |
| 165 | 218 | .on_hover_text("Synchroniser ce flux") |
| 166 | - .clicked() { | |
| 219 | + .clicked() | |
| 220 | + { | |
| 167 | 221 | sync_feed_async(state, index); |
| 168 | 222 | } |
| 169 | - | |
| 223 | + | |
| 170 | 224 | // Bouton Éditer |
| 171 | - if ui.button(RichText::new("✏️").size(18.0)) | |
| 225 | + if ui | |
| 226 | + .button(RichText::new("✏️").size(18.0)) | |
| 172 | 227 | .on_hover_text("Éditer ce flux") |
| 173 | - .clicked() { | |
| 228 | + .clicked() | |
| 229 | + { | |
| 174 | 230 | state.editing_feed_index = Some(index); |
| 175 | 231 | state.load_edit_feed_form(index); |
| 176 | 232 | state.show_edit_feed_dialog = true; |
| 177 | 233 | } |
| 178 | - | |
| 179 | - // Bouton Supprimer | |
| 180 | - if ui.button(RichText::new("🗑️").size(18.0)) | |
| 234 | + | |
| 235 | + // Bouton Supprimer (avec confirmation) | |
| 236 | + if ui | |
| 237 | + .button(RichText::new("🗑️").size(18.0)) | |
| 181 | 238 | .on_hover_text("Supprimer ce flux") |
| 182 | - .clicked() { | |
| 183 | - log::info!("Deleting feed at index {}: {}", index, feed_name); | |
| 184 | - state.feeds.remove(index); | |
| 185 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | |
| 186 | - log::info!("Feed deleted successfully"); | |
| 187 | - state.notification_manager.success("Flux supprimé"); | |
| 188 | - if state.current_feed == Some(index) { | |
| 189 | - state.select_feed(None); | |
| 190 | - } | |
| 239 | + .clicked() | |
| 240 | + { | |
| 241 | + state.confirm_delete_feed_id = Some(feed_id.clone()); | |
| 191 | 242 | } |
| 192 | 243 | }); |
| 193 | 244 | }); |
| @@ -198,51 +249,6 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 198 | 249 | ui.separator(); |
| 199 | 250 | } |
| 200 | 251 | |
| 201 | -/// Returns theme-appropriate colors for feed list items | |
| 202 | -/// Colors are aligned with the main theme colors from app.rs | |
| 203 | -fn get_theme_colors(theme: &Theme, _is_selected: bool) -> ( | |
| 204 | - Color32, Color32, Color32, // text_primary, text_secondary, text_dim | |
| 205 | - Color32, Color32, Color32, // success_color, warning_color, info_color | |
| 206 | - Color32, Color32, Color32, Color32 // bg_selected, bg_normal, stroke_selected, stroke_normal | |
| 207 | -) { | |
| 208 | - match theme { | |
| 209 | - Theme::Dark => { | |
| 210 | - let text_primary = Color32::from_rgb(220, 230, 245); | |
| 211 | - let text_secondary = Color32::from_rgb(180, 195, 210); | |
| 212 | - let text_dim = Color32::from_rgb(120, 140, 160); | |
| 213 | - let success_color = Color32::from_rgb(80, 220, 100); | |
| 214 | - let warning_color = Color32::from_rgb(245, 166, 35); | |
| 215 | - let info_color = Color32::from_rgb(130, 170, 250); | |
| 216 | - // Match theme colors from app.rs: bg_secondary = (46, 52, 72), bg_tertiary = (56, 62, 80) | |
| 217 | - let bg_normal = Color32::from_rgb(46, 52, 72); | |
| 218 | - let bg_selected = Color32::from_rgb(56, 62, 80); | |
| 219 | - let stroke_normal = Color32::from_rgb(80, 88, 106); | |
| 220 | - let stroke_selected = Color32::from_rgb(130, 170, 250); | |
| 221 | - | |
| 222 | - (text_primary, text_secondary, text_dim, | |
| 223 | - success_color, warning_color, info_color, | |
| 224 | - bg_selected, bg_normal, stroke_selected, stroke_normal) | |
| 225 | - } | |
| 226 | - Theme::Light => { | |
| 227 | - let text_primary = Color32::from_rgb(40, 42, 54); | |
| 228 | - let text_secondary = Color32::from_rgb(80, 90, 110); | |
| 229 | - let text_dim = Color32::from_rgb(120, 130, 150); | |
| 230 | - let success_color = Color32::from_rgb(20, 160, 80); | |
| 231 | - let warning_color = Color32::from_rgb(210, 120, 0); | |
| 232 | - let info_color = Color32::from_rgb(60, 100, 220); | |
| 233 | - // Match theme colors from app.rs: bg_secondary = (220, 230, 245), bg_tertiary = (200, 220, 240) | |
| 234 | - let bg_normal = Color32::from_rgb(220, 230, 245); | |
| 235 | - let bg_selected = Color32::from_rgb(200, 220, 240); | |
| 236 | - let stroke_normal = Color32::from_rgb(180, 195, 210); | |
| 237 | - let stroke_selected = Color32::from_rgb(100, 140, 220); | |
| 238 | - | |
| 239 | - (text_primary, text_secondary, text_dim, | |
| 240 | - success_color, warning_color, info_color, | |
| 241 | - bg_selected, bg_normal, stroke_selected, stroke_normal) | |
| 242 | - } | |
| 243 | - } | |
| 244 | -} | |
| 245 | - | |
| 246 | 252 | /// Synchronizes a single feed asynchronously |
| 247 | 253 | fn sync_feed_async(state: &mut AppState, index: usize) { |
| 248 | 254 | if index >= state.feeds.len() { |
| @@ -250,13 +256,18 @@ fn sync_feed_async(state: &mut AppState, index: usize) { | ||
| 250 | 256 | return; |
| 251 | 257 | } |
| 252 | 258 | |
| 253 | - log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name); | |
| 254 | - | |
| 259 | + log::info!( | |
| 260 | + "Starting asynchronous synchronization of feed: {}", | |
| 261 | + state.feeds[index].name | |
| 262 | + ); | |
| 263 | + | |
| 255 | 264 | // Clone the feed for async task |
| 256 | 265 | let feed_to_sync = state.feeds[index].clone(); |
| 257 | - | |
| 266 | + | |
| 258 | 267 | // Start async sync |
| 259 | 268 | state.sync_task_manager.start_sync_feed(feed_to_sync); |
| 260 | - | |
| 261 | - state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | |
| 269 | + | |
| 270 | + state | |
| 271 | + .notification_manager | |
| 272 | + .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | |
| 262 | 273 | } |
| @@ -2,29 +2,45 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module contains the feed list display and interaction logic. | 3 | //! This module contains the feed list display and interaction logic. |
| 4 | 4 | ||
| 5 | -use crate::config::{AppState, Theme}; | 5 | +use crate::config::AppState; |
| 6 | -use egui::{Align, Color32, Layout, RichText, ScrollArea, Ui}; | 6 | +use crate::ui::theme::ThemeColors; |
| 7 | +use egui::{Align, Layout, RichText, Ui}; | ||
| 7 | 8 | ||
| 8 | /// Draws the feed list | 9 | /// Draws the feed list |
| 9 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { |
| 10 | // Apply filters | 11 | // Apply filters |
| 11 | let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); | 12 | let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); |
| 12 | - | 13 | + |
| 14 | + let searching = !state.search_query.is_empty(); | ||
| 15 | + if searching { | ||
| 16 | + ui.label( | ||
| 17 | + RichText::new(format!( | ||
| 18 | + "{} flux trouvé(s) pour \"{}\"", | ||
| 19 | + filtered_indices.len(), | ||
| 20 | + state.search_query.query.trim() | ||
| 21 | + )) | ||
| 22 | + .weak(), | ||
| 23 | + ); | ||
| 24 | + } | ||
| 25 | + | ||
| 13 | if filtered_indices.is_empty() { | 26 | if filtered_indices.is_empty() { |
| 14 | - ui.label("Aucun flux correspondant aux filtres."); | 27 | + if searching { |
| 28 | + ui.label("Aucun flux correspondant à la recherche."); | ||
| 29 | + } else { | ||
| 30 | + ui.label("Aucun flux correspondant aux filtres."); | ||
| 31 | + } | ||
| 15 | return; | 32 | return; |
| 16 | } | 33 | } |
| 17 | 34 | ||
| 18 | - ScrollArea::vertical().show(ui, |ui| { | 35 | + for &index in &filtered_indices { |
| 19 | - for &index in &filtered_indices { | 36 | + draw_feed_item(state, ui, index); |
| 20 | - draw_feed_item(state, ui, index); | 37 | + } |
| 21 | - } | ||
| 22 | - }); | ||
| 23 | } | 38 | } |
| 24 | 39 | ||
| 25 | -/// Draws a single feed item with improved UI | 40 | +/// Draws a single feed item |
| 26 | fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | 41 | fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { |
| 27 | let feed = &state.feeds[index]; | 42 | let feed = &state.feeds[index]; |
| 43 | + let feed_id = feed.id.clone(); | ||
| 28 | let feed_name = feed.name.clone(); | 44 | let feed_name = feed.name.clone(); |
| 29 | let feed_url = feed.url.clone(); | 45 | let feed_url = feed.url.clone(); |
| 30 | let feed_path = feed.download_path.to_string_lossy().to_string(); | 46 | let feed_path = feed.download_path.to_string_lossy().to_string(); |
| @@ -34,160 +50,195 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 34 | let feed_downloaded_count = feed.downloaded_episodes_count; | 50 | let feed_downloaded_count = feed.downloaded_episodes_count; |
| 35 | let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone(); | 51 | let feed_last_downloaded_name = feed.last_downloaded_episode_name.clone(); |
| 36 | let feed_last_downloaded_date = feed.last_downloaded_episode_date; | 52 | let feed_last_downloaded_date = feed.last_downloaded_episode_date; |
| 37 | - let is_selected = state.current_feed == Some(index); | 53 | + let feed_sync_status = state |
| 38 | - let theme = state.get_theme(); | 54 | + .sync_task_manager |
| 39 | - | 55 | + .status |
| 40 | - // Theme-aware colors | 56 | + .feed_sync_status |
| 41 | - let (text_primary, text_secondary, text_dim, | 57 | + .get(&feed_id) |
| 42 | - success_color, warning_color, info_color, | 58 | + .cloned(); |
| 43 | - bg_selected, bg_normal, stroke_selected, stroke_normal) = | 59 | + let colors = ThemeColors::for_theme(state.get_theme()); |
| 44 | - get_theme_colors(&theme, is_selected); | ||
| 45 | - | ||
| 46 | - let bg_color = if is_selected { bg_selected } else { bg_normal }; | ||
| 47 | - let stroke_color = if is_selected { stroke_selected } else { stroke_normal }; | ||
| 48 | 60 | ||
| 49 | // Frame avec style amélioré | 61 | // Frame avec style amélioré |
| 50 | egui::Frame { | 62 | egui::Frame { |
| 51 | - fill: bg_color, | 63 | + fill: colors.bg_secondary, |
| 52 | - stroke: egui::Stroke::new(1.0, stroke_color), | 64 | + stroke: egui::Stroke::new(1.0, colors.border), |
| 53 | corner_radius: 6.0.into(), | 65 | corner_radius: 6.0.into(), |
| 54 | inner_margin: egui::Margin::symmetric(12, 8), | 66 | inner_margin: egui::Margin::symmetric(12, 8), |
| 55 | ..Default::default() | 67 | ..Default::default() |
| 56 | } | 68 | } |
| 57 | .show(ui, |ui| { | 69 | .show(ui, |ui| { |
| 58 | ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0); | 70 | ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 8.0); |
| 59 | - | 71 | + |
| 60 | ui.horizontal(|ui| { | 72 | ui.horizontal(|ui| { |
| 61 | // Section info principale | 73 | // Section info principale |
| 62 | ui.vertical(|ui| { | 74 | ui.vertical(|ui| { |
| 63 | // Nom du flux (plus gros et en gras) | 75 | // Nom du flux (plus gros et en gras) |
| 64 | - ui.label(RichText::new(&feed_name) | 76 | + ui.label( |
| 65 | - .size(16.0) | 77 | + RichText::new(&feed_name) |
| 66 | - .strong() | 78 | + .size(16.0) |
| 67 | - .color(text_primary)); | 79 | + .strong() |
| 68 | - | 80 | + .color(colors.text_primary), |
| 81 | + ); | ||
| 82 | + | ||
| 69 | // URL (plus petite) | 83 | // URL (plus petite) |
| 70 | - ui.label(RichText::new(format!("🔗 {}", feed_url)) | 84 | + ui.label( |
| 71 | - .size(12.0) | 85 | + RichText::new(format!("🔗 {}", feed_url)) |
| 72 | - .color(text_secondary)); | 86 | + .size(12.0) |
| 73 | - | 87 | + .color(colors.text_secondary), |
| 88 | + ); | ||
| 89 | + | ||
| 74 | // Dossier | 90 | // Dossier |
| 75 | - ui.label(RichText::new(format!("📁 {}", feed_path)) | 91 | + ui.label( |
| 76 | - .size(12.0) | 92 | + RichText::new(format!("📁 {}", feed_path)) |
| 77 | - .color(text_secondary)); | 93 | + .size(12.0) |
| 78 | - | 94 | + .color(colors.text_secondary), |
| 95 | + ); | ||
| 96 | + | ||
| 79 | // Tags | 97 | // Tags |
| 80 | if !feed_tags.is_empty() { | 98 | if !feed_tags.is_empty() { |
| 81 | ui.horizontal_wrapped(|ui| { | 99 | ui.horizontal_wrapped(|ui| { |
| 82 | - ui.label(RichText::new("🏷️ ").size(12.0).color(text_dim)); | 100 | + ui.label(RichText::new("🏷️ ").size(12.0).color(colors.text_dim)); |
| 83 | for tag in &feed_tags { | 101 | for tag in &feed_tags { |
| 84 | - ui.label(RichText::new(format!("#{} ", tag)) | 102 | + ui.label( |
| 85 | - .size(11.0) | 103 | + RichText::new(format!("#{} ", tag)) |
| 86 | - .color(info_color)); | 104 | + .size(11.0) |
| 105 | + .color(colors.accent), | ||
| 106 | + ); | ||
| 87 | } | 107 | } |
| 88 | }); | 108 | }); |
| 89 | } | 109 | } |
| 90 | }); | 110 | }); |
| 91 | - | 111 | + |
| 92 | // Section statut et statistiques | 112 | // Section statut et statistiques |
| 93 | ui.vertical(|ui| { | 113 | ui.vertical(|ui| { |
| 94 | ui.add_space(4.0); | 114 | ui.add_space(4.0); |
| 95 | - | 115 | + |
| 96 | // Statut Activé/Désactivé | 116 | // Statut Activé/Désactivé |
| 97 | - let status_text = if feed_enabled { | 117 | + let status_text = if feed_enabled { |
| 98 | - RichText::new("✅ Activé").color(success_color) | 118 | + RichText::new("✅ Activé").color(colors.success) |
| 99 | } else { | 119 | } else { |
| 100 | - RichText::new("❌ Désactivé").color(warning_color) | 120 | + RichText::new("❌ Désactivé").color(colors.warning) |
| 101 | }; | 121 | }; |
| 102 | ui.label(status_text.size(12.0)); | 122 | ui.label(status_text.size(12.0)); |
| 103 | - | 123 | + |
| 104 | - // Dernière vérification | 124 | + // Statut de synchronisation de ce flux |
| 105 | - if let Some(date) = feed_last_checked { | 125 | + match feed_sync_status { |
| 106 | - ui.label(RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M"))) | 126 | + Some(crate::config::sync_task::FeedSyncStatus::InProgress) => { |
| 107 | - .size(11.0) | 127 | + ui.label(RichText::new("⏳ Synchronisation…").size(11.0).color(colors.accent)); |
| 108 | - .color(text_secondary)); | 128 | + } |
| 109 | - } else { | 129 | + Some(crate::config::sync_task::FeedSyncStatus::Failed { error }) => { |
| 110 | - ui.label(RichText::new("🔍 Jamais vérifié").size(11.0).color(text_dim)); | 130 | + ui.label( |
| 131 | + RichText::new("⚠️ Échec de synchro") | ||
| 132 | + .size(11.0) | ||
| 133 | + .color(colors.error), | ||
| 134 | + ) | ||
| 135 | + .on_hover_text(&error); | ||
| 136 | + } | ||
| 137 | + _ => { | ||
| 138 | + // Dernière vérification | ||
| 139 | + if let Some(date) = feed_last_checked { | ||
| 140 | + ui.label( | ||
| 141 | + RichText::new(format!("🔍 Vérifié: {}", date.format("%d/%m %H:%M"))) | ||
| 142 | + .size(11.0) | ||
| 143 | + .color(colors.text_secondary), | ||
| 144 | + ); | ||
| 145 | + } else { | ||
| 146 | + ui.label( | ||
| 147 | + RichText::new("🔍 Jamais vérifié") | ||
| 148 | + .size(11.0) | ||
| 149 | + .color(colors.text_dim), | ||
| 150 | + ); | ||
| 151 | + } | ||
| 152 | + } | ||
| 111 | } | 153 | } |
| 112 | - | 154 | + |
| 113 | // Séparateur visuel | 155 | // Séparateur visuel |
| 114 | ui.add_space(6.0); | 156 | ui.add_space(6.0); |
| 115 | ui.separator(); | 157 | ui.separator(); |
| 116 | ui.add_space(4.0); | 158 | ui.add_space(4.0); |
| 117 | - | 159 | + |
| 118 | // Section Statistiques de téléchargement (mise en évidence) | 160 | // Section Statistiques de téléchargement (mise en évidence) |
| 119 | - ui.label(RichText::new("📊 Téléchargements").size(11.0).strong().color(info_color)) | 161 | + ui.label( |
| 120 | - .on_hover_text("Nombre et dernier épisode téléchargé"); | 162 | + RichText::new("📊 Téléchargements") |
| 121 | - | 163 | + .size(11.0) |
| 164 | + .strong() | ||
| 165 | + .color(colors.accent), | ||
| 166 | + ) | ||
| 167 | + .on_hover_text("Nombre et dernier épisode téléchargé"); | ||
| 168 | + | ||
| 122 | // Nombre de téléchargements | 169 | // Nombre de téléchargements |
| 123 | if feed_downloaded_count > 0 { | 170 | if feed_downloaded_count > 0 { |
| 124 | - ui.label(RichText::new(format!("📥 {} épisodes", feed_downloaded_count)) | 171 | + ui.label( |
| 125 | - .size(11.0) | 172 | + RichText::new(format!("📥 {} épisodes", feed_downloaded_count)) |
| 126 | - .color(success_color)); | 173 | + .size(11.0) |
| 174 | + .color(colors.success), | ||
| 175 | + ); | ||
| 127 | } else { | 176 | } else { |
| 128 | - ui.label(RichText::new("📥 Aucun téléchargement").size(11.0).color(text_dim)); | 177 | + ui.label( |
| 178 | + RichText::new("📥 Aucun téléchargement") | ||
| 179 | + .size(11.0) | ||
| 180 | + .color(colors.text_dim), | ||
| 181 | + ); | ||
| 129 | } | 182 | } |
| 130 | - | 183 | + |
| 131 | // Dernier épisode téléchargé | 184 | // Dernier épisode téléchargé |
| 132 | if let Some(last_name) = feed_last_downloaded_name { | 185 | if let Some(last_name) = feed_last_downloaded_name { |
| 133 | - if let Some(last_date) = feed_last_downloaded_date { | 186 | + let display_name = crate::ui::truncate(&last_name, 25); |
| 134 | - // Tronquer le nom si trop long | 187 | + ui.label( |
| 135 | - let display_name = if last_name.len() > 25 { | 188 | + RichText::new(format!("🎵 {}", display_name)) |
| 136 | - format!("{}...", &last_name[..22]) | ||
| 137 | - } else { | ||
| 138 | - last_name | ||
| 139 | - }; | ||
| 140 | - ui.label(RichText::new(format!("🎵 {}", display_name)) | ||
| 141 | - .size(11.0) | ||
| 142 | - .color(info_color)); | ||
| 143 | - ui.label(RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M"))) | ||
| 144 | - .size(10.0) | ||
| 145 | - .color(text_dim)); | ||
| 146 | - } else { | ||
| 147 | - let display_name = if last_name.len() > 25 { | ||
| 148 | - format!("{}...", &last_name[..22]) | ||
| 149 | - } else { | ||
| 150 | - last_name | ||
| 151 | - }; | ||
| 152 | - ui.label(RichText::new(format!("🎵 {}", display_name)) | ||
| 153 | .size(11.0) | 189 | .size(11.0) |
| 154 | - .color(info_color)); | 190 | + .color(colors.accent), |
| 191 | + ); | ||
| 192 | + if let Some(last_date) = feed_last_downloaded_date { | ||
| 193 | + ui.label( | ||
| 194 | + RichText::new(format!(" le {}", last_date.format("%d/%m %H:%M"))) | ||
| 195 | + .size(10.0) | ||
| 196 | + .color(colors.text_dim), | ||
| 197 | + ); | ||
| 155 | } | 198 | } |
| 156 | } | 199 | } |
| 157 | }); | 200 | }); |
| 158 | - | 201 | + |
| 159 | // Section actions (à droite) | 202 | // Section actions (à droite) |
| 160 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 203 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 161 | ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); | 204 | ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); |
| 162 | - | 205 | + |
| 206 | + // Bouton Détails | ||
| 207 | + if ui | ||
| 208 | + .button(RichText::new("📖").size(18.0)) | ||
| 209 | + .on_hover_text("Voir les épisodes de ce flux") | ||
| 210 | + .clicked() | ||
| 211 | + { | ||
| 212 | + state.open_feed_detail(feed_id.clone()); | ||
| 213 | + } | ||
| 214 | + | ||
| 163 | // Bouton Synchroniser | 215 | // Bouton Synchroniser |
| 164 | - if ui.button(RichText::new("🔄").size(18.0)) | 216 | + if ui |
| 217 | + .button(RichText::new("🔄").size(18.0)) | ||
| 165 | .on_hover_text("Synchroniser ce flux") | 218 | .on_hover_text("Synchroniser ce flux") |
| 166 | - .clicked() { | 219 | + .clicked() |
| 220 | + { | ||
| 167 | sync_feed_async(state, index); | 221 | sync_feed_async(state, index); |
| 168 | } | 222 | } |
| 169 | - | 223 | + |
| 170 | // Bouton Éditer | 224 | // Bouton Éditer |
| 171 | - if ui.button(RichText::new("✏️").size(18.0)) | 225 | + if ui |
| 226 | + .button(RichText::new("✏️").size(18.0)) | ||
| 172 | .on_hover_text("Éditer ce flux") | 227 | .on_hover_text("Éditer ce flux") |
| 173 | - .clicked() { | 228 | + .clicked() |
| 229 | + { | ||
| 174 | state.editing_feed_index = Some(index); | 230 | state.editing_feed_index = Some(index); |
| 175 | state.load_edit_feed_form(index); | 231 | state.load_edit_feed_form(index); |
| 176 | state.show_edit_feed_dialog = true; | 232 | state.show_edit_feed_dialog = true; |
| 177 | } | 233 | } |
| 178 | - | 234 | + |
| 179 | - // Bouton Supprimer | 235 | + // Bouton Supprimer (avec confirmation) |
| 180 | - if ui.button(RichText::new("🗑️").size(18.0)) | 236 | + if ui |
| 237 | + .button(RichText::new("🗑️").size(18.0)) | ||
| 181 | .on_hover_text("Supprimer ce flux") | 238 | .on_hover_text("Supprimer ce flux") |
| 182 | - .clicked() { | 239 | + .clicked() |
| 183 | - log::info!("Deleting feed at index {}: {}", index, feed_name); | 240 | + { |
| 184 | - state.feeds.remove(index); | 241 | + state.confirm_delete_feed_id = Some(feed_id.clone()); |
| 185 | - super::super::config::storage::save_feeds_default(&state.feeds).ok(); | ||
| 186 | - log::info!("Feed deleted successfully"); | ||
| 187 | - state.notification_manager.success("Flux supprimé"); | ||
| 188 | - if state.current_feed == Some(index) { | ||
| 189 | - state.select_feed(None); | ||
| 190 | - } | ||
| 191 | } | 242 | } |
| 192 | }); | 243 | }); |
| 193 | }); | 244 | }); |
| @@ -198,51 +249,6 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 198 | ui.separator(); | 249 | ui.separator(); |
| 199 | } | 250 | } |
| 200 | 251 | ||
| 201 | -/// Returns theme-appropriate colors for feed list items | ||
| 202 | -/// Colors are aligned with the main theme colors from app.rs | ||
| 203 | -fn get_theme_colors(theme: &Theme, _is_selected: bool) -> ( | ||
| 204 | - Color32, Color32, Color32, // text_primary, text_secondary, text_dim | ||
| 205 | - Color32, Color32, Color32, // success_color, warning_color, info_color | ||
| 206 | - Color32, Color32, Color32, Color32 // bg_selected, bg_normal, stroke_selected, stroke_normal | ||
| 207 | -) { | ||
| 208 | - match theme { | ||
| 209 | - Theme::Dark => { | ||
| 210 | - let text_primary = Color32::from_rgb(220, 230, 245); | ||
| 211 | - let text_secondary = Color32::from_rgb(180, 195, 210); | ||
| 212 | - let text_dim = Color32::from_rgb(120, 140, 160); | ||
| 213 | - let success_color = Color32::from_rgb(80, 220, 100); | ||
| 214 | - let warning_color = Color32::from_rgb(245, 166, 35); | ||
| 215 | - let info_color = Color32::from_rgb(130, 170, 250); | ||
| 216 | - // Match theme colors from app.rs: bg_secondary = (46, 52, 72), bg_tertiary = (56, 62, 80) | ||
| 217 | - let bg_normal = Color32::from_rgb(46, 52, 72); | ||
| 218 | - let bg_selected = Color32::from_rgb(56, 62, 80); | ||
| 219 | - let stroke_normal = Color32::from_rgb(80, 88, 106); | ||
| 220 | - let stroke_selected = Color32::from_rgb(130, 170, 250); | ||
| 221 | - | ||
| 222 | - (text_primary, text_secondary, text_dim, | ||
| 223 | - success_color, warning_color, info_color, | ||
| 224 | - bg_selected, bg_normal, stroke_selected, stroke_normal) | ||
| 225 | - } | ||
| 226 | - Theme::Light => { | ||
| 227 | - let text_primary = Color32::from_rgb(40, 42, 54); | ||
| 228 | - let text_secondary = Color32::from_rgb(80, 90, 110); | ||
| 229 | - let text_dim = Color32::from_rgb(120, 130, 150); | ||
| 230 | - let success_color = Color32::from_rgb(20, 160, 80); | ||
| 231 | - let warning_color = Color32::from_rgb(210, 120, 0); | ||
| 232 | - let info_color = Color32::from_rgb(60, 100, 220); | ||
| 233 | - // Match theme colors from app.rs: bg_secondary = (220, 230, 245), bg_tertiary = (200, 220, 240) | ||
| 234 | - let bg_normal = Color32::from_rgb(220, 230, 245); | ||
| 235 | - let bg_selected = Color32::from_rgb(200, 220, 240); | ||
| 236 | - let stroke_normal = Color32::from_rgb(180, 195, 210); | ||
| 237 | - let stroke_selected = Color32::from_rgb(100, 140, 220); | ||
| 238 | - | ||
| 239 | - (text_primary, text_secondary, text_dim, | ||
| 240 | - success_color, warning_color, info_color, | ||
| 241 | - bg_selected, bg_normal, stroke_selected, stroke_normal) | ||
| 242 | - } | ||
| 243 | - } | ||
| 244 | -} | ||
| 245 | - | ||
| 246 | /// Synchronizes a single feed asynchronously | 252 | /// Synchronizes a single feed asynchronously |
| 247 | fn sync_feed_async(state: &mut AppState, index: usize) { | 253 | fn sync_feed_async(state: &mut AppState, index: usize) { |
| 248 | if index >= state.feeds.len() { | 254 | if index >= state.feeds.len() { |
| @@ -250,13 +256,18 @@ fn sync_feed_async(state: &mut AppState, index: usize) { | |||
| 250 | return; | 256 | return; |
| 251 | } | 257 | } |
| 252 | 258 | ||
| 253 | - log::info!("Starting asynchronous synchronization of feed: {}", state.feeds[index].name); | 259 | + log::info!( |
| 254 | - | 260 | + "Starting asynchronous synchronization of feed: {}", |
| 261 | + state.feeds[index].name | ||
| 262 | + ); | ||
| 263 | + | ||
| 255 | // Clone the feed for async task | 264 | // Clone the feed for async task |
| 256 | let feed_to_sync = state.feeds[index].clone(); | 265 | let feed_to_sync = state.feeds[index].clone(); |
| 257 | - | 266 | + |
| 258 | // Start async sync | 267 | // Start async sync |
| 259 | state.sync_task_manager.start_sync_feed(feed_to_sync); | 268 | state.sync_task_manager.start_sync_feed(feed_to_sync); |
| 260 | - | 269 | + |
| 261 | - state.notification_manager.info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | 270 | + state |
| 271 | + .notification_manager | ||
| 272 | + .info(format!("Synchronisation du flux en cours: {}", state.feeds[index].name)); | ||
| 262 | } | 273 | } |
modified
src/ui/filter_panel.rs +80 -55 | @@ -10,51 +10,46 @@ use egui::Ui; | ||
| 10 | 10 | pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) { |
| 11 | 11 | ui.horizontal(|ui| { |
| 12 | 12 | ui.label("Filtres:"); |
| 13 | - | |
| 13 | + | |
| 14 | 14 | // Date filter |
| 15 | 15 | ui.label("Date de publication:"); |
| 16 | - | |
| 16 | + | |
| 17 | 17 | // Date from filter |
| 18 | 18 | if let Some(date_from) = state.filter_date_from { |
| 19 | - if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() { | |
| 20 | - state.show_date_picker_from = true; | |
| 21 | - } | |
| 22 | - } else { | |
| 23 | - if ui.button("Depuis...").clicked() { | |
| 19 | + if ui | |
| 20 | + .button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))) | |
| 21 | + .clicked() | |
| 22 | + { | |
| 24 | 23 | state.show_date_picker_from = true; |
| 25 | 24 | } |
| 25 | + } else if ui.button("Depuis...").clicked() { | |
| 26 | + state.show_date_picker_from = true; | |
| 26 | 27 | } |
| 27 | - | |
| 28 | + | |
| 28 | 29 | ui.add_space(5.0); |
| 29 | - | |
| 30 | + | |
| 30 | 31 | // Date to filter |
| 31 | 32 | if let Some(date_to) = state.filter_date_to { |
| 32 | - if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() { | |
| 33 | - state.show_date_picker_to = true; | |
| 34 | - } | |
| 35 | - } else { | |
| 36 | - if ui.button("Jusqu'à...").clicked() { | |
| 33 | + if ui | |
| 34 | + .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))) | |
| 35 | + .clicked() | |
| 36 | + { | |
| 37 | 37 | state.show_date_picker_to = true; |
| 38 | 38 | } |
| 39 | + } else if ui.button("Jusqu'à...").clicked() { | |
| 40 | + state.show_date_picker_to = true; | |
| 39 | 41 | } |
| 40 | - | |
| 41 | - ui.add_space(10.0); | |
| 42 | - | |
| 43 | - // Episode number filter | |
| 44 | - ui.label("Numéro d'épisode:"); | |
| 45 | - ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number) | |
| 46 | - .hint_text("ex: 10")); | |
| 47 | - | |
| 42 | + | |
| 48 | 43 | ui.add_space(10.0); |
| 49 | - | |
| 50 | - // Clear filters button | |
| 51 | - if ui.button("Effacer les filtres").clicked() { | |
| 44 | + | |
| 45 | + // Clear filters button (only when a filter is active) | |
| 46 | + if (state.filter_date_from.is_some() || state.filter_date_to.is_some()) | |
| 47 | + && ui.button("Effacer les filtres").clicked() | |
| 48 | + { | |
| 52 | 49 | state.clear_filters(); |
| 53 | 50 | } |
| 54 | 51 | }); |
| 55 | - | |
| 56 | - ui.separator(); | |
| 57 | - | |
| 52 | + | |
| 58 | 53 | // Draw date pickers |
| 59 | 54 | draw_date_picker_from(state, ui); |
| 60 | 55 | draw_date_picker_to(state, ui); |
| @@ -65,43 +60,55 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) { | ||
| 65 | 60 | if !state.show_date_picker_from { |
| 66 | 61 | return; |
| 67 | 62 | } |
| 68 | - | |
| 63 | + | |
| 69 | 64 | egui::Window::new("Sélectionner la date de début") |
| 70 | 65 | .resizable(false) |
| 71 | 66 | .show(ui.ctx(), |ui| { |
| 72 | 67 | ui.vertical(|ui| { |
| 73 | 68 | ui.label("Date de début (yyyy-mm-dd):"); |
| 74 | - | |
| 75 | - let mut date_str = state.filter_date_from | |
| 69 | + | |
| 70 | + let mut date_str = state | |
| 71 | + .filter_date_from | |
| 76 | 72 | .map(|d| d.format("%Y-%m-%d").to_string()) |
| 77 | 73 | .unwrap_or_default(); |
| 78 | - | |
| 79 | - if ui.text_edit_singleline(&mut date_str).changed() { | |
| 80 | - if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | |
| 81 | - state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc)); | |
| 74 | + | |
| 75 | + let response = ui.text_edit_singleline(&mut date_str); | |
| 76 | + if response.changed() { | |
| 77 | + if let Ok(date) = | |
| 78 | + chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d") | |
| 79 | + { | |
| 80 | + state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset( | |
| 81 | + date.and_hms_opt(0, 0, 0).unwrap(), | |
| 82 | + Utc, | |
| 83 | + )); | |
| 82 | 84 | state.show_date_picker_from = false; |
| 83 | 85 | } |
| 84 | 86 | } |
| 85 | - | |
| 87 | + if response.lost_focus() | |
| 88 | + && ui.input(|i| i.key_pressed(egui::Key::Escape)) | |
| 89 | + { | |
| 90 | + state.show_date_picker_from = false; | |
| 91 | + } | |
| 92 | + | |
| 86 | 93 | ui.separator(); |
| 87 | - | |
| 94 | + | |
| 88 | 95 | ui.horizontal(|ui| { |
| 89 | 96 | // Quick date buttons |
| 90 | 97 | if ui.button("Aujourd'hui").clicked() { |
| 91 | 98 | state.filter_date_from = Some(Utc::now()); |
| 92 | 99 | state.show_date_picker_from = false; |
| 93 | 100 | } |
| 94 | - | |
| 101 | + | |
| 95 | 102 | if ui.button("7 derniers jours").clicked() { |
| 96 | 103 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 97 | 104 | state.show_date_picker_from = false; |
| 98 | 105 | } |
| 99 | - | |
| 106 | + | |
| 100 | 107 | if ui.button("30 derniers jours").clicked() { |
| 101 | 108 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); |
| 102 | 109 | state.show_date_picker_from = false; |
| 103 | 110 | } |
| 104 | - | |
| 111 | + | |
| 105 | 112 | if ui.button("Annuler").clicked() { |
| 106 | 113 | state.show_date_picker_from = false; |
| 107 | 114 | } |
| @@ -115,39 +122,51 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | ||
| 115 | 122 | if !state.show_date_picker_to { |
| 116 | 123 | return; |
| 117 | 124 | } |
| 118 | - | |
| 125 | + | |
| 119 | 126 | egui::Window::new("Sélectionner la date de fin") |
| 120 | 127 | .resizable(false) |
| 121 | 128 | .show(ui.ctx(), |ui| { |
| 122 | 129 | ui.vertical(|ui| { |
| 123 | 130 | ui.label("Date de fin (yyyy-mm-dd):"); |
| 124 | - | |
| 125 | - let mut date_str = state.filter_date_to | |
| 131 | + | |
| 132 | + let mut date_str = state | |
| 133 | + .filter_date_to | |
| 126 | 134 | .map(|d| d.format("%Y-%m-%d").to_string()) |
| 127 | 135 | .unwrap_or_default(); |
| 128 | - | |
| 129 | - if ui.text_edit_singleline(&mut date_str).changed() { | |
| 130 | - if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | |
| 131 | - state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc)); | |
| 136 | + | |
| 137 | + let response = ui.text_edit_singleline(&mut date_str); | |
| 138 | + if response.changed() { | |
| 139 | + if let Ok(date) = | |
| 140 | + chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d") | |
| 141 | + { | |
| 142 | + state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset( | |
| 143 | + date.and_hms_opt(23, 59, 59).unwrap(), | |
| 144 | + Utc, | |
| 145 | + )); | |
| 132 | 146 | state.show_date_picker_to = false; |
| 133 | 147 | } |
| 134 | 148 | } |
| 135 | - | |
| 149 | + if response.lost_focus() | |
| 150 | + && ui.input(|i| i.key_pressed(egui::Key::Escape)) | |
| 151 | + { | |
| 152 | + state.show_date_picker_to = false; | |
| 153 | + } | |
| 154 | + | |
| 136 | 155 | ui.separator(); |
| 137 | - | |
| 156 | + | |
| 138 | 157 | ui.horizontal(|ui| { |
| 139 | 158 | // Quick date buttons |
| 140 | 159 | if ui.button("Aujourd'hui").clicked() { |
| 141 | 160 | state.filter_date_to = Some(Utc::now()); |
| 142 | 161 | state.show_date_picker_to = false; |
| 143 | 162 | } |
| 144 | - | |
| 163 | + | |
| 145 | 164 | if ui.button("7 derniers jours").clicked() { |
| 146 | 165 | state.filter_date_to = Some(Utc::now()); |
| 147 | 166 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 148 | 167 | state.show_date_picker_to = false; |
| 149 | 168 | } |
| 150 | - | |
| 169 | + | |
| 151 | 170 | if ui.button("Annuler").clicked() { |
| 152 | 171 | state.show_date_picker_to = false; |
| 153 | 172 | } |
| @@ -156,11 +175,17 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | ||
| 156 | 175 | }); |
| 157 | 176 | } |
| 158 | 177 | |
| 159 | -/// Filters feeds based on current filter criteria | |
| 178 | +/// Filters feeds based on current filter criteria (dates + search) | |
| 160 | 179 | pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> { |
| 161 | - feeds.iter() | |
| 180 | + feeds | |
| 181 | + .iter() | |
| 162 | 182 | .enumerate() |
| 163 | 183 | .filter(|(_, feed)| { |
| 184 | + // Search filter (name / url / tags) | |
| 185 | + if !crate::ui::search_bar::feed_matches_search(state, feed) { | |
| 186 | + return false; | |
| 187 | + } | |
| 188 | + | |
| 164 | 189 | // Check date filter |
| 165 | 190 | if let Some(date_from) = state.filter_date_from { |
| 166 | 191 | if let Some(last_episode) = feed.last_episode_date { |
| @@ -172,7 +197,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve | ||
| 172 | 197 | return false; |
| 173 | 198 | } |
| 174 | 199 | } |
| 175 | - | |
| 200 | + | |
| 176 | 201 | if let Some(date_to) = state.filter_date_to { |
| 177 | 202 | if let Some(last_episode) = feed.last_episode_date { |
| 178 | 203 | if last_episode > date_to { |
| @@ -183,7 +208,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve | ||
| 183 | 208 | return false; |
| 184 | 209 | } |
| 185 | 210 | } |
| 186 | - | |
| 211 | + | |
| 187 | 212 | true |
| 188 | 213 | }) |
| 189 | 214 | .map(|(i, _)| i) |
| @@ -10,51 +10,46 @@ use egui::Ui; | |||
| 10 | pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) { |
| 11 | ui.horizontal(|ui| { | 11 | ui.horizontal(|ui| { |
| 12 | ui.label("Filtres:"); | 12 | ui.label("Filtres:"); |
| 13 | - | 13 | + |
| 14 | // Date filter | 14 | // Date filter |
| 15 | ui.label("Date de publication:"); | 15 | ui.label("Date de publication:"); |
| 16 | - | 16 | + |
| 17 | // Date from filter | 17 | // Date from filter |
| 18 | if let Some(date_from) = state.filter_date_from { | 18 | if let Some(date_from) = state.filter_date_from { |
| 19 | - if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() { | 19 | + if ui |
| 20 | - state.show_date_picker_from = true; | 20 | + .button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))) |
| 21 | - } | 21 | + .clicked() |
| 22 | - } else { | 22 | + { |
| 23 | - if ui.button("Depuis...").clicked() { | ||
| 24 | state.show_date_picker_from = true; | 23 | state.show_date_picker_from = true; |
| 25 | } | 24 | } |
| 25 | + } else if ui.button("Depuis...").clicked() { | ||
| 26 | + state.show_date_picker_from = true; | ||
| 26 | } | 27 | } |
| 27 | - | 28 | + |
| 28 | ui.add_space(5.0); | 29 | ui.add_space(5.0); |
| 29 | - | 30 | + |
| 30 | // Date to filter | 31 | // Date to filter |
| 31 | if let Some(date_to) = state.filter_date_to { | 32 | if let Some(date_to) = state.filter_date_to { |
| 32 | - if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() { | 33 | + if ui |
| 33 | - state.show_date_picker_to = true; | 34 | + .button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))) |
| 34 | - } | 35 | + .clicked() |
| 35 | - } else { | 36 | + { |
| 36 | - if ui.button("Jusqu'à...").clicked() { | ||
| 37 | state.show_date_picker_to = true; | 37 | state.show_date_picker_to = true; |
| 38 | } | 38 | } |
| 39 | + } else if ui.button("Jusqu'à...").clicked() { | ||
| 40 | + state.show_date_picker_to = true; | ||
| 39 | } | 41 | } |
| 40 | - | 42 | + |
| 41 | - ui.add_space(10.0); | ||
| 42 | - | ||
| 43 | - // Episode number filter | ||
| 44 | - ui.label("Numéro d'épisode:"); | ||
| 45 | - ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number) | ||
| 46 | - .hint_text("ex: 10")); | ||
| 47 | - | ||
| 48 | ui.add_space(10.0); | 43 | ui.add_space(10.0); |
| 49 | - | 44 | + |
| 50 | - // Clear filters button | 45 | + // Clear filters button (only when a filter is active) |
| 51 | - if ui.button("Effacer les filtres").clicked() { | 46 | + if (state.filter_date_from.is_some() || state.filter_date_to.is_some()) |
| 47 | + && ui.button("Effacer les filtres").clicked() | ||
| 48 | + { | ||
| 52 | state.clear_filters(); | 49 | state.clear_filters(); |
| 53 | } | 50 | } |
| 54 | }); | 51 | }); |
| 55 | - | 52 | + |
| 56 | - ui.separator(); | ||
| 57 | - | ||
| 58 | // Draw date pickers | 53 | // Draw date pickers |
| 59 | draw_date_picker_from(state, ui); | 54 | draw_date_picker_from(state, ui); |
| 60 | draw_date_picker_to(state, ui); | 55 | draw_date_picker_to(state, ui); |
| @@ -65,43 +60,55 @@ fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) { | |||
| 65 | if !state.show_date_picker_from { | 60 | if !state.show_date_picker_from { |
| 66 | return; | 61 | return; |
| 67 | } | 62 | } |
| 68 | - | 63 | + |
| 69 | egui::Window::new("Sélectionner la date de début") | 64 | egui::Window::new("Sélectionner la date de début") |
| 70 | .resizable(false) | 65 | .resizable(false) |
| 71 | .show(ui.ctx(), |ui| { | 66 | .show(ui.ctx(), |ui| { |
| 72 | ui.vertical(|ui| { | 67 | ui.vertical(|ui| { |
| 73 | ui.label("Date de début (yyyy-mm-dd):"); | 68 | ui.label("Date de début (yyyy-mm-dd):"); |
| 74 | - | 69 | + |
| 75 | - let mut date_str = state.filter_date_from | 70 | + let mut date_str = state |
| 71 | + .filter_date_from | ||
| 76 | .map(|d| d.format("%Y-%m-%d").to_string()) | 72 | .map(|d| d.format("%Y-%m-%d").to_string()) |
| 77 | .unwrap_or_default(); | 73 | .unwrap_or_default(); |
| 78 | - | 74 | + |
| 79 | - if ui.text_edit_singleline(&mut date_str).changed() { | 75 | + let response = ui.text_edit_singleline(&mut date_str); |
| 80 | - if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | 76 | + if response.changed() { |
| 81 | - state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc)); | 77 | + if let Ok(date) = |
| 78 | + chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d") | ||
| 79 | + { | ||
| 80 | + state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset( | ||
| 81 | + date.and_hms_opt(0, 0, 0).unwrap(), | ||
| 82 | + Utc, | ||
| 83 | + )); | ||
| 82 | state.show_date_picker_from = false; | 84 | state.show_date_picker_from = false; |
| 83 | } | 85 | } |
| 84 | } | 86 | } |
| 85 | - | 87 | + if response.lost_focus() |
| 88 | + && ui.input(|i| i.key_pressed(egui::Key::Escape)) | ||
| 89 | + { | ||
| 90 | + state.show_date_picker_from = false; | ||
| 91 | + } | ||
| 92 | + | ||
| 86 | ui.separator(); | 93 | ui.separator(); |
| 87 | - | 94 | + |
| 88 | ui.horizontal(|ui| { | 95 | ui.horizontal(|ui| { |
| 89 | // Quick date buttons | 96 | // Quick date buttons |
| 90 | if ui.button("Aujourd'hui").clicked() { | 97 | if ui.button("Aujourd'hui").clicked() { |
| 91 | state.filter_date_from = Some(Utc::now()); | 98 | state.filter_date_from = Some(Utc::now()); |
| 92 | state.show_date_picker_from = false; | 99 | state.show_date_picker_from = false; |
| 93 | } | 100 | } |
| 94 | - | 101 | + |
| 95 | if ui.button("7 derniers jours").clicked() { | 102 | if ui.button("7 derniers jours").clicked() { |
| 96 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | 103 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 97 | state.show_date_picker_from = false; | 104 | state.show_date_picker_from = false; |
| 98 | } | 105 | } |
| 99 | - | 106 | + |
| 100 | if ui.button("30 derniers jours").clicked() { | 107 | if ui.button("30 derniers jours").clicked() { |
| 101 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); | 108 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); |
| 102 | state.show_date_picker_from = false; | 109 | state.show_date_picker_from = false; |
| 103 | } | 110 | } |
| 104 | - | 111 | + |
| 105 | if ui.button("Annuler").clicked() { | 112 | if ui.button("Annuler").clicked() { |
| 106 | state.show_date_picker_from = false; | 113 | state.show_date_picker_from = false; |
| 107 | } | 114 | } |
| @@ -115,39 +122,51 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | |||
| 115 | if !state.show_date_picker_to { | 122 | if !state.show_date_picker_to { |
| 116 | return; | 123 | return; |
| 117 | } | 124 | } |
| 118 | - | 125 | + |
| 119 | egui::Window::new("Sélectionner la date de fin") | 126 | egui::Window::new("Sélectionner la date de fin") |
| 120 | .resizable(false) | 127 | .resizable(false) |
| 121 | .show(ui.ctx(), |ui| { | 128 | .show(ui.ctx(), |ui| { |
| 122 | ui.vertical(|ui| { | 129 | ui.vertical(|ui| { |
| 123 | ui.label("Date de fin (yyyy-mm-dd):"); | 130 | ui.label("Date de fin (yyyy-mm-dd):"); |
| 124 | - | 131 | + |
| 125 | - let mut date_str = state.filter_date_to | 132 | + let mut date_str = state |
| 133 | + .filter_date_to | ||
| 126 | .map(|d| d.format("%Y-%m-%d").to_string()) | 134 | .map(|d| d.format("%Y-%m-%d").to_string()) |
| 127 | .unwrap_or_default(); | 135 | .unwrap_or_default(); |
| 128 | - | 136 | + |
| 129 | - if ui.text_edit_singleline(&mut date_str).changed() { | 137 | + let response = ui.text_edit_singleline(&mut date_str); |
| 130 | - if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | 138 | + if response.changed() { |
| 131 | - state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc)); | 139 | + if let Ok(date) = |
| 140 | + chrono::NaiveDate::parse_from_str(date_str.trim(), "%Y-%m-%d") | ||
| 141 | + { | ||
| 142 | + state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset( | ||
| 143 | + date.and_hms_opt(23, 59, 59).unwrap(), | ||
| 144 | + Utc, | ||
| 145 | + )); | ||
| 132 | state.show_date_picker_to = false; | 146 | state.show_date_picker_to = false; |
| 133 | } | 147 | } |
| 134 | } | 148 | } |
| 135 | - | 149 | + if response.lost_focus() |
| 150 | + && ui.input(|i| i.key_pressed(egui::Key::Escape)) | ||
| 151 | + { | ||
| 152 | + state.show_date_picker_to = false; | ||
| 153 | + } | ||
| 154 | + | ||
| 136 | ui.separator(); | 155 | ui.separator(); |
| 137 | - | 156 | + |
| 138 | ui.horizontal(|ui| { | 157 | ui.horizontal(|ui| { |
| 139 | // Quick date buttons | 158 | // Quick date buttons |
| 140 | if ui.button("Aujourd'hui").clicked() { | 159 | if ui.button("Aujourd'hui").clicked() { |
| 141 | state.filter_date_to = Some(Utc::now()); | 160 | state.filter_date_to = Some(Utc::now()); |
| 142 | state.show_date_picker_to = false; | 161 | state.show_date_picker_to = false; |
| 143 | } | 162 | } |
| 144 | - | 163 | + |
| 145 | if ui.button("7 derniers jours").clicked() { | 164 | if ui.button("7 derniers jours").clicked() { |
| 146 | state.filter_date_to = Some(Utc::now()); | 165 | state.filter_date_to = Some(Utc::now()); |
| 147 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | 166 | state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); |
| 148 | state.show_date_picker_to = false; | 167 | state.show_date_picker_to = false; |
| 149 | } | 168 | } |
| 150 | - | 169 | + |
| 151 | if ui.button("Annuler").clicked() { | 170 | if ui.button("Annuler").clicked() { |
| 152 | state.show_date_picker_to = false; | 171 | state.show_date_picker_to = false; |
| 153 | } | 172 | } |
| @@ -156,11 +175,17 @@ fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | |||
| 156 | }); | 175 | }); |
| 157 | } | 176 | } |
| 158 | 177 | ||
| 159 | -/// Filters feeds based on current filter criteria | 178 | +/// Filters feeds based on current filter criteria (dates + search) |
| 160 | pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> { | 179 | pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> { |
| 161 | - feeds.iter() | 180 | + feeds |
| 181 | + .iter() | ||
| 162 | .enumerate() | 182 | .enumerate() |
| 163 | .filter(|(_, feed)| { | 183 | .filter(|(_, feed)| { |
| 184 | + // Search filter (name / url / tags) | ||
| 185 | + if !crate::ui::search_bar::feed_matches_search(state, feed) { | ||
| 186 | + return false; | ||
| 187 | + } | ||
| 188 | + | ||
| 164 | // Check date filter | 189 | // Check date filter |
| 165 | if let Some(date_from) = state.filter_date_from { | 190 | if let Some(date_from) = state.filter_date_from { |
| 166 | if let Some(last_episode) = feed.last_episode_date { | 191 | if let Some(last_episode) = feed.last_episode_date { |
| @@ -172,7 +197,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve | |||
| 172 | return false; | 197 | return false; |
| 173 | } | 198 | } |
| 174 | } | 199 | } |
| 175 | - | 200 | + |
| 176 | if let Some(date_to) = state.filter_date_to { | 201 | if let Some(date_to) = state.filter_date_to { |
| 177 | if let Some(last_episode) = feed.last_episode_date { | 202 | if let Some(last_episode) = feed.last_episode_date { |
| 178 | if last_episode > date_to { | 203 | if last_episode > date_to { |
| @@ -183,7 +208,7 @@ pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Ve | |||
| 183 | return false; | 208 | return false; |
| 184 | } | 209 | } |
| 185 | } | 210 | } |
| 186 | - | 211 | + |
| 187 | true | 212 | true |
| 188 | }) | 213 | }) |
| 189 | .map(|(i, _)| i) | 214 | .map(|(i, _)| i) |
modified
src/ui/history_panel.rs +173 -211 | @@ -1,226 +1,188 @@ | ||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | |
| 2 | -use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | |
| 3 | -use eframe::egui; | |
| 4 | - | |
| 5 | -/// Panneau d'affichage de l'historique | |
| 6 | -pub struct HistoryPanel { | |
| 7 | - /// Visibilité du panneau | |
| 8 | - visible: bool, | |
| 9 | - /// Filtre de type d'opération | |
| 10 | - filter_type: Option<OperationType>, | |
| 11 | - /// Filtre de statut | |
| 12 | - filter_status: Option<OperationStatus>, | |
| 13 | - /// Nombre d'entrées à afficher | |
| 14 | - entries_to_show: usize, | |
| 15 | -} | |
| 16 | - | |
| 17 | -impl Default for HistoryPanel { | |
| 18 | - fn default() -> Self { | |
| 19 | - Self { | |
| 20 | - visible: false, | |
| 21 | - filter_type: None, | |
| 22 | - filter_status: None, | |
| 23 | - entries_to_show: 50, | |
| 24 | - } | |
| 25 | - } | |
| 26 | -} | |
| 27 | - | |
| 28 | -impl HistoryPanel { | |
| 29 | - /// Créer un nouveau panneau d'historique | |
| 30 | - pub fn new() -> Self { | |
| 31 | - Self::default() | |
| 32 | - } | |
| 33 | - | |
| 34 | - /// Définir la visibilité | |
| 35 | - pub fn set_visible(&mut self, visible: bool) { | |
| 36 | - self.visible = visible; | |
| 37 | - } | |
| 38 | - | |
| 39 | - /// Basculer la visibilité | |
| 40 | - pub fn toggle(&mut self) { | |
| 41 | - self.visible = !self.visible; | |
| 42 | - } | |
| 43 | - | |
| 44 | - /// Vérifier si le panneau est visible | |
| 45 | - pub fn is_visible(&self) -> bool { | |
| 46 | - self.visible | |
| 1 | +//! History Panel UI component | |
| 2 | +//! | |
| 3 | +//! Fenêtre flottante affichant l'historique des opérations | |
| 4 | +//! (synchronisations, téléchargements, ajouts/suppressions, import/export), | |
| 5 | +//! avec filtres par type et par statut. | |
| 6 | + | |
| 7 | +use crate::config::history::{OperationStatus, OperationType}; | |
| 8 | +use crate::config::AppState; | |
| 9 | +use crate::ui::theme::ThemeColors; | |
| 10 | +use crate::ui::truncate; | |
| 11 | +use egui::Ui; | |
| 12 | + | |
| 13 | +/// Draws the history window (toggled from the menu) | |
| 14 | +pub fn draw_history_window(state: &mut AppState, ui: &mut Ui) { | |
| 15 | + if !state.show_history_window { | |
| 16 | + return; | |
| 47 | 17 | } |
| 48 | 18 | |
| 49 | - /// Définir le filtre de type | |
| 50 | - pub fn set_filter_type(&mut self, op_type: Option<OperationType>) { | |
| 51 | - self.filter_type = op_type; | |
| 52 | - } | |
| 53 | - | |
| 54 | - /// Définir le filtre de statut | |
| 55 | - pub fn set_filter_status(&mut self, status: Option<OperationStatus>) { | |
| 56 | - self.filter_status = status; | |
| 57 | - } | |
| 58 | - | |
| 59 | - /// Afficher le panneau d'historique | |
| 60 | - pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 61 | - if !self.visible { | |
| 62 | - return; | |
| 63 | - } | |
| 19 | + let mut open = state.show_history_window; | |
| 20 | + egui::Window::new("📜 Historique") | |
| 21 | + .open(&mut open) | |
| 22 | + .default_size([640.0, 400.0]) | |
| 23 | + .show(ui.ctx(), |ui| { | |
| 24 | + draw_history_content(state, ui); | |
| 25 | + }); | |
| 26 | + state.show_history_window = open; | |
| 27 | +} | |
| 64 | 28 | |
| 65 | - egui::CollapsingHeader::new("📜 Historique") | |
| 66 | - .default_open(true) | |
| 67 | - .show(ui, |ui| { | |
| 68 | - self.show_content(ui, history); | |
| 29 | +/// Draws the history window content | |
| 30 | +fn draw_history_content(state: &mut AppState, ui: &mut Ui) { | |
| 31 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 32 | + | |
| 33 | + // Filtres | |
| 34 | + ui.horizontal(|ui| { | |
| 35 | + egui::ComboBox::from_id_salt("history_filter_type") | |
| 36 | + .width(160.0) | |
| 37 | + .selected_text(operation_type_label(state.history_filter_type)) | |
| 38 | + .show_ui(ui, |ui| { | |
| 39 | + ui.selectable_value(&mut state.history_filter_type, None, "Tous les types"); | |
| 40 | + ui.selectable_value( | |
| 41 | + &mut state.history_filter_type, | |
| 42 | + Some(OperationType::SyncFeed), | |
| 43 | + "Synchronisation flux", | |
| 44 | + ); | |
| 45 | + ui.selectable_value( | |
| 46 | + &mut state.history_filter_type, | |
| 47 | + Some(OperationType::SyncAll), | |
| 48 | + "Sync. tous flux", | |
| 49 | + ); | |
| 50 | + ui.selectable_value( | |
| 51 | + &mut state.history_filter_type, | |
| 52 | + Some(OperationType::DownloadEpisode), | |
| 53 | + "Téléchargement", | |
| 54 | + ); | |
| 55 | + ui.selectable_value( | |
| 56 | + &mut state.history_filter_type, | |
| 57 | + Some(OperationType::AddFeed), | |
| 58 | + "Ajout flux", | |
| 59 | + ); | |
| 60 | + ui.selectable_value( | |
| 61 | + &mut state.history_filter_type, | |
| 62 | + Some(OperationType::RemoveFeed), | |
| 63 | + "Suppr. flux", | |
| 64 | + ); | |
| 65 | + ui.selectable_value( | |
| 66 | + &mut state.history_filter_type, | |
| 67 | + Some(OperationType::UpdateFeed), | |
| 68 | + "Modif. flux", | |
| 69 | + ); | |
| 70 | + ui.selectable_value( | |
| 71 | + &mut state.history_filter_type, | |
| 72 | + Some(OperationType::ImportFeeds), | |
| 73 | + "Import flux", | |
| 74 | + ); | |
| 75 | + ui.selectable_value( | |
| 76 | + &mut state.history_filter_type, | |
| 77 | + Some(OperationType::ExportFeeds), | |
| 78 | + "Export flux", | |
| 79 | + ); | |
| 69 | 80 | }); |
| 70 | - } | |
| 71 | 81 | |
| 72 | - /// Afficher le contenu du panneau | |
| 73 | - fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 74 | - // Contrôles de filtre | |
| 75 | - self.show_filters(ui); | |
| 76 | - ui.separator(); | |
| 82 | + egui::ComboBox::from_id_salt("history_filter_status") | |
| 83 | + .width(120.0) | |
| 84 | + .selected_text(operation_status_label(state.history_filter_status)) | |
| 85 | + .show_ui(ui, |ui| { | |
| 86 | + ui.selectable_value( | |
| 87 | + &mut state.history_filter_status, | |
| 88 | + None, | |
| 89 | + "Tous les statuts", | |
| 90 | + ); | |
| 91 | + ui.selectable_value( | |
| 92 | + &mut state.history_filter_status, | |
| 93 | + Some(OperationStatus::Success), | |
| 94 | + "Succès", | |
| 95 | + ); | |
| 96 | + ui.selectable_value( | |
| 97 | + &mut state.history_filter_status, | |
| 98 | + Some(OperationStatus::Failed), | |
| 99 | + "Échec", | |
| 100 | + ); | |
| 101 | + }); | |
| 77 | 102 | |
| 78 | - // Afficher les entrées | |
| 79 | - self.show_entries(ui, history); | |
| 103 | + if (state.history_filter_type.is_some() || state.history_filter_status.is_some()) | |
| 104 | + && ui.button("Effacer les filtres").clicked() | |
| 105 | + { | |
| 106 | + state.history_filter_type = None; | |
| 107 | + state.history_filter_status = None; | |
| 108 | + } | |
| 109 | + }); | |
| 110 | + | |
| 111 | + ui.separator(); | |
| 112 | + | |
| 113 | + // Entrées filtrées, de la plus récente à la plus ancienne | |
| 114 | + let entries: Vec<_> = state | |
| 115 | + .history_manager | |
| 116 | + .get_all_entries() | |
| 117 | + .into_iter() | |
| 118 | + .filter(|e| { | |
| 119 | + state | |
| 120 | + .history_filter_type | |
| 121 | + .is_none_or(|t| e.operation_type == t) | |
| 122 | + }) | |
| 123 | + .filter(|e| { | |
| 124 | + state | |
| 125 | + .history_filter_status | |
| 126 | + .is_none_or(|s| e.status == s) | |
| 127 | + }) | |
| 128 | + .collect(); | |
| 129 | + | |
| 130 | + if entries.is_empty() { | |
| 131 | + ui.label("Aucune entrée d'historique."); | |
| 132 | + return; | |
| 80 | 133 | } |
| 81 | 134 | |
| 82 | - /// Afficher les contrôles de filtre | |
| 83 | - fn show_filters(&mut self, ui: &mut egui::Ui) { | |
| 84 | - ui.horizontal(|ui| { | |
| 85 | - // Filtre par type | |
| 86 | - egui::ComboBox::from_label("Type") | |
| 87 | - .width(150.0) | |
| 88 | - .selected_text(match self.filter_type { | |
| 89 | - Some(t) => format!("{}", t), | |
| 90 | - None => "Tous les types".to_string(), | |
| 91 | - }) | |
| 92 | - .show_ui(ui, |ui| { | |
| 93 | - ui.selectable_value(&mut self.filter_type, None, "Tous les types"); | |
| 94 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed)); | |
| 95 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll)); | |
| 96 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode)); | |
| 97 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed)); | |
| 98 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed)); | |
| 99 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds)); | |
| 100 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds)); | |
| 101 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError)); | |
| 102 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError)); | |
| 103 | - }); | |
| 104 | - | |
| 105 | - // Filtre par statut | |
| 106 | - egui::ComboBox::from_label("Statut") | |
| 107 | - .width(120.0) | |
| 108 | - .selected_text(match self.filter_status { | |
| 109 | - Some(s) => format!("{}", s), | |
| 110 | - None => "Tous les statuts".to_string(), | |
| 111 | - }) | |
| 112 | - .show_ui(ui, |ui| { | |
| 113 | - ui.selectable_value(&mut self.filter_status, None, "Tous les statuts"); | |
| 114 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress)); | |
| 115 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success)); | |
| 116 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed)); | |
| 117 | - }); | |
| 118 | - | |
| 119 | - // Bouton pour effacer les filtres | |
| 120 | - if (self.filter_type.is_some() || self.filter_status.is_some()) | |
| 121 | - && ui.button("Effacer filtres").clicked() { | |
| 122 | - self.filter_type = None; | |
| 123 | - self.filter_status = None; | |
| 135 | + egui::ScrollArea::vertical().show(ui, |ui| { | |
| 136 | + egui::Grid::new("history_grid") | |
| 137 | + .striped(true) | |
| 138 | + .show(ui, |ui| { | |
| 139 | + ui.strong("Date/Heure"); | |
| 140 | + ui.strong("Type"); | |
| 141 | + ui.strong("Description"); | |
| 142 | + ui.strong("Statut"); | |
| 143 | + ui.end_row(); | |
| 144 | + | |
| 145 | + for entry in entries.iter().rev() { | |
| 146 | + ui.label(entry.timestamp.format("%d/%m/%Y %H:%M").to_string()); | |
| 147 | + ui.label(entry.operation_type.to_string()); | |
| 148 | + ui.label(truncate(&entry.description, 50)) | |
| 149 | + .on_hover_text(&entry.description); | |
| 150 | + let (status_text, status_color) = match entry.status { | |
| 151 | + OperationStatus::Success => ("Succès", colors.success), | |
| 152 | + OperationStatus::Failed => ("Échec", colors.error), | |
| 153 | + OperationStatus::InProgress => ("En cours", colors.warning), | |
| 154 | + }; | |
| 155 | + let label = ui.colored_label(status_color, status_text); | |
| 156 | + if let Some(error) = &entry.error_message { | |
| 157 | + label.on_hover_text(error); | |
| 158 | + } | |
| 159 | + ui.end_row(); | |
| 124 | 160 | } |
| 125 | - }); | |
| 126 | - } | |
| 127 | - | |
| 128 | - /// Afficher les entrées d'historique | |
| 129 | - fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 130 | - let mut entries: Vec<&HistoryEntry> = history.get_all_entries(); | |
| 131 | - | |
| 132 | - // Appliquer les filtres | |
| 133 | - if let Some(op_type) = self.filter_type { | |
| 134 | - entries.retain(|e| e.operation_type == op_type); | |
| 135 | - } | |
| 136 | - if let Some(status) = self.filter_status { | |
| 137 | - entries.retain(|e| e.status == status); | |
| 138 | - } | |
| 139 | - | |
| 140 | - // Limiter le nombre d'entrées affichées | |
| 141 | - entries.truncate(self.entries_to_show); | |
| 142 | - | |
| 143 | - if entries.is_empty() { | |
| 144 | - ui.label("Aucune entrée d'historique trouvée"); | |
| 145 | - return; | |
| 161 | + }); | |
| 162 | + }); | |
| 163 | + | |
| 164 | + ui.separator(); | |
| 165 | + ui.horizontal(|ui| { | |
| 166 | + ui.label(format!("Total : {} entrées", state.history_manager.len())); | |
| 167 | + if ui.button("Effacer l'historique").clicked() { | |
| 168 | + state.history_manager.clear(); | |
| 169 | + state.notification_manager.info("Historique effacé"); | |
| 146 | 170 | } |
| 171 | + }); | |
| 172 | +} | |
| 147 | 173 | |
| 148 | - // Tableau des entrées | |
| 149 | - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { | |
| 150 | - egui::Grid::new("history_grid") | |
| 151 | - .striped(true) | |
| 152 | - .show(ui, |ui| { | |
| 153 | - // En-têtes | |
| 154 | - ui.strong("Date/Heure"); | |
| 155 | - ui.strong("Type"); | |
| 156 | - ui.strong("Description"); | |
| 157 | - ui.strong("Statut"); | |
| 158 | - ui.strong("Durée/Taille"); | |
| 159 | - ui.end_row(); | |
| 160 | - | |
| 161 | - // Lignes de données (afficher de la plus récente à la plus ancienne) | |
| 162 | - for entry in entries.iter().rev() { | |
| 163 | - ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S"))); | |
| 164 | - ui.label(format!("{}", entry.operation_type)); | |
| 165 | - | |
| 166 | - // Limiter la longueur de la description | |
| 167 | - let desc = if entry.description.len() > 40 { | |
| 168 | - format!("{}...", &entry.description[..37]) | |
| 169 | - } else { | |
| 170 | - entry.description.clone() | |
| 171 | - }; | |
| 172 | - ui.label(desc); | |
| 173 | - | |
| 174 | - // Afficher le statut avec une couleur | |
| 175 | - let status_text = format!("{}", entry.status); | |
| 176 | - let color = match entry.status { | |
| 177 | - OperationStatus::Success => egui::Color32::GREEN, | |
| 178 | - OperationStatus::Failed => egui::Color32::RED, | |
| 179 | - OperationStatus::InProgress => egui::Color32::YELLOW, | |
| 180 | - }; | |
| 181 | - ui.colored_label(color, &status_text); | |
| 182 | - | |
| 183 | - // Afficher durée ou taille | |
| 184 | - let info = if entry.duration_seconds.is_some() { | |
| 185 | - entry.get_formatted_duration() | |
| 186 | - } else { | |
| 187 | - entry.get_formatted_size() | |
| 188 | - }; | |
| 189 | - ui.label(info); | |
| 190 | - ui.end_row(); | |
| 191 | - } | |
| 192 | - }); | |
| 193 | - }); | |
| 194 | - | |
| 195 | - // Statistiques de l'historique | |
| 196 | - ui.separator(); | |
| 197 | - ui.horizontal(|ui| { | |
| 198 | - ui.label(format!("Total: {} entrées", history.len())); | |
| 199 | - if ui.button("Effacer historique").clicked() { | |
| 200 | - // L'appelant doit effacer l'historique | |
| 201 | - } | |
| 202 | - }); | |
| 174 | +/// Libellé du filtre par type d'opération | |
| 175 | +fn operation_type_label(filter: Option<OperationType>) -> String { | |
| 176 | + match filter { | |
| 177 | + None => "Tous les types".to_string(), | |
| 178 | + Some(t) => t.to_string(), | |
| 203 | 179 | } |
| 180 | +} | |
| 204 | 181 | |
| 205 | - /// Afficher une version simplifiée (dernières opérations) | |
| 206 | - pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) { | |
| 207 | - let entries = history.get_recent_entries(count); | |
| 208 | - | |
| 209 | - if entries.is_empty() { | |
| 210 | - ui.label("Aucune opération récente"); | |
| 211 | - return; | |
| 212 | - } | |
| 213 | - | |
| 214 | - ui.label("📜 Dernières opérations:"); | |
| 215 | - egui::ScrollArea::horizontal().show(ui, |ui| { | |
| 216 | - for entry in entries.iter().rev() { | |
| 217 | - let color = match entry.status { | |
| 218 | - OperationStatus::Success => egui::Color32::GREEN, | |
| 219 | - OperationStatus::Failed => egui::Color32::RED, | |
| 220 | - OperationStatus::InProgress => egui::Color32::YELLOW, | |
| 221 | - }; | |
| 222 | - ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description)); | |
| 223 | - } | |
| 224 | - }); | |
| 182 | +/// Libellé du filtre par statut | |
| 183 | +fn operation_status_label(filter: Option<OperationStatus>) -> String { | |
| 184 | + match filter { | |
| 185 | + None => "Tous les statuts".to_string(), | |
| 186 | + Some(s) => s.to_string(), | |
| 225 | 187 | } |
| 226 | 188 | } |
| @@ -1,226 +1,188 @@ | |||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | 1 | +//! History Panel UI component |
| 2 | -use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | 2 | +//! |
| 3 | -use eframe::egui; | 3 | +//! Fenêtre flottante affichant l'historique des opérations |
| 4 | - | 4 | +//! (synchronisations, téléchargements, ajouts/suppressions, import/export), |
| 5 | -/// Panneau d'affichage de l'historique | 5 | +//! avec filtres par type et par statut. |
| 6 | -pub struct HistoryPanel { | 6 | + |
| 7 | - /// Visibilité du panneau | 7 | +use crate::config::history::{OperationStatus, OperationType}; |
| 8 | - visible: bool, | 8 | +use crate::config::AppState; |
| 9 | - /// Filtre de type d'opération | 9 | +use crate::ui::theme::ThemeColors; |
| 10 | - filter_type: Option<OperationType>, | 10 | +use crate::ui::truncate; |
| 11 | - /// Filtre de statut | 11 | +use egui::Ui; |
| 12 | - filter_status: Option<OperationStatus>, | 12 | + |
| 13 | - /// Nombre d'entrées à afficher | 13 | +/// Draws the history window (toggled from the menu) |
| 14 | - entries_to_show: usize, | 14 | +pub fn draw_history_window(state: &mut AppState, ui: &mut Ui) { |
| 15 | -} | 15 | + if !state.show_history_window { |
| 16 | - | 16 | + return; |
| 17 | -impl Default for HistoryPanel { | ||
| 18 | - fn default() -> Self { | ||
| 19 | - Self { | ||
| 20 | - visible: false, | ||
| 21 | - filter_type: None, | ||
| 22 | - filter_status: None, | ||
| 23 | - entries_to_show: 50, | ||
| 24 | - } | ||
| 25 | - } | ||
| 26 | -} | ||
| 27 | - | ||
| 28 | -impl HistoryPanel { | ||
| 29 | - /// Créer un nouveau panneau d'historique | ||
| 30 | - pub fn new() -> Self { | ||
| 31 | - Self::default() | ||
| 32 | - } | ||
| 33 | - | ||
| 34 | - /// Définir la visibilité | ||
| 35 | - pub fn set_visible(&mut self, visible: bool) { | ||
| 36 | - self.visible = visible; | ||
| 37 | - } | ||
| 38 | - | ||
| 39 | - /// Basculer la visibilité | ||
| 40 | - pub fn toggle(&mut self) { | ||
| 41 | - self.visible = !self.visible; | ||
| 42 | - } | ||
| 43 | - | ||
| 44 | - /// Vérifier si le panneau est visible | ||
| 45 | - pub fn is_visible(&self) -> bool { | ||
| 46 | - self.visible | ||
| 47 | } | 17 | } |
| 48 | 18 | ||
| 49 | - /// Définir le filtre de type | 19 | + let mut open = state.show_history_window; |
| 50 | - pub fn set_filter_type(&mut self, op_type: Option<OperationType>) { | 20 | + egui::Window::new("📜 Historique") |
| 51 | - self.filter_type = op_type; | 21 | + .open(&mut open) |
| 52 | - } | 22 | + .default_size([640.0, 400.0]) |
| 53 | - | 23 | + .show(ui.ctx(), |ui| { |
| 54 | - /// Définir le filtre de statut | 24 | + draw_history_content(state, ui); |
| 55 | - pub fn set_filter_status(&mut self, status: Option<OperationStatus>) { | 25 | + }); |
| 56 | - self.filter_status = status; | 26 | + state.show_history_window = open; |
| 57 | - } | 27 | +} |
| 58 | - | ||
| 59 | - /// Afficher le panneau d'historique | ||
| 60 | - pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | ||
| 61 | - if !self.visible { | ||
| 62 | - return; | ||
| 63 | - } | ||
| 64 | 28 | ||
| 65 | - egui::CollapsingHeader::new("📜 Historique") | 29 | +/// Draws the history window content |
| 66 | - .default_open(true) | 30 | +fn draw_history_content(state: &mut AppState, ui: &mut Ui) { |
| 67 | - .show(ui, |ui| { | 31 | + let colors = ThemeColors::for_theme(state.get_theme()); |
| 68 | - self.show_content(ui, history); | 32 | + |
| 33 | + // Filtres | ||
| 34 | + ui.horizontal(|ui| { | ||
| 35 | + egui::ComboBox::from_id_salt("history_filter_type") | ||
| 36 | + .width(160.0) | ||
| 37 | + .selected_text(operation_type_label(state.history_filter_type)) | ||
| 38 | + .show_ui(ui, |ui| { | ||
| 39 | + ui.selectable_value(&mut state.history_filter_type, None, "Tous les types"); | ||
| 40 | + ui.selectable_value( | ||
| 41 | + &mut state.history_filter_type, | ||
| 42 | + Some(OperationType::SyncFeed), | ||
| 43 | + "Synchronisation flux", | ||
| 44 | + ); | ||
| 45 | + ui.selectable_value( | ||
| 46 | + &mut state.history_filter_type, | ||
| 47 | + Some(OperationType::SyncAll), | ||
| 48 | + "Sync. tous flux", | ||
| 49 | + ); | ||
| 50 | + ui.selectable_value( | ||
| 51 | + &mut state.history_filter_type, | ||
| 52 | + Some(OperationType::DownloadEpisode), | ||
| 53 | + "Téléchargement", | ||
| 54 | + ); | ||
| 55 | + ui.selectable_value( | ||
| 56 | + &mut state.history_filter_type, | ||
| 57 | + Some(OperationType::AddFeed), | ||
| 58 | + "Ajout flux", | ||
| 59 | + ); | ||
| 60 | + ui.selectable_value( | ||
| 61 | + &mut state.history_filter_type, | ||
| 62 | + Some(OperationType::RemoveFeed), | ||
| 63 | + "Suppr. flux", | ||
| 64 | + ); | ||
| 65 | + ui.selectable_value( | ||
| 66 | + &mut state.history_filter_type, | ||
| 67 | + Some(OperationType::UpdateFeed), | ||
| 68 | + "Modif. flux", | ||
| 69 | + ); | ||
| 70 | + ui.selectable_value( | ||
| 71 | + &mut state.history_filter_type, | ||
| 72 | + Some(OperationType::ImportFeeds), | ||
| 73 | + "Import flux", | ||
| 74 | + ); | ||
| 75 | + ui.selectable_value( | ||
| 76 | + &mut state.history_filter_type, | ||
| 77 | + Some(OperationType::ExportFeeds), | ||
| 78 | + "Export flux", | ||
| 79 | + ); | ||
| 69 | }); | 80 | }); |
| 70 | - } | ||
| 71 | 81 | ||
| 72 | - /// Afficher le contenu du panneau | 82 | + egui::ComboBox::from_id_salt("history_filter_status") |
| 73 | - fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | 83 | + .width(120.0) |
| 74 | - // Contrôles de filtre | 84 | + .selected_text(operation_status_label(state.history_filter_status)) |
| 75 | - self.show_filters(ui); | 85 | + .show_ui(ui, |ui| { |
| 76 | - ui.separator(); | 86 | + ui.selectable_value( |
| 87 | + &mut state.history_filter_status, | ||
| 88 | + None, | ||
| 89 | + "Tous les statuts", | ||
| 90 | + ); | ||
| 91 | + ui.selectable_value( | ||
| 92 | + &mut state.history_filter_status, | ||
| 93 | + Some(OperationStatus::Success), | ||
| 94 | + "Succès", | ||
| 95 | + ); | ||
| 96 | + ui.selectable_value( | ||
| 97 | + &mut state.history_filter_status, | ||
| 98 | + Some(OperationStatus::Failed), | ||
| 99 | + "Échec", | ||
| 100 | + ); | ||
| 101 | + }); | ||
| 77 | 102 | ||
| 78 | - // Afficher les entrées | 103 | + if (state.history_filter_type.is_some() || state.history_filter_status.is_some()) |
| 79 | - self.show_entries(ui, history); | 104 | + && ui.button("Effacer les filtres").clicked() |
| 105 | + { | ||
| 106 | + state.history_filter_type = None; | ||
| 107 | + state.history_filter_status = None; | ||
| 108 | + } | ||
| 109 | + }); | ||
| 110 | + | ||
| 111 | + ui.separator(); | ||
| 112 | + | ||
| 113 | + // Entrées filtrées, de la plus récente à la plus ancienne | ||
| 114 | + let entries: Vec<_> = state | ||
| 115 | + .history_manager | ||
| 116 | + .get_all_entries() | ||
| 117 | + .into_iter() | ||
| 118 | + .filter(|e| { | ||
| 119 | + state | ||
| 120 | + .history_filter_type | ||
| 121 | + .is_none_or(|t| e.operation_type == t) | ||
| 122 | + }) | ||
| 123 | + .filter(|e| { | ||
| 124 | + state | ||
| 125 | + .history_filter_status | ||
| 126 | + .is_none_or(|s| e.status == s) | ||
| 127 | + }) | ||
| 128 | + .collect(); | ||
| 129 | + | ||
| 130 | + if entries.is_empty() { | ||
| 131 | + ui.label("Aucune entrée d'historique."); | ||
| 132 | + return; | ||
| 80 | } | 133 | } |
| 81 | 134 | ||
| 82 | - /// Afficher les contrôles de filtre | 135 | + egui::ScrollArea::vertical().show(ui, |ui| { |
| 83 | - fn show_filters(&mut self, ui: &mut egui::Ui) { | 136 | + egui::Grid::new("history_grid") |
| 84 | - ui.horizontal(|ui| { | 137 | + .striped(true) |
| 85 | - // Filtre par type | 138 | + .show(ui, |ui| { |
| 86 | - egui::ComboBox::from_label("Type") | 139 | + ui.strong("Date/Heure"); |
| 87 | - .width(150.0) | 140 | + ui.strong("Type"); |
| 88 | - .selected_text(match self.filter_type { | 141 | + ui.strong("Description"); |
| 89 | - Some(t) => format!("{}", t), | 142 | + ui.strong("Statut"); |
| 90 | - None => "Tous les types".to_string(), | 143 | + ui.end_row(); |
| 91 | - }) | 144 | + |
| 92 | - .show_ui(ui, |ui| { | 145 | + for entry in entries.iter().rev() { |
| 93 | - ui.selectable_value(&mut self.filter_type, None, "Tous les types"); | 146 | + ui.label(entry.timestamp.format("%d/%m/%Y %H:%M").to_string()); |
| 94 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed)); | 147 | + ui.label(entry.operation_type.to_string()); |
| 95 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll)); | 148 | + ui.label(truncate(&entry.description, 50)) |
| 96 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode)); | 149 | + .on_hover_text(&entry.description); |
| 97 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed)); | 150 | + let (status_text, status_color) = match entry.status { |
| 98 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed)); | 151 | + OperationStatus::Success => ("Succès", colors.success), |
| 99 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds)); | 152 | + OperationStatus::Failed => ("Échec", colors.error), |
| 100 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds)); | 153 | + OperationStatus::InProgress => ("En cours", colors.warning), |
| 101 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError)); | 154 | + }; |
| 102 | - ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError)); | 155 | + let label = ui.colored_label(status_color, status_text); |
| 103 | - }); | 156 | + if let Some(error) = &entry.error_message { |
| 104 | - | 157 | + label.on_hover_text(error); |
| 105 | - // Filtre par statut | 158 | + } |
| 106 | - egui::ComboBox::from_label("Statut") | 159 | + ui.end_row(); |
| 107 | - .width(120.0) | ||
| 108 | - .selected_text(match self.filter_status { | ||
| 109 | - Some(s) => format!("{}", s), | ||
| 110 | - None => "Tous les statuts".to_string(), | ||
| 111 | - }) | ||
| 112 | - .show_ui(ui, |ui| { | ||
| 113 | - ui.selectable_value(&mut self.filter_status, None, "Tous les statuts"); | ||
| 114 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress)); | ||
| 115 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success)); | ||
| 116 | - ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed)); | ||
| 117 | - }); | ||
| 118 | - | ||
| 119 | - // Bouton pour effacer les filtres | ||
| 120 | - if (self.filter_type.is_some() || self.filter_status.is_some()) | ||
| 121 | - && ui.button("Effacer filtres").clicked() { | ||
| 122 | - self.filter_type = None; | ||
| 123 | - self.filter_status = None; | ||
| 124 | } | 160 | } |
| 125 | - }); | 161 | + }); |
| 126 | - } | 162 | + }); |
| 127 | - | 163 | + |
| 128 | - /// Afficher les entrées d'historique | 164 | + ui.separator(); |
| 129 | - fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | 165 | + ui.horizontal(|ui| { |
| 130 | - let mut entries: Vec<&HistoryEntry> = history.get_all_entries(); | 166 | + ui.label(format!("Total : {} entrées", state.history_manager.len())); |
| 131 | - | 167 | + if ui.button("Effacer l'historique").clicked() { |
| 132 | - // Appliquer les filtres | 168 | + state.history_manager.clear(); |
| 133 | - if let Some(op_type) = self.filter_type { | 169 | + state.notification_manager.info("Historique effacé"); |
| 134 | - entries.retain(|e| e.operation_type == op_type); | ||
| 135 | - } | ||
| 136 | - if let Some(status) = self.filter_status { | ||
| 137 | - entries.retain(|e| e.status == status); | ||
| 138 | - } | ||
| 139 | - | ||
| 140 | - // Limiter le nombre d'entrées affichées | ||
| 141 | - entries.truncate(self.entries_to_show); | ||
| 142 | - | ||
| 143 | - if entries.is_empty() { | ||
| 144 | - ui.label("Aucune entrée d'historique trouvée"); | ||
| 145 | - return; | ||
| 146 | } | 170 | } |
| 171 | + }); | ||
| 172 | +} | ||
| 147 | 173 | ||
| 148 | - // Tableau des entrées | 174 | +/// Libellé du filtre par type d'opération |
| 149 | - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { | 175 | +fn operation_type_label(filter: Option<OperationType>) -> String { |
| 150 | - egui::Grid::new("history_grid") | 176 | + match filter { |
| 151 | - .striped(true) | 177 | + None => "Tous les types".to_string(), |
| 152 | - .show(ui, |ui| { | 178 | + Some(t) => t.to_string(), |
| 153 | - // En-têtes | ||
| 154 | - ui.strong("Date/Heure"); | ||
| 155 | - ui.strong("Type"); | ||
| 156 | - ui.strong("Description"); | ||
| 157 | - ui.strong("Statut"); | ||
| 158 | - ui.strong("Durée/Taille"); | ||
| 159 | - ui.end_row(); | ||
| 160 | - | ||
| 161 | - // Lignes de données (afficher de la plus récente à la plus ancienne) | ||
| 162 | - for entry in entries.iter().rev() { | ||
| 163 | - ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S"))); | ||
| 164 | - ui.label(format!("{}", entry.operation_type)); | ||
| 165 | - | ||
| 166 | - // Limiter la longueur de la description | ||
| 167 | - let desc = if entry.description.len() > 40 { | ||
| 168 | - format!("{}...", &entry.description[..37]) | ||
| 169 | - } else { | ||
| 170 | - entry.description.clone() | ||
| 171 | - }; | ||
| 172 | - ui.label(desc); | ||
| 173 | - | ||
| 174 | - // Afficher le statut avec une couleur | ||
| 175 | - let status_text = format!("{}", entry.status); | ||
| 176 | - let color = match entry.status { | ||
| 177 | - OperationStatus::Success => egui::Color32::GREEN, | ||
| 178 | - OperationStatus::Failed => egui::Color32::RED, | ||
| 179 | - OperationStatus::InProgress => egui::Color32::YELLOW, | ||
| 180 | - }; | ||
| 181 | - ui.colored_label(color, &status_text); | ||
| 182 | - | ||
| 183 | - // Afficher durée ou taille | ||
| 184 | - let info = if entry.duration_seconds.is_some() { | ||
| 185 | - entry.get_formatted_duration() | ||
| 186 | - } else { | ||
| 187 | - entry.get_formatted_size() | ||
| 188 | - }; | ||
| 189 | - ui.label(info); | ||
| 190 | - ui.end_row(); | ||
| 191 | - } | ||
| 192 | - }); | ||
| 193 | - }); | ||
| 194 | - | ||
| 195 | - // Statistiques de l'historique | ||
| 196 | - ui.separator(); | ||
| 197 | - ui.horizontal(|ui| { | ||
| 198 | - ui.label(format!("Total: {} entrées", history.len())); | ||
| 199 | - if ui.button("Effacer historique").clicked() { | ||
| 200 | - // L'appelant doit effacer l'historique | ||
| 201 | - } | ||
| 202 | - }); | ||
| 203 | } | 179 | } |
| 180 | +} | ||
| 204 | 181 | ||
| 205 | - /// Afficher une version simplifiée (dernières opérations) | 182 | +/// Libellé du filtre par statut |
| 206 | - pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) { | 183 | +fn operation_status_label(filter: Option<OperationStatus>) -> String { |
| 207 | - let entries = history.get_recent_entries(count); | 184 | + match filter { |
| 208 | - | 185 | + None => "Tous les statuts".to_string(), |
| 209 | - if entries.is_empty() { | 186 | + Some(s) => s.to_string(), |
| 210 | - ui.label("Aucune opération récente"); | ||
| 211 | - return; | ||
| 212 | - } | ||
| 213 | - | ||
| 214 | - ui.label("📜 Dernières opérations:"); | ||
| 215 | - egui::ScrollArea::horizontal().show(ui, |ui| { | ||
| 216 | - for entry in entries.iter().rev() { | ||
| 217 | - let color = match entry.status { | ||
| 218 | - OperationStatus::Success => egui::Color32::GREEN, | ||
| 219 | - OperationStatus::Failed => egui::Color32::RED, | ||
| 220 | - OperationStatus::InProgress => egui::Color32::YELLOW, | ||
| 221 | - }; | ||
| 222 | - ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description)); | ||
| 223 | - } | ||
| 224 | - }); | ||
| 225 | } | 187 | } |
| 226 | } | 188 | } |
modified
src/ui/home.rs +238 -92 | @@ -3,25 +3,109 @@ | ||
| 3 | 3 | //! This module contains the main home screen layout. |
| 4 | 4 | |
| 5 | 5 | use crate::config::AppState; |
| 6 | +use crate::config::sync_task::SyncMessage; | |
| 7 | +use crate::ui::theme::ThemeColors; | |
| 6 | 8 | use egui::Ui; |
| 7 | 9 | |
| 8 | 10 | /// Draws the home screen |
| 9 | 11 | pub fn draw_home(state: &mut AppState, ui: &mut Ui) { |
| 10 | 12 | // Process sync messages first to update feeds with latest download statistics |
| 11 | - // This ensures the feed list displays up-to-date information | |
| 13 | + process_sync_messages(state); | |
| 14 | + | |
| 15 | + // Header avec titre et version | |
| 16 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 17 | + ui.horizontal(|ui| { | |
| 18 | + ui.heading( | |
| 19 | + egui::RichText::new("🎙️ RSS Podcast Downloader").color(colors.accent), | |
| 20 | + ); | |
| 21 | + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 22 | + ui.label( | |
| 23 | + egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) | |
| 24 | + .color(colors.accent), | |
| 25 | + ); | |
| 26 | + }); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + ui.separator(); | |
| 30 | + | |
| 31 | + // Barre d'outils : recherche + filtres | |
| 32 | + ui.horizontal(|ui| { | |
| 33 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); | |
| 34 | + crate::ui::search_bar::draw_search_bar(state, ui); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + ui.horizontal(|ui| { | |
| 38 | + crate::ui::filter_panel::draw_filter_panel(state, ui); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + ui.separator(); | |
| 42 | + | |
| 43 | + // Boutons d'action principaux | |
| 44 | + ui.horizontal(|ui| { | |
| 45 | + ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0); | |
| 46 | + draw_action_buttons(state, ui); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + ui.add_space(16.0); | |
| 50 | + ui.separator(); | |
| 51 | + | |
| 52 | + if state.detail_feed_id.is_some() { | |
| 53 | + // Vue détaillée d'un flux : remplace la liste des flux et les playlists | |
| 54 | + crate::ui::feed_detail::draw_feed_detail(state, ui); | |
| 55 | + } else { | |
| 56 | + // Section des flux (ouverte par défaut) | |
| 57 | + egui::CollapsingHeader::new("📻 Mes Flux RSS") | |
| 58 | + .default_open(true) | |
| 59 | + .show(ui, |ui| { | |
| 60 | + if state.has_feeds() { | |
| 61 | + crate::ui::feed_list::draw_feed_list(state, ui); | |
| 62 | + } else { | |
| 63 | + draw_empty_state(ui, &colors); | |
| 64 | + } | |
| 65 | + }); | |
| 66 | + | |
| 67 | + ui.separator(); | |
| 68 | + | |
| 69 | + // Section des playlists | |
| 70 | + egui::CollapsingHeader::new("📋 Playlists") | |
| 71 | + .default_open(true) | |
| 72 | + .show(ui, |ui| { | |
| 73 | + crate::ui::playlist_list::draw_playlist_list(state, ui); | |
| 74 | + }); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // Fenêtres Statistiques et Historique | |
| 78 | + crate::ui::stats_panel::draw_stats_window(state, ui); | |
| 79 | + crate::ui::history_panel::draw_history_window(state, ui); | |
| 80 | + | |
| 81 | + // Dialogues | |
| 82 | + crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); | |
| 83 | + crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); | |
| 84 | + crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | |
| 85 | + crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | |
| 86 | + crate::ui::export_import::draw_export_dialog(state, ui); | |
| 87 | + crate::ui::export_import::draw_import_dialog(state, ui); | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// Processes messages from the async sync tasks and updates the UI state | |
| 91 | +fn process_sync_messages(state: &mut AppState) { | |
| 12 | 92 | let messages = state.sync_task_manager.process_messages(); |
| 13 | 93 | let mut needs_save = false; |
| 14 | 94 | for message in messages { |
| 15 | 95 | match message { |
| 16 | - crate::config::sync_task::SyncMessage::SyncCompleted { | |
| 96 | + SyncMessage::SyncCompleted { | |
| 17 | 97 | feed_id, |
| 18 | 98 | downloaded_episodes_count, |
| 19 | 99 | last_downloaded_episode_name, |
| 20 | 100 | last_downloaded_episode_date, |
| 21 | 101 | last_checked, |
| 22 | - .. | |
| 23 | 102 | } => { |
| 24 | 103 | // Find the feed and update its statistics |
| 104 | + let feed_name = state | |
| 105 | + .feeds | |
| 106 | + .iter() | |
| 107 | + .find(|f| f.id == feed_id) | |
| 108 | + .map(|f| f.name.clone()); | |
| 25 | 109 | if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) { |
| 26 | 110 | feed.downloaded_episodes_count = downloaded_episodes_count; |
| 27 | 111 | feed.last_downloaded_episode_name = last_downloaded_episode_name; |
| @@ -29,8 +113,31 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 29 | 113 | feed.last_checked = last_checked; |
| 30 | 114 | needs_save = true; |
| 31 | 115 | } |
| 116 | + if let Some(name) = feed_name { | |
| 117 | + state.record_history( | |
| 118 | + crate::config::history::OperationType::SyncFeed, | |
| 119 | + format!("Synchronisation terminée : {}", name), | |
| 120 | + ); | |
| 121 | + } | |
| 122 | + } | |
| 123 | + SyncMessage::SyncFailed { feed_id, error } => { | |
| 124 | + let feed_name = state | |
| 125 | + .feeds | |
| 126 | + .iter() | |
| 127 | + .find(|f| f.id == feed_id) | |
| 128 | + .map(|f| f.name.clone()) | |
| 129 | + .unwrap_or_else(|| feed_id.clone()); | |
| 130 | + state.notification_manager.error(format!( | |
| 131 | + "Échec de la synchronisation de \"{}\" : {}", | |
| 132 | + feed_name, error | |
| 133 | + )); | |
| 134 | + state.record_history_failure( | |
| 135 | + crate::config::history::OperationType::SyncError, | |
| 136 | + format!("Échec de synchronisation : {}", feed_name), | |
| 137 | + error, | |
| 138 | + ); | |
| 32 | 139 | } |
| 33 | - crate::config::sync_task::SyncMessage::EpisodeDownloadFailed { | |
| 140 | + SyncMessage::EpisodeDownloadFailed { | |
| 34 | 141 | feed_id, |
| 35 | 142 | episode_title, |
| 36 | 143 | error, |
| @@ -41,98 +148,148 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 41 | 148 | .find(|f| f.id == feed_id) |
| 42 | 149 | .map(|f| f.name.clone()) |
| 43 | 150 | .unwrap_or_else(|| feed_id.clone()); |
| 151 | + | |
| 152 | + // Retirer l'épisode des téléchargements en cours (identifié par son titre) | |
| 153 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | |
| 154 | + if let Some(episode) = state | |
| 155 | + .detail_episodes | |
| 156 | + .iter() | |
| 157 | + .find(|e| e.title == episode_title) | |
| 158 | + { | |
| 159 | + let id = episode.id.clone(); | |
| 160 | + state.downloading_episode_ids.remove(&id); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 44 | 164 | state.notification_manager.error(format!( |
| 45 | 165 | "Échec du téléchargement de \"{}\" ({}) : {}", |
| 46 | 166 | episode_title, feed_name, error |
| 47 | 167 | )); |
| 168 | + state.record_history_failure( | |
| 169 | + crate::config::history::OperationType::DownloadError, | |
| 170 | + format!("Échec de téléchargement : {}", episode_title), | |
| 171 | + error, | |
| 172 | + ); | |
| 173 | + } | |
| 174 | + SyncMessage::EpisodesLoaded { feed_id, episodes } => { | |
| 175 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | |
| 176 | + // Nettoyer les téléchargements obsolètes (épisodes disparus du flux) | |
| 177 | + let loaded_ids: std::collections::HashSet<String> = | |
| 178 | + episodes.iter().map(|e| e.id.clone()).collect(); | |
| 179 | + state | |
| 180 | + .downloading_episode_ids | |
| 181 | + .retain(|id| loaded_ids.contains(id)); | |
| 182 | + | |
| 183 | + if let Some(feed) = state.feeds.iter().find(|f| f.id == feed_id) { | |
| 184 | + let download_path = feed.download_path.clone(); | |
| 185 | + let mut episodes = episodes; | |
| 186 | + for episode in &mut episodes { | |
| 187 | + episode.downloaded = !crate::rss::downloader::needs_download( | |
| 188 | + episode, | |
| 189 | + std::path::Path::new(&download_path), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + state.detail_episodes = episodes; | |
| 193 | + state.detail_loading = false; | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | + SyncMessage::EpisodesLoadFailed { feed_id, error } => { | |
| 198 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | |
| 199 | + state.detail_loading = false; | |
| 200 | + state.detail_error = Some(error); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + SyncMessage::EpisodeDownloadCompleted { | |
| 204 | + feed_id, | |
| 205 | + episode_id, | |
| 206 | + episode_title, | |
| 207 | + size, | |
| 208 | + } => { | |
| 209 | + state.downloading_episode_ids.remove(&episode_id); | |
| 210 | + let feed_name = state | |
| 211 | + .feeds | |
| 212 | + .iter() | |
| 213 | + .find(|f| f.id == feed_id) | |
| 214 | + .map(|f| f.name.clone()) | |
| 215 | + .unwrap_or_else(|| feed_id.clone()); | |
| 216 | + | |
| 217 | + // Update feed statistics | |
| 218 | + if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) { | |
| 219 | + feed.downloaded_episodes_count += 1; | |
| 220 | + feed.last_downloaded_episode_name = Some(episode_title.clone()); | |
| 221 | + feed.last_downloaded_episode_date = Some(chrono::Utc::now()); | |
| 222 | + needs_save = true; | |
| 223 | + } | |
| 224 | + | |
| 225 | + // Mark the episode as downloaded in the detail view | |
| 226 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | |
| 227 | + if let Some(episode) = state | |
| 228 | + .detail_episodes | |
| 229 | + .iter_mut() | |
| 230 | + .find(|e| e.id == episode_id) | |
| 231 | + { | |
| 232 | + episode.downloaded = true; | |
| 233 | + if let Some(size) = size { | |
| 234 | + episode.file_size = Some(size); | |
| 235 | + } | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + state | |
| 240 | + .notification_manager | |
| 241 | + .success(format!("Épisode téléchargé : {}", episode_title)); | |
| 242 | + state.record_history( | |
| 243 | + crate::config::history::OperationType::DownloadEpisode, | |
| 244 | + format!("Épisode téléchargé ({}), flux : {}", episode_title, feed_name), | |
| 245 | + ); | |
| 48 | 246 | } |
| 49 | - _ => {} | |
| 247 | + SyncMessage::SyncStarted { .. } => {} | |
| 50 | 248 | } |
| 51 | 249 | } |
| 52 | - | |
| 250 | + | |
| 53 | 251 | // Save feeds if any download statistics were updated |
| 54 | 252 | if needs_save { |
| 55 | 253 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { |
| 56 | 254 | log::error!("Failed to save feeds after download: {}", e); |
| 57 | 255 | } |
| 58 | 256 | } |
| 59 | - | |
| 60 | - // Header avec titre et icône | |
| 61 | - ui.horizontal(|ui| { | |
| 62 | - ui.heading(egui::RichText::new("🎙️ RSS Podcast Downloader").color(egui::Color32::from_rgb(130, 170, 250))); | |
| 63 | - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 64 | - ui.label(egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))).color(egui::Color32::from_rgb(150, 190, 255))); | |
| 65 | - }); | |
| 66 | - }); | |
| 67 | - | |
| 68 | - ui.separator(); | |
| 69 | - | |
| 70 | - // Barre d'outils avec filtres et actions | |
| 71 | - ui.horizontal(|ui| { | |
| 72 | - ui.vertical(|ui| { | |
| 73 | - // Filtres | |
| 74 | - crate::ui::filter_panel::draw_filter_panel(state, ui); | |
| 75 | - }); | |
| 76 | - }); | |
| 77 | - | |
| 78 | - ui.separator(); | |
| 79 | - | |
| 80 | - // Boutons d'action principaux (améliorés) | |
| 81 | - ui.horizontal(|ui| { | |
| 82 | - ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0); | |
| 83 | - draw_action_buttons(state, ui); | |
| 84 | - }); | |
| 85 | - | |
| 86 | - ui.add_space(16.0); | |
| 87 | - ui.separator(); | |
| 88 | - | |
| 89 | - // Section des flux | |
| 90 | - ui.collapsing("📻 Mes Flux RSS", |ui| { | |
| 91 | - if state.has_feeds() { | |
| 92 | - draw_feed_list(state, ui); | |
| 93 | - } else { | |
| 94 | - draw_empty_state(ui); | |
| 95 | - } | |
| 96 | - }); | |
| 97 | - | |
| 98 | - ui.separator(); | |
| 99 | - | |
| 100 | - // Section des playlists | |
| 101 | - ui.collapsing("📋 Playlists", |ui| { | |
| 102 | - crate::ui::playlist_list::draw_playlist_list(state, ui); | |
| 103 | - }); | |
| 104 | - | |
| 105 | - // Dialogues | |
| 106 | - crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); | |
| 107 | - crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); | |
| 108 | - crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | |
| 109 | - crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | |
| 110 | - crate::ui::export_import::draw_export_dialog(state, ui); | |
| 111 | - crate::ui::export_import::draw_import_dialog(state, ui); | |
| 112 | 257 | } |
| 113 | 258 | |
| 114 | 259 | /// Draws the action buttons |
| 115 | 260 | fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) { |
| 116 | 261 | ui.horizontal(|ui| { |
| 117 | - // Boutons principaux avec icônes et style amélioré | |
| 118 | - if ui.button(egui::RichText::new("➕ Ajouter un flux").size(14.0)).clicked() { | |
| 262 | + // Boutons principaux | |
| 263 | + if ui | |
| 264 | + .button(egui::RichText::new("➕ Ajouter un flux").size(14.0)) | |
| 265 | + .clicked() | |
| 266 | + { | |
| 119 | 267 | state.clear_new_feed_form(); |
| 120 | 268 | state.show_add_feed_dialog = true; |
| 121 | 269 | } |
| 122 | 270 | |
| 123 | - if ui.button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)).clicked() { | |
| 124 | - sync_all_feeds(state); | |
| 271 | + if ui | |
| 272 | + .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)) | |
| 273 | + .clicked() | |
| 274 | + { | |
| 275 | + state.start_sync_all_feeds(); | |
| 125 | 276 | } |
| 126 | 277 | |
| 127 | 278 | ui.add_space(10.0); |
| 128 | 279 | |
| 129 | 280 | // Boutons secondaires |
| 130 | - if ui.button(egui::RichText::new("📤 Exporter").size(14.0)).clicked() { | |
| 281 | + if ui | |
| 282 | + .button(egui::RichText::new("📤 Exporter").size(14.0)) | |
| 283 | + .clicked() | |
| 284 | + { | |
| 131 | 285 | state.clear_export_import_error(); |
| 132 | 286 | state.show_export_dialog = true; |
| 133 | 287 | } |
| 134 | 288 | |
| 135 | - if ui.button(egui::RichText::new("📥 Importer").size(14.0)).clicked() { | |
| 289 | + if ui | |
| 290 | + .button(egui::RichText::new("📥 Importer").size(14.0)) | |
| 291 | + .clicked() | |
| 292 | + { | |
| 136 | 293 | state.clear_export_import_error(); |
| 137 | 294 | state.show_import_dialog = true; |
| 138 | 295 | } |
| @@ -140,39 +297,28 @@ fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) { | ||
| 140 | 297 | } |
| 141 | 298 | |
| 142 | 299 | /// Draws the empty state message |
| 143 | -fn draw_empty_state(ui: &mut Ui) { | |
| 300 | +fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) { | |
| 144 | 301 | ui.vertical_centered_justified(|ui| { |
| 145 | 302 | ui.add_space(40.0); |
| 146 | 303 | ui.label(egui::RichText::new("🎧").size(48.0)); |
| 147 | 304 | ui.add_space(16.0); |
| 148 | - ui.label(egui::RichText::new("Bienvenue dans RSS Podcast Downloader").size(20.0).color(egui::Color32::from_rgb(220, 230, 245))); | |
| 305 | + ui.label( | |
| 306 | + egui::RichText::new("Bienvenue dans RSS Podcast Downloader") | |
| 307 | + .size(20.0) | |
| 308 | + .color(colors.text_primary), | |
| 309 | + ); | |
| 149 | 310 | ui.add_space(8.0); |
| 150 | - ui.label(egui::RichText::new("Aucun flux configuré pour le moment").size(16.0).color(egui::Color32::from_rgb(180, 190, 210))); | |
| 311 | + ui.label( | |
| 312 | + egui::RichText::new("Aucun flux configuré pour le moment") | |
| 313 | + .size(16.0) | |
| 314 | + .color(colors.text_secondary), | |
| 315 | + ); | |
| 151 | 316 | ui.add_space(16.0); |
| 152 | - ui.label(egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer").size(14.0).color(egui::Color32::from_rgb(150, 190, 255))); | |
| 317 | + ui.label( | |
| 318 | + egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer") | |
| 319 | + .size(14.0) | |
| 320 | + .color(colors.accent), | |
| 321 | + ); | |
| 153 | 322 | ui.add_space(40.0); |
| 154 | 323 | }); |
| 155 | 324 | } |
| 156 | - | |
| 157 | -/// Draws the feed list (delegates to feed_list module) | |
| 158 | -pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | |
| 159 | - crate::ui::feed_list::draw_feed_list(state, ui); | |
| 160 | -} | |
| 161 | - | |
| 162 | -/// Synchronizes all feeds asynchronously | |
| 163 | -fn sync_all_feeds(state: &mut AppState) { | |
| 164 | - if !state.has_feeds() { | |
| 165 | - state.notification_manager.info("Aucun flux à synchroniser"); | |
| 166 | - return; | |
| 167 | - } | |
| 168 | - | |
| 169 | - log::info!("Starting asynchronous synchronization of all feeds"); | |
| 170 | - | |
| 171 | - // Clone feeds for async task (we'll update state.feeds from messages) | |
| 172 | - let feeds_to_sync = state.feeds.clone(); | |
| 173 | - | |
| 174 | - // Start async sync | |
| 175 | - state.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | |
| 176 | - | |
| 177 | - state.notification_manager.info("Synchronisation de tous les flux en cours..."); | |
| 178 | -} | |
| @@ -3,25 +3,109 @@ | |||
| 3 | //! This module contains the main home screen layout. | 3 | //! This module contains the main home screen layout. |
| 4 | 4 | ||
| 5 | use crate::config::AppState; | 5 | use crate::config::AppState; |
| 6 | +use crate::config::sync_task::SyncMessage; | ||
| 7 | +use crate::ui::theme::ThemeColors; | ||
| 6 | use egui::Ui; | 8 | use egui::Ui; |
| 7 | 9 | ||
| 8 | /// Draws the home screen | 10 | /// Draws the home screen |
| 9 | pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | 11 | pub fn draw_home(state: &mut AppState, ui: &mut Ui) { |
| 10 | // Process sync messages first to update feeds with latest download statistics | 12 | // Process sync messages first to update feeds with latest download statistics |
| 11 | - // This ensures the feed list displays up-to-date information | 13 | + process_sync_messages(state); |
| 14 | + | ||
| 15 | + // Header avec titre et version | ||
| 16 | + let colors = ThemeColors::for_theme(state.get_theme()); | ||
| 17 | + ui.horizontal(|ui| { | ||
| 18 | + ui.heading( | ||
| 19 | + egui::RichText::new("🎙️ RSS Podcast Downloader").color(colors.accent), | ||
| 20 | + ); | ||
| 21 | + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | ||
| 22 | + ui.label( | ||
| 23 | + egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) | ||
| 24 | + .color(colors.accent), | ||
| 25 | + ); | ||
| 26 | + }); | ||
| 27 | + }); | ||
| 28 | + | ||
| 29 | + ui.separator(); | ||
| 30 | + | ||
| 31 | + // Barre d'outils : recherche + filtres | ||
| 32 | + ui.horizontal(|ui| { | ||
| 33 | + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); | ||
| 34 | + crate::ui::search_bar::draw_search_bar(state, ui); | ||
| 35 | + }); | ||
| 36 | + | ||
| 37 | + ui.horizontal(|ui| { | ||
| 38 | + crate::ui::filter_panel::draw_filter_panel(state, ui); | ||
| 39 | + }); | ||
| 40 | + | ||
| 41 | + ui.separator(); | ||
| 42 | + | ||
| 43 | + // Boutons d'action principaux | ||
| 44 | + ui.horizontal(|ui| { | ||
| 45 | + ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0); | ||
| 46 | + draw_action_buttons(state, ui); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + ui.add_space(16.0); | ||
| 50 | + ui.separator(); | ||
| 51 | + | ||
| 52 | + if state.detail_feed_id.is_some() { | ||
| 53 | + // Vue détaillée d'un flux : remplace la liste des flux et les playlists | ||
| 54 | + crate::ui::feed_detail::draw_feed_detail(state, ui); | ||
| 55 | + } else { | ||
| 56 | + // Section des flux (ouverte par défaut) | ||
| 57 | + egui::CollapsingHeader::new("📻 Mes Flux RSS") | ||
| 58 | + .default_open(true) | ||
| 59 | + .show(ui, |ui| { | ||
| 60 | + if state.has_feeds() { | ||
| 61 | + crate::ui::feed_list::draw_feed_list(state, ui); | ||
| 62 | + } else { | ||
| 63 | + draw_empty_state(ui, &colors); | ||
| 64 | + } | ||
| 65 | + }); | ||
| 66 | + | ||
| 67 | + ui.separator(); | ||
| 68 | + | ||
| 69 | + // Section des playlists | ||
| 70 | + egui::CollapsingHeader::new("📋 Playlists") | ||
| 71 | + .default_open(true) | ||
| 72 | + .show(ui, |ui| { | ||
| 73 | + crate::ui::playlist_list::draw_playlist_list(state, ui); | ||
| 74 | + }); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + // Fenêtres Statistiques et Historique | ||
| 78 | + crate::ui::stats_panel::draw_stats_window(state, ui); | ||
| 79 | + crate::ui::history_panel::draw_history_window(state, ui); | ||
| 80 | + | ||
| 81 | + // Dialogues | ||
| 82 | + crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); | ||
| 83 | + crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); | ||
| 84 | + crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | ||
| 85 | + crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | ||
| 86 | + crate::ui::export_import::draw_export_dialog(state, ui); | ||
| 87 | + crate::ui::export_import::draw_import_dialog(state, ui); | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +/// Processes messages from the async sync tasks and updates the UI state | ||
| 91 | +fn process_sync_messages(state: &mut AppState) { | ||
| 12 | let messages = state.sync_task_manager.process_messages(); | 92 | let messages = state.sync_task_manager.process_messages(); |
| 13 | let mut needs_save = false; | 93 | let mut needs_save = false; |
| 14 | for message in messages { | 94 | for message in messages { |
| 15 | match message { | 95 | match message { |
| 16 | - crate::config::sync_task::SyncMessage::SyncCompleted { | 96 | + SyncMessage::SyncCompleted { |
| 17 | feed_id, | 97 | feed_id, |
| 18 | downloaded_episodes_count, | 98 | downloaded_episodes_count, |
| 19 | last_downloaded_episode_name, | 99 | last_downloaded_episode_name, |
| 20 | last_downloaded_episode_date, | 100 | last_downloaded_episode_date, |
| 21 | last_checked, | 101 | last_checked, |
| 22 | - .. | ||
| 23 | } => { | 102 | } => { |
| 24 | // Find the feed and update its statistics | 103 | // Find the feed and update its statistics |
| 104 | + let feed_name = state | ||
| 105 | + .feeds | ||
| 106 | + .iter() | ||
| 107 | + .find(|f| f.id == feed_id) | ||
| 108 | + .map(|f| f.name.clone()); | ||
| 25 | if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) { | 109 | if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) { |
| 26 | feed.downloaded_episodes_count = downloaded_episodes_count; | 110 | feed.downloaded_episodes_count = downloaded_episodes_count; |
| 27 | feed.last_downloaded_episode_name = last_downloaded_episode_name; | 111 | feed.last_downloaded_episode_name = last_downloaded_episode_name; |
| @@ -29,8 +113,31 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 29 | feed.last_checked = last_checked; | 113 | feed.last_checked = last_checked; |
| 30 | needs_save = true; | 114 | needs_save = true; |
| 31 | } | 115 | } |
| 116 | + if let Some(name) = feed_name { | ||
| 117 | + state.record_history( | ||
| 118 | + crate::config::history::OperationType::SyncFeed, | ||
| 119 | + format!("Synchronisation terminée : {}", name), | ||
| 120 | + ); | ||
| 121 | + } | ||
| 122 | + } | ||
| 123 | + SyncMessage::SyncFailed { feed_id, error } => { | ||
| 124 | + let feed_name = state | ||
| 125 | + .feeds | ||
| 126 | + .iter() | ||
| 127 | + .find(|f| f.id == feed_id) | ||
| 128 | + .map(|f| f.name.clone()) | ||
| 129 | + .unwrap_or_else(|| feed_id.clone()); | ||
| 130 | + state.notification_manager.error(format!( | ||
| 131 | + "Échec de la synchronisation de \"{}\" : {}", | ||
| 132 | + feed_name, error | ||
| 133 | + )); | ||
| 134 | + state.record_history_failure( | ||
| 135 | + crate::config::history::OperationType::SyncError, | ||
| 136 | + format!("Échec de synchronisation : {}", feed_name), | ||
| 137 | + error, | ||
| 138 | + ); | ||
| 32 | } | 139 | } |
| 33 | - crate::config::sync_task::SyncMessage::EpisodeDownloadFailed { | 140 | + SyncMessage::EpisodeDownloadFailed { |
| 34 | feed_id, | 141 | feed_id, |
| 35 | episode_title, | 142 | episode_title, |
| 36 | error, | 143 | error, |
| @@ -41,98 +148,148 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 41 | .find(|f| f.id == feed_id) | 148 | .find(|f| f.id == feed_id) |
| 42 | .map(|f| f.name.clone()) | 149 | .map(|f| f.name.clone()) |
| 43 | .unwrap_or_else(|| feed_id.clone()); | 150 | .unwrap_or_else(|| feed_id.clone()); |
| 151 | + | ||
| 152 | + // Retirer l'épisode des téléchargements en cours (identifié par son titre) | ||
| 153 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | ||
| 154 | + if let Some(episode) = state | ||
| 155 | + .detail_episodes | ||
| 156 | + .iter() | ||
| 157 | + .find(|e| e.title == episode_title) | ||
| 158 | + { | ||
| 159 | + let id = episode.id.clone(); | ||
| 160 | + state.downloading_episode_ids.remove(&id); | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 44 | state.notification_manager.error(format!( | 164 | state.notification_manager.error(format!( |
| 45 | "Échec du téléchargement de \"{}\" ({}) : {}", | 165 | "Échec du téléchargement de \"{}\" ({}) : {}", |
| 46 | episode_title, feed_name, error | 166 | episode_title, feed_name, error |
| 47 | )); | 167 | )); |
| 168 | + state.record_history_failure( | ||
| 169 | + crate::config::history::OperationType::DownloadError, | ||
| 170 | + format!("Échec de téléchargement : {}", episode_title), | ||
| 171 | + error, | ||
| 172 | + ); | ||
| 173 | + } | ||
| 174 | + SyncMessage::EpisodesLoaded { feed_id, episodes } => { | ||
| 175 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | ||
| 176 | + // Nettoyer les téléchargements obsolètes (épisodes disparus du flux) | ||
| 177 | + let loaded_ids: std::collections::HashSet<String> = | ||
| 178 | + episodes.iter().map(|e| e.id.clone()).collect(); | ||
| 179 | + state | ||
| 180 | + .downloading_episode_ids | ||
| 181 | + .retain(|id| loaded_ids.contains(id)); | ||
| 182 | + | ||
| 183 | + if let Some(feed) = state.feeds.iter().find(|f| f.id == feed_id) { | ||
| 184 | + let download_path = feed.download_path.clone(); | ||
| 185 | + let mut episodes = episodes; | ||
| 186 | + for episode in &mut episodes { | ||
| 187 | + episode.downloaded = !crate::rss::downloader::needs_download( | ||
| 188 | + episode, | ||
| 189 | + std::path::Path::new(&download_path), | ||
| 190 | + ); | ||
| 191 | + } | ||
| 192 | + state.detail_episodes = episodes; | ||
| 193 | + state.detail_loading = false; | ||
| 194 | + } | ||
| 195 | + } | ||
| 196 | + } | ||
| 197 | + SyncMessage::EpisodesLoadFailed { feed_id, error } => { | ||
| 198 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | ||
| 199 | + state.detail_loading = false; | ||
| 200 | + state.detail_error = Some(error); | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + SyncMessage::EpisodeDownloadCompleted { | ||
| 204 | + feed_id, | ||
| 205 | + episode_id, | ||
| 206 | + episode_title, | ||
| 207 | + size, | ||
| 208 | + } => { | ||
| 209 | + state.downloading_episode_ids.remove(&episode_id); | ||
| 210 | + let feed_name = state | ||
| 211 | + .feeds | ||
| 212 | + .iter() | ||
| 213 | + .find(|f| f.id == feed_id) | ||
| 214 | + .map(|f| f.name.clone()) | ||
| 215 | + .unwrap_or_else(|| feed_id.clone()); | ||
| 216 | + | ||
| 217 | + // Update feed statistics | ||
| 218 | + if let Some(feed) = state.feeds.iter_mut().find(|f| f.id == feed_id) { | ||
| 219 | + feed.downloaded_episodes_count += 1; | ||
| 220 | + feed.last_downloaded_episode_name = Some(episode_title.clone()); | ||
| 221 | + feed.last_downloaded_episode_date = Some(chrono::Utc::now()); | ||
| 222 | + needs_save = true; | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + // Mark the episode as downloaded in the detail view | ||
| 226 | + if state.detail_feed_id.as_deref() == Some(feed_id.as_str()) { | ||
| 227 | + if let Some(episode) = state | ||
| 228 | + .detail_episodes | ||
| 229 | + .iter_mut() | ||
| 230 | + .find(|e| e.id == episode_id) | ||
| 231 | + { | ||
| 232 | + episode.downloaded = true; | ||
| 233 | + if let Some(size) = size { | ||
| 234 | + episode.file_size = Some(size); | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + state | ||
| 240 | + .notification_manager | ||
| 241 | + .success(format!("Épisode téléchargé : {}", episode_title)); | ||
| 242 | + state.record_history( | ||
| 243 | + crate::config::history::OperationType::DownloadEpisode, | ||
| 244 | + format!("Épisode téléchargé ({}), flux : {}", episode_title, feed_name), | ||
| 245 | + ); | ||
| 48 | } | 246 | } |
| 49 | - _ => {} | 247 | + SyncMessage::SyncStarted { .. } => {} |
| 50 | } | 248 | } |
| 51 | } | 249 | } |
| 52 | - | 250 | + |
| 53 | // Save feeds if any download statistics were updated | 251 | // Save feeds if any download statistics were updated |
| 54 | if needs_save { | 252 | if needs_save { |
| 55 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { | 253 | if let Err(e) = crate::config::storage::save_feeds_default(&state.feeds) { |
| 56 | log::error!("Failed to save feeds after download: {}", e); | 254 | log::error!("Failed to save feeds after download: {}", e); |
| 57 | } | 255 | } |
| 58 | } | 256 | } |
| 59 | - | ||
| 60 | - // Header avec titre et icône | ||
| 61 | - ui.horizontal(|ui| { | ||
| 62 | - ui.heading(egui::RichText::new("🎙️ RSS Podcast Downloader").color(egui::Color32::from_rgb(130, 170, 250))); | ||
| 63 | - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | ||
| 64 | - ui.label(egui::RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))).color(egui::Color32::from_rgb(150, 190, 255))); | ||
| 65 | - }); | ||
| 66 | - }); | ||
| 67 | - | ||
| 68 | - ui.separator(); | ||
| 69 | - | ||
| 70 | - // Barre d'outils avec filtres et actions | ||
| 71 | - ui.horizontal(|ui| { | ||
| 72 | - ui.vertical(|ui| { | ||
| 73 | - // Filtres | ||
| 74 | - crate::ui::filter_panel::draw_filter_panel(state, ui); | ||
| 75 | - }); | ||
| 76 | - }); | ||
| 77 | - | ||
| 78 | - ui.separator(); | ||
| 79 | - | ||
| 80 | - // Boutons d'action principaux (améliorés) | ||
| 81 | - ui.horizontal(|ui| { | ||
| 82 | - ui.spacing_mut().item_spacing = egui::Vec2::new(16.0, 0.0); | ||
| 83 | - draw_action_buttons(state, ui); | ||
| 84 | - }); | ||
| 85 | - | ||
| 86 | - ui.add_space(16.0); | ||
| 87 | - ui.separator(); | ||
| 88 | - | ||
| 89 | - // Section des flux | ||
| 90 | - ui.collapsing("📻 Mes Flux RSS", |ui| { | ||
| 91 | - if state.has_feeds() { | ||
| 92 | - draw_feed_list(state, ui); | ||
| 93 | - } else { | ||
| 94 | - draw_empty_state(ui); | ||
| 95 | - } | ||
| 96 | - }); | ||
| 97 | - | ||
| 98 | - ui.separator(); | ||
| 99 | - | ||
| 100 | - // Section des playlists | ||
| 101 | - ui.collapsing("📋 Playlists", |ui| { | ||
| 102 | - crate::ui::playlist_list::draw_playlist_list(state, ui); | ||
| 103 | - }); | ||
| 104 | - | ||
| 105 | - // Dialogues | ||
| 106 | - crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); | ||
| 107 | - crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); | ||
| 108 | - crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | ||
| 109 | - crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | ||
| 110 | - crate::ui::export_import::draw_export_dialog(state, ui); | ||
| 111 | - crate::ui::export_import::draw_import_dialog(state, ui); | ||
| 112 | } | 257 | } |
| 113 | 258 | ||
| 114 | /// Draws the action buttons | 259 | /// Draws the action buttons |
| 115 | fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) { | 260 | fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) { |
| 116 | ui.horizontal(|ui| { | 261 | ui.horizontal(|ui| { |
| 117 | - // Boutons principaux avec icônes et style amélioré | 262 | + // Boutons principaux |
| 118 | - if ui.button(egui::RichText::new("➕ Ajouter un flux").size(14.0)).clicked() { | 263 | + if ui |
| 264 | + .button(egui::RichText::new("➕ Ajouter un flux").size(14.0)) | ||
| 265 | + .clicked() | ||
| 266 | + { | ||
| 119 | state.clear_new_feed_form(); | 267 | state.clear_new_feed_form(); |
| 120 | state.show_add_feed_dialog = true; | 268 | state.show_add_feed_dialog = true; |
| 121 | } | 269 | } |
| 122 | 270 | ||
| 123 | - if ui.button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)).clicked() { | 271 | + if ui |
| 124 | - sync_all_feeds(state); | 272 | + .button(egui::RichText::new("🔄 Synchroniser tous").size(14.0)) |
| 273 | + .clicked() | ||
| 274 | + { | ||
| 275 | + state.start_sync_all_feeds(); | ||
| 125 | } | 276 | } |
| 126 | 277 | ||
| 127 | ui.add_space(10.0); | 278 | ui.add_space(10.0); |
| 128 | 279 | ||
| 129 | // Boutons secondaires | 280 | // Boutons secondaires |
| 130 | - if ui.button(egui::RichText::new("📤 Exporter").size(14.0)).clicked() { | 281 | + if ui |
| 282 | + .button(egui::RichText::new("📤 Exporter").size(14.0)) | ||
| 283 | + .clicked() | ||
| 284 | + { | ||
| 131 | state.clear_export_import_error(); | 285 | state.clear_export_import_error(); |
| 132 | state.show_export_dialog = true; | 286 | state.show_export_dialog = true; |
| 133 | } | 287 | } |
| 134 | 288 | ||
| 135 | - if ui.button(egui::RichText::new("📥 Importer").size(14.0)).clicked() { | 289 | + if ui |
| 290 | + .button(egui::RichText::new("📥 Importer").size(14.0)) | ||
| 291 | + .clicked() | ||
| 292 | + { | ||
| 136 | state.clear_export_import_error(); | 293 | state.clear_export_import_error(); |
| 137 | state.show_import_dialog = true; | 294 | state.show_import_dialog = true; |
| 138 | } | 295 | } |
| @@ -140,39 +297,28 @@ fn draw_action_buttons(state: &mut AppState, ui: &mut Ui) { | |||
| 140 | } | 297 | } |
| 141 | 298 | ||
| 142 | /// Draws the empty state message | 299 | /// Draws the empty state message |
| 143 | -fn draw_empty_state(ui: &mut Ui) { | 300 | +fn draw_empty_state(ui: &mut Ui, colors: &ThemeColors) { |
| 144 | ui.vertical_centered_justified(|ui| { | 301 | ui.vertical_centered_justified(|ui| { |
| 145 | ui.add_space(40.0); | 302 | ui.add_space(40.0); |
| 146 | ui.label(egui::RichText::new("🎧").size(48.0)); | 303 | ui.label(egui::RichText::new("🎧").size(48.0)); |
| 147 | ui.add_space(16.0); | 304 | ui.add_space(16.0); |
| 148 | - ui.label(egui::RichText::new("Bienvenue dans RSS Podcast Downloader").size(20.0).color(egui::Color32::from_rgb(220, 230, 245))); | 305 | + ui.label( |
| 306 | + egui::RichText::new("Bienvenue dans RSS Podcast Downloader") | ||
| 307 | + .size(20.0) | ||
| 308 | + .color(colors.text_primary), | ||
| 309 | + ); | ||
| 149 | ui.add_space(8.0); | 310 | ui.add_space(8.0); |
| 150 | - ui.label(egui::RichText::new("Aucun flux configuré pour le moment").size(16.0).color(egui::Color32::from_rgb(180, 190, 210))); | 311 | + ui.label( |
| 312 | + egui::RichText::new("Aucun flux configuré pour le moment") | ||
| 313 | + .size(16.0) | ||
| 314 | + .color(colors.text_secondary), | ||
| 315 | + ); | ||
| 151 | ui.add_space(16.0); | 316 | ui.add_space(16.0); |
| 152 | - ui.label(egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer").size(14.0).color(egui::Color32::from_rgb(150, 190, 255))); | 317 | + ui.label( |
| 318 | + egui::RichText::new("👉 Cliquez sur '➕ Ajouter un flux' pour commencer") | ||
| 319 | + .size(14.0) | ||
| 320 | + .color(colors.accent), | ||
| 321 | + ); | ||
| 153 | ui.add_space(40.0); | 322 | ui.add_space(40.0); |
| 154 | }); | 323 | }); |
| 155 | } | 324 | } |
| 156 | - | ||
| 157 | -/// Draws the feed list (delegates to feed_list module) | ||
| 158 | -pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | ||
| 159 | - crate::ui::feed_list::draw_feed_list(state, ui); | ||
| 160 | -} | ||
| 161 | - | ||
| 162 | -/// Synchronizes all feeds asynchronously | ||
| 163 | -fn sync_all_feeds(state: &mut AppState) { | ||
| 164 | - if !state.has_feeds() { | ||
| 165 | - state.notification_manager.info("Aucun flux à synchroniser"); | ||
| 166 | - return; | ||
| 167 | - } | ||
| 168 | - | ||
| 169 | - log::info!("Starting asynchronous synchronization of all feeds"); | ||
| 170 | - | ||
| 171 | - // Clone feeds for async task (we'll update state.feeds from messages) | ||
| 172 | - let feeds_to_sync = state.feeds.clone(); | ||
| 173 | - | ||
| 174 | - // Start async sync | ||
| 175 | - state.sync_task_manager.start_sync_all_feeds(feeds_to_sync); | ||
| 176 | - | ||
| 177 | - state.notification_manager.info("Synchronisation de tous les flux en cours..."); | ||
| 178 | -} | ||
modified
src/ui/mod.rs +15 -0 | @@ -4,6 +4,7 @@ | ||
| 4 | 4 | |
| 5 | 5 | pub mod home; |
| 6 | 6 | pub mod feed_list; |
| 7 | +pub mod feed_detail; | |
| 7 | 8 | pub mod add_feed_dialog; |
| 8 | 9 | pub mod feed_edit_dialog; |
| 9 | 10 | pub mod status_bar; |
| @@ -15,3 +16,17 @@ pub mod history_panel; | ||
| 15 | 16 | pub mod search_bar; |
| 16 | 17 | pub mod stats_panel; |
| 17 | 18 | pub mod sync_status; |
| 19 | +pub mod confirm_dialog; | |
| 20 | +pub mod menu_bar; | |
| 21 | +pub mod theme; | |
| 22 | + | |
| 23 | +/// Tronque une chaîne à `max_chars` caractères, sans couper au milieu | |
| 24 | +/// d'un caractère multi-octets (évite tout panic sur l'UTF-8). | |
| 25 | +pub fn truncate(text: &str, max_chars: usize) -> String { | |
| 26 | + if text.chars().count() <= max_chars { | |
| 27 | + return text.to_string(); | |
| 28 | + } | |
| 29 | + let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect(); | |
| 30 | + truncated.push('…'); | |
| 31 | + truncated | |
| 32 | +} | |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | pub mod home; | 5 | pub mod home; |
| 6 | pub mod feed_list; | 6 | pub mod feed_list; |
| 7 | +pub mod feed_detail; | ||
| 7 | pub mod add_feed_dialog; | 8 | pub mod add_feed_dialog; |
| 8 | pub mod feed_edit_dialog; | 9 | pub mod feed_edit_dialog; |
| 9 | pub mod status_bar; | 10 | pub mod status_bar; |
| @@ -15,3 +16,17 @@ pub mod history_panel; | |||
| 15 | pub mod search_bar; | 16 | pub mod search_bar; |
| 16 | pub mod stats_panel; | 17 | pub mod stats_panel; |
| 17 | pub mod sync_status; | 18 | pub mod sync_status; |
| 19 | +pub mod confirm_dialog; | ||
| 20 | +pub mod menu_bar; | ||
| 21 | +pub mod theme; | ||
| 22 | + | ||
| 23 | +/// Tronque une chaîne à `max_chars` caractères, sans couper au milieu | ||
| 24 | +/// d'un caractère multi-octets (évite tout panic sur l'UTF-8). | ||
| 25 | +pub fn truncate(text: &str, max_chars: usize) -> String { | ||
| 26 | + if text.chars().count() <= max_chars { | ||
| 27 | + return text.to_string(); | ||
| 28 | + } | ||
| 29 | + let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect(); | ||
| 30 | + truncated.push('…'); | ||
| 31 | + truncated | ||
| 32 | +} | ||
modified
src/ui/notifications.rs +116 -104 | @@ -1,14 +1,18 @@ | ||
| 1 | 1 | //! Notifications module for RSS Podcast Downloader |
| 2 | 2 | //! |
| 3 | 3 | //! This module handles displaying download notifications to the user. |
| 4 | +//! Les notifications s'affichent en overlay flottant (coin haut droit) | |
| 5 | +//! et n'affectent pas la mise en page. | |
| 4 | 6 | |
| 5 | -use egui::{Color32, Frame, RichText, Ui}; | |
| 7 | +use egui::{Align, Color32, Context, Frame, Layout, RichText, Ui}; | |
| 6 | 8 | use std::collections::VecDeque; |
| 7 | 9 | use std::time::{Duration, Instant}; |
| 8 | 10 | |
| 9 | 11 | /// A notification to display to the user |
| 10 | 12 | #[derive(Debug, Clone)] |
| 11 | 13 | pub struct Notification { |
| 14 | + /// Unique id, used for dismissal | |
| 15 | + pub id: u64, | |
| 12 | 16 | /// The message to display |
| 13 | 17 | pub message: String, |
| 14 | 18 | /// The type/severity of the notification |
| @@ -30,36 +34,6 @@ pub enum NotificationType { | ||
| 30 | 34 | } |
| 31 | 35 | |
| 32 | 36 | impl Notification { |
| 33 | - /// Creates a new info notification | |
| 34 | - pub fn info(message: impl Into<String>) -> Self { | |
| 35 | - Self { | |
| 36 | - message: message.into(), | |
| 37 | - notification_type: NotificationType::Info, | |
| 38 | - created_at: Instant::now(), | |
| 39 | - duration: Duration::from_secs(5), | |
| 40 | - } | |
| 41 | - } | |
| 42 | - | |
| 43 | - /// Creates a new success notification | |
| 44 | - pub fn success(message: impl Into<String>) -> Self { | |
| 45 | - Self { | |
| 46 | - message: message.into(), | |
| 47 | - notification_type: NotificationType::Success, | |
| 48 | - created_at: Instant::now(), | |
| 49 | - duration: Duration::from_secs(5), | |
| 50 | - } | |
| 51 | - } | |
| 52 | - | |
| 53 | - /// Creates a new error notification | |
| 54 | - pub fn error(message: impl Into<String>) -> Self { | |
| 55 | - Self { | |
| 56 | - message: message.into(), | |
| 57 | - notification_type: NotificationType::Error, | |
| 58 | - created_at: Instant::now(), | |
| 59 | - duration: Duration::from_secs(7), // Longer for errors | |
| 60 | - } | |
| 61 | - } | |
| 62 | - | |
| 63 | 37 | /// Returns true if the notification should still be displayed |
| 64 | 38 | pub fn is_visible(&self) -> bool { |
| 65 | 39 | self.created_at.elapsed() < self.duration |
| @@ -68,9 +42,9 @@ impl Notification { | ||
| 68 | 42 | /// Returns the color for this notification type |
| 69 | 43 | pub fn color(&self) -> Color32 { |
| 70 | 44 | match self.notification_type { |
| 71 | - NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue | |
| 72 | - NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green | |
| 73 | - NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red | |
| 45 | + NotificationType::Info => Color32::from_rgb(70, 130, 220), | |
| 46 | + NotificationType::Success => Color32::from_rgb(50, 180, 80), | |
| 47 | + NotificationType::Error => Color32::from_rgb(220, 80, 80), | |
| 74 | 48 | } |
| 75 | 49 | } |
| 76 | 50 | |
| @@ -88,6 +62,8 @@ impl Notification { | ||
| 88 | 62 | #[derive(Debug, Default)] |
| 89 | 63 | pub struct NotificationManager { |
| 90 | 64 | notifications: VecDeque<Notification>, |
| 65 | + /// Counter used to generate unique notification ids | |
| 66 | + next_id: u64, | |
| 91 | 67 | } |
| 92 | 68 | |
| 93 | 69 | impl NotificationManager { |
| @@ -97,25 +73,41 @@ impl NotificationManager { | ||
| 97 | 73 | } |
| 98 | 74 | |
| 99 | 75 | /// Adds a new notification |
| 100 | - pub fn add_notification(&mut self, notification: Notification) { | |
| 101 | - // Remove expired notifications first | |
| 76 | + fn push(&mut self, notification_type: NotificationType, message: String) { | |
| 102 | 77 | self.cleanup(); |
| 103 | - self.notifications.push_back(notification); | |
| 78 | + let duration = match notification_type { | |
| 79 | + NotificationType::Error => Duration::from_secs(7), // Longer for errors | |
| 80 | + _ => Duration::from_secs(5), | |
| 81 | + }; | |
| 82 | + let id = self.next_id; | |
| 83 | + self.next_id += 1; | |
| 84 | + self.notifications.push_back(Notification { | |
| 85 | + id, | |
| 86 | + message, | |
| 87 | + notification_type, | |
| 88 | + created_at: Instant::now(), | |
| 89 | + duration, | |
| 90 | + }); | |
| 104 | 91 | } |
| 105 | 92 | |
| 106 | 93 | /// Adds an info notification |
| 107 | 94 | pub fn info(&mut self, message: impl Into<String>) { |
| 108 | - self.add_notification(Notification::info(message)); | |
| 95 | + self.push(NotificationType::Info, message.into()); | |
| 109 | 96 | } |
| 110 | 97 | |
| 111 | 98 | /// Adds a success notification |
| 112 | 99 | pub fn success(&mut self, message: impl Into<String>) { |
| 113 | - self.add_notification(Notification::success(message)); | |
| 100 | + self.push(NotificationType::Success, message.into()); | |
| 114 | 101 | } |
| 115 | 102 | |
| 116 | 103 | /// Adds an error notification |
| 117 | 104 | pub fn error(&mut self, message: impl Into<String>) { |
| 118 | - self.add_notification(Notification::error(message)); | |
| 105 | + self.push(NotificationType::Error, message.into()); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Dismisses a notification by id (e.g. via its close button) | |
| 109 | + pub fn dismiss(&mut self, id: u64) { | |
| 110 | + self.notifications.retain(|n| n.id != id); | |
| 119 | 111 | } |
| 120 | 112 | |
| 121 | 113 | /// Removes expired notifications |
| @@ -123,75 +115,95 @@ impl NotificationManager { | ||
| 123 | 115 | self.notifications.retain(|n| n.is_visible()); |
| 124 | 116 | } |
| 125 | 117 | |
| 126 | - /// Returns the list of active notifications | |
| 127 | - pub fn get_active_notifications(&self) -> Vec<&Notification> { | |
| 128 | - self.notifications.iter().filter(|n| n.is_visible()).collect() | |
| 129 | - } | |
| 130 | - | |
| 131 | - /// Draws all active notifications in the UI | |
| 132 | - pub fn draw(&mut self, ui: &mut Ui) { | |
| 118 | + /// Draws all active notifications as a floating overlay in the top-right corner | |
| 119 | + pub fn draw_overlay(&mut self, ctx: &Context) { | |
| 133 | 120 | self.cleanup(); |
| 134 | - | |
| 135 | - let active_notifications = self.get_active_notifications(); | |
| 136 | - | |
| 137 | - if active_notifications.is_empty() { | |
| 121 | + | |
| 122 | + if self.notifications.is_empty() { | |
| 138 | 123 | return; |
| 139 | 124 | } |
| 140 | 125 | |
| 141 | - // Draw notifications in a vertical stack | |
| 142 | - egui::ScrollArea::vertical().show(ui, |ui| { | |
| 143 | - for notification in active_notifications { | |
| 144 | - let progress = notification.created_at.elapsed().as_secs_f32() / notification.duration.as_secs_f32(); | |
| 145 | - | |
| 146 | - let frame = Frame { | |
| 147 | - fill: notification.color().linear_multiply(0.2), | |
| 148 | - corner_radius: 5.0.into(), | |
| 149 | - inner_margin: egui::Margin::symmetric(10, 5), | |
| 150 | - ..Default::default() | |
| 151 | - }; | |
| 152 | - | |
| 153 | - frame.show(ui, |ui| { | |
| 154 | - ui.horizontal(|ui| { | |
| 155 | - // Icon | |
| 156 | - ui.label(RichText::new(notification.icon()).color(notification.color())); | |
| 157 | - ui.add_space(8.0); | |
| 158 | - | |
| 159 | - // Message | |
| 160 | - ui.label(RichText::new(¬ification.message).color(Color32::WHITE)); | |
| 161 | - | |
| 162 | - // Progress bar (optional) - shows time remaining | |
| 163 | - if notification.duration.as_secs() > 0 { | |
| 164 | - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 165 | - let bar_width = 100.0; | |
| 166 | - let bar_height = 4.0; | |
| 167 | - let fill_width = bar_width * (1.0 - progress.clamp(0.0, 1.0)); | |
| 168 | - | |
| 169 | - ui.add_space(10.0); | |
| 170 | - // Background | |
| 171 | - let (_, bg_rect) = ui.allocate_space(egui::vec2(bar_width, bar_height)); | |
| 172 | - ui.painter().rect_filled( | |
| 173 | - bg_rect, | |
| 174 | - 5.0, | |
| 175 | - Color32::from_black_alpha(128) | |
| 176 | - ); | |
| 177 | - // Foreground (remaining time) | |
| 178 | - let fg_rect = egui::Rect { | |
| 179 | - min: bg_rect.min, | |
| 180 | - max: egui::Pos2::new( | |
| 181 | - bg_rect.min.x + fill_width, | |
| 182 | - bg_rect.max.y | |
| 183 | - ) | |
| 184 | - }; | |
| 185 | - ui.painter().rect_filled( | |
| 186 | - fg_rect, | |
| 187 | - 5.0, | |
| 188 | - notification.color() | |
| 189 | - ); | |
| 190 | - }); | |
| 126 | + let notifications: Vec<Notification> = self.notifications.iter().cloned().collect(); | |
| 127 | + let mut dismissed: Vec<u64> = Vec::new(); | |
| 128 | + | |
| 129 | + egui::Area::new(egui::Id::new("notifications_overlay")) | |
| 130 | + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0)) | |
| 131 | + .order(egui::Order::Foreground) | |
| 132 | + .interactable(true) | |
| 133 | + .show(ctx, |ui| { | |
| 134 | + ui.set_width(360.0); | |
| 135 | + ui.spacing_mut().item_spacing = egui::vec2(0.0, 6.0); | |
| 136 | + | |
| 137 | + Frame::default() | |
| 138 | + .inner_margin(egui::Margin::same(0)) | |
| 139 | + .show(ui, |ui| { | |
| 140 | + for notification in ¬ifications { | |
| 141 | + let progress = notification.created_at.elapsed().as_secs_f32() | |
| 142 | + / notification.duration.as_secs_f32(); | |
| 143 | + | |
| 144 | + Frame::popup(ui.style()) | |
| 145 | + .inner_margin(egui::Margin::symmetric(10, 6)) | |
| 146 | + .stroke(egui::Stroke::new(1.0, notification.color())) | |
| 147 | + .show(ui, |ui| { | |
| 148 | + ui.horizontal(|ui| { | |
| 149 | + ui.label(RichText::new(notification.icon())); | |
| 150 | + ui.label( | |
| 151 | + RichText::new(¬ification.message) | |
| 152 | + .color(notification.color()) | |
| 153 | + .strong(), | |
| 154 | + ); | |
| 155 | + | |
| 156 | + ui.with_layout( | |
| 157 | + Layout::right_to_left(Align::Center), | |
| 158 | + |ui| { | |
| 159 | + if ui.small_button("✕").clicked() { | |
| 160 | + dismissed.push(notification.id); | |
| 161 | + } | |
| 162 | + | |
| 163 | + // Petite barre de temps restant | |
| 164 | + let bar_width = 60.0; | |
| 165 | + let (_, bg_rect) = | |
| 166 | + ui.allocate_space(egui::vec2(bar_width, 4.0)); | |
| 167 | + ui.painter().rect_filled( | |
| 168 | + bg_rect, | |
| 169 | + 2.0, | |
| 170 | + Color32::from_black_alpha(120), | |
| 171 | + ); | |
| 172 | + let fill_width = | |
| 173 | + bar_width * (1.0 - progress.clamp(0.0, 1.0)); | |
| 174 | + let fg_rect = egui::Rect { | |
| 175 | + min: bg_rect.min, | |
| 176 | + max: egui::Pos2::new( | |
| 177 | + bg_rect.min.x + fill_width, | |
| 178 | + bg_rect.max.y, | |
| 179 | + ), | |
| 180 | + }; | |
| 181 | + ui.painter().rect_filled( | |
| 182 | + fg_rect, | |
| 183 | + 2.0, | |
| 184 | + notification.color(), | |
| 185 | + ); | |
| 186 | + }, | |
| 187 | + ); | |
| 188 | + }); | |
| 189 | + }); | |
| 191 | 190 | } |
| 192 | 191 | }); |
| 193 | - }); | |
| 194 | - } | |
| 195 | - }); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + for id in dismissed { | |
| 195 | + self.dismiss(id); | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Draws notifications inline (used by the history of this API, e.g. tests) | |
| 200 | + #[allow(dead_code)] | |
| 201 | + pub fn draw(&mut self, ui: &mut Ui) { | |
| 202 | + self.cleanup(); | |
| 203 | + let active: Vec<&Notification> = | |
| 204 | + self.notifications.iter().filter(|n| n.is_visible()).collect(); | |
| 205 | + for notification in active { | |
| 206 | + ui.label(format!("{} {}", notification.icon(), notification.message)); | |
| 207 | + } | |
| 196 | 208 | } |
| 197 | 209 | } |
| @@ -1,14 +1,18 @@ | |||
| 1 | //! Notifications module for RSS Podcast Downloader | 1 | //! Notifications module for RSS Podcast Downloader |
| 2 | //! | 2 | //! |
| 3 | //! This module handles displaying download notifications to the user. | 3 | //! This module handles displaying download notifications to the user. |
| 4 | +//! Les notifications s'affichent en overlay flottant (coin haut droit) | ||
| 5 | +//! et n'affectent pas la mise en page. | ||
| 4 | 6 | ||
| 5 | -use egui::{Color32, Frame, RichText, Ui}; | 7 | +use egui::{Align, Color32, Context, Frame, Layout, RichText, Ui}; |
| 6 | use std::collections::VecDeque; | 8 | use std::collections::VecDeque; |
| 7 | use std::time::{Duration, Instant}; | 9 | use std::time::{Duration, Instant}; |
| 8 | 10 | ||
| 9 | /// A notification to display to the user | 11 | /// A notification to display to the user |
| 10 | #[derive(Debug, Clone)] | 12 | #[derive(Debug, Clone)] |
| 11 | pub struct Notification { | 13 | pub struct Notification { |
| 14 | + /// Unique id, used for dismissal | ||
| 15 | + pub id: u64, | ||
| 12 | /// The message to display | 16 | /// The message to display |
| 13 | pub message: String, | 17 | pub message: String, |
| 14 | /// The type/severity of the notification | 18 | /// The type/severity of the notification |
| @@ -30,36 +34,6 @@ pub enum NotificationType { | |||
| 30 | } | 34 | } |
| 31 | 35 | ||
| 32 | impl Notification { | 36 | impl Notification { |
| 33 | - /// Creates a new info notification | ||
| 34 | - pub fn info(message: impl Into<String>) -> Self { | ||
| 35 | - Self { | ||
| 36 | - message: message.into(), | ||
| 37 | - notification_type: NotificationType::Info, | ||
| 38 | - created_at: Instant::now(), | ||
| 39 | - duration: Duration::from_secs(5), | ||
| 40 | - } | ||
| 41 | - } | ||
| 42 | - | ||
| 43 | - /// Creates a new success notification | ||
| 44 | - pub fn success(message: impl Into<String>) -> Self { | ||
| 45 | - Self { | ||
| 46 | - message: message.into(), | ||
| 47 | - notification_type: NotificationType::Success, | ||
| 48 | - created_at: Instant::now(), | ||
| 49 | - duration: Duration::from_secs(5), | ||
| 50 | - } | ||
| 51 | - } | ||
| 52 | - | ||
| 53 | - /// Creates a new error notification | ||
| 54 | - pub fn error(message: impl Into<String>) -> Self { | ||
| 55 | - Self { | ||
| 56 | - message: message.into(), | ||
| 57 | - notification_type: NotificationType::Error, | ||
| 58 | - created_at: Instant::now(), | ||
| 59 | - duration: Duration::from_secs(7), // Longer for errors | ||
| 60 | - } | ||
| 61 | - } | ||
| 62 | - | ||
| 63 | /// Returns true if the notification should still be displayed | 37 | /// Returns true if the notification should still be displayed |
| 64 | pub fn is_visible(&self) -> bool { | 38 | pub fn is_visible(&self) -> bool { |
| 65 | self.created_at.elapsed() < self.duration | 39 | self.created_at.elapsed() < self.duration |
| @@ -68,9 +42,9 @@ impl Notification { | |||
| 68 | /// Returns the color for this notification type | 42 | /// Returns the color for this notification type |
| 69 | pub fn color(&self) -> Color32 { | 43 | pub fn color(&self) -> Color32 { |
| 70 | match self.notification_type { | 44 | match self.notification_type { |
| 71 | - NotificationType::Info => Color32::from_rgb(70, 130, 180), // Steel blue | 45 | + NotificationType::Info => Color32::from_rgb(70, 130, 220), |
| 72 | - NotificationType::Success => Color32::from_rgb(34, 139, 34), // Forest green | 46 | + NotificationType::Success => Color32::from_rgb(50, 180, 80), |
| 73 | - NotificationType::Error => Color32::from_rgb(220, 53, 69), // Red | 47 | + NotificationType::Error => Color32::from_rgb(220, 80, 80), |
| 74 | } | 48 | } |
| 75 | } | 49 | } |
| 76 | 50 | ||
| @@ -88,6 +62,8 @@ impl Notification { | |||
| 88 | #[derive(Debug, Default)] | 62 | #[derive(Debug, Default)] |
| 89 | pub struct NotificationManager { | 63 | pub struct NotificationManager { |
| 90 | notifications: VecDeque<Notification>, | 64 | notifications: VecDeque<Notification>, |
| 65 | + /// Counter used to generate unique notification ids | ||
| 66 | + next_id: u64, | ||
| 91 | } | 67 | } |
| 92 | 68 | ||
| 93 | impl NotificationManager { | 69 | impl NotificationManager { |
| @@ -97,25 +73,41 @@ impl NotificationManager { | |||
| 97 | } | 73 | } |
| 98 | 74 | ||
| 99 | /// Adds a new notification | 75 | /// Adds a new notification |
| 100 | - pub fn add_notification(&mut self, notification: Notification) { | 76 | + fn push(&mut self, notification_type: NotificationType, message: String) { |
| 101 | - // Remove expired notifications first | ||
| 102 | self.cleanup(); | 77 | self.cleanup(); |
| 103 | - self.notifications.push_back(notification); | 78 | + let duration = match notification_type { |
| 79 | + NotificationType::Error => Duration::from_secs(7), // Longer for errors | ||
| 80 | + _ => Duration::from_secs(5), | ||
| 81 | + }; | ||
| 82 | + let id = self.next_id; | ||
| 83 | + self.next_id += 1; | ||
| 84 | + self.notifications.push_back(Notification { | ||
| 85 | + id, | ||
| 86 | + message, | ||
| 87 | + notification_type, | ||
| 88 | + created_at: Instant::now(), | ||
| 89 | + duration, | ||
| 90 | + }); | ||
| 104 | } | 91 | } |
| 105 | 92 | ||
| 106 | /// Adds an info notification | 93 | /// Adds an info notification |
| 107 | pub fn info(&mut self, message: impl Into<String>) { | 94 | pub fn info(&mut self, message: impl Into<String>) { |
| 108 | - self.add_notification(Notification::info(message)); | 95 | + self.push(NotificationType::Info, message.into()); |
| 109 | } | 96 | } |
| 110 | 97 | ||
| 111 | /// Adds a success notification | 98 | /// Adds a success notification |
| 112 | pub fn success(&mut self, message: impl Into<String>) { | 99 | pub fn success(&mut self, message: impl Into<String>) { |
| 113 | - self.add_notification(Notification::success(message)); | 100 | + self.push(NotificationType::Success, message.into()); |
| 114 | } | 101 | } |
| 115 | 102 | ||
| 116 | /// Adds an error notification | 103 | /// Adds an error notification |
| 117 | pub fn error(&mut self, message: impl Into<String>) { | 104 | pub fn error(&mut self, message: impl Into<String>) { |
| 118 | - self.add_notification(Notification::error(message)); | 105 | + self.push(NotificationType::Error, message.into()); |
| 106 | + } | ||
| 107 | + | ||
| 108 | + /// Dismisses a notification by id (e.g. via its close button) | ||
| 109 | + pub fn dismiss(&mut self, id: u64) { | ||
| 110 | + self.notifications.retain(|n| n.id != id); | ||
| 119 | } | 111 | } |
| 120 | 112 | ||
| 121 | /// Removes expired notifications | 113 | /// Removes expired notifications |
| @@ -123,75 +115,95 @@ impl NotificationManager { | |||
| 123 | self.notifications.retain(|n| n.is_visible()); | 115 | self.notifications.retain(|n| n.is_visible()); |
| 124 | } | 116 | } |
| 125 | 117 | ||
| 126 | - /// Returns the list of active notifications | 118 | + /// Draws all active notifications as a floating overlay in the top-right corner |
| 127 | - pub fn get_active_notifications(&self) -> Vec<&Notification> { | 119 | + pub fn draw_overlay(&mut self, ctx: &Context) { |
| 128 | - self.notifications.iter().filter(|n| n.is_visible()).collect() | ||
| 129 | - } | ||
| 130 | - | ||
| 131 | - /// Draws all active notifications in the UI | ||
| 132 | - pub fn draw(&mut self, ui: &mut Ui) { | ||
| 133 | self.cleanup(); | 120 | self.cleanup(); |
| 134 | - | 121 | + |
| 135 | - let active_notifications = self.get_active_notifications(); | 122 | + if self.notifications.is_empty() { |
| 136 | - | ||
| 137 | - if active_notifications.is_empty() { | ||
| 138 | return; | 123 | return; |
| 139 | } | 124 | } |
| 140 | 125 | ||
| 141 | - // Draw notifications in a vertical stack | 126 | + let notifications: Vec<Notification> = self.notifications.iter().cloned().collect(); |
| 142 | - egui::ScrollArea::vertical().show(ui, |ui| { | 127 | + let mut dismissed: Vec<u64> = Vec::new(); |
| 143 | - for notification in active_notifications { | 128 | + |
| 144 | - let progress = notification.created_at.elapsed().as_secs_f32() / notification.duration.as_secs_f32(); | 129 | + egui::Area::new(egui::Id::new("notifications_overlay")) |
| 145 | - | 130 | + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0)) |
| 146 | - let frame = Frame { | 131 | + .order(egui::Order::Foreground) |
| 147 | - fill: notification.color().linear_multiply(0.2), | 132 | + .interactable(true) |
| 148 | - corner_radius: 5.0.into(), | 133 | + .show(ctx, |ui| { |
| 149 | - inner_margin: egui::Margin::symmetric(10, 5), | 134 | + ui.set_width(360.0); |
| 150 | - ..Default::default() | 135 | + ui.spacing_mut().item_spacing = egui::vec2(0.0, 6.0); |
| 151 | - }; | 136 | + |
| 152 | - | 137 | + Frame::default() |
| 153 | - frame.show(ui, |ui| { | 138 | + .inner_margin(egui::Margin::same(0)) |
| 154 | - ui.horizontal(|ui| { | 139 | + .show(ui, |ui| { |
| 155 | - // Icon | 140 | + for notification in ¬ifications { |
| 156 | - ui.label(RichText::new(notification.icon()).color(notification.color())); | 141 | + let progress = notification.created_at.elapsed().as_secs_f32() |
| 157 | - ui.add_space(8.0); | 142 | + / notification.duration.as_secs_f32(); |
| 158 | - | 143 | + |
| 159 | - // Message | 144 | + Frame::popup(ui.style()) |
| 160 | - ui.label(RichText::new(¬ification.message).color(Color32::WHITE)); | 145 | + .inner_margin(egui::Margin::symmetric(10, 6)) |
| 161 | - | 146 | + .stroke(egui::Stroke::new(1.0, notification.color())) |
| 162 | - // Progress bar (optional) - shows time remaining | 147 | + .show(ui, |ui| { |
| 163 | - if notification.duration.as_secs() > 0 { | 148 | + ui.horizontal(|ui| { |
| 164 | - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | 149 | + ui.label(RichText::new(notification.icon())); |
| 165 | - let bar_width = 100.0; | 150 | + ui.label( |
| 166 | - let bar_height = 4.0; | 151 | + RichText::new(¬ification.message) |
| 167 | - let fill_width = bar_width * (1.0 - progress.clamp(0.0, 1.0)); | 152 | + .color(notification.color()) |
| 168 | - | 153 | + .strong(), |
| 169 | - ui.add_space(10.0); | 154 | + ); |
| 170 | - // Background | 155 | + |
| 171 | - let (_, bg_rect) = ui.allocate_space(egui::vec2(bar_width, bar_height)); | 156 | + ui.with_layout( |
| 172 | - ui.painter().rect_filled( | 157 | + Layout::right_to_left(Align::Center), |
| 173 | - bg_rect, | 158 | + |ui| { |
| 174 | - 5.0, | 159 | + if ui.small_button("✕").clicked() { |
| 175 | - Color32::from_black_alpha(128) | 160 | + dismissed.push(notification.id); |
| 176 | - ); | 161 | + } |
| 177 | - // Foreground (remaining time) | 162 | + |
| 178 | - let fg_rect = egui::Rect { | 163 | + // Petite barre de temps restant |
| 179 | - min: bg_rect.min, | 164 | + let bar_width = 60.0; |
| 180 | - max: egui::Pos2::new( | 165 | + let (_, bg_rect) = |
| 181 | - bg_rect.min.x + fill_width, | 166 | + ui.allocate_space(egui::vec2(bar_width, 4.0)); |
| 182 | - bg_rect.max.y | 167 | + ui.painter().rect_filled( |
| 183 | - ) | 168 | + bg_rect, |
| 184 | - }; | 169 | + 2.0, |
| 185 | - ui.painter().rect_filled( | 170 | + Color32::from_black_alpha(120), |
| 186 | - fg_rect, | 171 | + ); |
| 187 | - 5.0, | 172 | + let fill_width = |
| 188 | - notification.color() | 173 | + bar_width * (1.0 - progress.clamp(0.0, 1.0)); |
| 189 | - ); | 174 | + let fg_rect = egui::Rect { |
| 190 | - }); | 175 | + min: bg_rect.min, |
| 176 | + max: egui::Pos2::new( | ||
| 177 | + bg_rect.min.x + fill_width, | ||
| 178 | + bg_rect.max.y, | ||
| 179 | + ), | ||
| 180 | + }; | ||
| 181 | + ui.painter().rect_filled( | ||
| 182 | + fg_rect, | ||
| 183 | + 2.0, | ||
| 184 | + notification.color(), | ||
| 185 | + ); | ||
| 186 | + }, | ||
| 187 | + ); | ||
| 188 | + }); | ||
| 189 | + }); | ||
| 191 | } | 190 | } |
| 192 | }); | 191 | }); |
| 193 | - }); | 192 | + }); |
| 194 | - } | 193 | + |
| 195 | - }); | 194 | + for id in dismissed { |
| 195 | + self.dismiss(id); | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + /// Draws notifications inline (used by the history of this API, e.g. tests) | ||
| 200 | + #[allow(dead_code)] | ||
| 201 | + pub fn draw(&mut self, ui: &mut Ui) { | ||
| 202 | + self.cleanup(); | ||
| 203 | + let active: Vec<&Notification> = | ||
| 204 | + self.notifications.iter().filter(|n| n.is_visible()).collect(); | ||
| 205 | + for notification in active { | ||
| 206 | + ui.label(format!("{} {}", notification.icon(), notification.message)); | ||
| 207 | + } | ||
| 196 | } | 208 | } |
| 197 | } | 209 | } |
modified
src/ui/playlist_list.rs +111 -93 | @@ -4,12 +4,12 @@ | ||
| 4 | 4 | |
| 5 | 5 | use crate::config::playlist::Playlist; |
| 6 | 6 | use crate::config::AppState; |
| 7 | -use egui::{Align, Layout, ScrollArea, Ui}; | |
| 7 | +use egui::{Align, Layout, Ui}; | |
| 8 | 8 | |
| 9 | 9 | /// Draws the playlist list |
| 10 | 10 | pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { |
| 11 | 11 | ui.horizontal(|ui| { |
| 12 | - ui.label("Playlists:"); | |
| 12 | + ui.strong("Playlists:"); | |
| 13 | 13 | if ui.button("➕ Ajouter").clicked() { |
| 14 | 14 | state.show_add_playlist_dialog = true; |
| 15 | 15 | } |
| @@ -22,25 +22,24 @@ pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { | ||
| 22 | 22 | return; |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | - ScrollArea::vertical().show(ui, |ui| { | |
| 26 | - let playlist_count = state.playlists.len(); | |
| 27 | - for index in 0..playlist_count { | |
| 28 | - draw_playlist_item(state, ui, index); | |
| 29 | - } | |
| 30 | - }); | |
| 25 | + let playlist_count = state.playlists.len(); | |
| 26 | + for index in 0..playlist_count { | |
| 27 | + draw_playlist_item(state, ui, index); | |
| 28 | + } | |
| 31 | 29 | } |
| 32 | 30 | |
| 33 | 31 | /// Draws a single playlist item |
| 34 | 32 | fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { |
| 35 | 33 | let playlist = &state.playlists[index]; |
| 34 | + let playlist_id = playlist.id.clone(); | |
| 36 | 35 | let playlist_name = playlist.name.clone(); |
| 37 | 36 | let feed_count = playlist.feed_count(); |
| 38 | 37 | |
| 39 | 38 | ui.horizontal(|ui| { |
| 40 | 39 | // Playlist name and info |
| 41 | 40 | ui.vertical(|ui| { |
| 42 | - ui.label(&playlist_name); | |
| 43 | - ui.label(format!("{} flux", feed_count)); | |
| 41 | + ui.label(egui::RichText::new(&playlist_name).strong()); | |
| 42 | + ui.label(egui::RichText::new(format!("{} flux", feed_count)).small().weak()); | |
| 44 | 43 | }); |
| 45 | 44 | |
| 46 | 45 | // Actions |
| @@ -52,12 +51,9 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 52 | 51 | state.show_edit_playlist_dialog = true; |
| 53 | 52 | } |
| 54 | 53 | |
| 55 | - // Delete button | |
| 54 | + // Delete button (with confirmation) | |
| 56 | 55 | if ui.button("🗑️").on_hover_text("Supprimer").clicked() { |
| 57 | - if let Some(playlist) = state.remove_playlist(index) { | |
| 58 | - log::info!("Deleted playlist: {}", playlist.name); | |
| 59 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 60 | - } | |
| 56 | + state.confirm_delete_playlist_id = Some(playlist_id.clone()); | |
| 61 | 57 | } |
| 62 | 58 | }); |
| 63 | 59 | }); |
| @@ -65,53 +61,63 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 65 | 61 | ui.separator(); |
| 66 | 62 | } |
| 67 | 63 | |
| 68 | -/// Draws the add playlist dialog | |
| 64 | +/// Draws the add playlist dialog (modal) | |
| 69 | 65 | pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) { |
| 70 | 66 | if !state.show_add_playlist_dialog { |
| 71 | 67 | return; |
| 72 | 68 | } |
| 73 | 69 | |
| 74 | - egui::Window::new("Ajouter une playlist") | |
| 75 | - .resizable(false) | |
| 76 | - .show(ui.ctx(), |ui| { | |
| 77 | - ui.vertical(|ui| { | |
| 78 | - ui.label("Nouvelle playlist"); | |
| 79 | - ui.separator(); | |
| 80 | - | |
| 81 | - ui.label("Nom de la playlist"); | |
| 82 | - ui.text_edit_singleline(&mut state.add_playlist_name); | |
| 83 | - | |
| 84 | - ui.label("Description (optionnel)"); | |
| 85 | - ui.text_edit_multiline(&mut state.add_playlist_description); | |
| 86 | - | |
| 87 | - ui.separator(); | |
| 88 | - | |
| 89 | - ui.horizontal(|ui| { | |
| 90 | - if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() { | |
| 91 | - log::info!("Adding new playlist: {}", state.add_playlist_name); | |
| 92 | - let mut playlist = Playlist::new(state.add_playlist_name.clone()); | |
| 93 | - if !state.add_playlist_description.is_empty() { | |
| 94 | - playlist.set_description(state.add_playlist_description.clone()); | |
| 95 | - } | |
| 96 | - state.add_playlist(playlist); | |
| 97 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 98 | - log::info!("Playlist added successfully"); | |
| 99 | - state.clear_add_playlist_form(); | |
| 100 | - state.show_add_playlist_dialog = false; | |
| 101 | - } else { | |
| 102 | - ui.label("Veuillez entrer un nom"); | |
| 103 | - } | |
| 104 | - | |
| 105 | - if ui.button("Annuler").clicked() { | |
| 106 | - state.clear_add_playlist_form(); | |
| 107 | - state.show_add_playlist_dialog = false; | |
| 108 | - } | |
| 109 | - }); | |
| 110 | - }); | |
| 70 | + let ctx = ui.ctx().clone(); | |
| 71 | + let modal = egui::Modal::new(egui::Id::new("add_playlist_dialog")); | |
| 72 | + let response = modal.show(&ctx, |ui| { | |
| 73 | + ui.set_min_width(380.0); | |
| 74 | + ui.heading("Ajouter une playlist"); | |
| 75 | + ui.separator(); | |
| 76 | + | |
| 77 | + ui.label("Nom de la playlist"); | |
| 78 | + ui.text_edit_singleline(&mut state.add_playlist_name); | |
| 79 | + | |
| 80 | + ui.label("Description (optionnel)"); | |
| 81 | + ui.text_edit_multiline(&mut state.add_playlist_description); | |
| 82 | + | |
| 83 | + ui.separator(); | |
| 84 | + | |
| 85 | + let valid = !state.add_playlist_name.trim().is_empty(); | |
| 86 | + let mut submitted = false; | |
| 87 | + ui.horizontal(|ui| { | |
| 88 | + if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() { | |
| 89 | + submitted = true; | |
| 90 | + } | |
| 91 | + if ui.button("Annuler").clicked() { | |
| 92 | + state.clear_add_playlist_form(); | |
| 93 | + state.show_add_playlist_dialog = false; | |
| 94 | + } | |
| 111 | 95 | }); |
| 96 | + if !valid { | |
| 97 | + ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small()); | |
| 98 | + } | |
| 99 | + | |
| 100 | + submitted | |
| 101 | + }); | |
| 102 | + | |
| 103 | + if response.inner { | |
| 104 | + log::info!("Adding new playlist: {}", state.add_playlist_name); | |
| 105 | + let mut playlist = Playlist::new(state.add_playlist_name.trim().to_string()); | |
| 106 | + if !state.add_playlist_description.is_empty() { | |
| 107 | + playlist.set_description(state.add_playlist_description.clone()); | |
| 108 | + } | |
| 109 | + state.add_playlist(playlist); | |
| 110 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 111 | + log::info!("Playlist added successfully"); | |
| 112 | + state.clear_add_playlist_form(); | |
| 113 | + state.show_add_playlist_dialog = false; | |
| 114 | + } else if response.should_close() { | |
| 115 | + state.clear_add_playlist_form(); | |
| 116 | + state.show_add_playlist_dialog = false; | |
| 117 | + } | |
| 112 | 118 | } |
| 113 | 119 | |
| 114 | -/// Draws the edit playlist dialog | |
| 120 | +/// Draws the edit playlist dialog (modal) | |
| 115 | 121 | pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { |
| 116 | 122 | let editing_index = match state.editing_playlist_index { |
| 117 | 123 | Some(idx) => idx, |
| @@ -120,47 +126,59 @@ pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 120 | 126 | |
| 121 | 127 | if editing_index >= state.playlists.len() { |
| 122 | 128 | state.clear_edit_playlist_form(); |
| 129 | + state.show_edit_playlist_dialog = false; | |
| 123 | 130 | return; |
| 124 | 131 | } |
| 125 | 132 | |
| 126 | - egui::Window::new("Éditer la playlist") | |
| 127 | - .resizable(false) | |
| 128 | - .show(ui.ctx(), |ui| { | |
| 129 | - ui.vertical(|ui| { | |
| 130 | - ui.label(format!("Éditer: {}", state.edit_playlist_name)); | |
| 131 | - ui.separator(); | |
| 132 | - | |
| 133 | - ui.label("Nom de la playlist"); | |
| 134 | - ui.text_edit_singleline(&mut state.edit_playlist_name); | |
| 135 | - | |
| 136 | - ui.label("Description (optionnel)"); | |
| 137 | - ui.text_edit_multiline(&mut state.edit_playlist_description); | |
| 138 | - | |
| 139 | - ui.separator(); | |
| 140 | - | |
| 141 | - ui.horizontal(|ui| { | |
| 142 | - if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() { | |
| 143 | - log::info!("Updating playlist at index {}", editing_index); | |
| 144 | - state.playlists[editing_index].name = state.edit_playlist_name.clone(); | |
| 145 | - if !state.edit_playlist_description.is_empty() { | |
| 146 | - state.playlists[editing_index].set_description(state.edit_playlist_description.clone()); | |
| 147 | - } else { | |
| 148 | - state.playlists[editing_index].clear_description(); | |
| 149 | - } | |
| 150 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 151 | - log::info!("Playlist updated successfully"); | |
| 152 | - state.notification_manager.success("Playlist modifiée"); | |
| 153 | - state.clear_edit_playlist_form(); | |
| 154 | - state.show_edit_playlist_dialog = false; | |
| 155 | - } else { | |
| 156 | - ui.label("Veuillez entrer un nom"); | |
| 157 | - } | |
| 158 | - | |
| 159 | - if ui.button("Annuler").clicked() { | |
| 160 | - state.clear_edit_playlist_form(); | |
| 161 | - state.show_edit_playlist_dialog = false; | |
| 162 | - } | |
| 163 | - }); | |
| 164 | - }); | |
| 133 | + let ctx = ui.ctx().clone(); | |
| 134 | + let modal = egui::Modal::new(egui::Id::new("edit_playlist_dialog")); | |
| 135 | + let response = modal.show(&ctx, |ui| { | |
| 136 | + ui.set_min_width(380.0); | |
| 137 | + ui.heading("Éditer la playlist"); | |
| 138 | + ui.separator(); | |
| 139 | + | |
| 140 | + ui.label("Nom de la playlist"); | |
| 141 | + ui.text_edit_singleline(&mut state.edit_playlist_name); | |
| 142 | + | |
| 143 | + ui.label("Description (optionnel)"); | |
| 144 | + ui.text_edit_multiline(&mut state.edit_playlist_description); | |
| 145 | + | |
| 146 | + ui.separator(); | |
| 147 | + | |
| 148 | + let valid = !state.edit_playlist_name.trim().is_empty(); | |
| 149 | + let mut submitted = false; | |
| 150 | + ui.horizontal(|ui| { | |
| 151 | + if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() { | |
| 152 | + submitted = true; | |
| 153 | + } | |
| 154 | + if ui.button("Annuler").clicked() { | |
| 155 | + state.clear_edit_playlist_form(); | |
| 156 | + state.show_edit_playlist_dialog = false; | |
| 157 | + } | |
| 165 | 158 | }); |
| 159 | + if !valid { | |
| 160 | + ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small()); | |
| 161 | + } | |
| 162 | + | |
| 163 | + submitted | |
| 164 | + }); | |
| 165 | + | |
| 166 | + if response.inner { | |
| 167 | + log::info!("Updating playlist at index {}", editing_index); | |
| 168 | + let playlist = &mut state.playlists[editing_index]; | |
| 169 | + playlist.name = state.edit_playlist_name.trim().to_string(); | |
| 170 | + if !state.edit_playlist_description.is_empty() { | |
| 171 | + playlist.set_description(state.edit_playlist_description.clone()); | |
| 172 | + } else { | |
| 173 | + playlist.clear_description(); | |
| 174 | + } | |
| 175 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 176 | + log::info!("Playlist updated successfully"); | |
| 177 | + state.notification_manager.success("Playlist modifiée"); | |
| 178 | + state.clear_edit_playlist_form(); | |
| 179 | + state.show_edit_playlist_dialog = false; | |
| 180 | + } else if response.should_close() { | |
| 181 | + state.clear_edit_playlist_form(); | |
| 182 | + state.show_edit_playlist_dialog = false; | |
| 183 | + } | |
| 166 | 184 | } |
| @@ -4,12 +4,12 @@ | |||
| 4 | 4 | ||
| 5 | use crate::config::playlist::Playlist; | 5 | use crate::config::playlist::Playlist; |
| 6 | use crate::config::AppState; | 6 | use crate::config::AppState; |
| 7 | -use egui::{Align, Layout, ScrollArea, Ui}; | 7 | +use egui::{Align, Layout, Ui}; |
| 8 | 8 | ||
| 9 | /// Draws the playlist list | 9 | /// Draws the playlist list |
| 10 | pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { |
| 11 | ui.horizontal(|ui| { | 11 | ui.horizontal(|ui| { |
| 12 | - ui.label("Playlists:"); | 12 | + ui.strong("Playlists:"); |
| 13 | if ui.button("➕ Ajouter").clicked() { | 13 | if ui.button("➕ Ajouter").clicked() { |
| 14 | state.show_add_playlist_dialog = true; | 14 | state.show_add_playlist_dialog = true; |
| 15 | } | 15 | } |
| @@ -22,25 +22,24 @@ pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { | |||
| 22 | return; | 22 | return; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | - ScrollArea::vertical().show(ui, |ui| { | 25 | + let playlist_count = state.playlists.len(); |
| 26 | - let playlist_count = state.playlists.len(); | 26 | + for index in 0..playlist_count { |
| 27 | - for index in 0..playlist_count { | 27 | + draw_playlist_item(state, ui, index); |
| 28 | - draw_playlist_item(state, ui, index); | 28 | + } |
| 29 | - } | ||
| 30 | - }); | ||
| 31 | } | 29 | } |
| 32 | 30 | ||
| 33 | /// Draws a single playlist item | 31 | /// Draws a single playlist item |
| 34 | fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | 32 | fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { |
| 35 | let playlist = &state.playlists[index]; | 33 | let playlist = &state.playlists[index]; |
| 34 | + let playlist_id = playlist.id.clone(); | ||
| 36 | let playlist_name = playlist.name.clone(); | 35 | let playlist_name = playlist.name.clone(); |
| 37 | let feed_count = playlist.feed_count(); | 36 | let feed_count = playlist.feed_count(); |
| 38 | 37 | ||
| 39 | ui.horizontal(|ui| { | 38 | ui.horizontal(|ui| { |
| 40 | // Playlist name and info | 39 | // Playlist name and info |
| 41 | ui.vertical(|ui| { | 40 | ui.vertical(|ui| { |
| 42 | - ui.label(&playlist_name); | 41 | + ui.label(egui::RichText::new(&playlist_name).strong()); |
| 43 | - ui.label(format!("{} flux", feed_count)); | 42 | + ui.label(egui::RichText::new(format!("{} flux", feed_count)).small().weak()); |
| 44 | }); | 43 | }); |
| 45 | 44 | ||
| 46 | // Actions | 45 | // Actions |
| @@ -52,12 +51,9 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 52 | state.show_edit_playlist_dialog = true; | 51 | state.show_edit_playlist_dialog = true; |
| 53 | } | 52 | } |
| 54 | 53 | ||
| 55 | - // Delete button | 54 | + // Delete button (with confirmation) |
| 56 | if ui.button("🗑️").on_hover_text("Supprimer").clicked() { | 55 | if ui.button("🗑️").on_hover_text("Supprimer").clicked() { |
| 57 | - if let Some(playlist) = state.remove_playlist(index) { | 56 | + state.confirm_delete_playlist_id = Some(playlist_id.clone()); |
| 58 | - log::info!("Deleted playlist: {}", playlist.name); | ||
| 59 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 60 | - } | ||
| 61 | } | 57 | } |
| 62 | }); | 58 | }); |
| 63 | }); | 59 | }); |
| @@ -65,53 +61,63 @@ fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 65 | ui.separator(); | 61 | ui.separator(); |
| 66 | } | 62 | } |
| 67 | 63 | ||
| 68 | -/// Draws the add playlist dialog | 64 | +/// Draws the add playlist dialog (modal) |
| 69 | pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | 65 | pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) { |
| 70 | if !state.show_add_playlist_dialog { | 66 | if !state.show_add_playlist_dialog { |
| 71 | return; | 67 | return; |
| 72 | } | 68 | } |
| 73 | 69 | ||
| 74 | - egui::Window::new("Ajouter une playlist") | 70 | + let ctx = ui.ctx().clone(); |
| 75 | - .resizable(false) | 71 | + let modal = egui::Modal::new(egui::Id::new("add_playlist_dialog")); |
| 76 | - .show(ui.ctx(), |ui| { | 72 | + let response = modal.show(&ctx, |ui| { |
| 77 | - ui.vertical(|ui| { | 73 | + ui.set_min_width(380.0); |
| 78 | - ui.label("Nouvelle playlist"); | 74 | + ui.heading("Ajouter une playlist"); |
| 79 | - ui.separator(); | 75 | + ui.separator(); |
| 80 | - | 76 | + |
| 81 | - ui.label("Nom de la playlist"); | 77 | + ui.label("Nom de la playlist"); |
| 82 | - ui.text_edit_singleline(&mut state.add_playlist_name); | 78 | + ui.text_edit_singleline(&mut state.add_playlist_name); |
| 83 | - | 79 | + |
| 84 | - ui.label("Description (optionnel)"); | 80 | + ui.label("Description (optionnel)"); |
| 85 | - ui.text_edit_multiline(&mut state.add_playlist_description); | 81 | + ui.text_edit_multiline(&mut state.add_playlist_description); |
| 86 | - | 82 | + |
| 87 | - ui.separator(); | 83 | + ui.separator(); |
| 88 | - | 84 | + |
| 89 | - ui.horizontal(|ui| { | 85 | + let valid = !state.add_playlist_name.trim().is_empty(); |
| 90 | - if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() { | 86 | + let mut submitted = false; |
| 91 | - log::info!("Adding new playlist: {}", state.add_playlist_name); | 87 | + ui.horizontal(|ui| { |
| 92 | - let mut playlist = Playlist::new(state.add_playlist_name.clone()); | 88 | + if ui.add_enabled(valid, egui::Button::new("Ajouter")).clicked() { |
| 93 | - if !state.add_playlist_description.is_empty() { | 89 | + submitted = true; |
| 94 | - playlist.set_description(state.add_playlist_description.clone()); | 90 | + } |
| 95 | - } | 91 | + if ui.button("Annuler").clicked() { |
| 96 | - state.add_playlist(playlist); | 92 | + state.clear_add_playlist_form(); |
| 97 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | 93 | + state.show_add_playlist_dialog = false; |
| 98 | - log::info!("Playlist added successfully"); | 94 | + } |
| 99 | - state.clear_add_playlist_form(); | ||
| 100 | - state.show_add_playlist_dialog = false; | ||
| 101 | - } else { | ||
| 102 | - ui.label("Veuillez entrer un nom"); | ||
| 103 | - } | ||
| 104 | - | ||
| 105 | - if ui.button("Annuler").clicked() { | ||
| 106 | - state.clear_add_playlist_form(); | ||
| 107 | - state.show_add_playlist_dialog = false; | ||
| 108 | - } | ||
| 109 | - }); | ||
| 110 | - }); | ||
| 111 | }); | 95 | }); |
| 96 | + if !valid { | ||
| 97 | + ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small()); | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + submitted | ||
| 101 | + }); | ||
| 102 | + | ||
| 103 | + if response.inner { | ||
| 104 | + log::info!("Adding new playlist: {}", state.add_playlist_name); | ||
| 105 | + let mut playlist = Playlist::new(state.add_playlist_name.trim().to_string()); | ||
| 106 | + if !state.add_playlist_description.is_empty() { | ||
| 107 | + playlist.set_description(state.add_playlist_description.clone()); | ||
| 108 | + } | ||
| 109 | + state.add_playlist(playlist); | ||
| 110 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 111 | + log::info!("Playlist added successfully"); | ||
| 112 | + state.clear_add_playlist_form(); | ||
| 113 | + state.show_add_playlist_dialog = false; | ||
| 114 | + } else if response.should_close() { | ||
| 115 | + state.clear_add_playlist_form(); | ||
| 116 | + state.show_add_playlist_dialog = false; | ||
| 117 | + } | ||
| 112 | } | 118 | } |
| 113 | 119 | ||
| 114 | -/// Draws the edit playlist dialog | 120 | +/// Draws the edit playlist dialog (modal) |
| 115 | pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | 121 | pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { |
| 116 | let editing_index = match state.editing_playlist_index { | 122 | let editing_index = match state.editing_playlist_index { |
| 117 | Some(idx) => idx, | 123 | Some(idx) => idx, |
| @@ -120,47 +126,59 @@ pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | |||
| 120 | 126 | ||
| 121 | if editing_index >= state.playlists.len() { | 127 | if editing_index >= state.playlists.len() { |
| 122 | state.clear_edit_playlist_form(); | 128 | state.clear_edit_playlist_form(); |
| 129 | + state.show_edit_playlist_dialog = false; | ||
| 123 | return; | 130 | return; |
| 124 | } | 131 | } |
| 125 | 132 | ||
| 126 | - egui::Window::new("Éditer la playlist") | 133 | + let ctx = ui.ctx().clone(); |
| 127 | - .resizable(false) | 134 | + let modal = egui::Modal::new(egui::Id::new("edit_playlist_dialog")); |
| 128 | - .show(ui.ctx(), |ui| { | 135 | + let response = modal.show(&ctx, |ui| { |
| 129 | - ui.vertical(|ui| { | 136 | + ui.set_min_width(380.0); |
| 130 | - ui.label(format!("Éditer: {}", state.edit_playlist_name)); | 137 | + ui.heading("Éditer la playlist"); |
| 131 | - ui.separator(); | 138 | + ui.separator(); |
| 132 | - | 139 | + |
| 133 | - ui.label("Nom de la playlist"); | 140 | + ui.label("Nom de la playlist"); |
| 134 | - ui.text_edit_singleline(&mut state.edit_playlist_name); | 141 | + ui.text_edit_singleline(&mut state.edit_playlist_name); |
| 135 | - | 142 | + |
| 136 | - ui.label("Description (optionnel)"); | 143 | + ui.label("Description (optionnel)"); |
| 137 | - ui.text_edit_multiline(&mut state.edit_playlist_description); | 144 | + ui.text_edit_multiline(&mut state.edit_playlist_description); |
| 138 | - | 145 | + |
| 139 | - ui.separator(); | 146 | + ui.separator(); |
| 140 | - | 147 | + |
| 141 | - ui.horizontal(|ui| { | 148 | + let valid = !state.edit_playlist_name.trim().is_empty(); |
| 142 | - if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() { | 149 | + let mut submitted = false; |
| 143 | - log::info!("Updating playlist at index {}", editing_index); | 150 | + ui.horizontal(|ui| { |
| 144 | - state.playlists[editing_index].name = state.edit_playlist_name.clone(); | 151 | + if ui.add_enabled(valid, egui::Button::new("Sauvegarder")).clicked() { |
| 145 | - if !state.edit_playlist_description.is_empty() { | 152 | + submitted = true; |
| 146 | - state.playlists[editing_index].set_description(state.edit_playlist_description.clone()); | 153 | + } |
| 147 | - } else { | 154 | + if ui.button("Annuler").clicked() { |
| 148 | - state.playlists[editing_index].clear_description(); | 155 | + state.clear_edit_playlist_form(); |
| 149 | - } | 156 | + state.show_edit_playlist_dialog = false; |
| 150 | - super::super::config::storage::save_playlists_default(&state.playlists).ok(); | 157 | + } |
| 151 | - log::info!("Playlist updated successfully"); | ||
| 152 | - state.notification_manager.success("Playlist modifiée"); | ||
| 153 | - state.clear_edit_playlist_form(); | ||
| 154 | - state.show_edit_playlist_dialog = false; | ||
| 155 | - } else { | ||
| 156 | - ui.label("Veuillez entrer un nom"); | ||
| 157 | - } | ||
| 158 | - | ||
| 159 | - if ui.button("Annuler").clicked() { | ||
| 160 | - state.clear_edit_playlist_form(); | ||
| 161 | - state.show_edit_playlist_dialog = false; | ||
| 162 | - } | ||
| 163 | - }); | ||
| 164 | - }); | ||
| 165 | }); | 158 | }); |
| 159 | + if !valid { | ||
| 160 | + ui.label(egui::RichText::new("Veuillez entrer un nom.").weak().small()); | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + submitted | ||
| 164 | + }); | ||
| 165 | + | ||
| 166 | + if response.inner { | ||
| 167 | + log::info!("Updating playlist at index {}", editing_index); | ||
| 168 | + let playlist = &mut state.playlists[editing_index]; | ||
| 169 | + playlist.name = state.edit_playlist_name.trim().to_string(); | ||
| 170 | + if !state.edit_playlist_description.is_empty() { | ||
| 171 | + playlist.set_description(state.edit_playlist_description.clone()); | ||
| 172 | + } else { | ||
| 173 | + playlist.clear_description(); | ||
| 174 | + } | ||
| 175 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 176 | + log::info!("Playlist updated successfully"); | ||
| 177 | + state.notification_manager.success("Playlist modifiée"); | ||
| 178 | + state.clear_edit_playlist_form(); | ||
| 179 | + state.show_edit_playlist_dialog = false; | ||
| 180 | + } else if response.should_close() { | ||
| 181 | + state.clear_edit_playlist_form(); | ||
| 182 | + state.show_edit_playlist_dialog = false; | ||
| 183 | + } | ||
| 166 | } | 184 | } |
modified
src/ui/search_bar.rs +78 -127 | @@ -1,137 +1,88 @@ | ||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | |
| 2 | -use crate::config::search::{SearchQuery, SearchType}; | |
| 3 | -use eframe::egui; | |
| 4 | - | |
| 5 | -/// Barre de recherche pour l'application | |
| 6 | -#[derive(Default)] | |
| 7 | -pub struct SearchBar { | |
| 8 | - /// Requête de recherche active | |
| 9 | - pub query: SearchQuery, | |
| 10 | - /// Focus sur la barre de recherche | |
| 11 | - focus: bool, | |
| 12 | -} | |
| 13 | - | |
| 14 | - | |
| 15 | -impl SearchBar { | |
| 16 | - /// Créer une nouvelle barre de recherche | |
| 17 | - pub fn new() -> Self { | |
| 18 | - Self::default() | |
| 19 | - } | |
| 20 | - | |
| 21 | - /// Créer une barre de recherche avec une requête initiale | |
| 22 | - pub fn with_query(query: SearchQuery) -> Self { | |
| 23 | - Self { | |
| 24 | - query, | |
| 25 | - focus: false, | |
| 26 | - } | |
| 27 | - } | |
| 28 | - | |
| 29 | - /// Effacer la requête de recherche | |
| 30 | - pub fn clear(&mut self) { | |
| 31 | - self.query.query.clear(); | |
| 32 | - } | |
| 33 | - | |
| 34 | - /// Définir le focus sur la barre de recherche | |
| 35 | - pub fn set_focus(&mut self, focus: bool) { | |
| 36 | - self.focus = focus; | |
| 37 | - } | |
| 38 | - | |
| 39 | - /// Vérifier si la recherche est active | |
| 40 | - pub fn is_searching(&self) -> bool { | |
| 41 | - !self.query.is_empty() | |
| 42 | - } | |
| 43 | - | |
| 44 | - /// Obtenir la requête de recherche actuelle | |
| 45 | - pub fn get_query(&self) -> &SearchQuery { | |
| 46 | - &self.query | |
| 47 | - } | |
| 48 | - | |
| 49 | - /// Définir la requête de recherche | |
| 50 | - pub fn set_query(&mut self, query: SearchQuery) { | |
| 51 | - self.query = query; | |
| 1 | +//! Search bar UI component | |
| 2 | +//! | |
| 3 | +//! Barre de recherche de la barre d'outils : filtre la liste des flux | |
| 4 | +//! par nom, URL ou tags selon le type de recherche sélectionné. | |
| 5 | + | |
| 6 | +use crate::config::search::SearchType; | |
| 7 | +use crate::config::AppState; | |
| 8 | +use egui::Ui; | |
| 9 | + | |
| 10 | +/// Affiche la barre de recherche et le sélecteur de type de recherche | |
| 11 | +pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) { | |
| 12 | + ui.label("🔍"); | |
| 13 | + | |
| 14 | + let response = ui.add( | |
| 15 | + egui::TextEdit::singleline(&mut state.search_query.query) | |
| 16 | + .hint_text("Rechercher un flux (Ctrl+F)…") | |
| 17 | + .desired_width(200.0), | |
| 18 | + ); | |
| 19 | + | |
| 20 | + // Focus demandé via le raccourci Ctrl+F | |
| 21 | + if state.focus_search { | |
| 22 | + response.request_focus(); | |
| 23 | + state.focus_search = false; | |
| 52 | 24 | } |
| 53 | 25 | |
| 54 | - /// Afficher la barre de recherche | |
| 55 | - pub fn show(&mut self, ui: &mut egui::Ui) -> bool { | |
| 56 | - let mut changed = false; | |
| 57 | - | |
| 58 | - // Simple horizontal layout sans Frame | |
| 59 | - ui.horizontal(|ui| { | |
| 60 | - // Icône de recherche | |
| 61 | - ui.label("🔍"); | |
| 62 | - | |
| 63 | - // Champ de texte de recherche | |
| 64 | - let text_edit = egui::TextEdit::singleline(&mut self.query.query) | |
| 65 | - .hint_text("Rechercher dans les flux et épisodes...") | |
| 66 | - .desired_width(f32::INFINITY); | |
| 67 | - | |
| 68 | - let response = ui.add(text_edit); | |
| 69 | - | |
| 70 | - // Si focus demandé, le demander | |
| 71 | - if self.focus { | |
| 72 | - response.request_focus(); | |
| 73 | - } | |
| 74 | - | |
| 75 | - // Vérifier si le texte a changé | |
| 76 | - if response.changed() { | |
| 77 | - changed = true; | |
| 78 | - } | |
| 79 | - | |
| 80 | - // Bouton pour effacer la recherche | |
| 81 | - if self.is_searching() && ui.button("✕").clicked() { | |
| 82 | - self.clear(); | |
| 83 | - changed = true; | |
| 84 | - } | |
| 85 | - | |
| 86 | - // Menu déroulant pour le type de recherche | |
| 87 | - egui::ComboBox::from_label("") | |
| 88 | - .width(120.0) | |
| 89 | - .selected_text(format!("{:?}", self.query.search_type)) | |
| 90 | - .show_ui(ui, |ui| { | |
| 91 | - ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous"); | |
| 92 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux"); | |
| 93 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux"); | |
| 94 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags"); | |
| 95 | - ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode"); | |
| 96 | - ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description"); | |
| 97 | - }); | |
| 98 | - }); | |
| 99 | - | |
| 100 | - changed | |
| 26 | + if !state.search_query.is_empty() | |
| 27 | + && ui | |
| 28 | + .button("✕") | |
| 29 | + .on_hover_text("Effacer la recherche") | |
| 30 | + .clicked() | |
| 31 | + { | |
| 32 | + state.search_query.query.clear(); | |
| 101 | 33 | } |
| 102 | 34 | |
| 103 | - /// Afficher une version simplifiée (juste le champ de recherche) | |
| 104 | - pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool { | |
| 105 | - let mut changed = false; | |
| 106 | - | |
| 107 | - ui.horizontal(|ui| { | |
| 108 | - ui.label("🔍"); | |
| 109 | - let response = ui.text_edit_singleline(&mut self.query.query); | |
| 110 | - if response.changed() { | |
| 111 | - changed = true; | |
| 112 | - } | |
| 113 | - if self.is_searching() && ui.button("✕").clicked() { | |
| 114 | - self.clear(); | |
| 115 | - changed = true; | |
| 116 | - } | |
| 117 | - }); | |
| 35 | + egui::ComboBox::from_id_salt("search_type") | |
| 36 | + .width(130.0) | |
| 37 | + .selected_text(search_type_label(state.search_query.search_type)) | |
| 38 | + .show_ui(ui, |ui| { | |
| 39 | + ui.selectable_value(&mut state.search_query.search_type, SearchType::All, "Tous"); | |
| 40 | + ui.selectable_value( | |
| 41 | + &mut state.search_query.search_type, | |
| 42 | + SearchType::FeedName, | |
| 43 | + "Nom du flux", | |
| 44 | + ); | |
| 45 | + ui.selectable_value( | |
| 46 | + &mut state.search_query.search_type, | |
| 47 | + SearchType::FeedUrl, | |
| 48 | + "URL du flux", | |
| 49 | + ); | |
| 50 | + ui.selectable_value( | |
| 51 | + &mut state.search_query.search_type, | |
| 52 | + SearchType::FeedTags, | |
| 53 | + "Tags", | |
| 54 | + ); | |
| 55 | + }) | |
| 56 | + .response | |
| 57 | + .on_hover_text("Champ de recherche"); | |
| 58 | +} | |
| 118 | 59 | |
| 119 | - changed | |
| 60 | +/// Libellé court du type de recherche sélectionné | |
| 61 | +fn search_type_label(search_type: SearchType) -> &'static str { | |
| 62 | + match search_type { | |
| 63 | + SearchType::All => "Tous", | |
| 64 | + SearchType::FeedName => "Nom", | |
| 65 | + SearchType::FeedUrl => "URL", | |
| 66 | + SearchType::FeedTags => "Tags", | |
| 67 | + // La recherche par épisode se fait dans la vue détaillée d'un flux | |
| 68 | + SearchType::EpisodeTitle | SearchType::EpisodeDescription => "Flux", | |
| 120 | 69 | } |
| 121 | 70 | } |
| 122 | 71 | |
| 123 | -/// Widget pour afficher les résultats de recherche | |
| 124 | -pub fn show_search_results( | |
| 125 | - ui: &mut egui::Ui, | |
| 126 | - total_matches: usize, | |
| 127 | - search_active: bool, | |
| 128 | -) { | |
| 129 | - if search_active { | |
| 130 | - ui.horizontal(|ui| { | |
| 131 | - ui.label(format!("{} résultats trouvés", total_matches)); | |
| 132 | - if ui.button("Effacer").clicked() { | |
| 133 | - // L'appelant doit effacer la recherche | |
| 134 | - } | |
| 135 | - }); | |
| 72 | +/// Vérifie si un flux correspond à la requête de recherche courante | |
| 73 | +pub fn feed_matches_search(state: &AppState, feed: &crate::config::feed::Feed) -> bool { | |
| 74 | + let query = &state.search_query; | |
| 75 | + if query.is_empty() { | |
| 76 | + return true; | |
| 77 | + } | |
| 78 | + match query.search_type { | |
| 79 | + SearchType::FeedName => query.contains_query(&feed.name), | |
| 80 | + SearchType::FeedUrl => query.contains_query(&feed.url), | |
| 81 | + SearchType::FeedTags => feed.tags.iter().any(|t| query.contains_query(t)), | |
| 82 | + _ => { | |
| 83 | + query.contains_query(&feed.name) | |
| 84 | + || query.contains_query(&feed.url) | |
| 85 | + || feed.tags.iter().any(|t| query.contains_query(t)) | |
| 86 | + } | |
| 136 | 87 | } |
| 137 | 88 | } |
| @@ -1,137 +1,88 @@ | |||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | 1 | +//! Search bar UI component |
| 2 | -use crate::config::search::{SearchQuery, SearchType}; | 2 | +//! |
| 3 | -use eframe::egui; | 3 | +//! Barre de recherche de la barre d'outils : filtre la liste des flux |
| 4 | - | 4 | +//! par nom, URL ou tags selon le type de recherche sélectionné. |
| 5 | -/// Barre de recherche pour l'application | 5 | + |
| 6 | -#[derive(Default)] | 6 | +use crate::config::search::SearchType; |
| 7 | -pub struct SearchBar { | 7 | +use crate::config::AppState; |
| 8 | - /// Requête de recherche active | 8 | +use egui::Ui; |
| 9 | - pub query: SearchQuery, | 9 | + |
| 10 | - /// Focus sur la barre de recherche | 10 | +/// Affiche la barre de recherche et le sélecteur de type de recherche |
| 11 | - focus: bool, | 11 | +pub fn draw_search_bar(state: &mut AppState, ui: &mut Ui) { |
| 12 | -} | 12 | + ui.label("🔍"); |
| 13 | - | 13 | + |
| 14 | - | 14 | + let response = ui.add( |
| 15 | -impl SearchBar { | 15 | + egui::TextEdit::singleline(&mut state.search_query.query) |
| 16 | - /// Créer une nouvelle barre de recherche | 16 | + .hint_text("Rechercher un flux (Ctrl+F)…") |
| 17 | - pub fn new() -> Self { | 17 | + .desired_width(200.0), |
| 18 | - Self::default() | 18 | + ); |
| 19 | - } | 19 | + |
| 20 | - | 20 | + // Focus demandé via le raccourci Ctrl+F |
| 21 | - /// Créer une barre de recherche avec une requête initiale | 21 | + if state.focus_search { |
| 22 | - pub fn with_query(query: SearchQuery) -> Self { | 22 | + response.request_focus(); |
| 23 | - Self { | 23 | + state.focus_search = false; |
| 24 | - query, | ||
| 25 | - focus: false, | ||
| 26 | - } | ||
| 27 | - } | ||
| 28 | - | ||
| 29 | - /// Effacer la requête de recherche | ||
| 30 | - pub fn clear(&mut self) { | ||
| 31 | - self.query.query.clear(); | ||
| 32 | - } | ||
| 33 | - | ||
| 34 | - /// Définir le focus sur la barre de recherche | ||
| 35 | - pub fn set_focus(&mut self, focus: bool) { | ||
| 36 | - self.focus = focus; | ||
| 37 | - } | ||
| 38 | - | ||
| 39 | - /// Vérifier si la recherche est active | ||
| 40 | - pub fn is_searching(&self) -> bool { | ||
| 41 | - !self.query.is_empty() | ||
| 42 | - } | ||
| 43 | - | ||
| 44 | - /// Obtenir la requête de recherche actuelle | ||
| 45 | - pub fn get_query(&self) -> &SearchQuery { | ||
| 46 | - &self.query | ||
| 47 | - } | ||
| 48 | - | ||
| 49 | - /// Définir la requête de recherche | ||
| 50 | - pub fn set_query(&mut self, query: SearchQuery) { | ||
| 51 | - self.query = query; | ||
| 52 | } | 24 | } |
| 53 | 25 | ||
| 54 | - /// Afficher la barre de recherche | 26 | + if !state.search_query.is_empty() |
| 55 | - pub fn show(&mut self, ui: &mut egui::Ui) -> bool { | 27 | + && ui |
| 56 | - let mut changed = false; | 28 | + .button("✕") |
| 57 | - | 29 | + .on_hover_text("Effacer la recherche") |
| 58 | - // Simple horizontal layout sans Frame | 30 | + .clicked() |
| 59 | - ui.horizontal(|ui| { | 31 | + { |
| 60 | - // Icône de recherche | 32 | + state.search_query.query.clear(); |
| 61 | - ui.label("🔍"); | ||
| 62 | - | ||
| 63 | - // Champ de texte de recherche | ||
| 64 | - let text_edit = egui::TextEdit::singleline(&mut self.query.query) | ||
| 65 | - .hint_text("Rechercher dans les flux et épisodes...") | ||
| 66 | - .desired_width(f32::INFINITY); | ||
| 67 | - | ||
| 68 | - let response = ui.add(text_edit); | ||
| 69 | - | ||
| 70 | - // Si focus demandé, le demander | ||
| 71 | - if self.focus { | ||
| 72 | - response.request_focus(); | ||
| 73 | - } | ||
| 74 | - | ||
| 75 | - // Vérifier si le texte a changé | ||
| 76 | - if response.changed() { | ||
| 77 | - changed = true; | ||
| 78 | - } | ||
| 79 | - | ||
| 80 | - // Bouton pour effacer la recherche | ||
| 81 | - if self.is_searching() && ui.button("✕").clicked() { | ||
| 82 | - self.clear(); | ||
| 83 | - changed = true; | ||
| 84 | - } | ||
| 85 | - | ||
| 86 | - // Menu déroulant pour le type de recherche | ||
| 87 | - egui::ComboBox::from_label("") | ||
| 88 | - .width(120.0) | ||
| 89 | - .selected_text(format!("{:?}", self.query.search_type)) | ||
| 90 | - .show_ui(ui, |ui| { | ||
| 91 | - ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous"); | ||
| 92 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux"); | ||
| 93 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux"); | ||
| 94 | - ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags"); | ||
| 95 | - ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode"); | ||
| 96 | - ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description"); | ||
| 97 | - }); | ||
| 98 | - }); | ||
| 99 | - | ||
| 100 | - changed | ||
| 101 | } | 33 | } |
| 102 | 34 | ||
| 103 | - /// Afficher une version simplifiée (juste le champ de recherche) | 35 | + egui::ComboBox::from_id_salt("search_type") |
| 104 | - pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool { | 36 | + .width(130.0) |
| 105 | - let mut changed = false; | 37 | + .selected_text(search_type_label(state.search_query.search_type)) |
| 106 | - | 38 | + .show_ui(ui, |ui| { |
| 107 | - ui.horizontal(|ui| { | 39 | + ui.selectable_value(&mut state.search_query.search_type, SearchType::All, "Tous"); |
| 108 | - ui.label("🔍"); | 40 | + ui.selectable_value( |
| 109 | - let response = ui.text_edit_singleline(&mut self.query.query); | 41 | + &mut state.search_query.search_type, |
| 110 | - if response.changed() { | 42 | + SearchType::FeedName, |
| 111 | - changed = true; | 43 | + "Nom du flux", |
| 112 | - } | 44 | + ); |
| 113 | - if self.is_searching() && ui.button("✕").clicked() { | 45 | + ui.selectable_value( |
| 114 | - self.clear(); | 46 | + &mut state.search_query.search_type, |
| 115 | - changed = true; | 47 | + SearchType::FeedUrl, |
| 116 | - } | 48 | + "URL du flux", |
| 117 | - }); | 49 | + ); |
| 50 | + ui.selectable_value( | ||
| 51 | + &mut state.search_query.search_type, | ||
| 52 | + SearchType::FeedTags, | ||
| 53 | + "Tags", | ||
| 54 | + ); | ||
| 55 | + }) | ||
| 56 | + .response | ||
| 57 | + .on_hover_text("Champ de recherche"); | ||
| 58 | +} | ||
| 118 | 59 | ||
| 119 | - changed | 60 | +/// Libellé court du type de recherche sélectionné |
| 61 | +fn search_type_label(search_type: SearchType) -> &'static str { | ||
| 62 | + match search_type { | ||
| 63 | + SearchType::All => "Tous", | ||
| 64 | + SearchType::FeedName => "Nom", | ||
| 65 | + SearchType::FeedUrl => "URL", | ||
| 66 | + SearchType::FeedTags => "Tags", | ||
| 67 | + // La recherche par épisode se fait dans la vue détaillée d'un flux | ||
| 68 | + SearchType::EpisodeTitle | SearchType::EpisodeDescription => "Flux", | ||
| 120 | } | 69 | } |
| 121 | } | 70 | } |
| 122 | 71 | ||
| 123 | -/// Widget pour afficher les résultats de recherche | 72 | +/// Vérifie si un flux correspond à la requête de recherche courante |
| 124 | -pub fn show_search_results( | 73 | +pub fn feed_matches_search(state: &AppState, feed: &crate::config::feed::Feed) -> bool { |
| 125 | - ui: &mut egui::Ui, | 74 | + let query = &state.search_query; |
| 126 | - total_matches: usize, | 75 | + if query.is_empty() { |
| 127 | - search_active: bool, | 76 | + return true; |
| 128 | -) { | 77 | + } |
| 129 | - if search_active { | 78 | + match query.search_type { |
| 130 | - ui.horizontal(|ui| { | 79 | + SearchType::FeedName => query.contains_query(&feed.name), |
| 131 | - ui.label(format!("{} résultats trouvés", total_matches)); | 80 | + SearchType::FeedUrl => query.contains_query(&feed.url), |
| 132 | - if ui.button("Effacer").clicked() { | 81 | + SearchType::FeedTags => feed.tags.iter().any(|t| query.contains_query(t)), |
| 133 | - // L'appelant doit effacer la recherche | 82 | + _ => { |
| 134 | - } | 83 | + query.contains_query(&feed.name) |
| 135 | - }); | 84 | + || query.contains_query(&feed.url) |
| 85 | + || feed.tags.iter().any(|t| query.contains_query(t)) | ||
| 86 | + } | ||
| 136 | } | 87 | } |
| 137 | } | 88 | } |
modified
src/ui/stats_panel.rs +93 -157 | @@ -1,173 +1,109 @@ | ||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | |
| 2 | -use crate::config::statistics::{AppStatistics, format_bytes}; | |
| 3 | -use eframe::egui; | |
| 4 | - | |
| 5 | -/// Panneau d'affichage des statistiques | |
| 6 | -#[derive(Default)] | |
| 7 | -pub struct StatsPanel { | |
| 8 | - /// Visibilité du panneau | |
| 9 | - visible: bool, | |
| 10 | -} | |
| 11 | - | |
| 12 | - | |
| 13 | -impl StatsPanel { | |
| 14 | - /// Créer un nouveau panneau de statistiques | |
| 15 | - pub fn new() -> Self { | |
| 16 | - Self::default() | |
| 1 | +//! Stats Panel UI component | |
| 2 | +//! | |
| 3 | +//! Fenêtre flottante affichant les statistiques de l'application, | |
| 4 | +//! calculées à partir des flux configurés. | |
| 5 | + | |
| 6 | +use crate::config::feed::Feed; | |
| 7 | +use crate::config::AppState; | |
| 8 | +use egui::Ui; | |
| 9 | + | |
| 10 | +/// Draws the statistics window (toggled from the menu) | |
| 11 | +pub fn draw_stats_window(state: &mut AppState, ui: &mut Ui) { | |
| 12 | + if !state.show_stats_window { | |
| 13 | + return; | |
| 17 | 14 | } |
| 18 | 15 | |
| 19 | - /// Définir la visibilité | |
| 20 | - pub fn set_visible(&mut self, visible: bool) { | |
| 21 | - self.visible = visible; | |
| 22 | - } | |
| 23 | - | |
| 24 | - /// Basculer la visibilité | |
| 25 | - pub fn toggle(&mut self) { | |
| 26 | - self.visible = !self.visible; | |
| 27 | - } | |
| 28 | - | |
| 29 | - /// Vérifier si le panneau est visible | |
| 30 | - pub fn is_visible(&self) -> bool { | |
| 31 | - self.visible | |
| 32 | - } | |
| 16 | + let mut open = state.show_stats_window; | |
| 17 | + egui::Window::new("📊 Statistiques") | |
| 18 | + .open(&mut open) | |
| 19 | + .default_size([520.0, 380.0]) | |
| 20 | + .show(ui.ctx(), |ui| { | |
| 21 | + draw_stats_content(&state.feeds, ui); | |
| 22 | + }); | |
| 23 | + state.show_stats_window = open; | |
| 24 | +} | |
| 33 | 25 | |
| 34 | - /// Afficher le panneau de statistiques | |
| 35 | - pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 36 | - if !self.visible { | |
| 37 | - return; | |
| 38 | - } | |
| 26 | +/// Draws the statistics content | |
| 27 | +fn draw_stats_content(feeds: &[Feed], ui: &mut Ui) { | |
| 28 | + // Statistiques globales | |
| 29 | + let total_feeds = feeds.len(); | |
| 30 | + let enabled_feeds = feeds.iter().filter(|f| f.enabled).count(); | |
| 31 | + let downloaded_episodes: usize = feeds.iter().map(|f| f.downloaded_episodes_count).sum(); | |
| 32 | + let last_checked = feeds.iter().filter_map(|f| f.last_checked).max(); | |
| 33 | + let last_download = feeds | |
| 34 | + .iter() | |
| 35 | + .filter_map(|f| f.last_downloaded_episode_date) | |
| 36 | + .max(); | |
| 37 | + | |
| 38 | + ui.label("🌍 Statistiques globales"); | |
| 39 | + ui.separator(); | |
| 40 | + | |
| 41 | + egui::Grid::new("global_stats_grid") | |
| 42 | + .striped(true) | |
| 43 | + .show(ui, |ui| { | |
| 44 | + ui.label("Nombre total de flux:"); | |
| 45 | + ui.strong(total_feeds.to_string()); | |
| 46 | + ui.end_row(); | |
| 47 | + | |
| 48 | + ui.label("Flux activés:"); | |
| 49 | + ui.strong(enabled_feeds.to_string()); | |
| 50 | + ui.end_row(); | |
| 51 | + | |
| 52 | + ui.label("Épisodes téléchargés (total):"); | |
| 53 | + ui.strong(downloaded_episodes.to_string()); | |
| 54 | + ui.end_row(); | |
| 55 | + | |
| 56 | + ui.label("Dernière synchronisation:"); | |
| 57 | + ui.strong(match last_checked { | |
| 58 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), | |
| 59 | + None => "Jamais".to_string(), | |
| 60 | + }); | |
| 61 | + ui.end_row(); | |
| 39 | 62 | |
| 40 | - egui::CollapsingHeader::new("📊 Statistiques") | |
| 41 | - .default_open(true) | |
| 42 | - .show(ui, |ui| { | |
| 43 | - self.show_content(ui, stats); | |
| 63 | + ui.label("Dernier téléchargement:"); | |
| 64 | + ui.strong(match last_download { | |
| 65 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), | |
| 66 | + None => "Jamais".to_string(), | |
| 44 | 67 | }); |
| 45 | - } | |
| 68 | + ui.end_row(); | |
| 69 | + }); | |
| 46 | 70 | |
| 47 | - /// Afficher le contenu du panneau | |
| 48 | - fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 49 | - // Statistiques globales | |
| 50 | - self.show_global_stats(ui, stats); | |
| 71 | + ui.separator(); | |
| 51 | 72 | |
| 52 | - ui.separator(); | |
| 73 | + // Statistiques par flux | |
| 74 | + ui.label("📻 Statistiques par flux"); | |
| 75 | + ui.separator(); | |
| 53 | 76 | |
| 54 | - // Statistiques par flux | |
| 55 | - self.show_feed_stats(ui, stats); | |
| 77 | + if feeds.is_empty() { | |
| 78 | + ui.label("Aucun flux configuré."); | |
| 79 | + return; | |
| 56 | 80 | } |
| 57 | 81 | |
| 58 | - /// Afficher les statistiques globales | |
| 59 | - fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 60 | - ui.label("🌍 Statistiques Globales"); | |
| 61 | - ui.separator(); | |
| 62 | - | |
| 63 | - egui::Grid::new("global_stats_grid") | |
| 82 | + egui::ScrollArea::vertical().show(ui, |ui| { | |
| 83 | + egui::Grid::new("feed_stats_grid") | |
| 64 | 84 | .striped(true) |
| 65 | 85 | .show(ui, |ui| { |
| 66 | - ui.label("Nombre total de flux:"); | |
| 67 | - ui.label(format!("{}", stats.total_feeds)); | |
| 68 | - ui.end_row(); | |
| 69 | - | |
| 70 | - ui.label("Flux activés:"); | |
| 71 | - ui.label(format!("{}", stats.enabled_feeds)); | |
| 72 | - ui.end_row(); | |
| 73 | - | |
| 74 | - ui.label("Nombre total d'épisodes:"); | |
| 75 | - ui.label(format!("{}", stats.total_episodes)); | |
| 76 | - ui.end_row(); | |
| 77 | - | |
| 78 | - ui.label("Épisodes téléchargés:"); | |
| 79 | - ui.label(format!("{}", stats.downloaded_episodes)); | |
| 86 | + ui.strong("Nom du flux"); | |
| 87 | + ui.strong("Téléchargés"); | |
| 88 | + ui.strong("Dernier épisode téléchargé"); | |
| 89 | + ui.strong("Dernière vérification"); | |
| 80 | 90 | ui.end_row(); |
| 81 | 91 | |
| 82 | - ui.label("Épisodes non téléchargés:"); | |
| 83 | - ui.label(format!("{}", stats.not_downloaded_episodes)); | |
| 84 | - ui.end_row(); | |
| 85 | - | |
| 86 | - ui.label("Taille totale des téléchargements:"); | |
| 87 | - ui.label(stats.get_formatted_total_size().to_string()); | |
| 88 | - ui.end_row(); | |
| 89 | - | |
| 90 | - ui.label("Pourcentage téléchargé:"); | |
| 91 | - ui.label(format!("{:.1}%", stats.get_download_percentage())); | |
| 92 | - ui.end_row(); | |
| 93 | - | |
| 94 | - ui.label("Taille moyenne par fichier:"); | |
| 95 | - ui.label(format_bytes(stats.get_average_file_size()).to_string()); | |
| 96 | - ui.end_row(); | |
| 97 | - | |
| 98 | - ui.label("Nombre de synchronisations:"); | |
| 99 | - ui.label(format!("{}", stats.total_sync_count)); | |
| 100 | - ui.end_row(); | |
| 101 | - | |
| 102 | - ui.label("Nombre de téléchargements:"); | |
| 103 | - ui.label(format!("{}", stats.total_download_count)); | |
| 104 | - ui.end_row(); | |
| 105 | - }); | |
| 106 | - | |
| 107 | - // Afficher les dates | |
| 108 | - ui.separator(); | |
| 109 | - ui.label("📅 Dates:"); | |
| 110 | - | |
| 111 | - if let Some(date) = stats.last_sync_date { | |
| 112 | - ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S"))); | |
| 113 | - } else { | |
| 114 | - ui.label("Dernière synchronisation: Jamais"); | |
| 115 | - } | |
| 116 | - | |
| 117 | - if let Some(date) = stats.last_download_date { | |
| 118 | - ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S"))); | |
| 119 | - } else { | |
| 120 | - ui.label("Dernier téléchargement: Jamais"); | |
| 121 | - } | |
| 122 | - } | |
| 123 | - | |
| 124 | - /// Afficher les statistiques par flux | |
| 125 | - fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 126 | - ui.label("📻 Statistiques par Flux"); | |
| 127 | - ui.separator(); | |
| 128 | - | |
| 129 | - if stats.feed_statistics.is_empty() { | |
| 130 | - ui.label("Aucun flux disponible"); | |
| 131 | - return; | |
| 132 | - } | |
| 133 | - | |
| 134 | - // Tableau des statistiques par flux | |
| 135 | - egui::ScrollArea::vertical().show(ui, |ui| { | |
| 136 | - egui::Grid::new("feed_stats_grid") | |
| 137 | - .striped(true) | |
| 138 | - .show(ui, |ui| { | |
| 139 | - // En-têtes | |
| 140 | - ui.strong("Nom du flux"); | |
| 141 | - ui.strong("Épisodes"); | |
| 142 | - ui.strong("Téléchargés"); | |
| 143 | - ui.strong("Taille"); | |
| 144 | - ui.strong("% Téléchargé"); | |
| 92 | + for feed in feeds { | |
| 93 | + ui.label(&feed.name); | |
| 94 | + ui.label(feed.downloaded_episodes_count.to_string()); | |
| 95 | + ui.label( | |
| 96 | + feed.last_downloaded_episode_name | |
| 97 | + .as_deref() | |
| 98 | + .map(|n| crate::ui::truncate(n, 25)) | |
| 99 | + .unwrap_or_else(|| "—".to_string()), | |
| 100 | + ); | |
| 101 | + ui.label(match feed.last_checked { | |
| 102 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), | |
| 103 | + None => "Jamais".to_string(), | |
| 104 | + }); | |
| 145 | 105 | ui.end_row(); |
| 146 | - | |
| 147 | - // Lignes de données | |
| 148 | - let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect(); | |
| 149 | - feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes)); | |
| 150 | - | |
| 151 | - for feed_stat in feed_stats { | |
| 152 | - ui.label(&feed_stat.feed_name); | |
| 153 | - ui.label(format!("{}", feed_stat.total_episodes)); | |
| 154 | - ui.label(format!("{}", feed_stat.downloaded_episodes)); | |
| 155 | - ui.label(feed_stat.get_formatted_size().to_string()); | |
| 156 | - ui.label(format!("{:.1}%", feed_stat.get_download_percentage())); | |
| 157 | - ui.end_row(); | |
| 158 | - } | |
| 159 | - }); | |
| 160 | - }); | |
| 161 | - } | |
| 162 | - | |
| 163 | - /// Afficher un widget de statistiques rapide (pour la barre de statut) | |
| 164 | - pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 165 | - ui.horizontal(|ui| { | |
| 166 | - ui.label(format!("📊 {} flux | {} épisodes | {}", | |
| 167 | - stats.total_feeds, | |
| 168 | - stats.total_episodes, | |
| 169 | - stats.get_formatted_total_size() | |
| 170 | - )); | |
| 171 | - }); | |
| 172 | - } | |
| 106 | + } | |
| 107 | + }); | |
| 108 | + }); | |
| 173 | 109 | } |
| @@ -1,173 +1,109 @@ | |||
| 1 | -#![allow(dead_code)] // Fonctionnalité prévue dans la spec (Phase 9+), pas encore branchée dans l'UI | 1 | +//! Stats Panel UI component |
| 2 | -use crate::config::statistics::{AppStatistics, format_bytes}; | 2 | +//! |
| 3 | -use eframe::egui; | 3 | +//! Fenêtre flottante affichant les statistiques de l'application, |
| 4 | - | 4 | +//! calculées à partir des flux configurés. |
| 5 | -/// Panneau d'affichage des statistiques | 5 | + |
| 6 | -#[derive(Default)] | 6 | +use crate::config::feed::Feed; |
| 7 | -pub struct StatsPanel { | 7 | +use crate::config::AppState; |
| 8 | - /// Visibilité du panneau | 8 | +use egui::Ui; |
| 9 | - visible: bool, | 9 | + |
| 10 | -} | 10 | +/// Draws the statistics window (toggled from the menu) |
| 11 | - | 11 | +pub fn draw_stats_window(state: &mut AppState, ui: &mut Ui) { |
| 12 | - | 12 | + if !state.show_stats_window { |
| 13 | -impl StatsPanel { | 13 | + return; |
| 14 | - /// Créer un nouveau panneau de statistiques | ||
| 15 | - pub fn new() -> Self { | ||
| 16 | - Self::default() | ||
| 17 | } | 14 | } |
| 18 | 15 | ||
| 19 | - /// Définir la visibilité | 16 | + let mut open = state.show_stats_window; |
| 20 | - pub fn set_visible(&mut self, visible: bool) { | 17 | + egui::Window::new("📊 Statistiques") |
| 21 | - self.visible = visible; | 18 | + .open(&mut open) |
| 22 | - } | 19 | + .default_size([520.0, 380.0]) |
| 23 | - | 20 | + .show(ui.ctx(), |ui| { |
| 24 | - /// Basculer la visibilité | 21 | + draw_stats_content(&state.feeds, ui); |
| 25 | - pub fn toggle(&mut self) { | 22 | + }); |
| 26 | - self.visible = !self.visible; | 23 | + state.show_stats_window = open; |
| 27 | - } | 24 | +} |
| 28 | - | ||
| 29 | - /// Vérifier si le panneau est visible | ||
| 30 | - pub fn is_visible(&self) -> bool { | ||
| 31 | - self.visible | ||
| 32 | - } | ||
| 33 | 25 | ||
| 34 | - /// Afficher le panneau de statistiques | 26 | +/// Draws the statistics content |
| 35 | - pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) { | 27 | +fn draw_stats_content(feeds: &[Feed], ui: &mut Ui) { |
| 36 | - if !self.visible { | 28 | + // Statistiques globales |
| 37 | - return; | 29 | + let total_feeds = feeds.len(); |
| 38 | - } | 30 | + let enabled_feeds = feeds.iter().filter(|f| f.enabled).count(); |
| 31 | + let downloaded_episodes: usize = feeds.iter().map(|f| f.downloaded_episodes_count).sum(); | ||
| 32 | + let last_checked = feeds.iter().filter_map(|f| f.last_checked).max(); | ||
| 33 | + let last_download = feeds | ||
| 34 | + .iter() | ||
| 35 | + .filter_map(|f| f.last_downloaded_episode_date) | ||
| 36 | + .max(); | ||
| 37 | + | ||
| 38 | + ui.label("🌍 Statistiques globales"); | ||
| 39 | + ui.separator(); | ||
| 40 | + | ||
| 41 | + egui::Grid::new("global_stats_grid") | ||
| 42 | + .striped(true) | ||
| 43 | + .show(ui, |ui| { | ||
| 44 | + ui.label("Nombre total de flux:"); | ||
| 45 | + ui.strong(total_feeds.to_string()); | ||
| 46 | + ui.end_row(); | ||
| 47 | + | ||
| 48 | + ui.label("Flux activés:"); | ||
| 49 | + ui.strong(enabled_feeds.to_string()); | ||
| 50 | + ui.end_row(); | ||
| 51 | + | ||
| 52 | + ui.label("Épisodes téléchargés (total):"); | ||
| 53 | + ui.strong(downloaded_episodes.to_string()); | ||
| 54 | + ui.end_row(); | ||
| 55 | + | ||
| 56 | + ui.label("Dernière synchronisation:"); | ||
| 57 | + ui.strong(match last_checked { | ||
| 58 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), | ||
| 59 | + None => "Jamais".to_string(), | ||
| 60 | + }); | ||
| 61 | + ui.end_row(); | ||
| 39 | 62 | ||
| 40 | - egui::CollapsingHeader::new("📊 Statistiques") | 63 | + ui.label("Dernier téléchargement:"); |
| 41 | - .default_open(true) | 64 | + ui.strong(match last_download { |
| 42 | - .show(ui, |ui| { | 65 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), |
| 43 | - self.show_content(ui, stats); | 66 | + None => "Jamais".to_string(), |
| 44 | }); | 67 | }); |
| 45 | - } | 68 | + ui.end_row(); |
| 69 | + }); | ||
| 46 | 70 | ||
| 47 | - /// Afficher le contenu du panneau | 71 | + ui.separator(); |
| 48 | - fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 49 | - // Statistiques globales | ||
| 50 | - self.show_global_stats(ui, stats); | ||
| 51 | 72 | ||
| 52 | - ui.separator(); | 73 | + // Statistiques par flux |
| 74 | + ui.label("📻 Statistiques par flux"); | ||
| 75 | + ui.separator(); | ||
| 53 | 76 | ||
| 54 | - // Statistiques par flux | 77 | + if feeds.is_empty() { |
| 55 | - self.show_feed_stats(ui, stats); | 78 | + ui.label("Aucun flux configuré."); |
| 79 | + return; | ||
| 56 | } | 80 | } |
| 57 | 81 | ||
| 58 | - /// Afficher les statistiques globales | 82 | + egui::ScrollArea::vertical().show(ui, |ui| { |
| 59 | - fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | 83 | + egui::Grid::new("feed_stats_grid") |
| 60 | - ui.label("🌍 Statistiques Globales"); | ||
| 61 | - ui.separator(); | ||
| 62 | - | ||
| 63 | - egui::Grid::new("global_stats_grid") | ||
| 64 | .striped(true) | 84 | .striped(true) |
| 65 | .show(ui, |ui| { | 85 | .show(ui, |ui| { |
| 66 | - ui.label("Nombre total de flux:"); | 86 | + ui.strong("Nom du flux"); |
| 67 | - ui.label(format!("{}", stats.total_feeds)); | 87 | + ui.strong("Téléchargés"); |
| 68 | - ui.end_row(); | 88 | + ui.strong("Dernier épisode téléchargé"); |
| 69 | - | 89 | + ui.strong("Dernière vérification"); |
| 70 | - ui.label("Flux activés:"); | ||
| 71 | - ui.label(format!("{}", stats.enabled_feeds)); | ||
| 72 | - ui.end_row(); | ||
| 73 | - | ||
| 74 | - ui.label("Nombre total d'épisodes:"); | ||
| 75 | - ui.label(format!("{}", stats.total_episodes)); | ||
| 76 | - ui.end_row(); | ||
| 77 | - | ||
| 78 | - ui.label("Épisodes téléchargés:"); | ||
| 79 | - ui.label(format!("{}", stats.downloaded_episodes)); | ||
| 80 | ui.end_row(); | 90 | ui.end_row(); |
| 81 | 91 | ||
| 82 | - ui.label("Épisodes non téléchargés:"); | 92 | + for feed in feeds { |
| 83 | - ui.label(format!("{}", stats.not_downloaded_episodes)); | 93 | + ui.label(&feed.name); |
| 84 | - ui.end_row(); | 94 | + ui.label(feed.downloaded_episodes_count.to_string()); |
| 85 | - | 95 | + ui.label( |
| 86 | - ui.label("Taille totale des téléchargements:"); | 96 | + feed.last_downloaded_episode_name |
| 87 | - ui.label(stats.get_formatted_total_size().to_string()); | 97 | + .as_deref() |
| 88 | - ui.end_row(); | 98 | + .map(|n| crate::ui::truncate(n, 25)) |
| 89 | - | 99 | + .unwrap_or_else(|| "—".to_string()), |
| 90 | - ui.label("Pourcentage téléchargé:"); | 100 | + ); |
| 91 | - ui.label(format!("{:.1}%", stats.get_download_percentage())); | 101 | + ui.label(match feed.last_checked { |
| 92 | - ui.end_row(); | 102 | + Some(date) => date.format("%d/%m/%Y %H:%M").to_string(), |
| 93 | - | 103 | + None => "Jamais".to_string(), |
| 94 | - ui.label("Taille moyenne par fichier:"); | 104 | + }); |
| 95 | - ui.label(format_bytes(stats.get_average_file_size()).to_string()); | ||
| 96 | - ui.end_row(); | ||
| 97 | - | ||
| 98 | - ui.label("Nombre de synchronisations:"); | ||
| 99 | - ui.label(format!("{}", stats.total_sync_count)); | ||
| 100 | - ui.end_row(); | ||
| 101 | - | ||
| 102 | - ui.label("Nombre de téléchargements:"); | ||
| 103 | - ui.label(format!("{}", stats.total_download_count)); | ||
| 104 | - ui.end_row(); | ||
| 105 | - }); | ||
| 106 | - | ||
| 107 | - // Afficher les dates | ||
| 108 | - ui.separator(); | ||
| 109 | - ui.label("📅 Dates:"); | ||
| 110 | - | ||
| 111 | - if let Some(date) = stats.last_sync_date { | ||
| 112 | - ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S"))); | ||
| 113 | - } else { | ||
| 114 | - ui.label("Dernière synchronisation: Jamais"); | ||
| 115 | - } | ||
| 116 | - | ||
| 117 | - if let Some(date) = stats.last_download_date { | ||
| 118 | - ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S"))); | ||
| 119 | - } else { | ||
| 120 | - ui.label("Dernier téléchargement: Jamais"); | ||
| 121 | - } | ||
| 122 | - } | ||
| 123 | - | ||
| 124 | - /// Afficher les statistiques par flux | ||
| 125 | - fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 126 | - ui.label("📻 Statistiques par Flux"); | ||
| 127 | - ui.separator(); | ||
| 128 | - | ||
| 129 | - if stats.feed_statistics.is_empty() { | ||
| 130 | - ui.label("Aucun flux disponible"); | ||
| 131 | - return; | ||
| 132 | - } | ||
| 133 | - | ||
| 134 | - // Tableau des statistiques par flux | ||
| 135 | - egui::ScrollArea::vertical().show(ui, |ui| { | ||
| 136 | - egui::Grid::new("feed_stats_grid") | ||
| 137 | - .striped(true) | ||
| 138 | - .show(ui, |ui| { | ||
| 139 | - // En-têtes | ||
| 140 | - ui.strong("Nom du flux"); | ||
| 141 | - ui.strong("Épisodes"); | ||
| 142 | - ui.strong("Téléchargés"); | ||
| 143 | - ui.strong("Taille"); | ||
| 144 | - ui.strong("% Téléchargé"); | ||
| 145 | ui.end_row(); | 105 | ui.end_row(); |
| 146 | - | 106 | + } |
| 147 | - // Lignes de données | 107 | + }); |
| 148 | - let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect(); | 108 | + }); |
| 149 | - feed_stats.sort_by_key(|s| std::cmp::Reverse(s.total_episodes)); | ||
| 150 | - | ||
| 151 | - for feed_stat in feed_stats { | ||
| 152 | - ui.label(&feed_stat.feed_name); | ||
| 153 | - ui.label(format!("{}", feed_stat.total_episodes)); | ||
| 154 | - ui.label(format!("{}", feed_stat.downloaded_episodes)); | ||
| 155 | - ui.label(feed_stat.get_formatted_size().to_string()); | ||
| 156 | - ui.label(format!("{:.1}%", feed_stat.get_download_percentage())); | ||
| 157 | - ui.end_row(); | ||
| 158 | - } | ||
| 159 | - }); | ||
| 160 | - }); | ||
| 161 | - } | ||
| 162 | - | ||
| 163 | - /// Afficher un widget de statistiques rapide (pour la barre de statut) | ||
| 164 | - pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 165 | - ui.horizontal(|ui| { | ||
| 166 | - ui.label(format!("📊 {} flux | {} épisodes | {}", | ||
| 167 | - stats.total_feeds, | ||
| 168 | - stats.total_episodes, | ||
| 169 | - stats.get_formatted_total_size() | ||
| 170 | - )); | ||
| 171 | - }); | ||
| 172 | - } | ||
| 173 | } | 109 | } |
modified
src/ui/status_bar.rs +27 -35 | @@ -3,27 +3,18 @@ | ||
| 3 | 3 | //! This module contains the status bar display logic. |
| 4 | 4 | |
| 5 | 5 | use crate::config::AppState; |
| 6 | -use crate::config::Theme; | |
| 6 | +use crate::ui::theme::ThemeColors; | |
| 7 | 7 | use egui::{Align, Layout, Ui}; |
| 8 | 8 | |
| 9 | 9 | /// Draws the status bar at the bottom of the application |
| 10 | 10 | pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { |
| 11 | - // Determine colors based on theme | |
| 12 | - let (bg_color, stroke_color) = match state.get_theme() { | |
| 13 | - Theme::Dark => ( | |
| 14 | - egui::Color32::from_rgb(36, 39, 58), | |
| 15 | - egui::Color32::from_rgb(70, 78, 95) | |
| 16 | - ), | |
| 17 | - Theme::Light => ( | |
| 18 | - egui::Color32::from_rgb(230, 235, 245), | |
| 19 | - egui::Color32::from_rgb(180, 195, 210) | |
| 20 | - ), | |
| 21 | - }; | |
| 22 | - | |
| 11 | + // Palette commune (voir ui/theme.rs) | |
| 12 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 13 | + | |
| 23 | 14 | // Frame pour la barre de statut |
| 24 | 15 | egui::Frame { |
| 25 | - fill: bg_color, | |
| 26 | - stroke: egui::Stroke::new(1.0, stroke_color), | |
| 16 | + fill: colors.bg_primary, | |
| 17 | + stroke: egui::Stroke::new(1.0, colors.border), | |
| 27 | 18 | corner_radius: 4.0.into(), |
| 28 | 19 | inner_margin: egui::Margin::symmetric(12, 8), |
| 29 | 20 | ..Default::default() |
| @@ -31,35 +22,32 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | ||
| 31 | 22 | .show(ui, |ui| { |
| 32 | 23 | ui.spacing_mut().item_spacing.x = 16.0; |
| 33 | 24 | |
| 34 | - // Sync status avec icône | |
| 35 | - // Note: Sync messages are processed in home.rs before drawing | |
| 25 | + // Sync messages are processed in home.rs before drawing | |
| 36 | 26 | let feed_count = state.feed_count(); |
| 37 | 27 | let enabled_count = state.enabled_feed_count(); |
| 38 | - | |
| 39 | - // Process remaining messages (for status display only) | |
| 40 | - state.sync_task_manager.process_messages(); | |
| 41 | - | |
| 28 | + | |
| 42 | 29 | ui.horizontal(|ui| { |
| 43 | 30 | // Use the sync indicator from sync_status module |
| 44 | 31 | crate::ui::sync_status::draw_sync_indicator(state, ui); |
| 45 | - | |
| 32 | + | |
| 46 | 33 | // Show ready status only if no sync activity at all |
| 47 | 34 | if !state.sync_task_manager.status.has_sync_activity() { |
| 48 | - ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100))); | |
| 49 | - ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt"); | |
| 35 | + ui.label(egui::RichText::new("✅ ").color(colors.success)); | |
| 36 | + ui.colored_label(colors.success, "Prêt"); | |
| 50 | 37 | } |
| 51 | 38 | |
| 52 | 39 | // Theme toggle button |
| 53 | 40 | let theme_icon = match state.get_theme() { |
| 54 | - Theme::Dark => "🌙", | |
| 55 | - Theme::Light => "☀️", | |
| 41 | + crate::config::Theme::Dark => "🌙", | |
| 42 | + crate::config::Theme::Light => "☀️", | |
| 56 | 43 | }; |
| 57 | 44 | let theme_tooltip = match state.get_theme() { |
| 58 | - Theme::Dark => "Basculer vers le thème clair", | |
| 59 | - Theme::Light => "Basculer vers le thème sombre", | |
| 45 | + crate::config::Theme::Dark => "Basculer vers le thème clair", | |
| 46 | + crate::config::Theme::Light => "Basculer vers le thème sombre", | |
| 60 | 47 | }; |
| 61 | - | |
| 62 | - if ui.link(egui::RichText::new(theme_icon).size(16.0)) | |
| 48 | + | |
| 49 | + if ui | |
| 50 | + .link(egui::RichText::new(theme_icon).size(16.0)) | |
| 63 | 51 | .on_hover_text(theme_tooltip) |
| 64 | 52 | .clicked() |
| 65 | 53 | { |
| @@ -68,13 +56,17 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | ||
| 68 | 56 | |
| 69 | 57 | // Feed count on the right avec icône |
| 70 | 58 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 71 | - ui.label(egui::RichText::new(format!("📊 {} flux ", feed_count)) | |
| 72 | - .color(egui::Color32::from_rgb(160, 180, 200))); | |
| 73 | - | |
| 59 | + ui.label( | |
| 60 | + egui::RichText::new(format!("📊 {} flux ", feed_count)) | |
| 61 | + .color(colors.text_secondary), | |
| 62 | + ); | |
| 63 | + | |
| 74 | 64 | // Nombre de flux activés |
| 75 | 65 | if enabled_count < feed_count { |
| 76 | - ui.label(egui::RichText::new(format!("| {} activés", enabled_count)) | |
| 77 | - .color(egui::Color32::from_rgb(140, 160, 180))); | |
| 66 | + ui.label( | |
| 67 | + egui::RichText::new(format!("| {} activés", enabled_count)) | |
| 68 | + .color(colors.text_dim), | |
| 69 | + ); | |
| 78 | 70 | } |
| 79 | 71 | }); |
| 80 | 72 | }); |
| @@ -3,27 +3,18 @@ | |||
| 3 | //! This module contains the status bar display logic. | 3 | //! This module contains the status bar display logic. |
| 4 | 4 | ||
| 5 | use crate::config::AppState; | 5 | use crate::config::AppState; |
| 6 | -use crate::config::Theme; | 6 | +use crate::ui::theme::ThemeColors; |
| 7 | use egui::{Align, Layout, Ui}; | 7 | use egui::{Align, Layout, Ui}; |
| 8 | 8 | ||
| 9 | /// Draws the status bar at the bottom of the application | 9 | /// Draws the status bar at the bottom of the application |
| 10 | pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | 10 | pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { |
| 11 | - // Determine colors based on theme | 11 | + // Palette commune (voir ui/theme.rs) |
| 12 | - let (bg_color, stroke_color) = match state.get_theme() { | 12 | + let colors = ThemeColors::for_theme(state.get_theme()); |
| 13 | - Theme::Dark => ( | 13 | + |
| 14 | - egui::Color32::from_rgb(36, 39, 58), | ||
| 15 | - egui::Color32::from_rgb(70, 78, 95) | ||
| 16 | - ), | ||
| 17 | - Theme::Light => ( | ||
| 18 | - egui::Color32::from_rgb(230, 235, 245), | ||
| 19 | - egui::Color32::from_rgb(180, 195, 210) | ||
| 20 | - ), | ||
| 21 | - }; | ||
| 22 | - | ||
| 23 | // Frame pour la barre de statut | 14 | // Frame pour la barre de statut |
| 24 | egui::Frame { | 15 | egui::Frame { |
| 25 | - fill: bg_color, | 16 | + fill: colors.bg_primary, |
| 26 | - stroke: egui::Stroke::new(1.0, stroke_color), | 17 | + stroke: egui::Stroke::new(1.0, colors.border), |
| 27 | corner_radius: 4.0.into(), | 18 | corner_radius: 4.0.into(), |
| 28 | inner_margin: egui::Margin::symmetric(12, 8), | 19 | inner_margin: egui::Margin::symmetric(12, 8), |
| 29 | ..Default::default() | 20 | ..Default::default() |
| @@ -31,35 +22,32 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | |||
| 31 | .show(ui, |ui| { | 22 | .show(ui, |ui| { |
| 32 | ui.spacing_mut().item_spacing.x = 16.0; | 23 | ui.spacing_mut().item_spacing.x = 16.0; |
| 33 | 24 | ||
| 34 | - // Sync status avec icône | 25 | + // Sync messages are processed in home.rs before drawing |
| 35 | - // Note: Sync messages are processed in home.rs before drawing | ||
| 36 | let feed_count = state.feed_count(); | 26 | let feed_count = state.feed_count(); |
| 37 | let enabled_count = state.enabled_feed_count(); | 27 | let enabled_count = state.enabled_feed_count(); |
| 38 | - | 28 | + |
| 39 | - // Process remaining messages (for status display only) | ||
| 40 | - state.sync_task_manager.process_messages(); | ||
| 41 | - | ||
| 42 | ui.horizontal(|ui| { | 29 | ui.horizontal(|ui| { |
| 43 | // Use the sync indicator from sync_status module | 30 | // Use the sync indicator from sync_status module |
| 44 | crate::ui::sync_status::draw_sync_indicator(state, ui); | 31 | crate::ui::sync_status::draw_sync_indicator(state, ui); |
| 45 | - | 32 | + |
| 46 | // Show ready status only if no sync activity at all | 33 | // Show ready status only if no sync activity at all |
| 47 | if !state.sync_task_manager.status.has_sync_activity() { | 34 | if !state.sync_task_manager.status.has_sync_activity() { |
| 48 | - ui.label(egui::RichText::new("✅ ").color(egui::Color32::from_rgb(100, 220, 100))); | 35 | + ui.label(egui::RichText::new("✅ ").color(colors.success)); |
| 49 | - ui.colored_label(egui::Color32::from_rgb(150, 200, 150), "Prêt"); | 36 | + ui.colored_label(colors.success, "Prêt"); |
| 50 | } | 37 | } |
| 51 | 38 | ||
| 52 | // Theme toggle button | 39 | // Theme toggle button |
| 53 | let theme_icon = match state.get_theme() { | 40 | let theme_icon = match state.get_theme() { |
| 54 | - Theme::Dark => "🌙", | 41 | + crate::config::Theme::Dark => "🌙", |
| 55 | - Theme::Light => "☀️", | 42 | + crate::config::Theme::Light => "☀️", |
| 56 | }; | 43 | }; |
| 57 | let theme_tooltip = match state.get_theme() { | 44 | let theme_tooltip = match state.get_theme() { |
| 58 | - Theme::Dark => "Basculer vers le thème clair", | 45 | + crate::config::Theme::Dark => "Basculer vers le thème clair", |
| 59 | - Theme::Light => "Basculer vers le thème sombre", | 46 | + crate::config::Theme::Light => "Basculer vers le thème sombre", |
| 60 | }; | 47 | }; |
| 61 | - | 48 | + |
| 62 | - if ui.link(egui::RichText::new(theme_icon).size(16.0)) | 49 | + if ui |
| 50 | + .link(egui::RichText::new(theme_icon).size(16.0)) | ||
| 63 | .on_hover_text(theme_tooltip) | 51 | .on_hover_text(theme_tooltip) |
| 64 | .clicked() | 52 | .clicked() |
| 65 | { | 53 | { |
| @@ -68,13 +56,17 @@ pub fn draw_status_bar(state: &mut AppState, ui: &mut Ui) { | |||
| 68 | 56 | ||
| 69 | // Feed count on the right avec icône | 57 | // Feed count on the right avec icône |
| 70 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | 58 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 71 | - ui.label(egui::RichText::new(format!("📊 {} flux ", feed_count)) | 59 | + ui.label( |
| 72 | - .color(egui::Color32::from_rgb(160, 180, 200))); | 60 | + egui::RichText::new(format!("📊 {} flux ", feed_count)) |
| 73 | - | 61 | + .color(colors.text_secondary), |
| 62 | + ); | ||
| 63 | + | ||
| 74 | // Nombre de flux activés | 64 | // Nombre de flux activés |
| 75 | if enabled_count < feed_count { | 65 | if enabled_count < feed_count { |
| 76 | - ui.label(egui::RichText::new(format!("| {} activés", enabled_count)) | 66 | + ui.label( |
| 77 | - .color(egui::Color32::from_rgb(140, 160, 180))); | 67 | + egui::RichText::new(format!("| {} activés", enabled_count)) |
| 68 | + .color(colors.text_dim), | ||
| 69 | + ); | ||
| 78 | } | 70 | } |
| 79 | }); | 71 | }); |
| 80 | }); | 72 | }); |
modified
src/ui/sync_status.rs +52 -53 | @@ -3,65 +3,57 @@ | ||
| 3 | 3 | //! This module displays the synchronization status and progress in the status bar. |
| 4 | 4 | |
| 5 | 5 | use crate::config::AppState; |
| 6 | -use egui::{Color32, RichText, Ui}; | |
| 6 | +use crate::ui::theme::ThemeColors; | |
| 7 | +use egui::{RichText, Ui}; | |
| 7 | 8 | |
| 8 | -/// Draws a progress bar | |
| 9 | -fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) { | |
| 9 | +/// Draws a progress bar with theme colors | |
| 10 | +fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { | |
| 10 | 11 | let progress_clamped = progress.clamp(0.0, 1.0); |
| 11 | - | |
| 12 | + | |
| 12 | 13 | // Background |
| 13 | - let bg_rect = egui::Rect::from_min_size( | |
| 14 | - ui.cursor().min, | |
| 15 | - egui::Vec2::new(width, 8.0) | |
| 16 | - ); | |
| 17 | - | |
| 14 | + let bg_rect = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::new(width, 8.0)); | |
| 15 | + | |
| 18 | 16 | // Progress fill |
| 19 | 17 | let fill_width = width * progress_clamped; |
| 20 | - let fill_rect = egui::Rect::from_min_size( | |
| 21 | - bg_rect.min, | |
| 22 | - egui::Vec2::new(fill_width, 8.0) | |
| 23 | - ); | |
| 24 | - | |
| 18 | + let fill_rect = egui::Rect::from_min_size(bg_rect.min, egui::Vec2::new(fill_width, 8.0)); | |
| 19 | + | |
| 25 | 20 | // Paint background |
| 26 | - ui.painter().rect_filled( | |
| 27 | - bg_rect, | |
| 28 | - 4.0, | |
| 29 | - Color32::from_rgb(60, 64, 80) | |
| 30 | - ); | |
| 31 | - | |
| 21 | + ui.painter().rect_filled(bg_rect, 4.0, colors.bg_tertiary); | |
| 22 | + | |
| 32 | 23 | // Paint progress fill |
| 33 | 24 | let fill_color = if progress_clamped >= 1.0 { |
| 34 | - Color32::from_rgb(80, 220, 100) // Green for complete | |
| 25 | + colors.success | |
| 35 | 26 | } else { |
| 36 | - Color32::from_rgb(130, 170, 250) // Blue for in progress | |
| 27 | + colors.accent | |
| 37 | 28 | }; |
| 38 | - | |
| 39 | - ui.painter().rect_filled( | |
| 40 | - fill_rect, | |
| 41 | - 4.0, | |
| 42 | - fill_color | |
| 43 | - ); | |
| 44 | - | |
| 29 | + | |
| 30 | + ui.painter().rect_filled(fill_rect, 4.0, fill_color); | |
| 31 | + | |
| 45 | 32 | // Advance cursor |
| 46 | 33 | ui.advance_cursor_after_rect(bg_rect); |
| 47 | 34 | } |
| 48 | 35 | |
| 49 | 36 | /// Draws a compact sync indicator for the status bar |
| 50 | 37 | pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { |
| 51 | - // Messages are processed in status_bar.rs, so we just display the status here | |
| 38 | + // Messages are processed in home.rs, so we just display the status here | |
| 52 | 39 | let status = &state.sync_task_manager.status; |
| 53 | - | |
| 40 | + let colors = ThemeColors::for_theme(state.get_theme()); | |
| 41 | + | |
| 54 | 42 | // Always show sync info if there's any activity |
| 55 | 43 | if !status.has_sync_activity() { |
| 56 | 44 | return; |
| 57 | 45 | } |
| 58 | - | |
| 46 | + | |
| 59 | 47 | let global_sync_in_progress = status.global_sync_in_progress; |
| 60 | 48 | let syncing_count = status.syncing_feed_count(); |
| 61 | - let completed_count = status.feed_sync_status.values() | |
| 49 | + let completed_count = status | |
| 50 | + .feed_sync_status | |
| 51 | + .values() | |
| 62 | 52 | .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed)) |
| 63 | 53 | .count(); |
| 64 | - let failed_errors: Vec<String> = status.feed_sync_status.values() | |
| 54 | + let failed_errors: Vec<String> = status | |
| 55 | + .feed_sync_status | |
| 56 | + .values() | |
| 65 | 57 | .filter_map(|s| match s { |
| 66 | 58 | crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()), |
| 67 | 59 | _ => None, |
| @@ -71,41 +63,48 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | ||
| 71 | 63 | let total_feeds = status.total_feeds; |
| 72 | 64 | let completed_feeds = status.completed_feeds; |
| 73 | 65 | let overall_progress = status.get_overall_progress(); |
| 74 | - | |
| 66 | + | |
| 75 | 67 | ui.horizontal(|ui| { |
| 76 | 68 | ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0); |
| 77 | - | |
| 69 | + | |
| 78 | 70 | if global_sync_in_progress { |
| 79 | 71 | // Global sync indicator |
| 80 | 72 | ui.label(RichText::new("🔄").size(16.0)); |
| 81 | - ui.label(RichText::new(format!("Synchro: {}/{} flux", | |
| 82 | - completed_feeds, | |
| 83 | - total_feeds)) | |
| 84 | - .size(13.0) | |
| 85 | - .color(Color32::from_rgb(180, 200, 255))); | |
| 86 | - | |
| 73 | + ui.label( | |
| 74 | + RichText::new(format!("Synchro: {}/{} flux", completed_feeds, total_feeds)) | |
| 75 | + .size(13.0) | |
| 76 | + .color(colors.accent), | |
| 77 | + ); | |
| 78 | + | |
| 87 | 79 | // Overall progress bar |
| 88 | - draw_progress_bar(ui, overall_progress, 60.0); | |
| 80 | + draw_progress_bar(ui, overall_progress, 60.0, &colors); | |
| 89 | 81 | } else if syncing_count > 0 { |
| 90 | 82 | // Individual feed sync indicator |
| 91 | 83 | ui.label(RichText::new("🔄").size(16.0)); |
| 92 | - ui.label(RichText::new(format!("{} flux en synchro", syncing_count)) | |
| 93 | - .size(13.0) | |
| 94 | - .color(Color32::from_rgb(180, 200, 255))); | |
| 84 | + ui.label( | |
| 85 | + RichText::new(format!("{} flux en synchro", syncing_count)) | |
| 86 | + .size(13.0) | |
| 87 | + .color(colors.accent), | |
| 88 | + ); | |
| 95 | 89 | } else if completed_count > 0 || failed_count > 0 { |
| 96 | 90 | // Show completion status briefly |
| 97 | - ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100))); | |
| 98 | - let total_completed = completed_count + failed_count; | |
| 99 | - let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)", | |
| 100 | - total_completed, completed_count, failed_count)) | |
| 91 | + ui.label(RichText::new("✅").size(16.0).color(colors.success)); | |
| 92 | + let completion_label = ui.label( | |
| 93 | + RichText::new(format!( | |
| 94 | + "Synchro terminée: {} flux ({} OK, {} erreur)", | |
| 95 | + completed_count + failed_count, | |
| 96 | + completed_count, | |
| 97 | + failed_count | |
| 98 | + )) | |
| 101 | 99 | .size(13.0) |
| 102 | - .color(Color32::from_rgb(150, 220, 150))); | |
| 103 | - | |
| 100 | + .color(colors.success), | |
| 101 | + ); | |
| 102 | + | |
| 104 | 103 | // Show the failure details on hover |
| 105 | 104 | if !failed_errors.is_empty() { |
| 106 | 105 | completion_label.on_hover_text(failed_errors.join("\n")); |
| 107 | 106 | } |
| 108 | - | |
| 107 | + | |
| 109 | 108 | // Add a button to clear completed sync status |
| 110 | 109 | if ui.button(RichText::new("✕").size(12.0)).clicked() { |
| 111 | 110 | state.sync_task_manager.clear_completed_sync_status(); |
| @@ -3,65 +3,57 @@ | |||
| 3 | //! This module displays the synchronization status and progress in the status bar. | 3 | //! This module displays the synchronization status and progress in the status bar. |
| 4 | 4 | ||
| 5 | use crate::config::AppState; | 5 | use crate::config::AppState; |
| 6 | -use egui::{Color32, RichText, Ui}; | 6 | +use crate::ui::theme::ThemeColors; |
| 7 | +use egui::{RichText, Ui}; | ||
| 7 | 8 | ||
| 8 | -/// Draws a progress bar | 9 | +/// Draws a progress bar with theme colors |
| 9 | -fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32) { | 10 | +fn draw_progress_bar(ui: &mut Ui, progress: f32, width: f32, colors: &ThemeColors) { |
| 10 | let progress_clamped = progress.clamp(0.0, 1.0); | 11 | let progress_clamped = progress.clamp(0.0, 1.0); |
| 11 | - | 12 | + |
| 12 | // Background | 13 | // Background |
| 13 | - let bg_rect = egui::Rect::from_min_size( | 14 | + let bg_rect = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::new(width, 8.0)); |
| 14 | - ui.cursor().min, | 15 | + |
| 15 | - egui::Vec2::new(width, 8.0) | ||
| 16 | - ); | ||
| 17 | - | ||
| 18 | // Progress fill | 16 | // Progress fill |
| 19 | let fill_width = width * progress_clamped; | 17 | let fill_width = width * progress_clamped; |
| 20 | - let fill_rect = egui::Rect::from_min_size( | 18 | + let fill_rect = egui::Rect::from_min_size(bg_rect.min, egui::Vec2::new(fill_width, 8.0)); |
| 21 | - bg_rect.min, | 19 | + |
| 22 | - egui::Vec2::new(fill_width, 8.0) | ||
| 23 | - ); | ||
| 24 | - | ||
| 25 | // Paint background | 20 | // Paint background |
| 26 | - ui.painter().rect_filled( | 21 | + ui.painter().rect_filled(bg_rect, 4.0, colors.bg_tertiary); |
| 27 | - bg_rect, | 22 | + |
| 28 | - 4.0, | ||
| 29 | - Color32::from_rgb(60, 64, 80) | ||
| 30 | - ); | ||
| 31 | - | ||
| 32 | // Paint progress fill | 23 | // Paint progress fill |
| 33 | let fill_color = if progress_clamped >= 1.0 { | 24 | let fill_color = if progress_clamped >= 1.0 { |
| 34 | - Color32::from_rgb(80, 220, 100) // Green for complete | 25 | + colors.success |
| 35 | } else { | 26 | } else { |
| 36 | - Color32::from_rgb(130, 170, 250) // Blue for in progress | 27 | + colors.accent |
| 37 | }; | 28 | }; |
| 38 | - | 29 | + |
| 39 | - ui.painter().rect_filled( | 30 | + ui.painter().rect_filled(fill_rect, 4.0, fill_color); |
| 40 | - fill_rect, | 31 | + |
| 41 | - 4.0, | ||
| 42 | - fill_color | ||
| 43 | - ); | ||
| 44 | - | ||
| 45 | // Advance cursor | 32 | // Advance cursor |
| 46 | ui.advance_cursor_after_rect(bg_rect); | 33 | ui.advance_cursor_after_rect(bg_rect); |
| 47 | } | 34 | } |
| 48 | 35 | ||
| 49 | /// Draws a compact sync indicator for the status bar | 36 | /// Draws a compact sync indicator for the status bar |
| 50 | pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | 37 | pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { |
| 51 | - // Messages are processed in status_bar.rs, so we just display the status here | 38 | + // Messages are processed in home.rs, so we just display the status here |
| 52 | let status = &state.sync_task_manager.status; | 39 | let status = &state.sync_task_manager.status; |
| 53 | - | 40 | + let colors = ThemeColors::for_theme(state.get_theme()); |
| 41 | + | ||
| 54 | // Always show sync info if there's any activity | 42 | // Always show sync info if there's any activity |
| 55 | if !status.has_sync_activity() { | 43 | if !status.has_sync_activity() { |
| 56 | return; | 44 | return; |
| 57 | } | 45 | } |
| 58 | - | 46 | + |
| 59 | let global_sync_in_progress = status.global_sync_in_progress; | 47 | let global_sync_in_progress = status.global_sync_in_progress; |
| 60 | let syncing_count = status.syncing_feed_count(); | 48 | let syncing_count = status.syncing_feed_count(); |
| 61 | - let completed_count = status.feed_sync_status.values() | 49 | + let completed_count = status |
| 50 | + .feed_sync_status | ||
| 51 | + .values() | ||
| 62 | .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed)) | 52 | .filter(|s| matches!(s, crate::config::sync_task::FeedSyncStatus::Completed)) |
| 63 | .count(); | 53 | .count(); |
| 64 | - let failed_errors: Vec<String> = status.feed_sync_status.values() | 54 | + let failed_errors: Vec<String> = status |
| 55 | + .feed_sync_status | ||
| 56 | + .values() | ||
| 65 | .filter_map(|s| match s { | 57 | .filter_map(|s| match s { |
| 66 | crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()), | 58 | crate::config::sync_task::FeedSyncStatus::Failed { error } => Some(error.clone()), |
| 67 | _ => None, | 59 | _ => None, |
| @@ -71,41 +63,48 @@ pub fn draw_sync_indicator(state: &mut AppState, ui: &mut Ui) { | |||
| 71 | let total_feeds = status.total_feeds; | 63 | let total_feeds = status.total_feeds; |
| 72 | let completed_feeds = status.completed_feeds; | 64 | let completed_feeds = status.completed_feeds; |
| 73 | let overall_progress = status.get_overall_progress(); | 65 | let overall_progress = status.get_overall_progress(); |
| 74 | - | 66 | + |
| 75 | ui.horizontal(|ui| { | 67 | ui.horizontal(|ui| { |
| 76 | ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0); | 68 | ui.spacing_mut().item_spacing = egui::Vec2::new(4.0, 0.0); |
| 77 | - | 69 | + |
| 78 | if global_sync_in_progress { | 70 | if global_sync_in_progress { |
| 79 | // Global sync indicator | 71 | // Global sync indicator |
| 80 | ui.label(RichText::new("🔄").size(16.0)); | 72 | ui.label(RichText::new("🔄").size(16.0)); |
| 81 | - ui.label(RichText::new(format!("Synchro: {}/{} flux", | 73 | + ui.label( |
| 82 | - completed_feeds, | 74 | + RichText::new(format!("Synchro: {}/{} flux", completed_feeds, total_feeds)) |
| 83 | - total_feeds)) | 75 | + .size(13.0) |
| 84 | - .size(13.0) | 76 | + .color(colors.accent), |
| 85 | - .color(Color32::from_rgb(180, 200, 255))); | 77 | + ); |
| 86 | - | 78 | + |
| 87 | // Overall progress bar | 79 | // Overall progress bar |
| 88 | - draw_progress_bar(ui, overall_progress, 60.0); | 80 | + draw_progress_bar(ui, overall_progress, 60.0, &colors); |
| 89 | } else if syncing_count > 0 { | 81 | } else if syncing_count > 0 { |
| 90 | // Individual feed sync indicator | 82 | // Individual feed sync indicator |
| 91 | ui.label(RichText::new("🔄").size(16.0)); | 83 | ui.label(RichText::new("🔄").size(16.0)); |
| 92 | - ui.label(RichText::new(format!("{} flux en synchro", syncing_count)) | 84 | + ui.label( |
| 93 | - .size(13.0) | 85 | + RichText::new(format!("{} flux en synchro", syncing_count)) |
| 94 | - .color(Color32::from_rgb(180, 200, 255))); | 86 | + .size(13.0) |
| 87 | + .color(colors.accent), | ||
| 88 | + ); | ||
| 95 | } else if completed_count > 0 || failed_count > 0 { | 89 | } else if completed_count > 0 || failed_count > 0 { |
| 96 | // Show completion status briefly | 90 | // Show completion status briefly |
| 97 | - ui.label(RichText::new("✅").size(16.0).color(Color32::from_rgb(80, 220, 100))); | 91 | + ui.label(RichText::new("✅").size(16.0).color(colors.success)); |
| 98 | - let total_completed = completed_count + failed_count; | 92 | + let completion_label = ui.label( |
| 99 | - let completion_label = ui.label(RichText::new(format!("Synchro terminée: {} flux ({} OK, {} erreur)", | 93 | + RichText::new(format!( |
| 100 | - total_completed, completed_count, failed_count)) | 94 | + "Synchro terminée: {} flux ({} OK, {} erreur)", |
| 95 | + completed_count + failed_count, | ||
| 96 | + completed_count, | ||
| 97 | + failed_count | ||
| 98 | + )) | ||
| 101 | .size(13.0) | 99 | .size(13.0) |
| 102 | - .color(Color32::from_rgb(150, 220, 150))); | 100 | + .color(colors.success), |
| 103 | - | 101 | + ); |
| 102 | + | ||
| 104 | // Show the failure details on hover | 103 | // Show the failure details on hover |
| 105 | if !failed_errors.is_empty() { | 104 | if !failed_errors.is_empty() { |
| 106 | completion_label.on_hover_text(failed_errors.join("\n")); | 105 | completion_label.on_hover_text(failed_errors.join("\n")); |
| 107 | } | 106 | } |
| 108 | - | 107 | + |
| 109 | // Add a button to clear completed sync status | 108 | // Add a button to clear completed sync status |
| 110 | if ui.button(RichText::new("✕").size(12.0)).clicked() { | 109 | if ui.button(RichText::new("✕").size(12.0)).clicked() { |
| 111 | state.sync_task_manager.clear_completed_sync_status(); | 110 | state.sync_task_manager.clear_completed_sync_status(); |
added
src/ui/theme.rs +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +//! Thème centralisé de l'application | |
| 2 | +//! | |
| 3 | +//! Palette de couleurs unique pour les deux thèmes (sombre/clair), | |
| 4 | +//! partagée par tous les composants UI pour éviter toute divergence. | |
| 5 | + | |
| 6 | +use crate::config::Theme; | |
| 7 | +use eframe::egui; | |
| 8 | + | |
| 9 | +/// Palette de couleurs pour un thème donné | |
| 10 | +#[derive(Debug, Clone, Copy)] | |
| 11 | +pub struct ThemeColors { | |
| 12 | + /// Arrière-plan de la fenêtre | |
| 13 | + pub window_bg: egui::Color32, | |
| 14 | + /// Arrière-plan principal (widgets non interactifs) | |
| 15 | + pub bg_primary: egui::Color32, | |
| 16 | + /// Arrière-plan secondaire (cartes, boutons inactifs) | |
| 17 | + pub bg_secondary: egui::Color32, | |
| 18 | + /// Arrière-plan tertiaire (survol) | |
| 19 | + pub bg_tertiary: egui::Color32, | |
| 20 | + /// Bordures | |
| 21 | + pub border: egui::Color32, | |
| 22 | + /// Couleur d'accentuation (liens, sélection, progression) | |
| 23 | + pub accent: egui::Color32, | |
| 24 | + /// Texte principal | |
| 25 | + pub text_primary: egui::Color32, | |
| 26 | + /// Texte secondaire | |
| 27 | + pub text_secondary: egui::Color32, | |
| 28 | + /// Texte atténué | |
| 29 | + pub text_dim: egui::Color32, | |
| 30 | + /// Succès / activé | |
| 31 | + pub success: egui::Color32, | |
| 32 | + /// Avertissement / désactivé | |
| 33 | + pub warning: egui::Color32, | |
| 34 | + /// Erreur | |
| 35 | + pub error: egui::Color32, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl ThemeColors { | |
| 39 | + /// Retourne la palette correspondant au thème | |
| 40 | + pub fn for_theme(theme: Theme) -> Self { | |
| 41 | + match theme { | |
| 42 | + Theme::Dark => Self { | |
| 43 | + window_bg: egui::Color32::from_rgb(30, 30, 30), | |
| 44 | + bg_primary: egui::Color32::from_rgb(36, 39, 58), | |
| 45 | + bg_secondary: egui::Color32::from_rgb(46, 52, 72), | |
| 46 | + bg_tertiary: egui::Color32::from_rgb(56, 62, 80), | |
| 47 | + border: egui::Color32::from_rgb(80, 88, 106), | |
| 48 | + accent: egui::Color32::from_rgb(130, 170, 250), | |
| 49 | + text_primary: egui::Color32::from_rgb(220, 230, 245), | |
| 50 | + text_secondary: egui::Color32::from_rgb(180, 195, 210), | |
| 51 | + text_dim: egui::Color32::from_rgb(120, 140, 160), | |
| 52 | + success: egui::Color32::from_rgb(80, 220, 100), | |
| 53 | + warning: egui::Color32::from_rgb(245, 166, 35), | |
| 54 | + error: egui::Color32::from_rgb(230, 90, 100), | |
| 55 | + }, | |
| 56 | + Theme::Light => Self { | |
| 57 | + window_bg: egui::Color32::from_rgb(248, 250, 255), | |
| 58 | + bg_primary: egui::Color32::from_rgb(240, 245, 252), | |
| 59 | + bg_secondary: egui::Color32::from_rgb(220, 230, 245), | |
| 60 | + bg_tertiary: egui::Color32::from_rgb(200, 220, 240), | |
| 61 | + border: egui::Color32::from_rgb(180, 195, 210), | |
| 62 | + accent: egui::Color32::from_rgb(100, 140, 220), | |
| 63 | + text_primary: egui::Color32::from_rgb(40, 42, 54), | |
| 64 | + text_secondary: egui::Color32::from_rgb(80, 90, 110), | |
| 65 | + text_dim: egui::Color32::from_rgb(120, 130, 150), | |
| 66 | + success: egui::Color32::from_rgb(20, 160, 80), | |
| 67 | + warning: egui::Color32::from_rgb(210, 120, 0), | |
| 68 | + error: egui::Color32::from_rgb(200, 60, 70), | |
| 69 | + }, | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// Applique le thème personnalisé au contexte egui et retourne la palette active | |
| 75 | +pub fn apply_custom_theme(ctx: &egui::Context, theme: Theme) -> ThemeColors { | |
| 76 | + let colors = ThemeColors::for_theme(theme); | |
| 77 | + | |
| 78 | + let egui_theme = match theme { | |
| 79 | + Theme::Dark => egui::Theme::Dark, | |
| 80 | + Theme::Light => egui::Theme::Light, | |
| 81 | + }; | |
| 82 | + let mut style = (*ctx.style_of(egui_theme)).clone(); | |
| 83 | + | |
| 84 | + style.visuals.dark_mode = matches!(theme, Theme::Dark); | |
| 85 | + | |
| 86 | + // Widgets non interactifs | |
| 87 | + style.visuals.widgets.noninteractive.bg_fill = colors.bg_primary; | |
| 88 | + style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border); | |
| 89 | + | |
| 90 | + // Boutons inactifs | |
| 91 | + style.visuals.widgets.inactive.bg_fill = colors.bg_secondary; | |
| 92 | + style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border); | |
| 93 | + | |
| 94 | + // Survol | |
| 95 | + style.visuals.widgets.hovered.bg_fill = colors.bg_tertiary; | |
| 96 | + | |
| 97 | + // Boutons actifs | |
| 98 | + style.visuals.widgets.active.bg_fill = colors.accent; | |
| 99 | + style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent); | |
| 100 | + | |
| 101 | + // Liens | |
| 102 | + style.visuals.hyperlink_color = colors.accent; | |
| 103 | + | |
| 104 | + // Espacement | |
| 105 | + style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0); | |
| 106 | + style.spacing.button_padding = egui::Vec2::new(12.0, 6.0); | |
| 107 | + style.spacing.icon_width = 20.0; | |
| 108 | + style.spacing.icon_spacing = 8.0; | |
| 109 | + | |
| 110 | + ctx.set_style_of(egui_theme, style); | |
| 111 | + | |
| 112 | + colors | |
| 113 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +//! Thème centralisé de l'application | ||
| 2 | +//! | ||
| 3 | +//! Palette de couleurs unique pour les deux thèmes (sombre/clair), | ||
| 4 | +//! partagée par tous les composants UI pour éviter toute divergence. | ||
| 5 | + | ||
| 6 | +use crate::config::Theme; | ||
| 7 | +use eframe::egui; | ||
| 8 | + | ||
| 9 | +/// Palette de couleurs pour un thème donné | ||
| 10 | +#[derive(Debug, Clone, Copy)] | ||
| 11 | +pub struct ThemeColors { | ||
| 12 | + /// Arrière-plan de la fenêtre | ||
| 13 | + pub window_bg: egui::Color32, | ||
| 14 | + /// Arrière-plan principal (widgets non interactifs) | ||
| 15 | + pub bg_primary: egui::Color32, | ||
| 16 | + /// Arrière-plan secondaire (cartes, boutons inactifs) | ||
| 17 | + pub bg_secondary: egui::Color32, | ||
| 18 | + /// Arrière-plan tertiaire (survol) | ||
| 19 | + pub bg_tertiary: egui::Color32, | ||
| 20 | + /// Bordures | ||
| 21 | + pub border: egui::Color32, | ||
| 22 | + /// Couleur d'accentuation (liens, sélection, progression) | ||
| 23 | + pub accent: egui::Color32, | ||
| 24 | + /// Texte principal | ||
| 25 | + pub text_primary: egui::Color32, | ||
| 26 | + /// Texte secondaire | ||
| 27 | + pub text_secondary: egui::Color32, | ||
| 28 | + /// Texte atténué | ||
| 29 | + pub text_dim: egui::Color32, | ||
| 30 | + /// Succès / activé | ||
| 31 | + pub success: egui::Color32, | ||
| 32 | + /// Avertissement / désactivé | ||
| 33 | + pub warning: egui::Color32, | ||
| 34 | + /// Erreur | ||
| 35 | + pub error: egui::Color32, | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +impl ThemeColors { | ||
| 39 | + /// Retourne la palette correspondant au thème | ||
| 40 | + pub fn for_theme(theme: Theme) -> Self { | ||
| 41 | + match theme { | ||
| 42 | + Theme::Dark => Self { | ||
| 43 | + window_bg: egui::Color32::from_rgb(30, 30, 30), | ||
| 44 | + bg_primary: egui::Color32::from_rgb(36, 39, 58), | ||
| 45 | + bg_secondary: egui::Color32::from_rgb(46, 52, 72), | ||
| 46 | + bg_tertiary: egui::Color32::from_rgb(56, 62, 80), | ||
| 47 | + border: egui::Color32::from_rgb(80, 88, 106), | ||
| 48 | + accent: egui::Color32::from_rgb(130, 170, 250), | ||
| 49 | + text_primary: egui::Color32::from_rgb(220, 230, 245), | ||
| 50 | + text_secondary: egui::Color32::from_rgb(180, 195, 210), | ||
| 51 | + text_dim: egui::Color32::from_rgb(120, 140, 160), | ||
| 52 | + success: egui::Color32::from_rgb(80, 220, 100), | ||
| 53 | + warning: egui::Color32::from_rgb(245, 166, 35), | ||
| 54 | + error: egui::Color32::from_rgb(230, 90, 100), | ||
| 55 | + }, | ||
| 56 | + Theme::Light => Self { | ||
| 57 | + window_bg: egui::Color32::from_rgb(248, 250, 255), | ||
| 58 | + bg_primary: egui::Color32::from_rgb(240, 245, 252), | ||
| 59 | + bg_secondary: egui::Color32::from_rgb(220, 230, 245), | ||
| 60 | + bg_tertiary: egui::Color32::from_rgb(200, 220, 240), | ||
| 61 | + border: egui::Color32::from_rgb(180, 195, 210), | ||
| 62 | + accent: egui::Color32::from_rgb(100, 140, 220), | ||
| 63 | + text_primary: egui::Color32::from_rgb(40, 42, 54), | ||
| 64 | + text_secondary: egui::Color32::from_rgb(80, 90, 110), | ||
| 65 | + text_dim: egui::Color32::from_rgb(120, 130, 150), | ||
| 66 | + success: egui::Color32::from_rgb(20, 160, 80), | ||
| 67 | + warning: egui::Color32::from_rgb(210, 120, 0), | ||
| 68 | + error: egui::Color32::from_rgb(200, 60, 70), | ||
| 69 | + }, | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +/// Applique le thème personnalisé au contexte egui et retourne la palette active | ||
| 75 | +pub fn apply_custom_theme(ctx: &egui::Context, theme: Theme) -> ThemeColors { | ||
| 76 | + let colors = ThemeColors::for_theme(theme); | ||
| 77 | + | ||
| 78 | + let egui_theme = match theme { | ||
| 79 | + Theme::Dark => egui::Theme::Dark, | ||
| 80 | + Theme::Light => egui::Theme::Light, | ||
| 81 | + }; | ||
| 82 | + let mut style = (*ctx.style_of(egui_theme)).clone(); | ||
| 83 | + | ||
| 84 | + style.visuals.dark_mode = matches!(theme, Theme::Dark); | ||
| 85 | + | ||
| 86 | + // Widgets non interactifs | ||
| 87 | + style.visuals.widgets.noninteractive.bg_fill = colors.bg_primary; | ||
| 88 | + style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border); | ||
| 89 | + | ||
| 90 | + // Boutons inactifs | ||
| 91 | + style.visuals.widgets.inactive.bg_fill = colors.bg_secondary; | ||
| 92 | + style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border); | ||
| 93 | + | ||
| 94 | + // Survol | ||
| 95 | + style.visuals.widgets.hovered.bg_fill = colors.bg_tertiary; | ||
| 96 | + | ||
| 97 | + // Boutons actifs | ||
| 98 | + style.visuals.widgets.active.bg_fill = colors.accent; | ||
| 99 | + style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent); | ||
| 100 | + | ||
| 101 | + // Liens | ||
| 102 | + style.visuals.hyperlink_color = colors.accent; | ||
| 103 | + | ||
| 104 | + // Espacement | ||
| 105 | + style.spacing.item_spacing = egui::Vec2::new(12.0, 12.0); | ||
| 106 | + style.spacing.button_padding = egui::Vec2::new(12.0, 6.0); | ||
| 107 | + style.spacing.icon_width = 20.0; | ||
| 108 | + style.spacing.icon_spacing = 8.0; | ||
| 109 | + | ||
| 110 | + ctx.set_style_of(egui_theme, style); | ||
| 111 | + | ||
| 112 | + colors | ||
| 113 | +} | ||