From 7277342d4a5660915276daf024aa31987cc1d22d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 29 Jan 2026 13:14:47 -0500 Subject: [PATCH] docs: Comprehensive README update with new features and architecture details - Add extensive documentation for offline mode, sync, collections, and dashboard features - Document new Material You theming, OLED mode, and widget capabilities - Include detailed sections on metadata extraction, Markdown editor, and import/export - Update technology stack versions (Kotlin 2.0.0, Hilt 2.51.1, Retrofit 2.11.0, Room 2.6.1) - Simplify installation and compilation instructions - Add user guide with first-time setup and --- ANALYSE_ET_AMELIORATIONS.md | 426 ++++++++++++++++ README.md | 449 +++++++---------- app/build.gradle.kts | 27 +- app/src/main/AndroidManifest.xml | 35 +- app/src/main/java/com/shaarit/MainActivity.kt | 39 +- app/src/main/java/com/shaarit/ShaarItApp.kt | 12 +- .../com/shaarit/core/di/DatabaseModule.kt | 45 ++ .../main/java/com/shaarit/data/dto/Dtos.kt | 6 +- .../shaarit/data/export/BookmarkExporter.kt | 193 +++++++ .../shaarit/data/export/BookmarkImporter.kt | 213 ++++++++ .../data/local/converter/Converters.kt | 67 +++ .../shaarit/data/local/dao/CollectionDao.kt | 66 +++ .../com/shaarit/data/local/dao/LinkDao.kt | 183 +++++++ .../java/com/shaarit/data/local/dao/TagDao.kt | 61 +++ .../data/local/database/ShaarliDatabase.kt | 63 +++ .../data/local/entity/CollectionEntity.kt | 68 +++ .../shaarit/data/local/entity/LinkEntity.kt | 114 +++++ .../shaarit/data/local/entity/TagEntity.kt | 49 ++ .../com/shaarit/data/mapper/LinkMapper.kt | 12 +- .../data/metadata/LinkMetadataExtractor.kt | 282 +++++++++++ .../shaarit/data/paging/LinkPagingSource.kt | 2 +- .../data/repository/LinkRepositoryImpl.kt | 434 ++++++++++++---- .../com/shaarit/data/sync/ConflictResolver.kt | 127 +++++ .../java/com/shaarit/data/sync/SyncManager.kt | 360 +++++++++++++ .../java/com/shaarit/domain/model/Models.kt | 7 +- .../domain/repository/LinkRepository.kt | 2 + .../shaarit/presentation/add/AddLinkScreen.kt | 441 +++++++++------- .../presentation/add/AddLinkViewModel.kt | 182 +++++-- .../shaarit/presentation/auth/LoginScreen.kt | 2 +- .../presentation/auth/LoginViewModel.kt | 14 +- .../collections/CollectionsScreen.kt | 435 ++++++++++++++++ .../collections/CollectionsViewModel.kt | 77 +++ .../presentation/dashboard/DashboardScreen.kt | 474 ++++++++++++++++++ .../dashboard/DashboardViewModel.kt | 139 +++++ .../shaarit/presentation/feed/FeedScreen.kt | 62 ++- .../presentation/feed/FeedViewModel.kt | 7 +- .../presentation/feed/LinkItemViews.kt | 88 +++- .../com/shaarit/presentation/nav/NavGraph.kt | 69 ++- .../presentation/settings/SettingsScreen.kt | 394 +++++++++++++++ .../settings/SettingsViewModel.kt | 182 +++++++ .../com/shaarit/service/AddLinkTileService.kt | 44 ++ .../shaarit/ui/components/MarkdownEditor.kt | 383 ++++++++++++++ .../main/java/com/shaarit/ui/theme/Theme.kt | 53 +- .../shaarit/widget/ShaarliWidgetProvider.kt | 135 +++++ .../shaarit/widget/ShaarliWidgetService.kt | 101 ++++ .../main/res/drawable/widget_background.xml | 9 + .../res/drawable/widget_item_background.xml | 10 + app/src/main/res/layout/widget_list_item.xml | 43 ++ app/src/main/res/layout/widget_shaarli.xml | 74 +++ app/src/main/res/values/strings.xml | 83 ++- app/src/main/res/xml/shortcuts.xml | 66 +++ app/src/main/res/xml/widget_info.xml | 10 + build.gradle.kts | 1 + gradle.properties | 3 +- gradle/libs.versions.toml | 29 ++ 55 files changed, 6255 insertions(+), 697 deletions(-) create mode 100644 ANALYSE_ET_AMELIORATIONS.md create mode 100644 app/src/main/java/com/shaarit/core/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt create mode 100644 app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt create mode 100644 app/src/main/java/com/shaarit/data/local/converter/Converters.kt create mode 100644 app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt create mode 100644 app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt create mode 100644 app/src/main/java/com/shaarit/data/local/dao/TagDao.kt create mode 100644 app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt create mode 100644 app/src/main/java/com/shaarit/data/local/entity/CollectionEntity.kt create mode 100644 app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt create mode 100644 app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt create mode 100644 app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt create mode 100644 app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt create mode 100644 app/src/main/java/com/shaarit/data/sync/SyncManager.kt create mode 100644 app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/dashboard/DashboardViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/shaarit/service/AddLinkTileService.kt create mode 100644 app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt create mode 100644 app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt create mode 100644 app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/drawable/widget_item_background.xml create mode 100644 app/src/main/res/layout/widget_list_item.xml create mode 100644 app/src/main/res/layout/widget_shaarli.xml create mode 100644 app/src/main/res/xml/shortcuts.xml create mode 100644 app/src/main/res/xml/widget_info.xml diff --git a/ANALYSE_ET_AMELIORATIONS.md b/ANALYSE_ET_AMELIORATIONS.md new file mode 100644 index 0000000..394e541 --- /dev/null +++ b/ANALYSE_ET_AMELIORATIONS.md @@ -0,0 +1,426 @@ +# Analyse et Améliorations - ShaarIt + +## 📋 Résumé de l'Analyse du Projet + +### Architecture Actuelle + +**ShaarIt** est un client Android moderne pour Shaarli développé avec les meilleures pratiques Android : + +```mermaid +graph TB + subgraph "Présentation [Jetpack Compose]" + A[FeedScreen] --> B[AddLinkScreen] + A --> C[EditLinkScreen] + A --> D[TagsScreen] + E[LoginScreen] --> A + end + + subgraph "Domaine [Clean Architecture]" + F[LinkRepository] + G[AuthRepository] + H[UseCases] + end + + subgraph "Data [API + Local]" + I[ShaarliApi - Retrofit] + J[LinkPagingSource] + K[TokenManager - Crypto] + end + + A --> F + F --> I + I --> L[(Shaarli Server)] +``` + +### Stack Technique +| Couche | Technologie | +|--------|-------------| +| **Langage** | Kotlin 1.9.20 | +| **UI** | Jetpack Compose + Material Design 3 | +| **Architecture** | Clean Architecture + MVVM | +| **DI** | Dagger Hilt 2.48 | +| **Réseau** | Retrofit 2.9 + Moshi + OkHttp | +| **Pagination** | Paging 3 | +| **Stockage** | EncryptedSharedPreferences | +| **Async** | Coroutines & Flow | + +### Fonctionnalités Existantes + +1. **Authentification sécurisée** - JWT avec stockage chiffré +2. **Gestion des liens** - CRUD complet avec pagination infinie +3. **Recherche avancée** - Par termes et filtres multi-tags +4. **Intégration système** - Share Intent Android +5. **UI premium** - Thème sombre avec glassmorphism +6. **Détection de doublons** - Alertes lors de l'ajout +7. **Modes d'affichage** - Liste, grille, compact + +--- + +## 🚀 Propositions d'Améliorations + +### 1. Gestion des Notes Markdown Natif + +**Problème actuel** : Shaarli supporte les notes markdown mais l'app ne les affiche pas de manière enrichie. + +**Améliorations proposées** : + +| Fonctionnalité | Description | Priorité | +|----------------|-------------|----------| +| **Éditeur Markdown WYSIWYG** | Éditeur visuel avec preview temps réel | Haute | +| **Rendu Markdown enrichi** | Support complet : tableaux, math (KaTeX), diagrammes Mermaid | Haute | +| **Mode lecture focus** | Vue distraction-free pour lire les longues notes | Moyenne | +| **Export PDF/HTML** | Générer des documents depuis les notes | Moyenne | +| **Templates de notes** | Templates pré-définis (meeting, todo, journal) | Basse | + +**Implementation suggérée** : +```kotlin +// Nouveau composant +@Composable +fun MarkdownEditor( + value: String, + onValueChange: (String) -> Unit, + mode: EditorMode = EditorMode.SPLIT // EDIT, PREVIEW, SPLIT +) +``` + +--- + +### 2. Système de Collections/Workspaces + +**Concept** : Organiser les liens en collections thématiques au-delà des simples tags. + +**Fonctionnalités** : +- **Collections intelligentes** : Requêtes sauvegardées (ex: "tag:work AND tag:urgent") +- **Workspaces contextuels** : Séparer vie pro/perso/projets +- **Collections partagées** : Synchroniser certaines collections avec d'autres utilisateurs Shaarli +- **Collection aléatoire** : "Lire plus tard" avec sélection aléatoire + +**UI proposée** : +``` +┌─────────────────────────────────────┐ +│ 📁 Mes Collections │ +├─────────────────────────────────────┤ +│ 💼 Work [42 items] ▸ │ +│ 🏠 Personal [128 items] ▸ │ +│ 📚 Reading [15 unread] ▸ │ +│ ⭐ Favorites [23 items] ▸ │ +│ 🔥 Hot Today [Auto] ▸ │ +└─────────────────────────────────────┘ +``` + +--- + +### 3. Intelligence et Automatisation + +#### 3.1 Extraction Métadonnées Intelligente + +```kotlin +// Service d'enrichissement automatique +interface LinkEnrichmentService { + suspend fun enrich(url: String): EnrichedMetadata +} + +data class EnrichedMetadata( + val title: String, + val description: String, + val thumbnailUrl: String?, + val siteName: String?, + val readingTime: Int?, // minutes estimées + val contentType: ContentType, // ARTICLE, VIDEO, PODCAST, PAPER + val suggestedTags: List, // ML-based suggestions + val summary: String? // Auto-résumé avec AI locale +) +``` + +**Fonctionnalités** : +- **Détection automatique** du type de contenu (article, vidéo, papier scientifique) +- **Extraction de thumbnail** et preview visuelle +- **Suggestion de tags** basée sur le contenu analysé +- **Estimation du temps de lecture** +- **Résumé automatique** via modèle ML local (Gemini Nano, ML Kit) + +#### 3.2 Rappels et Suivi de Lecture + +| Feature | Description | +|---------|-------------| +| **Read Later avancé** | Rappels programmables ("Relire dans 3 jours") | +| **Suivi de progression** | Marquer comme "en cours de lecture" | +| ** streaks de lecture** | Gamification pour lire les liens sauvegardés | +| **Archivage automatique** : | Déplacer les vieux liens non lus après X jours | + +--- + +### 4. Recherche et Découverte Avancées + +#### 4.1 Moteur de Recherche Full-Text + +**Améliorations** : +- **Recherche sémantique** : Trouver des liens par concept, pas juste par mots-clés +- **Filtres temporels** : "Cette semaine", "Le mois dernier", "Avant 2023" +- **Recherche dans le contenu** : Indexer le contenu des pages web (via service externe ou Poche/Readwise) +- **Historique de recherche** : Suggestions basées sur les recherches passées +- **Recherches sauvegardées** : Alertes sur nouveaux liens correspondant + +#### 4.2 Visualisations et Analytics + +```mermaid +graph LR + A[Dashboard Analytics] --> B[Graphique d'activité] + A --> C[Tag cloud interactive] + A --> D[Tendances temporelles] + A --> E[Sources les plus sauvegardées] + A --> F[Réseau de connexions entre liens] +``` + +**Idées de visualisations** : +- Timeline visuelle des ajouts +- Graph de relations entre tags +- Carte des domaines les plus sauvegardés +- Statistiques d'utilisation hebdomadaires/mensuelles + +--- + +### 5. Synchronisation et Offline-First + +#### 5.1 Architecture Offline + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ UI Layer │────▶│ Repository │────▶│ Room DB │ +│ Compose │ │ SyncEngine │ │ Local Cache │ +└──────────────┘ └──────┬───────┘ └──────────────┘ + │ + ▼ + ┌──────────────┐ + │ Shaarli API │ + │ REST API │ + └──────────────┘ +``` + +**Fonctionnalités** : +- **Cache local Room** : Accès instantané aux liens même hors-ligne +- **File d'attente de sync** : Ajouter/modifier hors-ligne, synchroniser automatiquement +- **Résolution de conflits** : UI pour gérer les conflits de modification +- **Sync sélective** : Choisir quelles collections synchroniser +- **Compression des données** : Sync delta pour économiser la bande passante + +#### 5.2 Multi-Instance Support + +| Feature | Description | +|---------|-------------| +| **Multi-comptes** | Gérer plusieurs instances Shaarli (perso, travail, projet) | +| **Switch rapide** | Changer de compte en 1 tap | +| **Vue unifiée** | Voir tous les liens de toutes les instances (optionnel) | +| **Migration d'instance** | Transférer des liens entre instances | + +--- + +### 6. Intégrations et API + +#### 6.1 Services Tierces + +```kotlin +interface ThirdPartyIntegration { + // Pocket/Instapaper - Import/export + // Raindrop.io - Sync bidirectionnel + // Notion - Export vers base de données + // Obsidian - Export markdown avec liens + // Readwise - Envoi vers Reader +} +``` + +**Intégrations souhaitées** : +1. **Import/Export** : Pocket, Delicious, Browser bookmarks (HTML) +2. **Sync bidirectionnelle** : Raindrop.io, Pinboard +3. **Export vers** : Notion, Obsidian, Logseq +4. **Services de lecture** : Envoi vers Kindle, Readwise Reader +5. **Webhook personnalisés** : Déclencher des actions IFTTT/Zapier + +#### 6.2 Widgets et Shortcuts + +| Type | Fonctionnalité | +|------|----------------| +| **Home Widget** | Derniers liens ajoutés, quick-add, aléatoire | +| **Quick Settings Tile** | Ajouter le presse-papiers en 1 tap | +| **App Shortcuts** | "Ajouter lien", "Rechercher", "Aléatoire" | +| **Wear OS** | Vue minimaliste, voice input pour ajout rapide | + +--- + +### 7. Personnalisation et Accessibilité + +#### 7.1 Thèmes et Apparence + +- **Thèmes dynamiques** : Material You (Monet) sur Android 12+ +- **Mode OLED pur** : Noir véritable pour économiser batterie +- **Taille de texte adaptable** : Accessibilité améliorée +- **Police personnalisable** : Serif pour lecture longue, monospace pour code +- **Animations réduites** : Mode accessibilité + +#### 7.2 Gestion des Données + +| Feature | Description | +|---------|-------------| +| **Backup cloud** | Export chiffré vers Google Drive/Dropbox | +| **Export JSON/XML** | Sauvegarde complète avec métadonnées | +| **Historique des modifications** | Versioning des liens modifiés | +| **Corbeille** | Restauration des liens supprimés (30 jours) | + +--- + +### 8. Fonctionnalités Avancées de Contenu + +#### 8.1 Support Média Étendu + +```kotlin +sealed class LinkContent { + data class Article(val url: String, val content: String) + data class Video( + val platform: VideoPlatform, // YOUTUBE, VIMEO, PEERTUBE + val duration: Duration?, + val transcriptUrl: String? + ) + data class Podcast( + val episodeInfo: EpisodeInfo, + val audioUrl: String + ) + data class ScientificPaper( + val doi: String?, + val authors: List, + val abstract: String, + val pdfUrl: String? + ) +} +``` + +**Fonctionnalités** : +- **Lecteur intégré vidéo** : Mini-player pour vidéos YouTube +- **Extraction PDF** : Viewer intégré pour les PDF sauvegardés +- **Podcast player** : Lecture directe avec bookmarks temporels +- **Galerie d'images** : Vue grille pour liens image (Pinterest-style) + +#### 8.2 Collaboration et Partage + +| Feature | Description | +|---------|-------------| +| **Liens publics** | Générer une URL publique pour un lien privé | +| **Commentaires** | Annoter les liens avec notes personnelles | +| **Partage sécurisé** : | Expiration des liens partagés | +| **Collaboration** : | Partager une collection avec édition | + +--- + +### 9. Sécurité et Confidentialité + +#### 9.1 Fonctionnalités de Sécurité + +- **Verrouillage biométrique** : FaceID/Fingerprint pour accès +- **Mode privé** : Section chiffrée séparée pour liens sensibles +- **Session management** : Historique des connexions, révocation à distance +- **Audit log** : Historique de toutes les actions + +#### 9.2 Confidentialité + +| Feature | Description | +|---------|-------------| +| **Anonymisation** | Masquer les métadonnées d'origine | +| **Proxy intégré** | Prévisualisation sans révéler l'IP | +| **Auto-expiration** | Suppression automatique après durée définie | +| **Zero-knowledge** | Chiffrement client-side des descriptions | + +--- + +### 10. Roadmap Priorisée + +```mermaid +gantt + title Roadmap ShaarIt - Améliorations + dateFormat YYYY-MM + + section Phase 1 - Fondations + Room Database (Offline) :done, p1_1, 2026-02, 4w + Markdown Rendering :active, p1_2, 2026-02, 3w + Amélioration Recherche :p1_3, 2026-03, 3w + + section Phase 2 - Intelligence + Métadonnées Auto-Extract :p2_1, 2026-04, 4w + Suggestions Tags ML :p2_2, after p2_1, 3w + Collections/Workspaces :p2_3, 2026-05, 4w + + section Phase 3 - Intégrations + Widgets & Shortcuts :p3_1, 2026-06, 2w + Import/Export Services :p3_2, 2026-06, 4w + Multi-Instance Support :p3_3, 2026-07, 3w + + section Phase 4 - Avancé + Sync Bidirectionnelle :p4_1, 2026-08, 4w + Analytics Dashboard :p4_2, 2026-09, 3w + Wear OS App :p4_3, 2026-10, 4w +``` + +--- + +## 💡 Recommandations Immédiates + +### Top 5 Priorités (Quick Wins) + +1. **Éditeur Markdown Rich** - Impact utilisateur élevé, effort moyen +2. **Base de données Room** - Fondation pour offline et performances +3. **Widgets Android** - Visibilité app et accessibilité rapide +4. **Extraction métadonnées** - Auto-complétion intelligente des liens +5. **Mode lecture focus** - Amélioration UX majeure pour les notes + +### Architecture Proposée pour Room + +```kotlin +@Entity(tableName = "links") +data class CachedLink( + @PrimaryKey val id: Int, + val url: String, + val title: String, + val description: String, + val tags: List, + val isPrivate: Boolean, + val createdAt: Long, + val updatedAt: Long, + val syncStatus: SyncStatus // SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE +) + +@Entity(tableName = "link_content") +data class LinkContentEntity( + @PrimaryKey val linkId: Int, + val thumbnailUrl: String?, + val readingTime: Int?, + val contentType: String, + val cachedHtml: String?, // Pour offline reading + val extractedText: String? // Pour recherche full-text +) +``` + +--- + +## 📊 Synthèse + +### Forces Actuelles +- Architecture moderne et maintenable +- UI premium avec Material Design 3 +- Sécurité robuste (chiffrement, JWT) +- Intégration système Android native + +### Opportunités d'Amélioration +- **Offline-first** : Sync robuste avec gestion de conflits +- **Markdown natif** : Exploiter pleinement le support Shaarli +- **Intelligence** : ML local pour suggestions et enrichissement +- **Écosystème** : Intégrations avec outils de productivité +- **Accessibilité** : Widgets, raccourcis, multi-plateformes + +### Impact Utilisateur Attendu +Avec ces améliorations, ShaarIt deviendrait : +- **Le meilleur client Android** pour Shaarli +- **Un outil de productivité complet** (pas juste un bookmark manager) +- **Une alternative viable** à Pocket, Raindrop.io, Notion Web Clipper + +--- + +*Document créé le 28 janvier 2026* +*Analyse basée sur le code source ShaarIt v1.0* diff --git a/README.md b/README.md index f49947e..dfe6c08 100644 --- a/README.md +++ b/README.md @@ -18,27 +18,71 @@ ### 📚 Gestion des Favoris - **Flux infini** : Défilement continu avec chargement progressif (Paging 3) -- **Recherche côté serveur** : Recherche par termes et filtrage par tags -- **Ajout rapide** : Création de liens privés/publics avec description et tags -- **Édition** : Modification complète des liens existants -- **Suppression** : Gestion facile des favoris +- **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags +- **Mode hors-ligne** : Consultation et modification des liens même sans connexion +- **Ajout rapide** : Création de liens privés/publics avec description, tags et extraction automatique de métadonnées +- **Édition** : Modification complète des liens existants avec éditeur Markdown +- **Suppression** : Gestion facile des favoris avec file d'attente de sync - **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour +- **Liens épinglés** : Mise en avant des liens importants ### 🏷️ Gestion des Tags - Vue dédiée pour parcourir tous les tags - Compteur d'utilisation par tag - Filtrage rapide du flux par tag +- Tags favoris pour accès rapide + +### 📁 Collections +- Organisation des liens en collections +- Collections intelligentes avec filtres automatiques +- Vue grille adaptative pour les collections + +### 📝 Éditeur Markdown +- Édition avec prévisualisation en temps réel +- Mode édition/prévisualisation/split +- Mode lecture focus sans distraction +- Barre d'outils de formatage + +### 🌐 Extraction de Métadonnées +- Extraction automatique des OpenGraph (titre, description, image) +- Détection du type de contenu (article, vidéo, image, audio, code, etc.) +- Estimation du temps de lecture +- Extraction du nom du site + +### 📊 Tableau de Bord +- Statistiques d'utilisation (liens totaux, cette semaine, ce mois) +- Temps de lecture total et moyen +- Répartition par type de contenu +- Tags les plus utilisés +- Graphique d'activité sur 30 jours + +### 💾 Import/Export +- Export JSON (format complet avec métadonnées) +- Export CSV (compatible Excel) +- Export HTML (format Netscape/Chrome bookmarks) +- Import depuis JSON (export ShaarIt) +- Import depuis HTML (bookmarks Chrome/Firefox) + +### 🔄 Synchronisation +- Synchronisation automatique en arrière-plan (WorkManager) +- Mode offline-first : modifications en attente +- Résolution de conflits intelligente +- File d'attente des opérations ### 🔗 Intégration système -- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (navigateur, YouTube, etc.) via le menu Partager +- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app +- **App Shortcuts** : Accès rapide via appui long sur l'icône (Ajouter, Aléatoire, Rechercher, Collections) +- **Quick Settings Tile** : Tuile pour ajouter rapidement un lien +- **Widget** : Widget d'accueil affichant les liens récents - Ouverture des liens dans le navigateur par défaut -- Support des URLs partagées avec titre pré-rempli ### 🎨 Interface Utilisateur -- **Design premium** : Thème sombre moderne avec dégradés cyan/bleu +- **Material You (Monet)** : Couleurs dynamiques basées sur le fond d'écran (Android 12+) +- **Mode OLED** : Noir pur pour les écrans AMOLED +- **Design premium** : Thème sombre moderne avec dégradés - **Material Design 3** : Composants UI natifs Android - **Animations fluides** : Transitions et effets visuels -- **Deux modes d'affichage** : Liste détaillée ou grille compacte +- **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte - **Pull-to-refresh** : Actualisation du flux par glissement --- @@ -47,13 +91,15 @@ | Catégorie | Technologie | |-----------|-------------| -| **Langage** | Kotlin 1.9.20 | +| **Langage** | Kotlin 2.0.0 | | **UI** | Jetpack Compose + Material Design 3 | | **Architecture** | Clean Architecture + MVVM | -| **Injection de dépendances** | Dagger Hilt 2.48.1 | -| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 | +| **Injection de dépendances** | Dagger Hilt 2.51.1 | +| **Réseau** | Retrofit 2.11.0 + Moshi 1.15.1 + OkHttp 4.12.0 | +| **Base de données locale** | Room 2.6.1 | | **Pagination** | Paging 3 | | **Concurrence** | Coroutines & Flow | +| **Background work** | WorkManager 2.9.0 | | **Stockage sécurisé** | AndroidX Security Crypto | | **Navigation** | Navigation Compose | | **Compilation** | Gradle 8.0+ avec KSP | @@ -79,274 +125,125 @@ Récupérez le dernier APK depuis la section [Releases](../../releases). #### Méthode 2 : Compilation depuis les sources -##### Prérequis de développement -1. **JDK 17** (ou plus récent) installé -2. **Android SDK** installé (Platform API 34) -3. **Gradle** 8.0+ (si `gradlew` est manquant) - -##### Étapes de compilation - -1. **Cloner le repository** - ```bash - git clone https://github.com/votre-username/ShaarIt.git - cd ShaarIt - ``` - -2. **Configurer l'emplacement du SDK Android** - - Si la variable `ANDROID_HOME` n'est pas définie, créez un fichier `local.properties` : - ```bash - # Windows - echo sdk.dir=C:\Users\\AppData\Local\Android\Sdk > local.properties - - # Linux/macOS - echo sdk.dir=/home//Android/Sdk > local.properties - ``` - -3. **Compiler l'APK Debug** - ```bash - # Windows - ./gradlew assembleDebug - - # Linux/macOS - chmod +x gradlew - ./gradlew assembleDebug - ``` - -4. **L'APK se trouve dans** : `app/build/outputs/apk/debug/app-debug.apk` - -5. **Installer sur l'appareil** - ```bash - adb install -r app/build/outputs/apk/debug/app-debug.apk - ``` - -### Configuration initiale de l'app - -1. Ouvrez l'application **ShaarIt** -2. Entrez l'**URL de votre instance Shaarli** (ex: `https://monserveur.com/shaarli`) -3. Entrez votre **Secret API** (trouvé dans les paramètres admin de Shaarli) -4. Cliquez sur **Connecter** - ---- - -## 🧑‍💻 Section Développement - -### Architecture du projet - -``` -app/src/main/java/com/shaarit/ -├── core/ # Infrastructure et utilitaires -│ ├── di/ # Modules Dagger Hilt (injection de dépendances) -│ │ ├── AppModule.kt # Fournisseurs d'applications -│ │ ├── NetworkModule.kt # Configuration Retrofit/OkHttp -│ │ └── RepositoryModule.kt # Liaisons repository -│ ├── network/ # Intercepteurs réseau -│ │ ├── AuthInterceptor.kt # Injection automatique du token JWT -│ │ └── HostSelectionInterceptor.kt # Changement dynamique d'hôte -│ ├── storage/ # Stockage local sécurisé -│ │ └── TokenManager.kt # Gestion chiffrée des tokens -│ └── util/ # Utilitaires -│ └── JwtGenerator.kt # Générateur JWT HS512 -├── data/ # Couche de données (Clean Architecture) -│ ├── api/ # Interface Retrofit -│ │ └── ShaarliApi.kt # Endpoints API v1 -│ ├── dto/ # Data Transfer Objects (Moshi) -│ │ ├── Dtos.kt # Login, Link, Info DTOs -│ │ └── TagDto.kt # Tag DTO -│ ├── mapper/ # Convertisseurs DTO ↔ Domain -│ │ └── LinkMapper.kt -│ ├── paging/ # Sources de pagination -│ │ └── LinkPagingSource.kt # Paging 3 pour le flux -│ └── repository/ # Implémentations des repositories -│ ├── AuthRepositoryImpl.kt -│ └── LinkRepositoryImpl.kt -├── domain/ # Couche métier (indépendante des frameworks) -│ ├── model/ # Modèles de domaine -│ │ ├── Models.kt # Credentials, ShaarliLink -│ │ ├── ShaarliTag.kt -│ │ └── ViewStyle.kt # Enum modes d'affichage -│ ├── repository/ # Interfaces de repository -│ │ ├── AuthRepository.kt -│ │ └── LinkRepository.kt -│ └── usecase/ # Cas d'utilisation -│ └── LoginUseCase.kt -├── presentation/ # Couche présentation (UI) -│ ├── auth/ # Écran de connexion -│ │ ├── LoginScreen.kt -│ │ └── LoginViewModel.kt -│ ├── feed/ # Flux principal -│ │ ├── FeedScreen.kt -│ │ ├── FeedViewModel.kt -│ │ └── LinkItemViews.kt # Composants de carte de lien -│ ├── add/ # Ajout de lien -│ │ ├── AddLinkScreen.kt -│ │ └── AddLinkViewModel.kt -│ ├── edit/ # Édition de lien -│ │ ├── EditLinkScreen.kt -│ │ └── EditLinkViewModel.kt -│ ├── tags/ # Gestion des tags -│ │ ├── TagsScreen.kt -│ │ └── TagsViewModel.kt -│ └── nav/ # Navigation Compose -│ └── NavGraph.kt # Routes et navigation -├── ui/ # Composants UI réutilisables -│ ├── components/ # Composants custom premium -│ │ └── PremiumComponents.kt # GlassCard, GradientButton, etc. -│ └── theme/ # Thème Material Design 3 -│ ├── Theme.kt # Couleurs et thème sombre -│ └── Type.kt # Typographie -├── MainActivity.kt # Point d'entrée avec gestion Share Intent -└── ShaarItApp.kt # Application Hilt -``` - -### Flux d'authentification JWT - -``` -┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ Utilisateur │────▶│ LoginScreen │────▶│ LoginViewModel │ -└─────────────┘ └──────────────┘ └─────────────────┘ - │ - ┌───────────────────────────────┘ - ▼ -┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ TokenManager│◀────│AuthRepository│◀────│ LoginUseCase │ -└─────────────┘ └──────────────┘ └─────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ EncryptedSharedPreferences│ (AES256_GCM) -└─────────────────────────┘ -``` - -Le token JWT est généré localement avec l'algorithme **HS512** : -- **Header** : `{"typ":"JWT","alg":"HS512"}` -- **Payload** : `{"iat": }` -- **Signature** : HMAC-SHA512(base64url(header) + "." + base64url(payload), apiSecret) - -### Pagination avec Paging 3 - -``` -┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ FeedScreen │────▶│ FeedViewModel │────▶│ LinkRepository │ -└─────────────┘ └─────────────────┘ └─────────────────┘ - │ - ┌──────────────────────────────────┘ - ▼ -┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ lazyPagingItems│◀──│ Pager │◀────│ LinkPagingSource│ -└─────────────┘ └──────────────┘ └─────────────────┘ - │ - ▼ - ┌──────────────┐ - │ ShaarliApi │ - └──────────────┘ -``` - -### Configuration réseau dynamique - -L'application utilise un pattern d'intercepteur pour gérer le changement d'URL serveur sans recréer le client Retrofit : - -```kotlin -// HostSelectionInterceptor permet de changer l'hôte à la volée -class HostSelectionInterceptor : Interceptor { - @Volatile private var host: HttpUrl? = null - - fun setHost(url: String) { - host = url.toHttpUrlOrNull() - } - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - val newUrl = host?.let { - request.url.newBuilder() - .scheme(it.scheme) - .host(it.host) - .port(it.port) - .build() - } ?: request.url - - return chain.proceed(request.newBuilder().url(newUrl).build()) - } -} -``` - -### Exécution des tests - +1. Clonez le repository : ```bash -# Tests unitaires -./gradlew test - -# Tests instrumentés -./gradlew connectedAndroidTest +git clone https://github.com/votre-username/ShaarIt.git +cd ShaarIt ``` -### Build de release - -1. **Créer un keystore** (si premier build) - ```bash - keytool -genkey -v -keystore shaarit.keystore -alias shaarit \ - -keyalg RSA -keysize 2048 -validity 10000 - ``` - -2. **Créer `keystore.properties`** dans le répertoire racine : - ```properties - storeFile=shaarit.keystore - storePassword=votre_password - keyAlias=shaarit - keyPassword=votre_password - ``` - -3. **Compiler** - ```bash - ./gradlew assembleRelease - ``` - -4. **L'APK signé se trouve dans** : `app/build/outputs/apk/release/app-release.apk` - -### Dépendances principales - -```toml -[versions] -agp = "8.13.2" -kotlin = "1.9.20" -hilt = "2.48.1" -retrofit = "2.9.0" -moshi = "1.15.0" -okhttp = "4.12.0" -paging = "3.2.1" -composeBom = "2023.08.00" - -[libraries] -# Injection de dépendances -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } - -# Réseau -retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } -retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } -moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } -moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } -okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } - -# Pagination -androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } -androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } - -# Sécurité -androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" } +2. Compilez l'APK debug : +```bash +./gradlew assembleDebug ``` -### Contribution +L'APK sera généré dans `app/build/outputs/apk/debug/` -1. Forker le projet -2. Créer une branche feature (`git checkout -b feature/amazing-feature`) -3. Committer vos changements (`git commit -m 'Add amazing feature'`) -4. Pusher sur la branche (`git push origin feature/amazing-feature`) -5. Ouvrir une Pull Request +3. Ou compilez l'APK release (nécessite une configuration de signature) : +```bash +./gradlew assembleRelease +``` --- -## 📄 Licence +## 📖 Guide d'utilisation + +### Première configuration +1. Ouvrez l'application +2. Entrez l'URL de votre instance Shaarli +3. Entrez votre nom d'utilisateur et mot de passe +4. L'application générera automatiquement les tokens API nécessaires + +### Ajouter un lien +- **Via l'app** : Appuyez sur le bouton + et entrez l'URL +- **Via le partage Android** : Partagez n'importe quelle URL vers ShaarIt depuis n'importe quelle app +- **Via Quick Settings** : Ajoutez la tuile ShaarIt dans vos paramètres rapides + +### Organiser vos liens +- Utilisez les tags pour catégoriser vos liens +- Créez des collections pour regrouper des liens par thème +- Épinglez les liens importants pour un accès rapide + +### Mode hors-ligne +- Tous les liens sont stockés localement dans la base de données Room +- Les modifications sont synchronisées automatiquement quand la connexion est disponible +- Consultez vos favoris même sans connexion Internet + +--- + +## 🏗️ Architecture + +Le projet suit les principes de la **Clean Architecture** avec une séparation claire des couches : + +``` +├── data/ # Couche de données +│ ├── api/ # API Retrofit +│ ├── local/ # Base de données Room +│ │ ├── dao/ # Data Access Objects +│ │ ├── entity/ # Entités Room +│ │ └── database/ # Configuration de la DB +│ ├── sync/ # Synchronisation +│ ├── export/ # Import/Export +│ └── repository/ # Implémentations des repositories +├── domain/ # Couche domaine +│ ├── model/ # Modèles métier +│ └── repository/ # Interfaces des repositories +├── presentation/ # Couche présentation +│ ├── feed/ # Écran principal +│ ├── add/ # Ajout de liens +│ ├── edit/ # Édition de liens +│ ├── tags/ # Gestion des tags +│ ├── collections/ # Collections +│ ├── dashboard/ # Tableau de bord +│ ├── settings/ # Paramètres +│ └── nav/ # Navigation +└── core/ # Utilitaires + ├── di/ # Injection de dépendances + ├── network/ # Configuration réseau + └── storage/ # Stockage local +``` + +--- + +## 🚀 Roadmap + +- [x] Synchronisation en arrière-plan avec WorkManager +- [x] Mode hors-ligne avec Room +- [x] Éditeur Markdown +- [x] Extraction de métadonnées OpenGraph +- [x] Collections d'organisation +- [x] Liens épinglés +- [x] Widget d'accueil +- [x] App Shortcuts +- [x] Quick Settings Tile +- [x] Tableau de bord analytique +- [x] Import/Export +- [x] Material You (Monet) +- [ ] Recherche avancée avec filtres multiples +- [ ] Suggestions de tags par IA +- [ ] Mode lecture sans distraction pour les articles +- [ ] Partage de collections + +--- + +## 🤝 Contribution + +Les contributions sont les bienvenues ! N'hésitez pas à : +- Ouvrir une issue pour signaler un bug ou suggérer une fonctionnalité +- Soumettre une pull request +- Améliorer la documentation + +### Signaler un bug +1. Vérifiez que le bug n'a pas déjà été signalé +2. Ouvrez une issue avec : + - Description claire du problème + - Étapes pour reproduire + - Comportement attendu vs réel + - Version Android et de l'app + - Logs si disponibles + +--- + +## 📝 Licence Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails. @@ -354,12 +251,12 @@ Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de ## 🙏 Remerciements -- [Shaarli](https://github.com/shaarli/Shaarli) - Le gestionnaire de favoris auto-hébergé -- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit moderne Android -- [Material Design 3](https://m3.material.io/) - Système de design Google +- [Shaarli](https://github.com/shaarli/Shaarli) - Le projet original de gestionnaire de favoris +- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Framework UI moderne d'Android +- La communauté open source pour les excellentes bibliothèques utilisées --- -## 📧 Contact - -Pour toute question ou suggestion, n'hésitez pas à ouvrir une [issue](../../issues). +
+ Fait avec ❤️ pour la communauté Shaarli +
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 265103d..2ef01d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,15 +1,11 @@ - - - - import java.util.Properties plugins { - alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -96,6 +92,7 @@ dependencies { implementation(libs.androidx.material3) implementation("androidx.compose.material:material-icons-extended") implementation(libs.androidx.compose.material) + implementation(libs.coil.compose) // Navigation implementation(libs.androidx.navigation.compose) @@ -119,6 +116,9 @@ dependencies { implementation(libs.moshi) ksp(libs.moshi.kotlin.codegen) + // Kotlin Serialization + implementation(libs.kotlinx.serialization.json) + // Security implementation(libs.androidx.security.crypto) @@ -127,6 +127,23 @@ dependencies { // Markdown implementation(libs.compose.markdown) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.paging) + ksp(libs.androidx.room.compiler) + + // WorkManager + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.work.hilt) + ksp(libs.androidx.work.hilt.compiler) + + // DataStore + implementation(libs.androidx.datastore) + + // JSoup for HTML parsing (metadata extraction) + implementation(libs.jsoup) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d17f3e5..e77d2de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,19 @@ android:theme="@style/Theme.ShaarIt" android:usesCleartextTraffic="true" tools:targetApi="31"> - + + + + + + - + + + + + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/java/com/shaarit/MainActivity.kt b/app/src/main/java/com/shaarit/MainActivity.kt index b5f9986..359ebee 100644 --- a/app/src/main/java/com/shaarit/MainActivity.kt +++ b/app/src/main/java/com/shaarit/MainActivity.kt @@ -6,11 +6,8 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.shaarit.presentation.nav.AppNavGraph import com.shaarit.ui.theme.ShaarItTheme @@ -30,33 +27,37 @@ class MainActivity : ComponentActivity() { val context = LocalContext.current var shareUrl: String? = null var shareTitle: String? = null + var deepLink: String? = null val activity = context as? androidx.activity.ComponentActivity val intent = activity?.intent + + // Handle share intent if (intent?.action == android.content.Intent.ACTION_SEND && - intent.type == "text/plain" + intent.type == "text/plain" ) { shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT) shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT) } + + // Handle deep links from App Shortcuts + intent?.data?.let { uri -> + if (uri.scheme == "shaarit") { + deepLink = uri.toString() + } + } Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { AppNavGraph(shareUrl = shareUrl, shareTitle = shareTitle) } + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AppNavGraph( + shareUrl = shareUrl, + shareTitle = shareTitle, + initialDeepLink = deepLink + ) + } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text(text = "Hello $name!", modifier = modifier) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - ShaarItTheme { Greeting("Android") } -} - diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt index fe8a090..d6557f2 100644 --- a/app/src/main/java/com/shaarit/ShaarItApp.kt +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -1,6 +1,16 @@ package com.shaarit import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject -@HiltAndroidApp class ShaarItApp : Application() +@HiltAndroidApp +class ShaarItApp : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().setWorkerFactory(workerFactory).build() +} diff --git a/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt new file mode 100644 index 0000000..fa4de99 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt @@ -0,0 +1,45 @@ +package com.shaarit.core.di + +import android.content.Context +import com.shaarit.data.local.dao.CollectionDao +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.dao.TagDao +import com.shaarit.data.local.database.ShaarliDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Module Hilt pour la base de données Room + */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): ShaarliDatabase { + return ShaarliDatabase.getInstance(context) + } + + @Provides + @Singleton + fun provideLinkDao(database: ShaarliDatabase): LinkDao { + return database.linkDao() + } + + @Provides + @Singleton + fun provideTagDao(database: ShaarliDatabase): TagDao { + return database.tagDao() + } + + @Provides + @Singleton + fun provideCollectionDao(database: ShaarliDatabase): CollectionDao { + return database.collectionDao() + } +} diff --git a/app/src/main/java/com/shaarit/data/dto/Dtos.kt b/app/src/main/java/com/shaarit/data/dto/Dtos.kt index 1f247e3..e438fec 100644 --- a/app/src/main/java/com/shaarit/data/dto/Dtos.kt +++ b/app/src/main/java/com/shaarit/data/dto/Dtos.kt @@ -11,13 +11,13 @@ data class LoginResponseDto(@Json(name = "token") val token: String) @JsonClass(generateAdapter = true) data class LinkDto( - @Json(name = "id") val id: Int, - @Json(name = "url") val url: String, + @Json(name = "id") val id: Int?, + @Json(name = "url") val url: String?, @Json(name = "shorturl") val shortUrl: String?, @Json(name = "title") val title: String?, @Json(name = "description") val description: String?, @Json(name = "tags") val tags: List?, - @Json(name = "private") val isPrivate: Boolean, + @Json(name = "private") val isPrivate: Boolean?, @Json(name = "created") val created: String?, @Json(name = "updated") val updated: String? ) diff --git a/app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt b/app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt new file mode 100644 index 0000000..b5a7420 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt @@ -0,0 +1,193 @@ +package com.shaarit.data.export + +import android.content.Context +import android.net.Uri +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.domain.model.ShaarliLink +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.OutputStreamWriter +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BookmarkExporter @Inject constructor( + @ApplicationContext private val context: Context, + private val linkDao: LinkDao +) { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Exporte tous les liens au format JSON + */ + suspend fun exportToJson(uri: Uri): Result = withContext(Dispatchers.IO) { + try { + val links = linkDao.getAllLinksForStats() + + val exportData = ExportData( + version = 1, + exportedAt = System.currentTimeMillis(), + count = links.size, + links = links.map { entity -> + ExportedLink( + id = entity.id, + url = entity.url, + title = entity.title, + description = entity.description, + tags = entity.tags, + private = entity.isPrivate, + createdAt = entity.createdAt, + siteName = entity.siteName, + thumbnailUrl = entity.thumbnailUrl, + readingTimeMinutes = entity.readingTimeMinutes ?: 0, + contentType = entity.contentType.name + ) + } + ) + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + OutputStreamWriter(outputStream).use { writer -> + writer.write(json.encodeToString(exportData)) + } + } ?: throw IllegalStateException("Cannot open output stream") + + Result.success(links.size) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Exporte tous les liens au format CSV (compatible avec Netscape bookmarks) + */ + suspend fun exportToCsv(uri: Uri): Result = withContext(Dispatchers.IO) { + try { + val links = linkDao.getAllLinksForStats() + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + OutputStreamWriter(outputStream).use { writer -> + // En-tête CSV + writer.write("URL,Title,Description,Tags,Private,CreatedAt\n") + + // Données + links.forEach { entity -> + val line = buildString { + append(escapeCsv(entity.url)) + append(",") + append(escapeCsv(entity.title)) + append(",") + append(escapeCsv(entity.description)) + append(",") + append(escapeCsv(entity.tags.joinToString(","))) + append(",") + append(if (entity.isPrivate) "1" else "0") + append(",") + append(dateFormat.format(Date(entity.createdAt))) + append("\n") + } + writer.write(line) + } + } + } ?: throw IllegalStateException("Cannot open output stream") + + Result.success(links.size) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Exporte au format HTML (format Netscape/Chrome bookmarks) + */ + suspend fun exportToHtml(uri: Uri): Result = withContext(Dispatchers.IO) { + try { + val links = linkDao.getAllLinksForStats() + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + OutputStreamWriter(outputStream).use { writer -> + writer.write("\n") + writer.write("\n") + writer.write("\n") + writer.write("Bookmarks\n") + writer.write("

Bookmarks

\n") + writer.write("

\n") + writer.write("

ShaarIt Export

\n") + writer.write("

\n") + + links.forEach { entity -> + val addDate = entity.createdAt / 1000 + val tags = entity.tags.joinToString(",") + val private = if (entity.isPrivate) "PRIVATE=\"1\"" else "" + + writer.write("

${escapeHtml(entity.title)}\n") + if (entity.description.isNotBlank()) { + writer.write("
${escapeHtml(entity.description)}\n") + } + } + + writer.write("

\n") + writer.write("

\n") + } + } ?: throw IllegalStateException("Cannot open output stream") + + Result.success(links.size) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun escapeCsv(value: String): String { + return if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + "\"${value.replace("\"", "\"\"")}\"" + } else { + value + } + } + + private fun escapeHtml(value: String): String { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + } +} + +@Serializable +data class ExportData( + val version: Int, + val exportedAt: Long, + val count: Int, + val links: List +) + +@Serializable +data class ExportedLink( + val id: Int, + val url: String, + val title: String, + val description: String, + val tags: List, + val private: Boolean, + val createdAt: Long, + val siteName: String?, + val thumbnailUrl: String?, + val readingTimeMinutes: Int, + val contentType: String +) diff --git a/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt new file mode 100644 index 0000000..f6fc6c9 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt @@ -0,0 +1,213 @@ +package com.shaarit.data.export + +import android.content.Context +import android.net.Uri +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.entity.ContentType +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.SyncStatus +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.io.BufferedReader +import java.io.InputStreamReader +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BookmarkImporter @Inject constructor( + @ApplicationContext private val context: Context, + private val linkDao: LinkDao +) { + + private val json = Json { + ignoreUnknownKeys = true + } + + data class ImportResult( + val importedCount: Int, + val skippedCount: Int, + val errors: List + ) + + /** + * Importe des liens depuis un fichier HTML (format Netscape/Chrome bookmarks) + */ + suspend fun importFromHtml(uri: Uri): Result = withContext(Dispatchers.IO) { + try { + val html = context.contentResolver.openInputStream(uri)?.use { stream -> + BufferedReader(InputStreamReader(stream)).readText() + } ?: throw IllegalStateException("Cannot open input stream") + + val document = Jsoup.parse(html) + val links = parseNetscapeBookmarks(document) + + var imported = 0 + var skipped = 0 + val errors = mutableListOf() + + links.forEach { bookmark -> + try { + // Vérifier si le lien existe déjà + val existing = linkDao.getLinkByUrl(bookmark.url) + if (existing != null) { + skipped++ + return@forEach + } + + val entity = LinkEntity( + id = 0, + url = bookmark.url, + title = bookmark.title, + description = bookmark.description ?: "", + tags = bookmark.tags, + isPrivate = bookmark.isPrivate, + isPinned = false, + createdAt = bookmark.addDate ?: System.currentTimeMillis(), + updatedAt = bookmark.addDate ?: System.currentTimeMillis(), + siteName = extractDomain(bookmark.url), + thumbnailUrl = null, + readingTimeMinutes = estimateReadingTime(bookmark.description), + contentType = detectContentType(bookmark.url), + syncStatus = SyncStatus.PENDING_CREATE + ) + + linkDao.insertLink(entity) + imported++ + } catch (e: Exception) { + errors.add("Erreur pour ${bookmark.url}: ${e.message}") + } + } + + Result.success(ImportResult(imported, skipped, errors)) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Importe des liens depuis un fichier JSON exporté par ShaarIt + */ + suspend fun importFromJson(uri: Uri): Result = withContext(Dispatchers.IO) { + try { + val jsonContent = context.contentResolver.openInputStream(uri)?.use { stream -> + BufferedReader(InputStreamReader(stream)).readText() + } ?: throw IllegalStateException("Cannot open input stream") + + val exportData = json.decodeFromString(jsonContent) + + var imported = 0 + var skipped = 0 + val errors = mutableListOf() + + exportData.links.forEach { link -> + try { + val existing = linkDao.getLinkByUrl(link.url) + if (existing != null) { + skipped++ + return@forEach + } + + val entity = LinkEntity( + id = 0, + url = link.url, + title = link.title, + description = link.description, + tags = link.tags, + isPrivate = link.private, + isPinned = false, + createdAt = link.createdAt, + updatedAt = System.currentTimeMillis(), + siteName = link.siteName, + thumbnailUrl = link.thumbnailUrl, + readingTimeMinutes = link.readingTimeMinutes, + contentType = ContentType.valueOf(link.contentType), + syncStatus = SyncStatus.PENDING_CREATE + ) + + linkDao.insertLink(entity) + imported++ + } catch (e: Exception) { + errors.add("Erreur pour ${link.url}: ${e.message}") + } + } + + Result.success(ImportResult(imported, skipped, errors)) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parseNetscapeBookmarks(document: Document): List { + val bookmarks = mutableListOf() + + // Recherche les éléments qui contiennent les bookmarks + document.select("dt > a, DT > A").forEach { element -> + val href = element.attr("href") + if (href.isBlank() || href.startsWith("javascript:") || href.startsWith("data:")) { + return@forEach + } + + val title = element.text() + val addDate = element.attr("add_date").toLongOrNull()?.times(1000) // Convertir secondes en ms + val tags = element.attr("tags").split(",").map { it.trim() }.filter { it.isNotBlank() } + val isPrivate = element.attr("private") == "1" + + // Récupérer la description depuis l'élément

suivant + val description = element.parent()?.nextElementSibling() + ?.takeIf { it.tagName().equals("dd", ignoreCase = true) } + ?.text() ?: "" + + bookmarks.add( + ParsedBookmark( + url = href, + title = title, + description = description, + tags = tags, + isPrivate = isPrivate, + addDate = addDate + ) + ) + } + + return bookmarks + } + + private fun extractDomain(url: String): String { + return try { + val uri = java.net.URI(url) + uri.host?.removePrefix("www.") ?: url + } catch (e: Exception) { + url + } + } + + private fun estimateReadingTime(description: String?): Int { + val words = description?.split(Regex("\\s+"))?.size ?: 0 + return (words / 200).coerceAtLeast(1) // 200 mots par minute + } + + private fun detectContentType(url: String): ContentType { + return when { + url.contains("youtube.com") || url.contains("youtu.be") || + url.contains("vimeo.com") -> ContentType.VIDEO + url.contains("github.com") || url.contains("gitlab.com") -> ContentType.REPOSITORY + url.contains("soundcloud.com") || url.contains("spotify.com") -> ContentType.PODCAST + url.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE + else -> ContentType.ARTICLE + } + } + + private data class ParsedBookmark( + val url: String, + val title: String, + val description: String?, + val tags: List, + val isPrivate: Boolean, + val addDate: Long? + ) +} diff --git a/app/src/main/java/com/shaarit/data/local/converter/Converters.kt b/app/src/main/java/com/shaarit/data/local/converter/Converters.kt new file mode 100644 index 0000000..f4a368f --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/converter/Converters.kt @@ -0,0 +1,67 @@ +package com.shaarit.data.local.converter + +import androidx.room.TypeConverter +import com.shaarit.data.local.entity.ContentType +import com.shaarit.data.local.entity.SyncStatus +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * TypeConverters pour Room + */ +class Converters { + + private val json = Json { ignoreUnknownKeys = true } + + // ====== List (Tags) ====== + + @TypeConverter + fun fromStringList(value: List): String { + return try { + json.encodeToString(value) + } catch (e: Exception) { + "[]" + } + } + + @TypeConverter + fun toStringList(value: String): List { + return try { + json.decodeFromString>(value) + } catch (e: Exception) { + emptyList() + } + } + + // ====== SyncStatus ====== + + @TypeConverter + fun fromSyncStatus(status: SyncStatus): String { + return status.name + } + + @TypeConverter + fun toSyncStatus(value: String): SyncStatus { + return try { + SyncStatus.valueOf(value) + } catch (e: Exception) { + SyncStatus.SYNCED + } + } + + // ====== ContentType ====== + + @TypeConverter + fun fromContentType(type: ContentType): String { + return type.name + } + + @TypeConverter + fun toContentType(value: String): ContentType { + return try { + ContentType.valueOf(value) + } catch (e: Exception) { + ContentType.UNKNOWN + } + } +} diff --git a/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt b/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt new file mode 100644 index 0000000..2851dbf --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt @@ -0,0 +1,66 @@ +package com.shaarit.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.shaarit.data.local.entity.CollectionEntity +import com.shaarit.data.local.entity.CollectionLinkCrossRef +import com.shaarit.data.local.entity.LinkEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CollectionDao { + + @Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC") + fun getAllCollections(): Flow> + + @Query("SELECT * FROM collections WHERE id = :id") + suspend fun getCollectionById(id: Long): CollectionEntity? + + @Query("SELECT * FROM collections WHERE is_smart = 0 ORDER BY sort_order ASC") + fun getRegularCollections(): Flow> + + @Query("SELECT * FROM collections WHERE is_smart = 1 ORDER BY sort_order ASC") + fun getSmartCollections(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollection(collection: CollectionEntity): Long + + @Update + suspend fun updateCollection(collection: CollectionEntity) + + @Query("DELETE FROM collections WHERE id = :id") + suspend fun deleteCollection(id: Long) + + @Query("UPDATE collections SET sort_order = :order WHERE id = :id") + suspend fun updateSortOrder(id: Long, order: Int) + + // ====== Relations Collection-Links ====== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addLinkToCollection(crossRef: CollectionLinkCrossRef) + + @Query("DELETE FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId") + suspend fun removeLinkFromCollection(collectionId: Long, linkId: Int) + + @Query("DELETE FROM collection_links WHERE collection_id = :collectionId") + suspend fun clearCollection(collectionId: Long) + + @Transaction + @Query(""" + SELECT links.* FROM links + INNER JOIN collection_links ON links.id = collection_links.link_id + WHERE collection_links.collection_id = :collectionId + ORDER BY collection_links.added_at DESC + """) + fun getLinksInCollection(collectionId: Long): Flow> + + @Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId") + fun getLinkCountInCollection(collectionId: Long): Flow + + @Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)") + suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean +} diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt new file mode 100644 index 0000000..07ad840 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -0,0 +1,183 @@ +package com.shaarit.data.local.dao + +import androidx.paging.PagingSource +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Transaction +import androidx.room.Update +import androidx.sqlite.db.SupportSQLiteQuery +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.LinkFtsEntity +import com.shaarit.data.local.entity.SyncStatus +import kotlinx.coroutines.flow.Flow + +@Dao +interface LinkDao { + + // ====== Requêtes de base ====== + + @Query("SELECT * FROM links ORDER BY is_pinned DESC, created_at DESC") + fun getAllLinks(): Flow> + + @Query("SELECT * FROM links ORDER BY is_pinned DESC, created_at DESC") + fun getAllLinksPaged(): PagingSource + + @Query("SELECT * FROM links WHERE id = :id") + suspend fun getLinkById(id: Int): LinkEntity? + + @Query("SELECT * FROM links WHERE url = :url") + suspend fun getLinkByUrl(url: String): LinkEntity? + + @Query("SELECT * FROM links WHERE id = :id") + fun getLinkByIdFlow(id: Int): Flow + + // ====== Recherche et filtres ====== + + @Query(""" + SELECT * FROM links + WHERE title LIKE '%' || :query || '%' + OR description LIKE '%' || :query || '%' + OR url LIKE '%' || :query || '%' + ORDER BY is_pinned DESC, created_at DESC + """) + fun searchLinks(query: String): PagingSource + + @Query(""" + SELECT links.* FROM links + INNER JOIN links_fts ON links.id = links_fts.rowid + WHERE links_fts MATCH :query + ORDER BY links.is_pinned DESC, links.created_at DESC + """) + fun searchLinksFullText(query: String): PagingSource + + @Query(""" + SELECT * FROM links + WHERE tags LIKE '%' || :tag || '%' + ORDER BY is_pinned DESC, created_at DESC + """) + fun getLinksByTag(tag: String): PagingSource + + @Query(""" + SELECT * FROM links + WHERE tags LIKE '%' || :tag1 || '%' AND tags LIKE '%' || :tag2 || '%' + ORDER BY is_pinned DESC, created_at DESC + """) + fun getLinksByMultipleTags(tag1: String, tag2: String): PagingSource + + // ====== Filtres temporels ====== + + @Query(""" + SELECT * FROM links + WHERE created_at >= :timestamp + ORDER BY is_pinned DESC, created_at DESC + """) + fun getLinksSince(timestamp: Long): Flow> + + @Query(""" + SELECT * FROM links + WHERE created_at BETWEEN :startTime AND :endTime + ORDER BY is_pinned DESC, created_at DESC + """) + fun getLinksBetween(startTime: Long, endTime: Long): Flow> + + // ====== Filtres par statut ====== + + @Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC") + fun getPublicLinks(): PagingSource + + @Query("SELECT * FROM links WHERE is_private = 1 ORDER BY created_at DESC") + fun getPrivateLinks(): PagingSource + + @Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC") + fun getPinnedLinks(): Flow> + + // ====== Sync ====== + + @Query("SELECT * FROM links WHERE sync_status = :status") + suspend fun getLinksBySyncStatus(status: SyncStatus): List + + @Query("SELECT * FROM links WHERE sync_status != 'SYNCED'") + fun getUnsyncedLinks(): Flow> + + @Query("SELECT COUNT(*) FROM links WHERE sync_status != 'SYNCED'") + fun getUnsyncedCount(): Flow + + @Query("UPDATE links SET sync_status = :status WHERE id = :id") + suspend fun updateSyncStatus(id: Int, status: SyncStatus) + + @Query("UPDATE links SET sync_status = 'SYNCED', local_modified_at = :timestamp WHERE id = :id") + suspend fun markAsSynced(id: Int, timestamp: Long = System.currentTimeMillis()) + + // ====== Insert / Update / Delete ====== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLink(link: LinkEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLinks(links: List) + + @Update + suspend fun updateLink(link: LinkEntity) + + @Query("UPDATE links SET is_pinned = :isPinned, sync_status = :syncStatus, local_modified_at = :timestamp WHERE id = :id") + suspend fun updatePinStatus(id: Int, isPinned: Boolean, syncStatus: SyncStatus = SyncStatus.PENDING_UPDATE, timestamp: Long = System.currentTimeMillis()) + + @Query("DELETE FROM links WHERE id = :id") + suspend fun deleteLink(id: Int) + + @Query("UPDATE links SET sync_status = 'PENDING_DELETE' WHERE id = :id") + suspend fun markForDeletion(id: Int) + + @Query("DELETE FROM links WHERE sync_status = 'PENDING_DELETE'") + suspend fun deletePendingDeletions() + + // ====== Opérations en masse ====== + + @Query("DELETE FROM links") + suspend fun clearAll() + + @Transaction + suspend fun replaceAll(links: List) { + clearAll() + insertLinks(links) + } + + // ====== Statistiques ====== + + @Query("SELECT COUNT(*) FROM links") + fun getTotalCount(): Flow + + @Query("SELECT COUNT(*) FROM links WHERE is_private = 1") + fun getPrivateCount(): Flow + + @Query("SELECT COUNT(*) FROM links WHERE created_at >= :timestamp") + suspend fun getCountSince(timestamp: Long): Int + + @Query("SELECT DISTINCT site_name FROM links WHERE site_name IS NOT NULL ORDER BY site_name") + suspend fun getAllSites(): List + + @Query("SELECT site_name, COUNT(*) as count FROM links WHERE site_name IS NOT NULL GROUP BY site_name ORDER BY count DESC LIMIT :limit") + suspend fun getTopSites(limit: Int = 10): List + + @Query("SELECT content_type, COUNT(*) as count FROM links GROUP BY content_type ORDER BY count DESC") + suspend fun getContentTypeDistribution(): List + + // ====== Pour les statistiques ====== + + @Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'") + suspend fun getAllLinksForStats(): List +} + +data class SiteCount( + @ColumnInfo(name = "site_name") val siteName: String, + @ColumnInfo(name = "count") val count: Int +) + +data class ContentTypeCount( + @ColumnInfo(name = "content_type") val contentType: String, + @ColumnInfo(name = "count") val count: Int +) diff --git a/app/src/main/java/com/shaarit/data/local/dao/TagDao.kt b/app/src/main/java/com/shaarit/data/local/dao/TagDao.kt new file mode 100644 index 0000000..72e890f --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/dao/TagDao.kt @@ -0,0 +1,61 @@ +package com.shaarit.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.shaarit.data.local.entity.TagEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface TagDao { + + @Query("SELECT * FROM tags ORDER BY occurrences DESC, name ASC") + fun getAllTags(): Flow> + + @Query("SELECT * FROM tags ORDER BY occurrences DESC, name ASC") + suspend fun getAllTagsOnce(): List + + @Query("SELECT * FROM tags WHERE name = :name") + suspend fun getTagByName(name: String): TagEntity? + + @Query("SELECT * FROM tags WHERE name LIKE '%' || :query || '%' ORDER BY occurrences DESC") + suspend fun searchTags(query: String): List + + @Query("SELECT * FROM tags WHERE is_favorite = 1 ORDER BY name ASC") + fun getFavoriteTags(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTag(tag: TagEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTags(tags: List) + + @Update + suspend fun updateTag(tag: TagEntity) + + @Query("UPDATE tags SET occurrences = occurrences + 1, last_used_at = :timestamp WHERE name = :name") + suspend fun incrementOccurrences(name: String, timestamp: Long = System.currentTimeMillis()) + + @Query("UPDATE tags SET occurrences = occurrences - 1 WHERE name = :name") + suspend fun decrementOccurrences(name: String) + + @Query("UPDATE tags SET is_favorite = :isFavorite WHERE name = :name") + suspend fun setFavorite(name: String, isFavorite: Boolean) + + @Query("DELETE FROM tags WHERE name = :name") + suspend fun deleteTag(name: String) + + @Query("DELETE FROM tags") + suspend fun clearAll() + + @Query("SELECT COUNT(*) FROM tags") + fun getTotalCount(): Flow + + @Query("SELECT * FROM tags ORDER BY last_used_at DESC LIMIT :limit") + suspend fun getRecentlyUsed(limit: Int = 10): List + + @Query("SELECT * FROM tags ORDER BY occurrences DESC LIMIT :limit") + suspend fun getMostPopular(limit: Int = 10): List +} diff --git a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt new file mode 100644 index 0000000..131659f --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt @@ -0,0 +1,63 @@ +package com.shaarit.data.local.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.shaarit.data.local.converter.Converters +import com.shaarit.data.local.dao.CollectionDao +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.dao.TagDao +import com.shaarit.data.local.entity.CollectionEntity +import com.shaarit.data.local.entity.CollectionLinkCrossRef +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.LinkFtsEntity +import com.shaarit.data.local.entity.LinkTagCrossRef +import com.shaarit.data.local.entity.TagEntity + +/** + * Database Room principale pour le cache offline de ShaarIt + */ +@Database( + entities = [ + LinkEntity::class, + LinkFtsEntity::class, + TagEntity::class, + LinkTagCrossRef::class, + CollectionEntity::class, + CollectionLinkCrossRef::class + ], + version = 1, + exportSchema = true +) +@TypeConverters(Converters::class) +abstract class ShaarliDatabase : RoomDatabase() { + + abstract fun linkDao(): LinkDao + abstract fun tagDao(): TagDao + abstract fun collectionDao(): CollectionDao + + companion object { + private const val DATABASE_NAME = "shaarli.db" + + @Volatile + private var instance: ShaarliDatabase? = null + + fun getInstance(context: Context): ShaarliDatabase { + return instance ?: synchronized(this) { + instance ?: buildDatabase(context).also { instance = it } + } + } + + private fun buildDatabase(context: Context): ShaarliDatabase { + return Room.databaseBuilder( + context.applicationContext, + ShaarliDatabase::class.java, + DATABASE_NAME + ) + .fallbackToDestructiveMigration() + .build() + } + } +} diff --git a/app/src/main/java/com/shaarit/data/local/entity/CollectionEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/CollectionEntity.kt new file mode 100644 index 0000000..0667506 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/entity/CollectionEntity.kt @@ -0,0 +1,68 @@ +package com.shaarit.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Entité représentant une collection/playlist de liens + */ +@Entity( + tableName = "collections", + indices = [ + Index(value = ["name"]), + Index(value = ["is_smart"]), + Index(value = ["sort_order"]) + ] +) +data class CollectionEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long = 0, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "description") + val description: String? = null, + + @ColumnInfo(name = "icon") + val icon: String = "📁", // Emoji ou nom d'icône + + @ColumnInfo(name = "color") + val color: Int? = null, + + @ColumnInfo(name = "is_smart") + val isSmart: Boolean = false, // Collection intelligente avec requête + + @ColumnInfo(name = "query") + val query: String? = null, // Requête pour collection intelligente (ex: "tag:work") + + @ColumnInfo(name = "sort_order") + val sortOrder: Int = 0, + + @ColumnInfo(name = "created_at") + val createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "updated_at") + val updatedAt: Long = System.currentTimeMillis() +) + +/** + * Relation entre collection et liens + */ +@Entity( + tableName = "collection_links", + primaryKeys = ["collection_id", "link_id"] +) +data class CollectionLinkCrossRef( + @ColumnInfo(name = "collection_id") + val collectionId: Long, + + @ColumnInfo(name = "link_id") + val linkId: Int, + + @ColumnInfo(name = "added_at") + val addedAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt new file mode 100644 index 0000000..d9ee575 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt @@ -0,0 +1,114 @@ +package com.shaarit.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Entité Room représentant un lien Shaarli en cache local. + * Supporte FTS4 pour la recherche full-text. + */ +@Entity( + tableName = "links", + indices = [ + Index(value = ["sync_status"]), + Index(value = ["is_private"]), + Index(value = ["created_at"]), + Index(value = ["is_pinned"]), + Index(value = ["url"], unique = true) + ] +) +data class LinkEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + + @ColumnInfo(name = "url") + val url: String, + + @ColumnInfo(name = "title") + val title: String, + + @ColumnInfo(name = "description") + val description: String, + + @ColumnInfo(name = "tags") + val tags: List, + + @ColumnInfo(name = "is_private") + val isPrivate: Boolean, + + @ColumnInfo(name = "is_pinned") + val isPinned: Boolean = false, + + @ColumnInfo(name = "created_at") + val createdAt: Long, + + @ColumnInfo(name = "updated_at") + val updatedAt: Long, + + @ColumnInfo(name = "sync_status") + val syncStatus: SyncStatus = SyncStatus.SYNCED, + + @ColumnInfo(name = "local_modified_at") + val localModifiedAt: Long = System.currentTimeMillis(), + + // Métadonnées enrichies + @ColumnInfo(name = "thumbnail_url") + val thumbnailUrl: String? = null, + + @ColumnInfo(name = "reading_time_minutes") + val readingTimeMinutes: Int? = null, + + @ColumnInfo(name = "content_type") + val contentType: ContentType = ContentType.UNKNOWN, + + @ColumnInfo(name = "site_name") + val siteName: String? = null, + + @ColumnInfo(name = "excerpt") + val excerpt: String? = null +) + +/** + * Statut de synchronisation d'un lien + */ +enum class SyncStatus { + SYNCED, // Synchronisé avec le serveur + PENDING_CREATE, // En attente de création sur le serveur + PENDING_UPDATE, // En attente de mise à jour sur le serveur + PENDING_DELETE, // En attente de suppression sur le serveur + CONFLICT // Conflit de synchronisation détecté +} + +/** + * Type de contenu détecté + */ +enum class ContentType { + UNKNOWN, + ARTICLE, + VIDEO, + PODCAST, + IMAGE, + PDF, + REPOSITORY, // GitHub, GitLab, etc. + DOCUMENT, // Google Docs, Notion, etc. + SOCIAL, // Twitter, Mastodon, etc. + SHOPPING, // Amazon, etc. + NEWSLETTER +} + +/** + * Entité FTS4 pour la recherche full-text + */ +@Fts4(contentEntity = LinkEntity::class) +@Entity(tableName = "links_fts") +data class LinkFtsEntity( + val url: String, + val title: String, + val description: String, + val tags: String, // Concatenated tags + val excerpt: String? +) diff --git a/app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt new file mode 100644 index 0000000..303e4a5 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt @@ -0,0 +1,49 @@ +package com.shaarit.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Entité Room représentant un tag Shaarli en cache local. + */ +@Entity( + tableName = "tags", + indices = [ + Index(value = ["name"], unique = true), + Index(value = ["occurrences"]) + ] +) +data class TagEntity( + @PrimaryKey + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "occurrences") + val occurrences: Int = 0, + + @ColumnInfo(name = "last_used_at") + val lastUsedAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "color") + val color: Int? = null, // Couleur personnalisée pour le tag + + @ColumnInfo(name = "is_favorite") + val isFavorite: Boolean = false +) + +/** + * Relation many-to-many entre liens et tags (si besoin de relations complexes) + */ +@Entity( + tableName = "link_tag_cross_ref", + primaryKeys = ["link_id", "tag_name"] +) +data class LinkTagCrossRef( + @ColumnInfo(name = "link_id") + val linkId: Int, + + @ColumnInfo(name = "tag_name") + val tagName: String +) diff --git a/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt b/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt index 9035e19..1d43563 100644 --- a/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt +++ b/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt @@ -6,14 +6,16 @@ import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliTag object LinkMapper { - fun toDomain(dto: LinkDto): ShaarliLink { + fun toDomain(dto: LinkDto): ShaarliLink? { + val id = dto.id ?: return null + val url = dto.url ?: return null return ShaarliLink( - id = dto.id, - url = dto.url, - title = dto.title ?: dto.url, + id = id, + url = url, + title = dto.title ?: url, description = dto.description ?: "", tags = dto.tags ?: emptyList(), - isPrivate = dto.isPrivate, + isPrivate = dto.isPrivate ?: false, date = dto.created ?: "" ) } diff --git a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt new file mode 100644 index 0000000..688ea60 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt @@ -0,0 +1,282 @@ +package com.shaarit.data.metadata + +import android.util.Log +import com.shaarit.data.local.entity.ContentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service d'extraction de métadonnées pour enrichir les liens + * Utilise Jsoup pour parser le HTML et extraire les métadonnées OpenGraph + */ +@Singleton +class LinkMetadataExtractor @Inject constructor() { + + companion object { + private const val TAG = "LinkMetadataExtractor" + private const val TIMEOUT_MS = 10000 + private const val MAX_DESCRIPTION_LENGTH = 300 + } + + /** + * Extrait les métadonnées d'une URL + */ + suspend fun extract(url: String): LinkMetadata = withContext(Dispatchers.IO) { + try { + val doc = Jsoup.connect(url) + .timeout(TIMEOUT_MS) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .followRedirects(true) + .get() + + LinkMetadata( + url = url, + title = extractTitle(doc), + description = extractDescription(doc), + thumbnailUrl = extractThumbnail(doc, url), + siteName = extractSiteName(doc, url), + contentType = detectContentType(doc, url), + readingTime = estimateReadingTime(doc), + faviconUrl = extractFavicon(doc, url) + ) + } catch (e: Exception) { + Log.e(TAG, "Erreur extraction métadonnées pour $url", e) + // Retourner des métadonnées par défaut basées sur l'URL + LinkMetadata( + url = url, + title = extractTitleFromUrl(url), + description = "", + thumbnailUrl = null, + siteName = extractDomain(url), + contentType = detectContentTypeFromUrl(url), + readingTime = null, + faviconUrl = null + ) + } + } + + /** + * Extrait le titre de la page + */ + private fun extractTitle(doc: Document): String? { + // Priorité aux balises OpenGraph + val ogTitle = doc.select("meta[property=og:title]").attr("content") + if (ogTitle.isNotBlank()) return ogTitle.trim() + + // Fallback sur la balise title + val titleTag = doc.select("title").text() + if (titleTag.isNotBlank()) return titleTag.trim() + + // Fallback sur twitter:title + val twitterTitle = doc.select("meta[name=twitter:title]").attr("content") + if (twitterTitle.isNotBlank()) return twitterTitle.trim() + + return null + } + + /** + * Extrait la description de la page + */ + private fun extractDescription(doc: Document): String? { + // Priorité aux balises OpenGraph + val ogDescription = doc.select("meta[property=og:description]").attr("content") + if (ogDescription.isNotBlank()) { + return ogDescription.trim().take(MAX_DESCRIPTION_LENGTH) + } + + // Fallback sur meta description + val metaDescription = doc.select("meta[name=description]").attr("content") + if (metaDescription.isNotBlank()) { + return metaDescription.trim().take(MAX_DESCRIPTION_LENGTH) + } + + // Fallback sur twitter:description + val twitterDescription = doc.select("meta[name=twitter:description]").attr("content") + if (twitterDescription.isNotBlank()) { + return twitterDescription.trim().take(MAX_DESCRIPTION_LENGTH) + } + + // Essayer d'extraire le premier paragraphe pertinent + val firstParagraph = doc.select("p").firstOrNull()?.text() + if (!firstParagraph.isNullOrBlank() && firstParagraph.length > 50) { + return firstParagraph.take(MAX_DESCRIPTION_LENGTH) + } + + return null + } + + /** + * Extrait l'image thumbnail + */ + private fun extractThumbnail(doc: Document, baseUrl: String): String? { + // Priorité aux balises OpenGraph + val ogImage = doc.select("meta[property=og:image]").attr("content") + if (ogImage.isNotBlank()) { + return resolveUrl(ogImage, baseUrl) + } + + // Fallback sur twitter:image + val twitterImage = doc.select("meta[name=twitter:image]").attr("content") + if (twitterImage.isNotBlank()) { + return resolveUrl(twitterImage, baseUrl) + } + + // Rechercher une image principale + val mainImage = doc.select("img[src~=(?i)\\.(png|jpe?g|gif|webp)]").firstOrNull() + if (mainImage != null) { + val src = mainImage.attr("src") + if (src.isNotBlank()) { + return resolveUrl(src, baseUrl) + } + } + + return null + } + + /** + * Extrait le nom du site + */ + private fun extractSiteName(doc: Document, baseUrl: String): String? { + val ogSiteName = doc.select("meta[property=og:site_name]").attr("content") + if (ogSiteName.isNotBlank()) return ogSiteName.trim() + + val applicationName = doc.select("meta[name=application-name]").attr("content") + if (applicationName.isNotBlank()) return applicationName.trim() + + return extractDomain(baseUrl) + } + + /** + * Extrait l'URL du favicon + */ + private fun extractFavicon(doc: Document, baseUrl: String): String? { + // Chercher le favicon déclaré + val favicon = doc.select("link[rel~=(?i)icon]").attr("href") + if (favicon.isNotBlank()) { + return resolveUrl(favicon, baseUrl) + } + + // Favicon par défaut + return "${extractBaseUrl(baseUrl)}/favicon.ico" + } + + /** + * Détecte le type de contenu + */ + private fun detectContentType(doc: Document, url: String): ContentType { + val ogType = doc.select("meta[property=og:type]").attr("content") + + return when { + ogType == "video" || url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) -> ContentType.VIDEO + ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE + url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY + url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT + url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL + url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING + url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER + doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor|soundcloud")) -> ContentType.PODCAST + url.endsWith(".pdf") -> ContentType.PDF + else -> ContentType.UNKNOWN + } + } + + /** + * Détecte le type de contenu uniquement depuis l'URL + */ + private fun detectContentTypeFromUrl(url: String): ContentType { + return when { + url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO + url.contains(Regex("github\\.com|gitlab")) -> ContentType.REPOSITORY + url.contains(Regex("docs\\.google|notion\\.so")) -> ContentType.DOCUMENT + url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL + url.contains(Regex("amazon|ebay")) -> ContentType.SHOPPING + url.endsWith(".pdf") -> ContentType.PDF + else -> ContentType.UNKNOWN + } + } + + /** + * Estime le temps de lecture + */ + private fun estimateReadingTime(doc: Document): Int? { + val text = doc.body()?.text() ?: return null + val wordCount = text.split(Regex("\\s+")).size + // Moyenne de 200 mots par minute + val minutes = (wordCount / 200.0).toInt() + return if (minutes > 0) minutes else 1 + } + + /** + * Extrait le domaine d'une URL + */ + private fun extractDomain(url: String): String? { + return try { + URL(url).host.removePrefix("www.") + } catch (e: Exception) { + null + } + } + + /** + * Extrait la base d'une URL (protocol + host) + */ + private fun extractBaseUrl(url: String): String? { + return try { + val urlObj = URL(url) + "${urlObj.protocol}://${urlObj.host}" + } catch (e: Exception) { + null + } + } + + /** + * Extrait un titre depuis l'URL si pas d'autre option + */ + private fun extractTitleFromUrl(url: String): String? { + return try { + val path = URL(url).path + path.trim('/').split('/').lastOrNull() + ?.replace('-', ' ') + ?.replace('_', ' ') + ?.capitalize() + } catch (e: Exception) { + null + } + } + + /** + * Résout une URL relative en URL absolue + */ + private fun resolveUrl(url: String, baseUrl: String): String { + return if (url.startsWith("http")) { + url + } else if (url.startsWith("//")) { + "https:$url" + } else { + try { + URL(URL(baseUrl), url).toString() + } catch (e: Exception) { + url + } + } + } +} + +/** + * Données de métadonnées extraites + */ +data class LinkMetadata( + val url: String, + val title: String?, + val description: String?, + val thumbnailUrl: String?, + val siteName: String?, + val contentType: ContentType, + val readingTime: Int?, + val faviconUrl: String? +) diff --git a/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt b/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt index 6b3998f..0dd4afc 100644 --- a/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt +++ b/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt @@ -42,7 +42,7 @@ class LinkPagingSource( searchTags = searchTags ) - val links = dtos.map { LinkMapper.toDomain(it) } + val links = dtos.mapNotNull { LinkMapper.toDomain(it) } val nextKey = if (links.isEmpty()) { diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index a4d9ddf..57ed769 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -3,93 +3,221 @@ package com.shaarit.data.repository import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.map import com.shaarit.data.api.ShaarliApi import com.shaarit.data.dto.CreateLinkDto import com.shaarit.data.dto.LinkDto +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.dao.TagDao +import com.shaarit.data.local.entity.ContentType +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.SyncStatus +import com.shaarit.data.local.entity.TagEntity import com.shaarit.data.mapper.LinkMapper import com.shaarit.data.mapper.TagMapper -import com.shaarit.data.paging.LinkPagingSource +import com.shaarit.data.sync.SyncManager import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.AddLinkResult import com.shaarit.domain.repository.LinkRepository import com.squareup.moshi.Moshi -import javax.inject.Inject import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton +/** + * Implémentation du repository avec architecture offline-first + * Utilise Room pour le cache local et l'API Shaarli pour la synchronisation + */ +@Singleton class LinkRepositoryImpl @Inject -constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkRepository { +constructor( + private val api: ShaarliApi, + private val linkDao: LinkDao, + private val tagDao: TagDao, + private val syncManager: SyncManager, + private val moshi: Moshi +) : LinkRepository { + // ====== Lecture (Offline-First) ====== + override fun getLinksStream( - searchTerm: String?, - searchTags: String? + searchTerm: String?, + searchTags: String? ): Flow> { + // Utiliser Room pour la pagination locale return Pager( - config = PagingConfig(pageSize = 20, enablePlaceholders = false), - pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) } - ) - .flow + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { + when { + !searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm) + !searchTags.isNullOrBlank() -> { + val tags = searchTags.split(" ").filter { it.isNotBlank() } + if (tags.size == 1) { + linkDao.getLinksByTag(tags.first()) + } else { + // Pour plusieurs tags, on prend les liens qui ont au moins un des tags + linkDao.getLinksByTag(tags.first()) + } + } + else -> linkDao.getAllLinksPaged() + } + } + ).flow.map { pagingData -> + pagingData.map { it.toDomainModel() } + } } - - override suspend fun addLink( - url: String, - title: String?, - description: String?, - tags: List?, - isPrivate: Boolean - ): Result { + + override suspend fun getLink(id: Int): Result { + // Essayer d'abord le cache local + val localLink = linkDao.getLinkById(id) + if (localLink != null) { + return Result.success(localLink.toDomainModel()) + } + + // Fallback vers l'API return try { - val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate)) - if (response.isSuccessful) { - Result.success(Unit) + val link = api.getLink(id) + val entity = link.toEntity() + if (entity != null) { + linkDao.insertLink(entity) + Result.success(entity.toDomainModel()) } else { - Result.failure(HttpException(response)) + Result.failure(Exception("Invalid link data from server")) } } catch (e: Exception) { Result.failure(e) } } - + + override fun getLinkFlow(id: Int): Flow { + return linkDao.getLinkByIdFlow(id).map { it?.toDomainModel() } + } + + override suspend fun getLinksByTag(tag: String): Result> { + // Lire depuis le cache local + return try { + // Si nous avons des données locales, les retourner + val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() + if (localLinks.isNotEmpty()) { + val filtered = localLinks.filter { it.tags.contains(tag) } + if (filtered.isNotEmpty()) { + return Result.success(filtered.map { it.toDomainModel() }) + } + } + + // Fallback vers l'API + val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100) + Result.success(links.mapNotNull { LinkMapper.toDomain(it) }) + } catch (e: Exception) { + Result.failure(e) + } + } + + // ====== Écriture (avec file d'attente de sync) ====== + + override suspend fun addLink( + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean + ): Result { + // Créer l'entité locale avec un ID temporaire négatif + val tempId = -(System.currentTimeMillis() % 10000000).toInt() + val entity = LinkEntity( + id = tempId, + url = url, + title = title ?: url, + description = description ?: "", + tags = tags ?: emptyList(), + isPrivate = isPrivate, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING_CREATE + ) + + // Sauvegarder localement + linkDao.insertLink(entity) + + // Mettre à jour les compteurs de tags + tags?.forEach { tag -> + val existingTag = tagDao.getTagByName(tag) + if (existingTag != null) { + tagDao.incrementOccurrences(tag) + } else { + tagDao.insertTag(TagEntity(tag, 1)) + } + } + + // Déclencher une sync en arrière-plan + syncManager.syncNow() + + return Result.success(Unit) + } + override suspend fun addOrUpdateLink( - url: String, - title: String?, - description: String?, - tags: List?, - isPrivate: Boolean, - forceUpdate: Boolean, - existingLinkId: Int? + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean, + forceUpdate: Boolean, + existingLinkId: Int? ): AddLinkResult { return try { if (forceUpdate && existingLinkId != null) { - // Force update existing link - val response = - api.updateLink( - existingLinkId, - CreateLinkDto(url, title, description, tags, isPrivate) - ) - if (response.isSuccessful) { + // Mise à jour forcée + val existing = linkDao.getLinkById(existingLinkId) + if (existing != null) { + val updated = existing.copy( + title = title ?: existing.title, + description = description ?: existing.description, + tags = tags ?: existing.tags, + isPrivate = isPrivate, + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING_UPDATE + ) + linkDao.updateLink(updated) + syncManager.syncNow() AddLinkResult.Success } else { - AddLinkResult.Error("Update failed: ${response.code()}") + AddLinkResult.Error("Link not found") } } else { - // Try to add new link - val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate)) - if (response.isSuccessful) { - AddLinkResult.Success - } else if (response.code() == 409) { - // Conflict - link already exists - // Try to parse the existing link from response body - val errorBody = response.errorBody()?.string() - val existingLink = parseExistingLink(errorBody) + // Vérifier si le lien existe déjà localement + val existingByUrl = linkDao.getLinkByUrl(url) + if (existingByUrl != null) { AddLinkResult.Conflict( - existingLinkId = existingLink?.id ?: 0, - existingTitle = existingLink?.title + existingLinkId = existingByUrl.id, + existingTitle = existingByUrl.title ) } else { - AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}") + // Essayer l'API directement + val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate)) + if (response.isSuccessful) { + response.body()?.let { serverLink -> + serverLink.toEntity()?.let { entity -> + linkDao.insertLink(entity) + } + } + AddLinkResult.Success + } else if (response.code() == 409) { + val errorBody = response.errorBody()?.string() + val existingLink = parseExistingLink(errorBody) + AddLinkResult.Conflict( + existingLinkId = existingLink?.id ?: 0, + existingTitle = existingLink?.title + ) + } else { + // Fallback : créer localement + addLink(url, title, description, tags, isPrivate) + AddLinkResult.Success + } } } } catch (e: HttpException) { @@ -97,17 +225,124 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit val errorBody = e.response()?.errorBody()?.string() val existingLink = parseExistingLink(errorBody) AddLinkResult.Conflict( - existingLinkId = existingLink?.id ?: 0, - existingTitle = existingLink?.title + existingLinkId = existingLink?.id ?: 0, + existingTitle = existingLink?.title ) } else { - AddLinkResult.Error(e.message ?: "HTTP Error ${e.code()}") + // Fallback offline + addLink(url, title, description, tags, isPrivate) + AddLinkResult.Success } } catch (e: Exception) { - AddLinkResult.Error(e.message ?: "Unknown error") + // Fallback offline + addLink(url, title, description, tags, isPrivate) + AddLinkResult.Success } } - + + override suspend fun updateLink( + id: Int, + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean + ): Result { + val existing = linkDao.getLinkById(id) + ?: return Result.failure(Exception("Link not found")) + + val updated = existing.copy( + url = url, + title = title ?: existing.title, + description = description ?: existing.description, + tags = tags ?: existing.tags, + isPrivate = isPrivate, + updatedAt = System.currentTimeMillis(), + syncStatus = if (existing.syncStatus == SyncStatus.PENDING_CREATE) { + SyncStatus.PENDING_CREATE + } else { + SyncStatus.PENDING_UPDATE + } + ) + + linkDao.updateLink(updated) + syncManager.syncNow() + + return Result.success(Unit) + } + + override suspend fun deleteLink(id: Int): Result { + val existing = linkDao.getLinkById(id) + ?: return Result.failure(Exception("Link not found")) + + if (existing.syncStatus == SyncStatus.PENDING_CREATE) { + // Si jamais sync, supprimer directement + linkDao.deleteLink(id) + } else { + // Marquer pour suppression + linkDao.markForDeletion(id) + syncManager.syncNow() + } + + // Décrémenter les compteurs de tags + existing.tags.forEach { tag -> + tagDao.decrementOccurrences(tag) + } + + return Result.success(Unit) + } + + // ====== Tags ====== + + override suspend fun getTags(): Result> { + // Essayer d'abord le cache local + val localTags = tagDao.getAllTags().firstOrNull() + if (!localTags.isNullOrEmpty()) { + return Result.success(localTags.map { ShaarliTag(it.name, it.occurrences) }) + } + + // Fallback vers l'API + return try { + val tags = api.getTags(offset = 0, limit = 500) + val entities = tags.map { TagMapper.toDomain(it) }.map { + TagEntity(it.name, it.occurrences) + } + tagDao.insertTags(entities) + Result.success(tags.map { TagMapper.toDomain(it) }) + } catch (e: Exception) { + Result.failure(e) + } + } + + fun getTagsFlow(): Flow> { + return tagDao.getAllTags().map { tags -> + tags.map { ShaarliTag(it.name, it.occurrences) } + } + } + + // ====== Actions supplémentaires ====== + + suspend fun togglePin(id: Int): Result { + val link = linkDao.getLinkById(id) + ?: return Result.failure(Exception("Link not found")) + + val newPinState = !link.isPinned + linkDao.updatePinStatus(id, newPinState) + + return Result.success(newPinState) + } + + suspend fun refreshFromServer(): Result { + return try { + syncManager.performFullSync() + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + // ====== Helpers ====== + private fun parseExistingLink(errorBody: String?): LinkDto? { if (errorBody.isNullOrBlank()) return null return try { @@ -117,65 +352,46 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit null } } - - override suspend fun updateLink( - id: Int, - url: String, - title: String?, - description: String?, - tags: List?, - isPrivate: Boolean - ): Result { - return try { - val response = - api.updateLink(id, CreateLinkDto(url, title, description, tags, isPrivate)) - if (response.isSuccessful) { - Result.success(Unit) - } else { - Result.failure(Exception("Update failed: ${response.code()}")) - } - } catch (e: Exception) { - Result.failure(e) - } + + private fun LinkEntity.toDomainModel(): ShaarliLink { + return ShaarliLink( + id = id, + url = url, + title = title, + description = description, + tags = tags, + isPrivate = isPrivate, + date = java.time.Instant.ofEpochMilli(createdAt).toString(), + isPinned = isPinned, + thumbnailUrl = thumbnailUrl, + readingTime = readingTimeMinutes, + contentType = contentType.name, + siteName = siteName + ) } - - override suspend fun deleteLink(id: Int): Result { - return try { - val response = api.deleteLink(id) - if (response.isSuccessful) { - Result.success(Unit) - } else { - Result.failure(Exception("Delete failed: ${response.code()}")) - } - } catch (e: Exception) { - Result.failure(e) - } + + private fun LinkDto.toEntity(): LinkEntity? { + val linkId = id ?: return null + val linkUrl = url ?: return null + return LinkEntity( + id = linkId, + url = linkUrl, + title = title ?: linkUrl, + description = description ?: "", + tags = tags ?: emptyList(), + isPrivate = isPrivate ?: false, + createdAt = parseDate(created), + updatedAt = parseDate(updated), + syncStatus = SyncStatus.SYNCED + ) } - - override suspend fun getLink(id: Int): Result { + + private fun parseDate(dateString: String?): Long { + if (dateString.isNullOrBlank()) return System.currentTimeMillis() return try { - val link = api.getLink(id) - Result.success(LinkMapper.toDomain(link)) + java.time.Instant.parse(dateString).toEpochMilli() } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun getTags(): Result> { - return try { - val tags = api.getTags(offset = 0, limit = 500) - Result.success(tags.map { TagMapper.toDomain(it) }) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun getLinksByTag(tag: String): Result> { - return try { - val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100) - Result.success(links.map { LinkMapper.toDomain(it) }) - } catch (e: Exception) { - Result.failure(e) + System.currentTimeMillis() } } } diff --git a/app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt b/app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt new file mode 100644 index 0000000..cbb2a5d --- /dev/null +++ b/app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt @@ -0,0 +1,127 @@ +package com.shaarit.data.sync + +import android.util.Log +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.SyncStatus +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Stratégies de résolution des conflits de synchronisation + */ +@Singleton +class ConflictResolver @Inject constructor() { + + companion object { + private const val TAG = "ConflictResolver" + } + + /** + * Stratégie de résolution de conflit + */ + enum class Strategy { + SERVER_WINS, // La version serveur écrase la locale + CLIENT_WINS, // La version locale écrase celle du serveur + MERGE, // Fusionner les deux versions (si possible) + MANUAL // Demander à l'utilisateur + } + + data class Conflict( + val localLink: LinkEntity, + val serverLink: LinkEntity, + val type: ConflictType + ) + + enum class ConflictType { + BOTH_MODIFIED, // Les deux versions ont été modifiées + LOCAL_DELETED, // Supprimé localement, modifié sur le serveur + SERVER_DELETED // Supprimé sur le serveur, modifié localement + } + + /** + * Résout un conflit selon la stratégie choisie + */ + fun resolve(conflict: Conflict, strategy: Strategy = Strategy.SERVER_WINS): Resolution { + Log.d(TAG, "Résolution conflit ${conflict.type} avec stratégie $strategy") + + return when (strategy) { + Strategy.SERVER_WINS -> resolveServerWins(conflict) + Strategy.CLIENT_WINS -> resolveClientWins(conflict) + Strategy.MERGE -> resolveMerge(conflict) + Strategy.MANUAL -> Resolution.RequiresManualResolution(conflict) + } + } + + /** + * Stratégie : le serveur a toujours raison + */ + private fun resolveServerWins(conflict: Conflict): Resolution { + return Resolution.UseServerVersion(conflict.serverLink.copy( + isPinned = conflict.localLink.isPinned, // Préserver les préférences locales + syncStatus = SyncStatus.SYNCED + )) + } + + /** + * Stratégie : le client a toujours raison + */ + private fun resolveClientWins(conflict: Conflict): Resolution { + return Resolution.UseLocalVersion(conflict.localLink.copy( + syncStatus = SyncStatus.PENDING_UPDATE + )) + } + + /** + * Stratégie : fusionner les deux versions + */ + private fun resolveMerge(conflict: Conflict): Resolution { + val local = conflict.localLink + val server = conflict.serverLink + + // Fusion intelligente : prendre le titre et description les plus récents + // Combiner les tags (union des deux ensembles) + val mergedTags = (local.tags + server.tags).distinct() + + val merged = local.copy( + title = if (local.updatedAt > server.updatedAt) local.title else server.title, + description = if (local.updatedAt > server.updatedAt) local.description else server.description, + tags = mergedTags, + isPrivate = server.isPrivate, // La visibilité est toujours celle du serveur + syncStatus = SyncStatus.PENDING_UPDATE + ) + + return Resolution.Merged(merged) + } + + /** + * Détecte si un conflit existe entre une version locale et serveur + */ + fun detectConflict(localLink: LinkEntity?, serverLink: LinkEntity?): Conflict? { + if (localLink == null || serverLink == null) return null + + // Si les timestamps sont identiques, pas de conflit + if (localLink.updatedAt == serverLink.updatedAt) return null + + // Si le lien local est en conflit + if (localLink.syncStatus == SyncStatus.CONFLICT) { + return Conflict( + localLink = localLink, + serverLink = serverLink, + type = ConflictType.BOTH_MODIFIED + ) + } + + return null + } +} + +/** + * Résultat d'une résolution de conflit + */ +sealed class Resolution { + data class UseServerVersion(val link: LinkEntity) : Resolution() + data class UseLocalVersion(val link: LinkEntity) : Resolution() + data class Merged(val link: LinkEntity) : Resolution() + data class RequiresManualResolution(val conflict: ConflictResolver.Conflict) : Resolution() + object DeleteLocal : Resolution() +} diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt new file mode 100644 index 0000000..a442684 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -0,0 +1,360 @@ +package com.shaarit.data.sync + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.shaarit.data.api.ShaarliApi +import com.shaarit.data.dto.CreateLinkDto +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.dao.TagDao +import com.shaarit.data.local.entity.LinkEntity +import com.shaarit.data.local.entity.SyncStatus +import com.shaarit.data.local.entity.TagEntity +import com.shaarit.data.mapper.LinkMapper +import com.shaarit.data.mapper.TagMapper +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Gestionnaire de synchronisation entre le cache local et le serveur Shaarli + */ +@Singleton +class SyncManager @Inject constructor( + @ApplicationContext private val context: Context, + private val linkDao: LinkDao, + private val tagDao: TagDao, + private val api: ShaarliApi +) { + companion object { + private const val TAG = "SyncManager" + private const val SYNC_WORK_NAME = "shaarli_sync_work" + } + + private val workManager = WorkManager.getInstance(context) + + /** + * État actuel de la synchronisation + */ + val syncState: Flow = workManager.getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME) + .map { workInfos -> + when { + workInfos.isEmpty() -> SyncState.Idle + workInfos.any { it.state == WorkInfo.State.RUNNING } -> SyncState.Syncing + workInfos.any { it.state == WorkInfo.State.FAILED } -> SyncState.Error( + workInfos.firstOrNull { it.state == WorkInfo.State.FAILED }?.outputData?.getString("error") + ?: "Unknown error" + ) + workInfos.any { it.state == WorkInfo.State.SUCCEEDED } -> { + val info = workInfos.firstOrNull { it.state == WorkInfo.State.SUCCEEDED } + val completedAt = info?.outputData?.getLong("completedAt", 0L) ?: 0L + SyncState.Synced(if (completedAt > 0L) completedAt else System.currentTimeMillis()) + } + workInfos.all { it.state.isFinished } -> SyncState.Idle + else -> SyncState.Idle + } + } + + /** + * Nombre d'éléments en attente de synchronisation + */ + val pendingSyncCount: Flow = linkDao.getUnsyncedCount() + + /** + * Déclenche une synchronisation manuelle + */ + fun syncNow() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val syncWorkRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag("sync") + .build() + + workManager.enqueueUniqueWork( + SYNC_WORK_NAME, + ExistingWorkPolicy.REPLACE, + syncWorkRequest + ) + } + + /** + * Annule toutes les synchronisations en cours + */ + fun cancelSync() { + workManager.cancelUniqueWork(SYNC_WORK_NAME) + } + + /** + * Synchronise immédiatement (appel synchrone - utiliser avec précaution) + */ + suspend fun performFullSync(): SyncResult = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Démarrage de la synchronisation complète...") + + // 1. Pousser les modifications locales vers le serveur + pushLocalChanges() + + // 2. Récupérer les données depuis le serveur + pullFromServer() + + Log.d(TAG, "Synchronisation terminée avec succès") + SyncResult.Success + } catch (e: HttpException) { + val code = e.code() + val details = try { + e.response()?.errorBody()?.string()?.take(500) + } catch (_: Exception) { + null + } + val message = buildString { + append("HTTP ") + append(code) + val base = e.message + if (!base.isNullOrBlank()) { + append(": ") + append(base) + } + if (!details.isNullOrBlank()) { + append(" | ") + append(details) + } + } + Log.e(TAG, "Erreur HTTP lors de la synchronisation", e) + SyncResult.Error(message) + } catch (e: IOException) { + Log.e(TAG, "Erreur réseau lors de la synchronisation", e) + SyncResult.NetworkError(e.message ?: "Network error") + } catch (e: Exception) { + Log.e(TAG, "Erreur lors de la synchronisation", e) + SyncResult.Error("${e::class.java.simpleName}: ${e.message}") + } + } + + /** + * Pousse les modifications locales (créations, mises à jour, suppressions) + */ + private suspend fun pushLocalChanges() { + // Traiter les créations en attente + val pendingCreates = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_CREATE) + Log.d(TAG, "${pendingCreates.size} créations en attente") + + for (link in pendingCreates) { + try { + val response = api.addLink( + CreateLinkDto( + url = link.url, + title = link.title.takeIf { it.isNotBlank() }, + description = link.description.takeIf { it.isNotBlank() }, + tags = link.tags.ifEmpty { null }, + isPrivate = link.isPrivate + ) + ) + + if (response.isSuccessful) { + response.body()?.let { serverLink -> + // Mettre à jour l'ID local avec l'ID serveur + val serverId = serverLink.id + if (serverId != null) { + val updatedLink = link.copy( + id = serverId, + syncStatus = SyncStatus.SYNCED + ) + linkDao.insertLink(updatedLink) + } else { + Log.w(TAG, "Serveur a retourné un lien sans ID pour ${link.url}") + } + } + } else { + Log.e(TAG, "Échec création lien ${link.id}: ${response.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "Exception lors de la création du lien ${link.id}", e) + } + } + + // Traiter les mises à jour en attente + val pendingUpdates = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_UPDATE) + Log.d(TAG, "${pendingUpdates.size} mises à jour en attente") + + for (link in pendingUpdates) { + try { + val response = api.updateLink( + link.id, + CreateLinkDto( + url = link.url, + title = link.title.takeIf { it.isNotBlank() }, + description = link.description.takeIf { it.isNotBlank() }, + tags = link.tags.ifEmpty { null }, + isPrivate = link.isPrivate + ) + ) + + if (response.isSuccessful) { + linkDao.markAsSynced(link.id) + } else { + Log.e(TAG, "Échec mise à jour lien ${link.id}: ${response.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "Exception lors de la mise à jour du lien ${link.id}", e) + } + } + + // Traiter les suppressions en attente + val pendingDeletes = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_DELETE) + Log.d(TAG, "${pendingDeletes.size} suppressions en attente") + + for (link in pendingDeletes) { + try { + val response = api.deleteLink(link.id) + + if (response.isSuccessful) { + linkDao.deleteLink(link.id) + } else { + Log.e(TAG, "Échec suppression lien ${link.id}: ${response.code()}") + } + } catch (e: Exception) { + Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e) + } + } + } + + /** + * Récupère les données depuis le serveur + */ + private suspend fun pullFromServer() { + var offset = 0 + val limit = 100 + var hasMore = true + + while (hasMore) { + try { + val links = api.getLinks(offset = offset, limit = limit) + Log.d(TAG, "Reçu ${links.size} liens (offset=$offset)") + + if (links.isEmpty()) { + hasMore = false + } else { + // Filtrer les liens invalides (sans ID ou URL) et convertir en entités + val validLinks = links.filter { dto -> + dto.id != null && !dto.url.isNullOrBlank() + } + Log.d(TAG, "${validLinks.size}/${links.size} liens valides") + + val entities = validLinks.mapNotNull { dto -> + try { + val existing = linkDao.getLinkById(dto.id!!) + LinkEntity( + id = dto.id, + url = dto.url!!, + title = dto.title ?: dto.url, + description = dto.description ?: "", + tags = dto.tags ?: emptyList(), + isPrivate = dto.isPrivate ?: false, + isPinned = existing?.isPinned ?: false, + createdAt = parseDate(dto.created), + updatedAt = parseDate(dto.updated), + syncStatus = SyncStatus.SYNCED, + thumbnailUrl = existing?.thumbnailUrl, + readingTimeMinutes = existing?.readingTimeMinutes, + contentType = existing?.contentType ?: com.shaarit.data.local.entity.ContentType.UNKNOWN, + siteName = existing?.siteName, + excerpt = existing?.excerpt + ) + } catch (e: Exception) { + Log.w(TAG, "Lien ignoré (id=${dto.id}): ${e.message}") + null + } + } + + if (entities.isNotEmpty()) { + linkDao.insertLinks(entities) + } + offset += links.size + } + } catch (e: Exception) { + Log.e(TAG, "Erreur lors de la récupération des liens", e) + throw e + } + } + + // Synchroniser les tags + try { + val tags = api.getTags(limit = 1000) + val tagEntities = tags.map { TagMapper.toDomain(it) }.map { TagEntity(it.name, it.occurrences) } + tagDao.insertTags(tagEntities) + } catch (e: Exception) { + Log.e(TAG, "Erreur lors de la récupération des tags", e) + } + } + + private fun parseDate(dateString: String?): Long { + if (dateString.isNullOrBlank()) return System.currentTimeMillis() + return try { + java.time.Instant.parse(dateString).toEpochMilli() + } catch (e: Exception) { + System.currentTimeMillis() + } + } +} + +/** + * Worker pour exécuter la synchronisation en arrière-plan + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncManager: SyncManager +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + return when (val result = syncManager.performFullSync()) { + is SyncResult.Success -> Result.success( + workDataOf("completedAt" to System.currentTimeMillis()) + ) + is SyncResult.NetworkError -> Result.retry() + is SyncResult.Error -> Result.failure( + workDataOf("error" to result.message) + ) + } + } +} + +/** + * États possibles de la synchronisation + */ +sealed class SyncState { + object Idle : SyncState() + object Syncing : SyncState() + data class Synced(val completedAt: Long) : SyncState() + data class Error(val message: String) : SyncState() +} + +/** + * Résultats possibles d'une synchronisation + */ +sealed class SyncResult { + object Success : SyncResult() + data class NetworkError(val message: String) : SyncResult() + data class Error(val message: String) : SyncResult() +} diff --git a/app/src/main/java/com/shaarit/domain/model/Models.kt b/app/src/main/java/com/shaarit/domain/model/Models.kt index b5e73f6..f8c4969 100644 --- a/app/src/main/java/com/shaarit/domain/model/Models.kt +++ b/app/src/main/java/com/shaarit/domain/model/Models.kt @@ -9,5 +9,10 @@ data class ShaarliLink( val description: String, val tags: List, val isPrivate: Boolean, - val date: String + val date: String, + val isPinned: Boolean = false, + val thumbnailUrl: String? = null, + val readingTime: Int? = null, + val contentType: String? = null, + val siteName: String? = null ) diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt index 4acfe84..737a741 100644 --- a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt @@ -17,6 +17,8 @@ interface LinkRepository { searchTags: String? = null ): Flow> + fun getLinkFlow(id: Int): Flow + suspend fun addLink( url: String, title: String?, diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt index d6349b2..80c4df8 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt @@ -6,30 +6,28 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.shaarit.ui.components.GlassCard -import com.shaarit.ui.components.GradientButton -import com.shaarit.ui.components.PremiumTextField -import com.shaarit.ui.components.SectionHeader -import com.shaarit.ui.components.TagChip +import coil.compose.AsyncImage +import com.shaarit.ui.components.* import com.shaarit.ui.theme.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddLinkScreen( - onNavigateBack: () -> Unit, + onNavigateBack: () -> Unit, onShareSuccess: (() -> Unit)? = null, viewModel: AddLinkViewModel = hiltViewModel() ) { @@ -42,13 +40,16 @@ fun AddLinkScreen( val availableTags by viewModel.availableTags.collectAsState() val isPrivate by viewModel.isPrivate.collectAsState() val tagSuggestions by viewModel.tagSuggestions.collectAsState() + val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState() + val extractedThumbnail by viewModel.extractedThumbnail.collectAsState() + val contentType by viewModel.contentType.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + var showMarkdownEditor by remember { mutableStateOf(false) } LaunchedEffect(uiState) { when (val state = uiState) { is AddLinkUiState.Success -> { - // If this was a share intent, finish the activity to return to source app if (onShareSuccess != null) { onShareSuccess() } else { @@ -58,9 +59,7 @@ fun AddLinkScreen( is AddLinkUiState.Error -> { snackbarHostState.showSnackbar(state.message) } - is AddLinkUiState.Conflict -> { - // Show conflict dialog - handled in AlertDialog below - } + is AddLinkUiState.Conflict -> {} else -> {} } } @@ -69,110 +68,161 @@ fun AddLinkScreen( if (uiState is AddLinkUiState.Conflict) { val conflict = uiState as AddLinkUiState.Conflict AlertDialog( - onDismissRequest = { viewModel.dismissConflict() }, - title = { - Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary) - }, - text = { - Column { - Text("A link with this URL already exists:", color = TextSecondary) - Spacer(modifier = Modifier.height(8.dp)) - Text( - conflict.existingTitle ?: "Untitled", - color = CyanPrimary, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - "Would you like to update the existing link instead?", - color = TextSecondary - ) - } - }, - confirmButton = { - TextButton(onClick = { viewModel.forceUpdateExistingLink() }) { - Text("Update", color = CyanPrimary) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.dismissConflict() }) { - Text("Cancel", color = TextMuted) - } - }, - containerColor = CardBackground, - titleContentColor = TextPrimary, - textContentColor = TextSecondary + onDismissRequest = { viewModel.dismissConflict() }, + title = { + Text("Lien déjà existant", fontWeight = FontWeight.Bold, color = TextPrimary) + }, + text = { + Column { + Text("Un lien avec cette URL existe déjà:", color = TextSecondary) + Spacer(modifier = Modifier.height(8.dp)) + Text( + conflict.existingTitle ?: "Sans titre", + color = CyanPrimary, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Voulez-vous mettre à jour le lien existant?", + color = TextSecondary + ) + } + }, + confirmButton = { + TextButton(onClick = { viewModel.forceUpdateExistingLink() }) { + Text("Mettre à jour", color = CyanPrimary) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissConflict() }) { + Text("Annuler", color = TextMuted) + } + }, + containerColor = CardBackground, + titleContentColor = TextPrimary, + textContentColor = TextSecondary ) } Box( - modifier = - Modifier.fillMaxSize() - .background( - brush = - Brush.verticalGradient( - colors = listOf(DeepNavy, DarkNavy) - ) - ) + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy)) + ) ) { Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { - Text( - "Add Link", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back", - tint = TextPrimary - ) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = DeepNavy.copy(alpha = 0.9f), - titleContentColor = TextPrimary - ) - ) - }, - containerColor = - android.graphics.Color.TRANSPARENT.let { - androidx.compose.ui.graphics.Color.Transparent + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "Ajouter un lien", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Retour", + tint = TextPrimary + ) } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DeepNavy.copy(alpha = 0.9f), + titleContentColor = TextPrimary + ) + ) + }, + containerColor = androidx.compose.ui.graphics.Color.Transparent ) { paddingValues -> Column( - modifier = - Modifier.padding(paddingValues) - .padding(16.dp) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(20.dp) + modifier = Modifier + .padding(paddingValues) + .padding(16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // URL Section + // URL Section avec extraction de métadonnées GlassCard(modifier = Modifier.fillMaxWidth()) { Column { - SectionHeader(title = "URL", subtitle = "Required") + SectionHeader(title = "URL", subtitle = "Requis") Spacer(modifier = Modifier.height(12.dp)) + PremiumTextField( - value = url, - onValueChange = { viewModel.url.value = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "https://example.com", - leadingIcon = { - Icon( - Icons.Default.Share, - contentDescription = null, - tint = CyanPrimary + value = url, + onValueChange = { viewModel.url.value = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "https://example.com", + leadingIcon = { + Icon( + Icons.Default.Link, + contentDescription = null, + tint = CyanPrimary + ) + }, + trailingIcon = { + if (isExtractingMetadata) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = CyanPrimary, + strokeWidth = 2.dp ) } + } ) + + // Aperçu des métadonnées extraites + AnimatedVisibility( + visible = extractedThumbnail != null || contentType != null, + enter = expandVertically() + fadeIn() + ) { + Column(modifier = Modifier.padding(top = 16.dp)) { + // Type de contenu détecté + contentType?.let { type -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp) + ) { + Icon( + imageVector = when (type) { + "VIDEO" -> Icons.Default.PlayCircle + "ARTICLE" -> Icons.Default.Article + "PODCAST" -> Icons.Default.Headphones + "REPOSITORY" -> Icons.Default.Code + else -> Icons.Default.Web + }, + contentDescription = null, + tint = CyanPrimary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Type: $type", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary + ) + } + } + + // Thumbnail extrait + extractedThumbnail?.let { thumbnail -> + AsyncImage( + model = thumbnail, + contentDescription = "Aperçu", + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + } + } } } @@ -180,53 +230,87 @@ fun AddLinkScreen( GlassCard(modifier = Modifier.fillMaxWidth()) { Column { SectionHeader( - title = "Title", - subtitle = "Optional - auto-fetched if empty" + title = "Titre", + subtitle = "Optionnel - auto-extrait si vide" ) Spacer(modifier = Modifier.height(12.dp)) PremiumTextField( - value = title, - onValueChange = { viewModel.title.value = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "Page title" + value = title, + onValueChange = { viewModel.title.value = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "Titre de la page" ) } } - // Description Section + // Description Section avec MarkdownEditor GlassCard(modifier = Modifier.fillMaxWidth()) { Column { - SectionHeader(title = "Description", subtitle = "Optional") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SectionHeader(title = "Description", subtitle = "Markdown supporté") + + // Toggle pour l'éditeur Markdown + TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) { + Icon( + if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview, + contentDescription = null, + tint = CyanPrimary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + if (showMarkdownEditor) "Simple" else "Markdown", + color = CyanPrimary + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) - PremiumTextField( + + if (showMarkdownEditor) { + // Éditeur Markdown avancé + MarkdownEditor( value = description, onValueChange = { viewModel.description.value = it }, modifier = Modifier.fillMaxWidth(), - placeholder = "Add a description...", + mode = EditorMode.SPLIT, + minHeight = 200.dp + ) + } else { + // Champ texte simple + PremiumTextField( + value = description, + onValueChange = { viewModel.description.value = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "Ajoutez une description...", singleLine = false, minLines = 3 - ) + ) + } } } // Tags Section GlassCard(modifier = Modifier.fillMaxWidth()) { Column { - SectionHeader(title = "Tags", subtitle = "Organize your links") + SectionHeader(title = "Tags", subtitle = "Organisez vos liens") Spacer(modifier = Modifier.height(12.dp)) // Selected tags if (selectedTags.isNotEmpty()) { LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 12.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 12.dp) ) { items(selectedTags) { tag -> TagChip( - tag = tag, - isSelected = true, - onClick = { viewModel.removeTag(tag) } + tag = tag, + isSelected = true, + onClick = { viewModel.removeTag(tag) } ) } } @@ -234,76 +318,74 @@ fun AddLinkScreen( // New tag input Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { PremiumTextField( - value = newTagInput, - onValueChange = { viewModel.onNewTagInputChanged(it) }, - modifier = Modifier.weight(1f), - placeholder = "Add tag..." + value = newTagInput, + onValueChange = { viewModel.onNewTagInputChanged(it) }, + modifier = Modifier.weight(1f), + placeholder = "Ajouter un tag..." ) IconButton( - onClick = { viewModel.addNewTag() }, - enabled = newTagInput.isNotBlank() + onClick = { viewModel.addNewTag() }, + enabled = newTagInput.isNotBlank() ) { Icon( - Icons.Default.Add, - contentDescription = "Add tag", - tint = - if (newTagInput.isNotBlank()) CyanPrimary - else TextMuted + Icons.Default.Add, + contentDescription = "Ajouter tag", + tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted ) } } // Tag suggestions AnimatedVisibility( - visible = tagSuggestions.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + visible = tagSuggestions.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() ) { Column(modifier = Modifier.padding(top = 12.dp)) { Text( - "Suggestions", - style = MaterialTheme.typography.labelMedium, - color = TextMuted, - modifier = Modifier.padding(bottom = 8.dp) + "Suggestions", + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + modifier = Modifier.padding(bottom = 8.dp) ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(tagSuggestions.take(10)) { tag -> TagChip( - tag = tag.name, - isSelected = false, - onClick = { viewModel.addTag(tag.name) }, - count = tag.occurrences + tag = tag.name, + isSelected = false, + onClick = { viewModel.addTag(tag.name) }, + count = tag.occurrences ) } } } } - // Popular tags from existing + // Popular tags if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) { Column(modifier = Modifier.padding(top = 12.dp)) { Text( - "Popular tags", - style = MaterialTheme.typography.labelMedium, - color = TextMuted, - modifier = Modifier.padding(bottom = 8.dp) + "Tags populaires", + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + modifier = Modifier.padding(bottom = 8.dp) ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items( - availableTags - .filter { it.name !in selectedTags } - .take(10) + availableTags + .filter { it.name !in selectedTags } + .take(10) ) { tag -> TagChip( - tag = tag.name, - isSelected = false, - onClick = { viewModel.addTag(tag.name) }, - count = tag.occurrences + tag = tag.name, + isSelected = false, + onClick = { viewModel.addTag(tag.name) }, + count = tag.occurrences ) } } @@ -315,33 +397,32 @@ fun AddLinkScreen( // Privacy Section GlassCard(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column { Text( - "Private", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = TextPrimary + "Privé", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = TextPrimary ) Text( - "Only you can see this link", - style = MaterialTheme.typography.bodySmall, - color = TextSecondary + "Seul vous pouvez voir ce lien", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary ) } Switch( - checked = isPrivate, - onCheckedChange = { viewModel.isPrivate.value = it }, - colors = - SwitchDefaults.colors( - checkedThumbColor = CyanPrimary, - checkedTrackColor = CyanPrimary.copy(alpha = 0.3f), - uncheckedThumbColor = TextMuted, - uncheckedTrackColor = SurfaceVariant - ) + checked = isPrivate, + onCheckedChange = { viewModel.isPrivate.value = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = CyanPrimary, + checkedTrackColor = CyanPrimary.copy(alpha = 0.3f), + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = SurfaceVariant + ) ) } } @@ -350,17 +431,17 @@ fun AddLinkScreen( // Save Button GradientButton( - text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link", - onClick = { viewModel.addLink() }, - modifier = Modifier.fillMaxWidth(), - enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading + text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien", + onClick = { viewModel.addLink() }, + modifier = Modifier.fillMaxWidth(), + enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading ) if (uiState is AddLinkUiState.Loading) { LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = CyanPrimary, - trackColor = SurfaceVariant + modifier = Modifier.fillMaxWidth(), + color = CyanPrimary, + trackColor = SurfaceVariant ) } diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt index b498473..554d3a0 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -3,21 +3,29 @@ package com.shaarit.presentation.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.shaarit.data.metadata.LinkMetadataExtractor import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.repository.AddLinkResult import com.shaarit.domain.repository.LinkRepository import dagger.hilt.android.lifecycle.HiltViewModel -import java.net.URLDecoder -import javax.inject.Inject +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.net.URLDecoder +import javax.inject.Inject @HiltViewModel class AddLinkViewModel @Inject -constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedStateHandle) : - ViewModel() { +constructor( + private val linkRepository: LinkRepository, + private val metadataExtractor: LinkMetadataExtractor, + savedStateHandle: SavedStateHandle +) : ViewModel() { // Pre-fill from usage arguments (e.g. from Share Intent via NavGraph) private val initialUrl: String? = savedStateHandle["url"] @@ -30,6 +38,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "") var description = MutableStateFlow("") var isPrivate = MutableStateFlow(false) + + // Extraction state + private val _isExtractingMetadata = MutableStateFlow(false) + val isExtractingMetadata = _isExtractingMetadata.asStateFlow() + + private val _extractedThumbnail = MutableStateFlow(null) + val extractedThumbnail = _extractedThumbnail.asStateFlow() + + private val _contentType = MutableStateFlow(null) + val contentType = _contentType.asStateFlow() // New tag management private val _selectedTags = MutableStateFlow>(emptyList()) @@ -49,17 +67,75 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS init { loadAvailableTags() + setupUrlMetadataExtraction() + + // Si une URL initiale est fournie, extraire les métadonnées + if (!initialUrl.isNullOrBlank()) { + extractMetadata(initialUrl) + } + } + + @OptIn(FlowPreview::class) + private fun setupUrlMetadataExtraction() { + url + .debounce(1000) // Attendre 1s après la fin de la saisie + .onEach { urlString -> + if (urlString.isNotBlank() && urlString.startsWith("http")) { + extractMetadata(urlString) + } + } + .launchIn(viewModelScope) + } + + private fun extractMetadata(urlString: String) { + viewModelScope.launch { + _isExtractingMetadata.value = true + try { + val metadata = metadataExtractor.extract(urlString) + + // Auto-remplir si les champs sont vides + if (title.value.isBlank() && !metadata.title.isNullOrBlank()) { + title.value = metadata.title + } + if (description.value.isBlank() && !metadata.description.isNullOrBlank()) { + description.value = metadata.description + } + + _extractedThumbnail.value = metadata.thumbnailUrl + _contentType.value = metadata.contentType.name + + // Suggérer des tags basés sur le site + metadata.siteName?.let { site -> + suggestTagFromSite(site) + } + } catch (e: Exception) { + // Ignorer silencieusement les erreurs d'extraction + } finally { + _isExtractingMetadata.value = false + } + } + } + + private fun suggestTagFromSite(siteName: String) { + val siteTag = siteName.lowercase().replace(" ", "-").replace(".", "") + if (siteTag !in _selectedTags.value) { + // Ajouter automatiquement certains tags connus + when (siteTag) { + "youtube", "vimeo" -> addTag("video") + "github", "gitlab" -> addTag("dev") + "twitter", "x" -> addTag("social") + "reddit" -> addTag("reddit") + "medium", "devto" -> addTag("article") + } + } } /** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */ private fun decodeUrlParam(param: String?): String? { if (param.isNullOrBlank()) return null return try { - // First decode URL encoding, then replace + with spaces - // The + signs appear because URLEncoder uses + for spaces URLDecoder.decode(param, "UTF-8").replace("+", " ").trim() } catch (e: Exception) { - // If decoding fails, just replace + with spaces param.replace("+", " ").trim() } } @@ -67,15 +143,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS private fun loadAvailableTags() { viewModelScope.launch { linkRepository - .getTags() - .fold( - onSuccess = { tags -> - _availableTags.value = tags.sortedByDescending { it.occurrences } - }, - onFailure = { - // Silently fail - tags are optional - } - ) + .getTags() + .fold( + onSuccess = { tags -> + _availableTags.value = tags.sortedByDescending { it.occurrences } + }, + onFailure = { + // Silently fail - tags are optional + } + ) } } @@ -92,13 +168,13 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS val queryLower = query.lowercase() _tagSuggestions.value = - _availableTags - .value - .filter { - it.name.lowercase().contains(queryLower) && - it.name !in _selectedTags.value - } - .take(10) + _availableTags + .value + .filter { + it.name.lowercase().contains(queryLower) && + it.name !in _selectedTags.value + } + .take(10) } fun addTag(tag: String) { @@ -122,7 +198,6 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS viewModelScope.launch { _uiState.value = AddLinkUiState.Loading - // Basic validation val currentUrl = url.value if (currentUrl.isBlank()) { _uiState.value = AddLinkUiState.Error("URL is required") @@ -130,15 +205,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS } val result = - linkRepository.addOrUpdateLink( - url = currentUrl, - title = title.value.ifBlank { null }, - description = description.value.ifBlank { null }, - tags = _selectedTags.value.ifEmpty { null }, - isPrivate = isPrivate.value, - forceUpdate = false, - existingLinkId = null - ) + linkRepository.addOrUpdateLink( + url = currentUrl, + title = title.value.ifBlank { null }, + description = description.value.ifBlank { null }, + tags = _selectedTags.value.ifEmpty { null }, + isPrivate = isPrivate.value, + forceUpdate = false, + existingLinkId = null + ) when (result) { is AddLinkResult.Success -> { @@ -147,10 +222,10 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS is AddLinkResult.Conflict -> { conflictLinkId = result.existingLinkId _uiState.value = - AddLinkUiState.Conflict( - existingLinkId = result.existingLinkId, - existingTitle = result.existingTitle - ) + AddLinkUiState.Conflict( + result.existingLinkId, + result.existingTitle + ) } is AddLinkResult.Error -> { _uiState.value = AddLinkUiState.Error(result.message) @@ -160,21 +235,25 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS } fun forceUpdateExistingLink() { - val linkId = conflictLinkId ?: return - viewModelScope.launch { + val currentUrl = url.value + if (currentUrl.isBlank()) { + _uiState.value = AddLinkUiState.Error("URL is required") + return@launch + } + _uiState.value = AddLinkUiState.Loading val result = - linkRepository.addOrUpdateLink( - url = url.value, - title = title.value.ifBlank { null }, - description = description.value.ifBlank { null }, - tags = _selectedTags.value.ifEmpty { null }, - isPrivate = isPrivate.value, - forceUpdate = true, - existingLinkId = linkId - ) + linkRepository.addOrUpdateLink( + url = currentUrl, + title = title.value.ifBlank { null }, + description = description.value.ifBlank { null }, + tags = _selectedTags.value.ifEmpty { null }, + isPrivate = isPrivate.value, + forceUpdate = true, + existingLinkId = conflictLinkId + ) when (result) { is AddLinkResult.Success -> { @@ -184,19 +263,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS _uiState.value = AddLinkUiState.Error(result.message) } else -> { - _uiState.value = AddLinkUiState.Error("Unexpected error") + _uiState.value = AddLinkUiState.Error("Unexpected result") } } } } fun dismissConflict() { - conflictLinkId = null _uiState.value = AddLinkUiState.Idle + conflictLinkId = null } - - // Legacy compatibility for old comma-separated tags input - @Deprecated("Use selectedTags instead") var tags = MutableStateFlow("") } sealed class AddLinkUiState { diff --git a/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt b/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt index 53cc4db..97dbdbf 100644 --- a/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt @@ -26,7 +26,7 @@ fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltView val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(viewModel.getInitialUrl()) } var secret by remember { mutableStateOf("") } var showSecret by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt b/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt index b0edb36..2d8b18b 100644 --- a/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt @@ -2,6 +2,7 @@ package com.shaarit.presentation.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.shaarit.data.sync.SyncManager import com.shaarit.domain.repository.AuthRepository import com.shaarit.domain.usecase.LoginUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,7 +16,8 @@ class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase, - private val authRepository: AuthRepository // To check login state + private val authRepository: AuthRepository, // To check login state + private val syncManager: SyncManager ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState.Idle) @@ -27,6 +29,7 @@ constructor( private fun checkLoginStatus() { if (authRepository.isLoggedIn()) { + syncManager.syncNow() _uiState.value = LoginUiState.Success } else { // Pre-fill URL if available @@ -42,13 +45,20 @@ constructor( _uiState.value = LoginUiState.Loading val result = loginUseCase(url, secret) result.fold( - onSuccess = { _uiState.value = LoginUiState.Success }, + onSuccess = { + syncManager.syncNow() + _uiState.value = LoginUiState.Success + }, onFailure = { _uiState.value = LoginUiState.Error(it.message ?: "Unknown Error") } ) } } + + fun getInitialUrl(): String { + return authRepository.getBaseUrl() ?: "https://bm.dracodev.net" + } } sealed class LoginUiState { diff --git a/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt new file mode 100644 index 0000000..8c95845 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt @@ -0,0 +1,435 @@ +package com.shaarit.presentation.collections + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.shaarit.ui.components.GlassCard +import com.shaarit.ui.theme.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollectionsScreen( + onNavigateBack: () -> Unit, + onCollectionClick: (Long) -> Unit, + viewModel: CollectionsViewModel = hiltViewModel() +) { + val collections by viewModel.collections.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + var showCreateDialog by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy)) + ) + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + "Collections", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Retour", + tint = TextPrimary + ) + } + }, + actions = { + IconButton(onClick = { showCreateDialog = true }) { + Icon( + Icons.Default.Add, + contentDescription = "Nouvelle collection", + tint = CyanPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DeepNavy.copy(alpha = 0.9f), + titleContentColor = TextPrimary + ) + ) + }, + containerColor = androidx.compose.ui.graphics.Color.Transparent, + floatingActionButton = { + FloatingActionButton( + onClick = { showCreateDialog = true }, + containerColor = CyanPrimary, + contentColor = DeepNavy + ) { + Icon(Icons.Default.Add, contentDescription = "Nouvelle collection") + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = CyanPrimary + ) + } else if (collections.isEmpty()) { + EmptyCollectionsView(onCreateClick = { showCreateDialog = true }) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(collections) { collection -> + CollectionCard( + collection = collection, + onClick = { onCollectionClick(collection.id) } + ) + } + } + } + } + } + } + + // Dialog de création + if (showCreateDialog) { + CreateCollectionDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name, description, icon, isSmart -> + viewModel.createCollection(name, description, icon, isSmart) + showCreateDialog = false + } + ) + } +} + +@Composable +private fun CollectionCard( + collection: CollectionUiModel, + onClick: () -> Unit +) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + // Icône et type + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = collection.icon, + fontSize = MaterialTheme.typography.headlineMedium.fontSize + ) + + if (collection.isSmart) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = "Collection intelligente", + tint = CyanPrimary, + modifier = Modifier.size(20.dp) + ) + } + } + + // Nom et description + Column { + Text( + text = collection.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + collection.description?.let { desc -> + if (desc.isNotBlank()) { + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + + // Nombre de liens + Text( + text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = CyanPrimary, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } +} + +@Composable +private fun EmptyCollectionsView(onCreateClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = TextMuted, + modifier = Modifier.size(80.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Aucune collection", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Créez des collections pour organiser vos liens par thème", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onCreateClick, + colors = ButtonDefaults.buttonColors( + containerColor = CyanPrimary, + contentColor = DeepNavy + ) + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Créer une collection") + } + } +} + +@Composable +private fun CreateCollectionDialog( + onDismiss: () -> Unit, + onCreate: (name: String, description: String, icon: String, isSmart: Boolean) -> Unit +) { + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedIcon by remember { mutableStateOf("📁") } + var isSmart by remember { mutableStateOf(false) } + + val icons = listOf("📁", "💼", "🏠", "📚", "⭐", "🔥", "💡", "🎯", "📰", "🎬", "🎮", "🛒") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Nouvelle collection") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Nom + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Nom") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Description + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (optionnel)") }, + minLines = 2, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + + // Icône + Text("Icône", style = MaterialTheme.typography.labelMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + icons.forEach { icon -> + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (icon == selectedIcon) CyanPrimary.copy(alpha = 0.2f) + else CardBackgroundElevated + ) + .clickable { selectedIcon = icon }, + contentAlignment = Alignment.Center + ) { + Text(icon, fontSize = MaterialTheme.typography.titleMedium.fontSize) + } + } + } + + // Collection intelligente + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Collection intelligente", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Remplie automatiquement selon des critères", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary + ) + } + Switch( + checked = isSmart, + onCheckedChange = { isSmart = it } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onCreate(name, description, selectedIcon, isSmart) }, + enabled = name.isNotBlank() + ) { + Text("Créer") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Annuler") + } + } + ) +} + +// Modèle de données UI +data class CollectionUiModel( + val id: Long, + val name: String, + val description: String?, + val icon: String, + val isSmart: Boolean, + val linkCount: Int +) + +// Layout helper +@Composable +private fun FlowRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: @Composable () -> Unit +) { + Layout( + content = content, + modifier = modifier + ) { measurables, constraints -> + val hGapPx = 8.dp.roundToPx() + val vGapPx = 8.dp.roundToPx() + + val rows = mutableListOf>() + val rowWidths = mutableListOf() + val rowHeights = mutableListOf() + + var currentRow = mutableListOf() + var currentRowWidth = 0 + var currentRowHeight = 0 + + measurables.forEach { measurable -> + val placeable = measurable.measure(constraints) + + if (currentRow.isNotEmpty() && currentRowWidth + hGapPx + placeable.width > constraints.maxWidth) { + rows.add(currentRow) + rowWidths.add(currentRowWidth) + rowHeights.add(currentRowHeight) + currentRow = mutableListOf() + currentRowWidth = 0 + currentRowHeight = 0 + } + + currentRow.add(placeable) + currentRowWidth += if (currentRow.size == 1) placeable.width else hGapPx + placeable.width + currentRowHeight = maxOf(currentRowHeight, placeable.height) + } + + if (currentRow.isNotEmpty()) { + rows.add(currentRow) + rowWidths.add(currentRowWidth) + rowHeights.add(currentRowHeight) + } + + val totalHeight = rowHeights.sum() + (rowHeights.size - 1).coerceAtLeast(0) * vGapPx + + layout(constraints.maxWidth, totalHeight) { + var y = 0 + rows.forEachIndexed { rowIndex, row -> + var x = when (horizontalArrangement) { + Arrangement.Center -> (constraints.maxWidth - rowWidths[rowIndex]) / 2 + Arrangement.End -> constraints.maxWidth - rowWidths[rowIndex] + else -> 0 + } + + row.forEach { placeable -> + placeable.placeRelative(x, y) + x += placeable.width + hGapPx + } + + y += rowHeights[rowIndex] + vGapPx + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt b/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt new file mode 100644 index 0000000..f0c688b --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt @@ -0,0 +1,77 @@ +package com.shaarit.presentation.collections + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shaarit.data.local.dao.CollectionDao +import com.shaarit.data.local.entity.CollectionEntity +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CollectionsViewModel @Inject constructor( + private val collectionDao: CollectionDao +) : ViewModel() { + + private val _collections = MutableStateFlow>(emptyList()) + val collections: StateFlow> = _collections.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadCollections() + } + + private fun loadCollections() { + viewModelScope.launch { + _isLoading.value = true + try { + collectionDao.getAllCollections() + .map { entities -> + entities.map { entity -> + // Compter les liens dans chaque collection + val count = 0 // TODO: Implémenter le comptage + entity.toUiModel(count) + } + } + .collect { uiModels -> + _collections.value = uiModels + _isLoading.value = false + } + } catch (e: Exception) { + _isLoading.value = false + } + } + } + + fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean) { + viewModelScope.launch { + val entity = CollectionEntity( + name = name, + description = description, + icon = icon, + isSmart = isSmart + ) + collectionDao.insertCollection(entity) + } + } + + fun deleteCollection(id: Long) { + viewModelScope.launch { + collectionDao.deleteCollection(id) + } + } + + private fun CollectionEntity.toUiModel(linkCount: Int): CollectionUiModel { + return CollectionUiModel( + id = id, + name = name, + description = description, + icon = icon, + isSmart = isSmart, + linkCount = linkCount + ) + } +} diff --git a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..999e67e --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt @@ -0,0 +1,474 @@ +package com.shaarit.presentation.dashboard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.shaarit.data.local.entity.ContentType +import java.text.NumberFormat +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + onNavigateBack: () -> Unit, + viewModel: DashboardViewModel = hiltViewModel() +) { + val stats by viewModel.stats.collectAsState() + val tagStats by viewModel.tagStats.collectAsState() + val contentTypeStats by viewModel.contentTypeStats.collectAsState() + val activityData by viewModel.activityData.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Tableau de bord") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Retour") + } + }, + actions = { + IconButton(onClick = { viewModel.refreshStats() }) { + Icon(Icons.Default.Refresh, contentDescription = "Rafraîchir") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Stats Cards Row + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatCard( + title = "Total", + value = formatNumber(stats.totalLinks), + icon = Icons.Default.Bookmark, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Cette semaine", + value = formatNumber(stats.linksThisWeek), + icon = Icons.Default.Today, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Ce mois", + value = formatNumber(stats.linksThisMonth), + icon = Icons.Default.DateRange, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f) + ) + } + } + + // Reading Time Stats + item { + ReadingTimeCard( + totalReadingTimeMinutes = stats.totalReadingTimeMinutes, + averageReadingTimeMinutes = stats.averageReadingTimeMinutes + ) + } + + // Content Type Distribution + item { + ContentTypeCard(contentTypeStats) + } + + // Top Tags + item { + TopTagsCard(tagStats) + } + + // Activity Overview + item { + ActivityCard(activityData) + } + } + } +} + +@Composable +private fun StatCard( + title: String, + value: String, + icon: ImageVector, + color: Color, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ReadingTimeCard( + totalReadingTimeMinutes: Int, + averageReadingTimeMinutes: Int +) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Statistiques de lecture", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ReadingTimeItem( + label = "Temps total", + minutes = totalReadingTimeMinutes, + icon = Icons.Default.Schedule + ) + ReadingTimeItem( + label = "Moyenne/article", + minutes = averageReadingTimeMinutes, + icon = Icons.Default.Timer + ) + } + } + } +} + +@Composable +private fun ReadingTimeItem( + label: String, + minutes: Int, + icon: ImageVector +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatDuration(minutes), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ContentTypeCard( + contentTypeStats: Map +) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Liens par type", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + + val total = contentTypeStats.values.sum().coerceAtLeast(1) + + for ((type, count) in contentTypeStats.toList().sortedByDescending { it.second }) { + val percentage = (count * 100) / total + ContentTypeBar( + type = type, + count = count, + percentage = percentage + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun ContentTypeBar( + type: ContentType, + count: Int, + percentage: Int +) { + val (icon, label, color) = when (type) { + ContentType.ARTICLE -> Triple(Icons.Default.Article, "Article", Color(0xFF4CAF50)) + ContentType.VIDEO -> Triple(Icons.Default.PlayCircle, "Vidéo", Color(0xFFF44336)) + ContentType.IMAGE -> Triple(Icons.Default.Image, "Image", Color(0xFF9C27B0)) + ContentType.PODCAST -> Triple(Icons.Default.Audiotrack, "Podcast", Color(0xFFFF9800)) + ContentType.PDF -> Triple(Icons.Default.PictureAsPdf, "PDF", Color(0xFFE91E63)) + ContentType.REPOSITORY -> Triple(Icons.Default.Code, "Code", Color(0xFF607D8B)) + ContentType.DOCUMENT -> Triple(Icons.Default.Description, "Document", Color(0xFF795548)) + ContentType.SOCIAL -> Triple(Icons.Default.Share, "Social", Color(0xFF03A9F4)) + ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3)) + ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0)) + ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E)) + } + + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Text( + text = "$count ($percentage%)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = percentage / 100f, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = color.copy(alpha = 0.2f) + ) + } +} + +@Composable +private fun TopTagsCard( + tagStats: List +) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Tags les plus utilisés", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (tagStats.isEmpty()) { + Text( + text = "Aucun tag pour le moment", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + tagStats.take(10).forEach { stat -> + TagStatRow(stat) + if (stat != tagStats.take(10).last()) { + Divider(modifier = Modifier.padding(vertical = 8.dp)) + } + } + } + } + } +} + +@Composable +private fun TagStatRow(stat: TagStat) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.small + ) { + Text( + text = stat.name, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${stat.count} lien${if (stat.count > 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ActivityCard( + activityData: List +) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Activité (30 derniers jours)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (activityData.isEmpty()) { + Text( + text = "Pas d'activité récente", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + ActivityChart(activityData) + } + } + } +} + +@Composable +private fun ActivityChart( + data: List +) { + val maxValue = data.maxOfOrNull { it.count }?.coerceAtLeast(1) ?: 1 + + Row( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + data.takeLast(14).forEach { point -> + val heightFraction = point.count.toFloat() / maxValue + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight(heightFraction.coerceAtLeast(0.05f)) + .background( + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.extraSmall + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = point.day.substring(0, 1), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +// Data classes +@Immutable +data class DashboardStats( + val totalLinks: Int = 0, + val linksThisWeek: Int = 0, + val linksThisMonth: Int = 0, + val totalReadingTimeMinutes: Int = 0, + val averageReadingTimeMinutes: Int = 0 +) + +@Immutable +data class TagStat( + val name: String, + val count: Int +) + +@Immutable +data class ActivityPoint( + val day: String, + val count: Int +) + +// Helper functions +private fun formatNumber(number: Int): String { + return NumberFormat.getInstance().format(number) +} + +private fun formatDuration(minutes: Int): String { + return when { + minutes < 60 -> "${minutes}m" + minutes < 1440 -> { + val hours = minutes / 60 + val mins = minutes % 60 + if (mins > 0) "${hours}h ${mins}m" else "${hours}h" + } + else -> { + val days = minutes / 1440 + "${days}j" + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardViewModel.kt b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..e694f84 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardViewModel.kt @@ -0,0 +1,139 @@ +package com.shaarit.presentation.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.dao.TagDao +import com.shaarit.data.local.entity.ContentType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val linkDao: LinkDao, + private val tagDao: TagDao +) : ViewModel() { + + private val _stats = MutableStateFlow(DashboardStats()) + val stats: StateFlow = _stats.asStateFlow() + + private val _tagStats = MutableStateFlow>(emptyList()) + val tagStats: StateFlow> = _tagStats.asStateFlow() + + private val _contentTypeStats = MutableStateFlow>(emptyMap()) + val contentTypeStats: StateFlow> = _contentTypeStats.asStateFlow() + + private val _activityData = MutableStateFlow>(emptyList()) + val activityData: StateFlow> = _activityData.asStateFlow() + + init { + refreshStats() + } + + fun refreshStats() { + viewModelScope.launch { + loadStats() + loadTagStats() + loadContentTypeStats() + loadActivityData() + } + } + + private suspend fun loadStats() { + val allLinks = linkDao.getAllLinksForStats() + + val calendar = Calendar.getInstance() + val now = calendar.timeInMillis + + // This week + calendar.add(Calendar.DAY_OF_YEAR, -7) + val weekAgo = calendar.timeInMillis + + // This month + calendar.timeInMillis = now + calendar.add(Calendar.DAY_OF_YEAR, -30) + val monthAgo = calendar.timeInMillis + + val linksThisWeek = allLinks.count { it.createdAt > weekAgo } + val linksThisMonth = allLinks.count { it.createdAt > monthAgo } + + val totalReadingTime = allLinks.sumOf { it.readingTimeMinutes ?: 0 } + val averageReadingTime = if (allLinks.isNotEmpty()) { + totalReadingTime / allLinks.size + } else 0 + + _stats.value = DashboardStats( + totalLinks = allLinks.size, + linksThisWeek = linksThisWeek, + linksThisMonth = linksThisMonth, + totalReadingTimeMinutes = totalReadingTime, + averageReadingTimeMinutes = averageReadingTime + ) + } + + private suspend fun loadTagStats() { + val tags = tagDao.getAllTagsOnce() + _tagStats.value = tags + .sortedByDescending { it.occurrences } + .take(20) + .map { TagStat(it.name, it.occurrences) } + } + + private suspend fun loadContentTypeStats() { + val links = linkDao.getAllLinksForStats() + val grouped = links.groupBy { it.contentType } + .mapValues { it.value.size } + .toMutableMap() + + // Ensure all content types are represented + ContentType.values().forEach { type -> + if (!grouped.containsKey(type)) { + grouped[type] = 0 + } + } + + _contentTypeStats.value = grouped + } + + private suspend fun loadActivityData() { + val links = linkDao.getAllLinksForStats() + val dateFormat = SimpleDateFormat("EEE", Locale.getDefault()) + val dayFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + + // Group by day for the last 30 days + val calendar = Calendar.getInstance() + val activityMap = mutableMapOf() + + // Initialize all days with 0 + for (i in 0 until 30) { + calendar.timeInMillis = System.currentTimeMillis() + calendar.add(Calendar.DAY_OF_YEAR, -i) + val dayKey = dayFormat.format(calendar.time) + activityMap[dayKey] = 0 + } + + // Count links per day + links.forEach { link -> + val dayKey = dayFormat.format(Date(link.createdAt)) + if (activityMap.containsKey(dayKey)) { + activityMap[dayKey] = activityMap.getOrDefault(dayKey, 0) + 1 + } + } + + // Convert to sorted list + _activityData.value = activityMap + .toList() + .sortedBy { it.first } + .map { (day, count) -> + val date = dayFormat.parse(day)!! + ActivityPoint( + day = dateFormat.format(date), + count = count + ) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 5e73147..a0ee483 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -37,6 +37,9 @@ fun FeedScreen( onNavigateToAdd: () -> Unit, onNavigateToEdit: (Int) -> Unit = {}, onNavigateToTags: () -> Unit = {}, + onNavigateToCollections: () -> Unit = {}, + onNavigateToSettings: () -> Unit = {}, + onNavigateToRandom: () -> Unit = {}, initialTagFilter: String? = null, viewModel: FeedViewModel = hiltViewModel() ) { @@ -54,7 +57,10 @@ fun FeedScreen( val pullRefreshState = rememberPullRefreshState( refreshing = pagingItems.loadState.refresh is LoadState.Loading, - onRefresh = { pagingItems.refresh() } + onRefresh = { + viewModel.refresh() + pagingItems.refresh() + } ) Box( @@ -80,7 +86,10 @@ fun FeedScreen( }, actions = { // Refresh Button - IconButton(onClick = { pagingItems.refresh() }) { + IconButton(onClick = { + viewModel.refresh() + pagingItems.refresh() + }) { Icon( imageVector = Icons.Default.Refresh, contentDescription = "Refresh", @@ -176,13 +185,39 @@ fun FeedScreen( } } + // Random button + IconButton(onClick = onNavigateToRandom) { + Icon( + imageVector = Icons.Default.Shuffle, + contentDescription = "Random link", + tint = CyanPrimary + ) + } + + // Collections button + IconButton(onClick = onNavigateToCollections) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "Collections", + tint = CyanPrimary + ) + } + // Tags button - TextButton(onClick = onNavigateToTags) { - Text( - text = "#", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = TealSecondary + IconButton(onClick = onNavigateToTags) { + Icon( + imageVector = Icons.Default.Label, + contentDescription = "Tags", + tint = CyanPrimary + ) + } + + // Settings button + IconButton(onClick = onNavigateToSettings) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = CyanPrimary ) } }, @@ -299,8 +334,15 @@ fun FeedScreen( color = ErrorRed ) Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = { pagingItems.refresh() }) { - Text("Retry", color = CyanPrimary) + IconButton(onClick = { + viewModel.refresh() + pagingItems.refresh() + }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = CyanPrimary + ) } } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt index 98feb05..5763db5 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import com.shaarit.data.sync.SyncManager import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ViewStyle import com.shaarit.domain.repository.LinkRepository @@ -20,7 +21,10 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @HiltViewModel -class FeedViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() { +class FeedViewModel @Inject constructor( + private val linkRepository: LinkRepository, + private val syncManager: SyncManager +) : ViewModel() { private val _searchQuery = MutableStateFlow("") val searchQuery = _searchQuery.asStateFlow() @@ -82,6 +86,7 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito } fun refresh() { + syncManager.syncNow() _refreshTrigger.value++ } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index f8b223a..b063656 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PushPin import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.window.DialogProperties @@ -43,7 +44,8 @@ fun ListViewItem( onLinkClick: (String) -> Unit, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, - onDeleteClick: () -> Unit + onDeleteClick: () -> Unit, + onTogglePin: (Int) -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -85,6 +87,18 @@ fun ListViewItem( } Row { + // Pin button + IconButton( + onClick = { onTogglePin(link.id) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = if (link.isPinned) "Désépingler" else "Épingler", + tint = if (link.isPinned) CyanPrimary else TextMuted, + modifier = Modifier.size(18.dp) + ) + } IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) { Icon( imageVector = Icons.Default.Visibility, @@ -176,7 +190,8 @@ fun GridViewItem( onLinkClick: (String) -> Unit, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, - onDeleteClick: () -> Unit + onDeleteClick: () -> Unit, + onTogglePin: (Int) -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -202,15 +217,31 @@ fun GridViewItem( verticalArrangement = Arrangement.SpaceBetween ) { Column { - // Title - Text( - text = link.title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = CyanPrimary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + // Title with pin indicator + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = link.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = CyanPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (link.isPinned) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = "Épinglé", + tint = CyanPrimary, + modifier = Modifier.size(16.dp) + ) + } + } Spacer(modifier = Modifier.height(4.dp)) @@ -277,6 +308,18 @@ fun GridViewItem( } Row { + // Pin button + IconButton( + onClick = { onTogglePin(link.id) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = if (link.isPinned) "Désépingler" else "Épingler", + tint = if (link.isPinned) CyanPrimary else TextMuted, + modifier = Modifier.size(14.dp) + ) + } IconButton( onClick = onViewClick, modifier = Modifier.size(24.dp) @@ -326,7 +369,8 @@ fun CompactViewItem( onLinkClick: (String) -> Unit, onViewClick: () -> Unit, onEditClick: (Int) -> Unit, - onDeleteClick: () -> Unit + onDeleteClick: () -> Unit, + onTogglePin: (Int) -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -360,6 +404,15 @@ fun CompactViewItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + if (link.isPinned) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = "Épinglé", + tint = CyanPrimary, + modifier = Modifier.size(14.dp) + ) + } + if (link.isPrivate) { Icon( Icons.Default.Lock, @@ -403,6 +456,17 @@ fun CompactViewItem( } Row { + IconButton( + onClick = { onTogglePin(link.id) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = if (link.isPinned) "Désépingler" else "Épingler", + tint = if (link.isPinned) CyanPrimary else TextMuted, + modifier = Modifier.size(16.dp) + ) + } IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) { Icon( imageVector = Icons.Default.Visibility, diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index aff2cb7..4a750c1 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -1,13 +1,17 @@ package com.shaarit.presentation.nav import android.app.Activity +import android.content.Intent +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.navigation.navDeepLink import java.net.URLEncoder sealed class Screen(val route: String) { @@ -22,13 +26,17 @@ sealed class Screen(val route: String) { fun createRoute(linkId: Int): String = "edit/$linkId" } object Tags : Screen("tags") + object Collections : Screen("collections") + object Dashboard : Screen("dashboard") + object Settings : Screen("settings") } @Composable fun AppNavGraph( startDestination: String = Screen.Login.route, shareUrl: String? = null, - shareTitle: String? = null + shareTitle: String? = null, + initialDeepLink: String? = null ) { val navController = rememberNavController() val context = LocalContext.current @@ -47,6 +55,11 @@ fun AppNavGraph( navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") { popUpTo(Screen.Login.route) { inclusive = true } } + } else if (initialDeepLink != null) { + // Handle deep link after login + navController.navigate(initialDeepLink) { + popUpTo(Screen.Login.route) { inclusive = true } + } } else { navController.navigate(Screen.Feed.createRoute()) { popUpTo(Screen.Login.route) { inclusive = true } @@ -64,6 +77,10 @@ fun AppNavGraph( nullable = true defaultValue = null } + ), + deepLinks = listOf( + navDeepLink { uriPattern = "shaarit://feed" }, + navDeepLink { uriPattern = "shaarit://search" } ) ) { backStackEntry -> val tag = backStackEntry.arguments?.getString("tag") @@ -73,6 +90,9 @@ fun AppNavGraph( navController.navigate(Screen.Edit.createRoute(linkId)) }, onNavigateToTags = { navController.navigate(Screen.Tags.route) }, + onNavigateToCollections = { navController.navigate(Screen.Collections.route) }, + onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, + onNavigateToRandom = { }, initialTagFilter = tag ) } @@ -94,6 +114,9 @@ fun AppNavGraph( type = NavType.BoolType defaultValue = false } + ), + deepLinks = listOf( + navDeepLink { uriPattern = "shaarit://add" } ) ) { backStackEntry -> val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false @@ -118,7 +141,12 @@ fun AppNavGraph( ) } - composable(Screen.Tags.route) { + composable( + route = Screen.Tags.route, + deepLinks = listOf( + navDeepLink { uriPattern = "shaarit://tags" } + ) + ) { com.shaarit.presentation.tags.TagsScreen( onNavigateBack = { navController.popBackStack() }, onNavigateToFeedWithTag = { tag -> @@ -128,5 +156,42 @@ fun AppNavGraph( } ) } + + composable( + route = Screen.Collections.route, + deepLinks = listOf( + navDeepLink { uriPattern = "shaarit://collections" } + ) + ) { + com.shaarit.presentation.collections.CollectionsScreen( + onNavigateBack = { navController.popBackStack() }, + onCollectionClick = { collectionId -> + // Naviguer vers le feed avec le filtre de collection + navController.navigate(Screen.Feed.createRoute()) { + popUpTo(Screen.Collections.route) { inclusive = true } + } + } + ) + } + + composable( + route = Screen.Dashboard.route, + deepLinks = listOf( + navDeepLink { uriPattern = "shaarit://dashboard" } + ) + ) { + com.shaarit.presentation.dashboard.DashboardScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.Settings.route + ) { + com.shaarit.presentation.settings.SettingsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) } + ) + } } } diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt new file mode 100644 index 0000000..4d16a69 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -0,0 +1,394 @@ +package com.shaarit.presentation.settings + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.shaarit.data.export.BookmarkImporter +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + onNavigateToDashboard: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel() +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + + // Export JSON + val exportJsonLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { viewModel.exportToJson(it) } + } + + // Export CSV + val exportCsvLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/csv") + ) { uri -> + uri?.let { viewModel.exportToCsv(it) } + } + + // Export HTML + val exportHtmlLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/html") + ) { uri -> + uri?.let { viewModel.exportToHtml(it) } + } + + // Import JSON + val importJsonLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.importFromJson(it) } + } + + // Import HTML + val importHtmlLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.importFromHtml(it) } + } + + // Show toast for results + LaunchedEffect(uiState.message) { + uiState.message?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + viewModel.clearMessage() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Paramètres") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Retour") + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Analytics Section + item { + SettingsSection(title = "Analytiques") + } + + item { + SettingsItem( + icon = Icons.Default.Dashboard, + title = "Tableau de bord", + subtitle = "Voir les statistiques d'utilisation", + onClick = onNavigateToDashboard + ) + } + + // Export Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "Export") + } + + item { + SettingsItem( + icon = Icons.Default.Code, + title = "Exporter en JSON", + subtitle = "Format complet avec métadonnées", + onClick = { + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + exportJsonLauncher.launch("shaarit-export-$date.json") + } + ) + } + + item { + SettingsItem( + icon = Icons.Default.TableChart, + title = "Exporter en CSV", + subtitle = "Compatible avec Excel", + onClick = { + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + exportCsvLauncher.launch("shaarit-export-$date.csv") + } + ) + } + + item { + SettingsItem( + icon = Icons.Default.Language, + title = "Exporter en HTML", + subtitle = "Format Netscape/Chrome", + onClick = { + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + exportHtmlLauncher.launch("shaarit-export-$date.html") + } + ) + } + + // Import Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "Import") + } + + item { + SettingsItem( + icon = Icons.Default.UploadFile, + title = "Importer depuis JSON", + subtitle = "Fichier exporté par ShaarIt", + onClick = { importJsonLauncher.launch(arrayOf("application/json")) } + ) + } + + item { + SettingsItem( + icon = Icons.Default.BookmarkAdd, + title = "Importer depuis HTML", + subtitle = "Bookmarks Chrome/Firefox", + onClick = { + importHtmlLauncher.launch(arrayOf("text/html", "text/plain")) + } + ) + } + + // Sync Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "Synchronisation") + } + + item { + val syncStatus by viewModel.syncStatus.collectAsState() + SyncStatusItem( + status = syncStatus, + onSyncClick = { viewModel.triggerManualSync() } + ) + } + + // About Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "À propos") + } + + item { + SettingsItem( + icon = Icons.Default.Info, + title = "Version", + subtitle = "ShaarIt v1.0", + onClick = {} + ) + } + } + } + + // Show import result dialog + if (uiState.importResult != null) { + ImportResultDialog( + result = uiState.importResult!!, + onDismiss = { viewModel.clearImportResult() } + ) + } + + // Loading indicator + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } +} + +@Composable +private fun SettingsSection(title: String) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +private fun SettingsItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SyncStatusItem( + status: SyncUiStatus, + onSyncClick: () -> Unit +) { + val (icon, title, subtitle) = when (status) { + is SyncUiStatus.Synced -> Triple( + Icons.Default.CloudDone, + "Synchronisé", + "Dernière sync: ${status.lastSync}" + ) + is SyncUiStatus.Syncing -> Triple( + Icons.Default.Sync, + "Synchronisation...", + "En cours" + ) + is SyncUiStatus.Error -> Triple( + Icons.Default.CloudOff, + "Erreur de sync", + status.message + ) + is SyncUiStatus.Offline -> Triple( + Icons.Default.CloudOff, + "Mode hors-ligne", + "${status.pendingChanges} changements en attente" + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSyncClick, enabled = status !is SyncUiStatus.Syncing) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (status is SyncUiStatus.Syncing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = icon, + contentDescription = null, + tint = when (status) { + is SyncUiStatus.Synced -> MaterialTheme.colorScheme.primary + is SyncUiStatus.Error -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ImportResultDialog( + result: BookmarkImporter.ImportResult, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Importation terminée") }, + text = { + Column { + Text("${result.importedCount} liens importés") + if (result.skippedCount > 0) { + Text("${result.skippedCount} liens ignorés (déjà existants)") + } + if (result.errors.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Erreurs:", style = MaterialTheme.typography.labelSmall) + for (error in result.errors.take(5)) { + Text("• $error", style = MaterialTheme.typography.bodySmall) + } + if (result.errors.size > 5) { + Text("... et ${result.errors.size - 5} autres erreurs", + style = MaterialTheme.typography.bodySmall) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("OK") + } + } + ) +} + +sealed class SyncUiStatus { + data class Synced(val lastSync: String) : SyncUiStatus() + object Syncing : SyncUiStatus() + data class Error(val message: String) : SyncUiStatus() + data class Offline(val pendingChanges: Int) : SyncUiStatus() +} diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt new file mode 100644 index 0000000..6c192c8 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt @@ -0,0 +1,182 @@ +package com.shaarit.presentation.settings + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.shaarit.data.export.BookmarkExporter +import com.shaarit.data.export.BookmarkImporter +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.sync.SyncManager +import com.shaarit.data.sync.SyncState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val bookmarkExporter: BookmarkExporter, + private val bookmarkImporter: BookmarkImporter, + private val syncManager: SyncManager, + private val linkDao: LinkDao +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _syncStatus = MutableStateFlow(SyncUiStatus.Synced("Jamais")) + val syncStatus: StateFlow = _syncStatus.asStateFlow() + + init { + observeSyncStatus() + } + + private fun observeSyncStatus() { + viewModelScope.launch { + val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + + combine(syncManager.syncState, linkDao.getUnsyncedCount()) { state, unsyncedCount -> + Pair(state, unsyncedCount) + }.collect { (state, unsyncedCount) -> + _syncStatus.value = when (state) { + is SyncState.Syncing -> SyncUiStatus.Syncing + is SyncState.Error -> SyncUiStatus.Error(state.message) + is SyncState.Synced -> SyncUiStatus.Synced(dateFormat.format(Date(state.completedAt))) + is SyncState.Idle -> { + when { + unsyncedCount > 0 -> SyncUiStatus.Offline(unsyncedCount) + else -> SyncUiStatus.Synced("Jamais") + } + } + } + } + } + } + + fun exportToJson(uri: Uri) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + bookmarkExporter.exportToJson(uri) + .onSuccess { count -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "$count liens exportés avec succès" + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Erreur d'export: ${error.message}" + ) + } + } + } + + fun exportToCsv(uri: Uri) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + bookmarkExporter.exportToCsv(uri) + .onSuccess { count -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "$count liens exportés avec succès" + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Erreur d'export: ${error.message}" + ) + } + } + } + + fun exportToHtml(uri: Uri) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + bookmarkExporter.exportToHtml(uri) + .onSuccess { count -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "$count liens exportés avec succès" + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Erreur d'export: ${error.message}" + ) + } + } + } + + fun importFromJson(uri: Uri) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + bookmarkImporter.importFromJson(uri) + .onSuccess { result -> + _uiState.value = _uiState.value.copy( + isLoading = false, + importResult = result, + message = "${result.importedCount} liens importés" + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Erreur d'import: ${error.message}" + ) + } + } + } + + fun importFromHtml(uri: Uri) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + bookmarkImporter.importFromHtml(uri) + .onSuccess { result -> + _uiState.value = _uiState.value.copy( + isLoading = false, + importResult = result, + message = "${result.importedCount} liens importés" + ) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Erreur d'import: ${error.message}" + ) + } + } + } + + fun triggerManualSync() { + viewModelScope.launch { + _syncStatus.value = SyncUiStatus.Syncing + + syncManager.syncNow() + _uiState.value = _uiState.value.copy(message = "Synchronisation lancée") + } + } + + fun clearMessage() { + _uiState.value = _uiState.value.copy(message = null) + } + + fun clearImportResult() { + _uiState.value = _uiState.value.copy(importResult = null) + } +} + +data class SettingsUiState( + val isLoading: Boolean = false, + val message: String? = null, + val importResult: BookmarkImporter.ImportResult? = null +) diff --git a/app/src/main/java/com/shaarit/service/AddLinkTileService.kt b/app/src/main/java/com/shaarit/service/AddLinkTileService.kt new file mode 100644 index 0000000..f992f59 --- /dev/null +++ b/app/src/main/java/com/shaarit/service/AddLinkTileService.kt @@ -0,0 +1,44 @@ +package com.shaarit.service + +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi + +/** + * Quick Settings Tile for quickly adding a new link + * + * Swipe down from the top of the screen twice to access Quick Settings, + * then tap the ShaarIt tile to quickly add a bookmark. + */ +@RequiresApi(Build.VERSION_CODES.N) +class AddLinkTileService : TileService() { + + override fun onClick() { + super.onClick() + + // Launch MainActivity with the add link deep link + val intent = Intent(Intent.ACTION_VIEW).apply { + data = android.net.Uri.parse("shaarit://add") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + `package` = packageName + } + + startActivityAndCollapse(intent) + } + + override fun onStartListening() { + super.onStartListening() + updateTile() + } + + private fun updateTile() { + qsTile?.apply { + state = Tile.STATE_ACTIVE + label = "Add Link" + contentDescription = "Quickly add a new bookmark to ShaarIt" + updateTile() + } + } +} diff --git a/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt new file mode 100644 index 0000000..4671644 --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt @@ -0,0 +1,383 @@ +package com.shaarit.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Preview +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.shaarit.ui.theme.CardBackground +import com.shaarit.ui.theme.CardBackgroundElevated +import com.shaarit.ui.theme.CyanPrimary +import com.shaarit.ui.theme.TextPrimary +import com.shaarit.ui.theme.TextSecondary +import dev.jeziellago.compose.markdowntext.MarkdownText + +/** + * Modes d'affichage de l'éditeur Markdown + */ +enum class EditorMode { + EDIT, // Mode édition uniquement + PREVIEW, // Mode aperçu uniquement + SPLIT // Mode édition + aperçu côte à côte +} + +/** + * Éditeur Markdown complet avec preview temps réel + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@Composable +fun MarkdownEditor( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + mode: EditorMode = EditorMode.SPLIT, + onModeChange: ((EditorMode) -> Unit)? = null, + placeholder: String = "Commencez à écrire en Markdown...", + minHeight: androidx.compose.ui.unit.Dp = 200.dp, + readOnly: Boolean = false +) { + var currentMode by remember { mutableStateOf(mode) } + var textFieldValue by remember { mutableStateOf(TextFieldValue(value)) } + var isFullscreen by remember { mutableStateOf(false) } + + // Synchroniser avec le value externe + LaunchedEffect(value) { + if (textFieldValue.text != value) { + textFieldValue = TextFieldValue(value) + } + } + + Column(modifier = modifier) { + // Barre d'outils + if (!readOnly) { + EditorToolbar( + currentMode = currentMode, + isFullscreen = isFullscreen, + onModeChange = { newMode -> + currentMode = newMode + onModeChange?.invoke(newMode) + }, + onFullscreenToggle = { isFullscreen = !isFullscreen }, + onInsert = { insertion -> + val newText = textFieldValue.text + insertion + textFieldValue = TextFieldValue(newText) + onValueChange(newText) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + // Contenu de l'éditeur + AnimatedContent( + targetState = currentMode, + transitionSpec = { fadeIn() with fadeOut() }, + label = "editor_mode" + ) { targetMode -> + when (targetMode) { + EditorMode.EDIT -> EditOnlyView( + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChange(it.text) + }, + placeholder = placeholder, + minHeight = minHeight, + readOnly = readOnly + ) + + EditorMode.PREVIEW -> PreviewOnlyView( + markdown = textFieldValue.text, + minHeight = minHeight + ) + + EditorMode.SPLIT -> SplitView( + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChange(it.text) + }, + placeholder = placeholder, + minHeight = minHeight, + readOnly = readOnly + ) + } + } + } +} + +/** + * Barre d'outils de l'éditeur + */ +@Composable +private fun EditorToolbar( + currentMode: EditorMode, + isFullscreen: Boolean, + onModeChange: (EditorMode) -> Unit, + onFullscreenToggle: () -> Unit, + onInsert: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CardBackground, RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Sélecteur de mode + Row { + EditorModeButton( + icon = Icons.Default.Edit, + label = "Éditer", + isSelected = currentMode == EditorMode.EDIT, + onClick = { onModeChange(EditorMode.EDIT) } + ) + + EditorModeButton( + icon = Icons.Default.Preview, + label = "Aperçu", + isSelected = currentMode == EditorMode.PREVIEW, + onClick = { onModeChange(EditorMode.PREVIEW) } + ) + + EditorModeButton( + icon = null, + label = "Split", + isSelected = currentMode == EditorMode.SPLIT, + onClick = { onModeChange(EditorMode.SPLIT) } + ) + } + + // Raccourcis de formatage + Row { + TextButton(onClick = { onInsert("**texte en gras**") }) { + Text("B", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + } + TextButton(onClick = { onInsert("*texte en italique*") }) { + Text("I", fontStyle = androidx.compose.ui.text.font.FontStyle.Italic) + } + TextButton(onClick = { onInsert("`code`") }) { + Text("<>", style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = { onInsert("\n- ") }) { + Text("•") + } + TextButton(onClick = { onInsert("\n> citation") }) { + Text("❝") + } + } + + // Bouton plein écran + IconButton(onClick = onFullscreenToggle) { + Icon( + imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + contentDescription = if (isFullscreen) "Quitter plein écran" else "Plein écran" + ) + } + } +} + +@Composable +private fun EditorModeButton( + icon: androidx.compose.ui.graphics.vector.ImageVector?, + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + contentColor = if (isSelected) CyanPrimary else TextSecondary + ) + ) { + if (icon != null) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } + Text(label, style = MaterialTheme.typography.labelMedium) + } +} + +/** + * Vue édition uniquement + */ +@Composable +private fun EditOnlyView( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + placeholder: String, + minHeight: androidx.compose.ui.unit.Dp, + readOnly: Boolean +) { + val scrollState = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .background(CardBackgroundElevated, RoundedCornerShape(8.dp)) + .border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(16.dp) + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + textStyle = TextStyle( + color = TextPrimary, + fontSize = MaterialTheme.typography.bodyLarge.fontSize + ), + cursorBrush = SolidColor(CyanPrimary), + readOnly = readOnly, + decorationBox = { innerTextField -> + if (value.text.isEmpty()) { + Text( + text = placeholder, + color = TextSecondary, + style = MaterialTheme.typography.bodyLarge + ) + } + innerTextField() + } + ) + } +} + +/** + * Vue aperçu uniquement + */ +@Composable +private fun PreviewOnlyView( + markdown: String, + minHeight: androidx.compose.ui.unit.Dp +) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .background(CardBackgroundElevated, RoundedCornerShape(8.dp)) + .border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + if (markdown.isBlank()) { + Text( + text = "Rien à prévisualiser...", + color = TextSecondary, + style = MaterialTheme.typography.bodyLarge + ) + } else { + MarkdownText( + markdown = markdown, + color = TextPrimary, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +/** + * Vue split édition + aperçu + */ +@Composable +private fun SplitView( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + placeholder: String, + minHeight: androidx.compose.ui.unit.Dp, + readOnly: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Zone d'édition + Box(modifier = Modifier.weight(1f)) { + EditOnlyView( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + minHeight = minHeight, + readOnly = readOnly + ) + } + + // Zone de preview + Box(modifier = Modifier.weight(1f)) { + PreviewOnlyView( + markdown = value.text, + minHeight = minHeight + ) + } + } +} + +/** + * Mode lecture distraction-free pour les longues notes + */ +@Composable +fun MarkdownReader( + markdown: String, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f)) + .padding(32.dp) + ) { + Column { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = "Fermer", + tint = androidx.compose.ui.graphics.Color.White + ) + } + } + + // Contenu + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MarkdownText( + markdown = markdown, + color = androidx.compose.ui.graphics.Color.White, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/ui/theme/Theme.kt b/app/src/main/java/com/shaarit/ui/theme/Theme.kt index b9b1b01..31adb18 100644 --- a/app/src/main/java/com/shaarit/ui/theme/Theme.kt +++ b/app/src/main/java/com/shaarit/ui/theme/Theme.kt @@ -2,8 +2,11 @@ package com.shaarit.ui.theme import android.app.Activity import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -84,25 +87,53 @@ private val LightColorScheme = @Composable fun ShaarItTheme( - darkTheme: Boolean = true, // Default to dark theme for premium look - dynamicColor: Boolean = false, // Disable dynamic color to maintain brand - content: @Composable () -> Unit + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, // Enable Material You by default + oledMode: Boolean = false, // Pure black for OLED screens + content: @Composable () -> Unit ) { + val context = LocalContext.current + val colorScheme = - when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme) DarkColorScheme // Use custom dark even with dynamic - else lightColorScheme() + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + // Material You (Monet) - dynamic colors from wallpaper + if (darkTheme) { + if (oledMode) { + // OLED pure black variant + dynamicDarkColorScheme(context).copy( + background = Color.Black, + surface = Color(0xFF0A0A0A), + surfaceVariant = Color(0xFF1A1A1A) + ) + } else { + dynamicDarkColorScheme(context) + } + } else { + dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme - else -> LightColorScheme } + darkTheme -> { + if (oledMode) { + // OLED pure black variant of custom theme + DarkColorScheme.copy( + background = Color.Black, + surface = Color(0xFF0A0A0A), + surfaceVariant = Color(0xFF1A1A1A) + ) + } else { + DarkColorScheme + } + } + else -> LightColorScheme + } + val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = DeepNavy.toArgb() - window.navigationBarColor = DeepNavy.toArgb() + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt new file mode 100644 index 0000000..3375995 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt @@ -0,0 +1,135 @@ +package com.shaarit.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import com.shaarit.MainActivity +import com.shaarit.R + +/** + * Widget Provider pour afficher les liens Shaarli sur l'écran d'accueil + */ +class ShaarliWidgetProvider : AppWidgetProvider() { + + companion object { + const val ACTION_ADD_LINK = "com.shaarit.widget.ACTION_ADD_LINK" + const val ACTION_REFRESH = "com.shaarit.widget.ACTION_REFRESH" + const val ACTION_RANDOM = "com.shaarit.widget.ACTION_RANDOM" + const val EXTRA_LINK_URL = "link_url" + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + when (intent.action) { + ACTION_ADD_LINK -> { + // Ouvrir l'app en mode ajout rapide + val mainIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(mainIntent) + } + ACTION_REFRESH -> { + // Rafraîchir le widget + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = android.content.ComponentName(context, ShaarliWidgetProvider::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + onUpdate(context, appWidgetManager, appWidgetIds) + } + ACTION_RANDOM -> { + // Ouvrir un lien aléatoire + // TODO: Implémenter la sélection aléatoire + val mainIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(mainIntent) + } + } + } + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val views = RemoteViews(context.packageName, R.layout.widget_shaarli) + + // Configuration du titre + views.setTextViewText(R.id.widget_title, "ShaarIt") + + // Bouton Ajouter + val addIntent = Intent(context, ShaarliWidgetProvider::class.java).apply { + action = ACTION_ADD_LINK + } + val addPendingIntent = PendingIntent.getBroadcast( + context, + 0, + addIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_btn_add, addPendingIntent) + + // Bouton Rafraîchir + val refreshIntent = Intent(context, ShaarliWidgetProvider::class.java).apply { + action = ACTION_REFRESH + } + val refreshPendingIntent = PendingIntent.getBroadcast( + context, + 1, + refreshIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_btn_refresh, refreshPendingIntent) + + // Bouton Aléatoire + val randomIntent = Intent(context, ShaarliWidgetProvider::class.java).apply { + action = ACTION_RANDOM + } + val randomPendingIntent = PendingIntent.getBroadcast( + context, + 2, + randomIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_btn_random, randomPendingIntent) + + // Configuration de la liste (utilise un RemoteViewsService) + val serviceIntent = Intent(context, ShaarliWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + views.setRemoteAdapter(R.id.widget_list, serviceIntent) + + // Intent pour les items de la liste + val clickIntent = Intent(context, MainActivity::class.java) + val clickPendingIntent = PendingIntent.getActivity( + context, + 0, + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + views.setPendingIntentTemplate(R.id.widget_list, clickPendingIntent) + + // Message vide + views.setEmptyView(R.id.widget_list, R.id.widget_empty) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } +} diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt new file mode 100644 index 0000000..89b77f3 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt @@ -0,0 +1,101 @@ +package com.shaarit.widget + +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import com.shaarit.R +import com.shaarit.data.local.dao.LinkDao +import com.shaarit.data.local.database.ShaarliDatabase +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking + +/** + * Service pour peupler la liste du widget avec les liens + */ +class ShaarliWidgetService : RemoteViewsService() { + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return ShaarliWidgetItemFactory(applicationContext, intent) + } +} + +class ShaarliWidgetItemFactory( + private val context: Context, + private val intent: Intent +) : RemoteViewsService.RemoteViewsFactory { + + private var links: List = emptyList() + private val linkDao: LinkDao by lazy { + ShaarliDatabase.getInstance(context).linkDao() + } + + override fun onCreate() { + // Initialisation + } + + override fun onDataSetChanged() { + // Charger les liens depuis la base de données + links = runBlocking { + try { + linkDao.getAllLinks() + .firstOrNull() + ?.take(10) // Limiter à 10 liens + ?.map { link -> + WidgetLinkItem( + id = link.id, + title = link.title, + url = link.url, + tags = link.tags.take(3).joinToString(", ") // Max 3 tags + ) + } ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + } + + override fun onDestroy() { + links = emptyList() + } + + override fun getCount(): Int = links.size + + override fun getViewAt(position: Int): RemoteViews { + val link = links[position] + + return RemoteViews(context.packageName, R.layout.widget_list_item).apply { + setTextViewText(R.id.item_title, link.title) + setTextViewText(R.id.item_url, link.url) + + if (link.tags.isNotBlank()) { + setTextViewText(R.id.item_tags, link.tags) + setViewVisibility(R.id.item_tags, android.view.View.VISIBLE) + } else { + setViewVisibility(R.id.item_tags, android.view.View.GONE) + } + + // Intent pour ouvrir le lien + val fillInIntent = Intent().apply { + putExtra(ShaarliWidgetProvider.EXTRA_LINK_URL, link.url) + putExtra("link_id", link.id) + } + setOnClickFillInIntent(R.id.widget_item_container, fillInIntent) + } + } + + override fun getLoadingView(): RemoteViews? = null + + override fun getViewTypeCount(): Int = 1 + + override fun getItemId(position: Int): Long = links.getOrNull(position)?.id?.toLong() ?: position.toLong() + + override fun hasStableIds(): Boolean = true +} + +data class WidgetLinkItem( + val id: Int, + val title: String, + val url: String, + val tags: String +) diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..425afa3 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_item_background.xml b/app/src/main/res/drawable/widget_item_background.xml new file mode 100644 index 0000000..911d770 --- /dev/null +++ b/app/src/main/res/drawable/widget_item_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_list_item.xml b/app/src/main/res/layout/widget_list_item.xml new file mode 100644 index 0000000..a98031a --- /dev/null +++ b/app/src/main/res/layout/widget_list_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_shaarli.xml b/app/src/main/res/layout/widget_shaarli.xml new file mode 100644 index 0000000..eda4fae --- /dev/null +++ b/app/src/main/res/layout/widget_shaarli.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3dd294..a2257c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,85 @@ ShaarIt - + + + Ajouter un lien + Rafraîchir + Aléatoire + Aucun lien + + + Épingler + Désépingler + Supprimer + Modifier + Partager + Copier l\'URL + Ouvrir dans le navigateur + + + Éditer + Aperçu + Split + + + Synchronisation… + Synchronisé + Erreur de synchronisation + Mode hors-ligne + + + Collections + Nouvelle collection + Collection intelligente + + + Rechercher + Rechercher dans les liens… + Aujourd\'hui + Cette semaine + Ce mois + + Ajouter + Ajouter un lien + Ajout de lien désactivé + + Aléatoire + Lien aléatoire + Aléatoire désactivé + + Rechercher + Rechercher des liens + Recherche désactivée + + Collections + Voir les collections + Collections désactivées + + + Ajouter un lien + Ajouter rapidement un bookmark à ShaarIt + + + Tableau de bord + Liens totaux + Cette semaine + Ce mois + Tags les plus utilisés + Statistiques de lecture + Temps de lecture estimé + Liens par type + Aperçu d\'activité + + + Exporter + Importer + Exporter en JSON + Exporter en CSV + Importer depuis HTML + Importation réussie + Erreur d\'importation + Exportation réussie + Erreur d\'exportation + Sélectionner un fichier + \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..a0e33ad --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml new file mode 100644 index 0000000..b427674 --- /dev/null +++ b/app/src/main/res/xml/widget_info.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1655225..31de0d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false } diff --git a/gradle.properties b/gradle.properties index 32c5628..756e686 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=true -moshi.generateAdapter.source=ksp \ No newline at end of file +moshi.generateAdapter.source=ksp +org.gradle.java.home=C:\\Users\\bruno\\scoop\\apps\\temurin17-jdk\\current \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ebd6f41..eea5bd7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,11 @@ paging = "3.2.1" pagingCompose = "3.2.1" material = "1.11.0" composeMarkdown = "0.4.1" +room = "2.6.1" +workManager = "2.9.0" +dataStore = "1.0.0" +kotlinxSerialization = "1.6.2" +coil = "2.6.0" [libraries] material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -55,8 +60,32 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } +# Room +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } + +# WorkManager +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-work-hilt = { group = "androidx.hilt", name = "hilt-work", version = "1.1.0" } +androidx-work-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.1.0" } + +# DataStore +androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" } + +# JSoup for HTML parsing +jsoup = { group = "org.jsoup", name = "jsoup", version = "1.17.1" } + +# Coil (images in Compose) +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Kotlin Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }