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
This commit is contained in:
parent
a9475c16b1
commit
7277342d4a
426
ANALYSE_ET_AMELIORATIONS.md
Normal file
426
ANALYSE_ET_AMELIORATIONS.md
Normal file
@ -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<String>, // 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<String>,
|
||||||
|
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<String>,
|
||||||
|
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*
|
||||||
449
README.md
449
README.md
@ -18,27 +18,71 @@
|
|||||||
|
|
||||||
### 📚 Gestion des Favoris
|
### 📚 Gestion des Favoris
|
||||||
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
|
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
|
||||||
- **Recherche côté serveur** : Recherche par termes et filtrage par tags
|
- **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags
|
||||||
- **Ajout rapide** : Création de liens privés/publics avec description et tags
|
- **Mode hors-ligne** : Consultation et modification des liens même sans connexion
|
||||||
- **Édition** : Modification complète des liens existants
|
- **Ajout rapide** : Création de liens privés/publics avec description, tags et extraction automatique de métadonnées
|
||||||
- **Suppression** : Gestion facile des favoris
|
- **É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
|
- **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
|
### 🏷️ Gestion des Tags
|
||||||
- Vue dédiée pour parcourir tous les tags
|
- Vue dédiée pour parcourir tous les tags
|
||||||
- Compteur d'utilisation par tag
|
- Compteur d'utilisation par tag
|
||||||
- Filtrage rapide du flux 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
|
### 🔗 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
|
- Ouverture des liens dans le navigateur par défaut
|
||||||
- Support des URLs partagées avec titre pré-rempli
|
|
||||||
|
|
||||||
### 🎨 Interface Utilisateur
|
### 🎨 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
|
- **Material Design 3** : Composants UI natifs Android
|
||||||
- **Animations fluides** : Transitions et effets visuels
|
- **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
|
- **Pull-to-refresh** : Actualisation du flux par glissement
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -47,13 +91,15 @@
|
|||||||
|
|
||||||
| Catégorie | Technologie |
|
| Catégorie | Technologie |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| **Langage** | Kotlin 1.9.20 |
|
| **Langage** | Kotlin 2.0.0 |
|
||||||
| **UI** | Jetpack Compose + Material Design 3 |
|
| **UI** | Jetpack Compose + Material Design 3 |
|
||||||
| **Architecture** | Clean Architecture + MVVM |
|
| **Architecture** | Clean Architecture + MVVM |
|
||||||
| **Injection de dépendances** | Dagger Hilt 2.48.1 |
|
| **Injection de dépendances** | Dagger Hilt 2.51.1 |
|
||||||
| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
|
| **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 |
|
| **Pagination** | Paging 3 |
|
||||||
| **Concurrence** | Coroutines & Flow |
|
| **Concurrence** | Coroutines & Flow |
|
||||||
|
| **Background work** | WorkManager 2.9.0 |
|
||||||
| **Stockage sécurisé** | AndroidX Security Crypto |
|
| **Stockage sécurisé** | AndroidX Security Crypto |
|
||||||
| **Navigation** | Navigation Compose |
|
| **Navigation** | Navigation Compose |
|
||||||
| **Compilation** | Gradle 8.0+ avec KSP |
|
| **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
|
#### Méthode 2 : Compilation depuis les sources
|
||||||
|
|
||||||
##### Prérequis de développement
|
1. Clonez le repository :
|
||||||
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\<Username>\AppData\Local\Android\Sdk > local.properties
|
|
||||||
|
|
||||||
# Linux/macOS
|
|
||||||
echo sdk.dir=/home/<username>/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": <unix_timestamp>}`
|
|
||||||
- **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tests unitaires
|
git clone https://github.com/votre-username/ShaarIt.git
|
||||||
./gradlew test
|
cd ShaarIt
|
||||||
|
|
||||||
# Tests instrumentés
|
|
||||||
./gradlew connectedAndroidTest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build de release
|
2. Compilez l'APK debug :
|
||||||
|
```bash
|
||||||
1. **Créer un keystore** (si premier build)
|
./gradlew assembleDebug
|
||||||
```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" }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contribution
|
L'APK sera généré dans `app/build/outputs/apk/debug/`
|
||||||
|
|
||||||
1. Forker le projet
|
3. Ou compilez l'APK release (nécessite une configuration de signature) :
|
||||||
2. Créer une branche feature (`git checkout -b feature/amazing-feature`)
|
```bash
|
||||||
3. Committer vos changements (`git commit -m 'Add amazing feature'`)
|
./gradlew assembleRelease
|
||||||
4. Pusher sur la branche (`git push origin feature/amazing-feature`)
|
```
|
||||||
5. Ouvrir une Pull Request
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 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.
|
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
|
## 🙏 Remerciements
|
||||||
|
|
||||||
- [Shaarli](https://github.com/shaarli/Shaarli) - Le gestionnaire de favoris auto-hébergé
|
- [Shaarli](https://github.com/shaarli/Shaarli) - Le projet original de gestionnaire de favoris
|
||||||
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit moderne Android
|
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Framework UI moderne d'Android
|
||||||
- [Material Design 3](https://m3.material.io/) - Système de design Google
|
- La communauté open source pour les excellentes bibliothèques utilisées
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📧 Contact
|
<div align="center">
|
||||||
|
<sub>Fait avec ❤️ pour la communauté Shaarli</sub>
|
||||||
Pour toute question ou suggestion, n'hésitez pas à ouvrir une [issue](../../issues).
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -96,6 +92,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation(libs.androidx.compose.material)
|
implementation(libs.androidx.compose.material)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
@ -119,6 +116,9 @@ dependencies {
|
|||||||
implementation(libs.moshi)
|
implementation(libs.moshi)
|
||||||
ksp(libs.moshi.kotlin.codegen)
|
ksp(libs.moshi.kotlin.codegen)
|
||||||
|
|
||||||
|
// Kotlin Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
@ -128,6 +128,23 @@ dependencies {
|
|||||||
// Markdown
|
// Markdown
|
||||||
implementation(libs.compose.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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@ -17,6 +17,18 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<!-- Disable default WorkManager initialization to use HiltWorkerFactory -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -33,7 +45,24 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- App Shortcuts -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Quick Settings Tile -->
|
||||||
|
<service
|
||||||
|
android:name=".service.AddLinkTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/tile_add_link"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@ -6,11 +6,8 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.shaarit.presentation.nav.AppNavGraph
|
import com.shaarit.presentation.nav.AppNavGraph
|
||||||
import com.shaarit.ui.theme.ShaarItTheme
|
import com.shaarit.ui.theme.ShaarItTheme
|
||||||
@ -30,9 +27,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var shareUrl: String? = null
|
var shareUrl: String? = null
|
||||||
var shareTitle: String? = null
|
var shareTitle: String? = null
|
||||||
|
var deepLink: String? = null
|
||||||
|
|
||||||
val activity = context as? androidx.activity.ComponentActivity
|
val activity = context as? androidx.activity.ComponentActivity
|
||||||
val intent = activity?.intent
|
val intent = activity?.intent
|
||||||
|
|
||||||
|
// Handle share intent
|
||||||
if (intent?.action == android.content.Intent.ACTION_SEND &&
|
if (intent?.action == android.content.Intent.ACTION_SEND &&
|
||||||
intent.type == "text/plain"
|
intent.type == "text/plain"
|
||||||
) {
|
) {
|
||||||
@ -40,23 +40,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
|
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(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) { AppNavGraph(shareUrl = shareUrl, shareTitle = shareTitle) }
|
) {
|
||||||
|
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") }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
package com.shaarit
|
package com.shaarit
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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()
|
||||||
|
}
|
||||||
|
|||||||
45
app/src/main/java/com/shaarit/core/di/DatabaseModule.kt
Normal file
45
app/src/main/java/com/shaarit/core/di/DatabaseModule.kt
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,13 +11,13 @@ data class LoginResponseDto(@Json(name = "token") val token: String)
|
|||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class LinkDto(
|
data class LinkDto(
|
||||||
@Json(name = "id") val id: Int,
|
@Json(name = "id") val id: Int?,
|
||||||
@Json(name = "url") val url: String,
|
@Json(name = "url") val url: String?,
|
||||||
@Json(name = "shorturl") val shortUrl: String?,
|
@Json(name = "shorturl") val shortUrl: String?,
|
||||||
@Json(name = "title") val title: String?,
|
@Json(name = "title") val title: String?,
|
||||||
@Json(name = "description") val description: String?,
|
@Json(name = "description") val description: String?,
|
||||||
@Json(name = "tags") val tags: List<String>?,
|
@Json(name = "tags") val tags: List<String>?,
|
||||||
@Json(name = "private") val isPrivate: Boolean,
|
@Json(name = "private") val isPrivate: Boolean?,
|
||||||
@Json(name = "created") val created: String?,
|
@Json(name = "created") val created: String?,
|
||||||
@Json(name = "updated") val updated: String?
|
@Json(name = "updated") val updated: String?
|
||||||
)
|
)
|
||||||
|
|||||||
193
app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt
Normal file
193
app/src/main/java/com/shaarit/data/export/BookmarkExporter.kt
Normal file
@ -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<Int> = 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<Int> = 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<Int> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val links = linkDao.getAllLinksForStats()
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
OutputStreamWriter(outputStream).use { writer ->
|
||||||
|
writer.write("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n")
|
||||||
|
writer.write("<!-- This is an automatically generated file.\n")
|
||||||
|
writer.write(" It will be read and overwritten.\n")
|
||||||
|
writer.write(" DO NOT EDIT! -->\n")
|
||||||
|
writer.write("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n")
|
||||||
|
writer.write("<TITLE>Bookmarks</TITLE>\n")
|
||||||
|
writer.write("<H1>Bookmarks</H1>\n")
|
||||||
|
writer.write("<DL><p>\n")
|
||||||
|
writer.write(" <DT><H3 ADD_DATE=\"${System.currentTimeMillis() / 1000}\" LAST_MODIFIED=\"${System.currentTimeMillis() / 1000}\">ShaarIt Export</H3>\n")
|
||||||
|
writer.write(" <DL><p>\n")
|
||||||
|
|
||||||
|
links.forEach { entity ->
|
||||||
|
val addDate = entity.createdAt / 1000
|
||||||
|
val tags = entity.tags.joinToString(",")
|
||||||
|
val private = if (entity.isPrivate) "PRIVATE=\"1\"" else ""
|
||||||
|
|
||||||
|
writer.write(" <DT><A HREF=\"${escapeHtml(entity.url)}\" ADD_DATE=\"$addDate\" $private TAGS=\"${escapeHtml(tags)}\">${escapeHtml(entity.title)}</A>\n")
|
||||||
|
if (entity.description.isNotBlank()) {
|
||||||
|
writer.write(" <DD>${escapeHtml(entity.description)}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write(" </DL><p>\n")
|
||||||
|
writer.write("</DL><p>\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<ExportedLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExportedLink(
|
||||||
|
val id: Int,
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val tags: List<String>,
|
||||||
|
val private: Boolean,
|
||||||
|
val createdAt: Long,
|
||||||
|
val siteName: String?,
|
||||||
|
val thumbnailUrl: String?,
|
||||||
|
val readingTimeMinutes: Int,
|
||||||
|
val contentType: String
|
||||||
|
)
|
||||||
213
app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt
Normal file
213
app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt
Normal file
@ -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<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importe des liens depuis un fichier HTML (format Netscape/Chrome bookmarks)
|
||||||
|
*/
|
||||||
|
suspend fun importFromHtml(uri: Uri): Result<ImportResult> = 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<String>()
|
||||||
|
|
||||||
|
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<ImportResult> = 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<ExportData>(jsonContent)
|
||||||
|
|
||||||
|
var imported = 0
|
||||||
|
var skipped = 0
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
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<ParsedBookmark> {
|
||||||
|
val bookmarks = mutableListOf<ParsedBookmark>()
|
||||||
|
|
||||||
|
// Recherche les éléments <A> 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 <DD> 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<String>,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val addDate: Long?
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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<String> (Tags) ======
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>): String {
|
||||||
|
return try {
|
||||||
|
json.encodeToString(value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"[]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String): List<String> {
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<String>>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<List<CollectionEntity>>
|
||||||
|
|
||||||
|
@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<List<CollectionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM collections WHERE is_smart = 1 ORDER BY sort_order ASC")
|
||||||
|
fun getSmartCollections(): Flow<List<CollectionEntity>>
|
||||||
|
|
||||||
|
@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<List<LinkEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
|
||||||
|
fun getLinkCountInCollection(collectionId: Long): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
|
||||||
|
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
|
||||||
|
}
|
||||||
183
app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt
Normal file
183
app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt
Normal file
@ -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<List<LinkEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links ORDER BY is_pinned DESC, created_at DESC")
|
||||||
|
fun getAllLinksPaged(): PagingSource<Int, LinkEntity>
|
||||||
|
|
||||||
|
@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<LinkEntity?>
|
||||||
|
|
||||||
|
// ====== 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<Int, LinkEntity>
|
||||||
|
|
||||||
|
@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<Int, LinkEntity>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM links
|
||||||
|
WHERE tags LIKE '%' || :tag || '%'
|
||||||
|
ORDER BY is_pinned DESC, created_at DESC
|
||||||
|
""")
|
||||||
|
fun getLinksByTag(tag: String): PagingSource<Int, LinkEntity>
|
||||||
|
|
||||||
|
@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<Int, LinkEntity>
|
||||||
|
|
||||||
|
// ====== Filtres temporels ======
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM links
|
||||||
|
WHERE created_at >= :timestamp
|
||||||
|
ORDER BY is_pinned DESC, created_at DESC
|
||||||
|
""")
|
||||||
|
fun getLinksSince(timestamp: Long): Flow<List<LinkEntity>>
|
||||||
|
|
||||||
|
@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<List<LinkEntity>>
|
||||||
|
|
||||||
|
// ====== Filtres par statut ======
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC")
|
||||||
|
fun getPublicLinks(): PagingSource<Int, LinkEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE is_private = 1 ORDER BY created_at DESC")
|
||||||
|
fun getPrivateLinks(): PagingSource<Int, LinkEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC")
|
||||||
|
fun getPinnedLinks(): Flow<List<LinkEntity>>
|
||||||
|
|
||||||
|
// ====== Sync ======
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE sync_status = :status")
|
||||||
|
suspend fun getLinksBySyncStatus(status: SyncStatus): List<LinkEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE sync_status != 'SYNCED'")
|
||||||
|
fun getUnsyncedLinks(): Flow<List<LinkEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM links WHERE sync_status != 'SYNCED'")
|
||||||
|
fun getUnsyncedCount(): Flow<Int>
|
||||||
|
|
||||||
|
@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<LinkEntity>)
|
||||||
|
|
||||||
|
@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<LinkEntity>) {
|
||||||
|
clearAll()
|
||||||
|
insertLinks(links)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Statistiques ======
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM links")
|
||||||
|
fun getTotalCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM links WHERE is_private = 1")
|
||||||
|
fun getPrivateCount(): Flow<Int>
|
||||||
|
|
||||||
|
@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<String>
|
||||||
|
|
||||||
|
@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<SiteCount>
|
||||||
|
|
||||||
|
@Query("SELECT content_type, COUNT(*) as count FROM links GROUP BY content_type ORDER BY count DESC")
|
||||||
|
suspend fun getContentTypeDistribution(): List<ContentTypeCount>
|
||||||
|
|
||||||
|
// ====== Pour les statistiques ======
|
||||||
|
|
||||||
|
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
|
||||||
|
suspend fun getAllLinksForStats(): List<LinkEntity>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
61
app/src/main/java/com/shaarit/data/local/dao/TagDao.kt
Normal file
61
app/src/main/java/com/shaarit/data/local/dao/TagDao.kt
Normal file
@ -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<List<TagEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tags ORDER BY occurrences DESC, name ASC")
|
||||||
|
suspend fun getAllTagsOnce(): List<TagEntity>
|
||||||
|
|
||||||
|
@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<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tags WHERE is_favorite = 1 ORDER BY name ASC")
|
||||||
|
fun getFavoriteTags(): Flow<List<TagEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTag(tag: TagEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTags(tags: List<TagEntity>)
|
||||||
|
|
||||||
|
@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<Int>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tags ORDER BY last_used_at DESC LIMIT :limit")
|
||||||
|
suspend fun getRecentlyUsed(limit: Int = 10): List<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tags ORDER BY occurrences DESC LIMIT :limit")
|
||||||
|
suspend fun getMostPopular(limit: Int = 10): List<TagEntity>
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
)
|
||||||
114
app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt
Normal file
114
app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt
Normal file
@ -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<String>,
|
||||||
|
|
||||||
|
@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?
|
||||||
|
)
|
||||||
49
app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt
Normal file
49
app/src/main/java/com/shaarit/data/local/entity/TagEntity.kt
Normal file
@ -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
|
||||||
|
)
|
||||||
@ -6,14 +6,16 @@ import com.shaarit.domain.model.ShaarliLink
|
|||||||
import com.shaarit.domain.model.ShaarliTag
|
import com.shaarit.domain.model.ShaarliTag
|
||||||
|
|
||||||
object LinkMapper {
|
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(
|
return ShaarliLink(
|
||||||
id = dto.id,
|
id = id,
|
||||||
url = dto.url,
|
url = url,
|
||||||
title = dto.title ?: dto.url,
|
title = dto.title ?: url,
|
||||||
description = dto.description ?: "",
|
description = dto.description ?: "",
|
||||||
tags = dto.tags ?: emptyList(),
|
tags = dto.tags ?: emptyList(),
|
||||||
isPrivate = dto.isPrivate,
|
isPrivate = dto.isPrivate ?: false,
|
||||||
date = dto.created ?: ""
|
date = dto.created ?: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?
|
||||||
|
)
|
||||||
@ -42,7 +42,7 @@ class LinkPagingSource(
|
|||||||
searchTags = searchTags
|
searchTags = searchTags
|
||||||
)
|
)
|
||||||
|
|
||||||
val links = dtos.map { LinkMapper.toDomain(it) }
|
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
|
||||||
|
|
||||||
val nextKey =
|
val nextKey =
|
||||||
if (links.isEmpty()) {
|
if (links.isEmpty()) {
|
||||||
|
|||||||
@ -3,35 +3,122 @@ package com.shaarit.data.repository
|
|||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
|
import androidx.paging.map
|
||||||
import com.shaarit.data.api.ShaarliApi
|
import com.shaarit.data.api.ShaarliApi
|
||||||
import com.shaarit.data.dto.CreateLinkDto
|
import com.shaarit.data.dto.CreateLinkDto
|
||||||
import com.shaarit.data.dto.LinkDto
|
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.LinkMapper
|
||||||
import com.shaarit.data.mapper.TagMapper
|
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.ShaarliLink
|
||||||
import com.shaarit.domain.model.ShaarliTag
|
import com.shaarit.domain.model.ShaarliTag
|
||||||
import com.shaarit.domain.repository.AddLinkResult
|
import com.shaarit.domain.repository.AddLinkResult
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.HttpException
|
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
|
class LinkRepositoryImpl
|
||||||
@Inject
|
@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(
|
override fun getLinksStream(
|
||||||
searchTerm: String?,
|
searchTerm: String?,
|
||||||
searchTags: String?
|
searchTags: String?
|
||||||
): Flow<PagingData<ShaarliLink>> {
|
): Flow<PagingData<ShaarliLink>> {
|
||||||
|
// Utiliser Room pour la pagination locale
|
||||||
return Pager(
|
return Pager(
|
||||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||||
pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) }
|
pagingSourceFactory = {
|
||||||
)
|
when {
|
||||||
.flow
|
!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 getLink(id: Int): Result<ShaarliLink> {
|
||||||
|
// 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 link = api.getLink(id)
|
||||||
|
val entity = link.toEntity()
|
||||||
|
if (entity != null) {
|
||||||
|
linkDao.insertLink(entity)
|
||||||
|
Result.success(entity.toDomainModel())
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Invalid link data from server"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLinkFlow(id: Int): Flow<ShaarliLink?> {
|
||||||
|
return linkDao.getLinkByIdFlow(id).map { it?.toDomainModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
|
||||||
|
// 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(
|
override suspend fun addLink(
|
||||||
url: String,
|
url: String,
|
||||||
@ -40,16 +127,37 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
tags: List<String>?,
|
tags: List<String>?,
|
||||||
isPrivate: Boolean
|
isPrivate: Boolean
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return try {
|
// Créer l'entité locale avec un ID temporaire négatif
|
||||||
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
val tempId = -(System.currentTimeMillis() % 10000000).toInt()
|
||||||
if (response.isSuccessful) {
|
val entity = LinkEntity(
|
||||||
Result.success(Unit)
|
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 {
|
} else {
|
||||||
Result.failure(HttpException(response))
|
tagDao.insertTag(TagEntity(tag, 1))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Déclencher une sync en arrière-plan
|
||||||
|
syncManager.syncNow()
|
||||||
|
|
||||||
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addOrUpdateLink(
|
override suspend fun addOrUpdateLink(
|
||||||
@ -63,25 +171,42 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
): AddLinkResult {
|
): AddLinkResult {
|
||||||
return try {
|
return try {
|
||||||
if (forceUpdate && existingLinkId != null) {
|
if (forceUpdate && existingLinkId != null) {
|
||||||
// Force update existing link
|
// Mise à jour forcée
|
||||||
val response =
|
val existing = linkDao.getLinkById(existingLinkId)
|
||||||
api.updateLink(
|
if (existing != null) {
|
||||||
existingLinkId,
|
val updated = existing.copy(
|
||||||
CreateLinkDto(url, title, description, tags, isPrivate)
|
title = title ?: existing.title,
|
||||||
|
description = description ?: existing.description,
|
||||||
|
tags = tags ?: existing.tags,
|
||||||
|
isPrivate = isPrivate,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING_UPDATE
|
||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
linkDao.updateLink(updated)
|
||||||
|
syncManager.syncNow()
|
||||||
AddLinkResult.Success
|
AddLinkResult.Success
|
||||||
} else {
|
} else {
|
||||||
AddLinkResult.Error("Update failed: ${response.code()}")
|
AddLinkResult.Error("Link not found")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try to add new link
|
// Vérifier si le lien existe déjà localement
|
||||||
|
val existingByUrl = linkDao.getLinkByUrl(url)
|
||||||
|
if (existingByUrl != null) {
|
||||||
|
AddLinkResult.Conflict(
|
||||||
|
existingLinkId = existingByUrl.id,
|
||||||
|
existingTitle = existingByUrl.title
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Essayer l'API directement
|
||||||
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { serverLink ->
|
||||||
|
serverLink.toEntity()?.let { entity ->
|
||||||
|
linkDao.insertLink(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
AddLinkResult.Success
|
AddLinkResult.Success
|
||||||
} else if (response.code() == 409) {
|
} else if (response.code() == 409) {
|
||||||
// Conflict - link already exists
|
|
||||||
// Try to parse the existing link from response body
|
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
val existingLink = parseExistingLink(errorBody)
|
val existingLink = parseExistingLink(errorBody)
|
||||||
AddLinkResult.Conflict(
|
AddLinkResult.Conflict(
|
||||||
@ -89,7 +214,10 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
existingTitle = existingLink?.title
|
existingTitle = existingLink?.title
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}")
|
// Fallback : créer localement
|
||||||
|
addLink(url, title, description, tags, isPrivate)
|
||||||
|
AddLinkResult.Success
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
@ -101,20 +229,14 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
existingTitle = existingLink?.title
|
existingTitle = existingLink?.title
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AddLinkResult.Error(e.message ?: "HTTP Error ${e.code()}")
|
// Fallback offline
|
||||||
|
addLink(url, title, description, tags, isPrivate)
|
||||||
|
AddLinkResult.Success
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
AddLinkResult.Error(e.message ?: "Unknown error")
|
// Fallback offline
|
||||||
}
|
addLink(url, title, description, tags, isPrivate)
|
||||||
}
|
AddLinkResult.Success
|
||||||
|
|
||||||
private fun parseExistingLink(errorBody: String?): LinkDto? {
|
|
||||||
if (errorBody.isNullOrBlank()) return null
|
|
||||||
return try {
|
|
||||||
val adapter = moshi.adapter(LinkDto::class.java)
|
|
||||||
adapter.fromJson(errorBody)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,56 +248,150 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
tags: List<String>?,
|
tags: List<String>?,
|
||||||
isPrivate: Boolean
|
isPrivate: Boolean
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return try {
|
val existing = linkDao.getLinkById(id)
|
||||||
val response =
|
?: return Result.failure(Exception("Link not found"))
|
||||||
api.updateLink(id, CreateLinkDto(url, title, description, tags, isPrivate))
|
|
||||||
if (response.isSuccessful) {
|
val updated = existing.copy(
|
||||||
Result.success(Unit)
|
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 {
|
} else {
|
||||||
Result.failure(Exception("Update failed: ${response.code()}"))
|
SyncStatus.PENDING_UPDATE
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
linkDao.updateLink(updated)
|
||||||
|
syncManager.syncNow()
|
||||||
|
|
||||||
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteLink(id: Int): Result<Unit> {
|
override suspend fun deleteLink(id: Int): Result<Unit> {
|
||||||
return try {
|
val existing = linkDao.getLinkById(id)
|
||||||
val response = api.deleteLink(id)
|
?: return Result.failure(Exception("Link not found"))
|
||||||
if (response.isSuccessful) {
|
|
||||||
Result.success(Unit)
|
if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||||
|
// Si jamais sync, supprimer directement
|
||||||
|
linkDao.deleteLink(id)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Delete failed: ${response.code()}"))
|
// Marquer pour suppression
|
||||||
}
|
linkDao.markForDeletion(id)
|
||||||
} catch (e: Exception) {
|
syncManager.syncNow()
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLink(id: Int): Result<ShaarliLink> {
|
// Décrémenter les compteurs de tags
|
||||||
return try {
|
existing.tags.forEach { tag ->
|
||||||
val link = api.getLink(id)
|
tagDao.decrementOccurrences(tag)
|
||||||
Result.success(LinkMapper.toDomain(link))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== Tags ======
|
||||||
|
|
||||||
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
||||||
|
// 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 {
|
return try {
|
||||||
val tags = api.getTags(offset = 0, limit = 500)
|
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) })
|
Result.success(tags.map { TagMapper.toDomain(it) })
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
|
fun getTagsFlow(): Flow<List<ShaarliTag>> {
|
||||||
|
return tagDao.getAllTags().map { tags ->
|
||||||
|
tags.map { ShaarliTag(it.name, it.occurrences) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Actions supplémentaires ======
|
||||||
|
|
||||||
|
suspend fun togglePin(id: Int): Result<Boolean> {
|
||||||
|
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<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
|
syncManager.performFullSync()
|
||||||
Result.success(links.map { LinkMapper.toDomain(it) })
|
Result.success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== Helpers ======
|
||||||
|
|
||||||
|
private fun parseExistingLink(errorBody: String?): LinkDto? {
|
||||||
|
if (errorBody.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
val adapter = moshi.adapter(LinkDto::class.java)
|
||||||
|
adapter.fromJson(errorBody)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt
Normal file
127
app/src/main/java/com/shaarit/data/sync/ConflictResolver.kt
Normal file
@ -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()
|
||||||
|
}
|
||||||
360
app/src/main/java/com/shaarit/data/sync/SyncManager.kt
Normal file
360
app/src/main/java/com/shaarit/data/sync/SyncManager.kt
Normal file
@ -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<SyncState> = 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<Int> = linkDao.getUnsyncedCount()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche une synchronisation manuelle
|
||||||
|
*/
|
||||||
|
fun syncNow() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.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()
|
||||||
|
}
|
||||||
@ -9,5 +9,10 @@ data class ShaarliLink(
|
|||||||
val description: String,
|
val description: String,
|
||||||
val tags: List<String>,
|
val tags: List<String>,
|
||||||
val isPrivate: Boolean,
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,6 +17,8 @@ interface LinkRepository {
|
|||||||
searchTags: String? = null
|
searchTags: String? = null
|
||||||
): Flow<PagingData<ShaarliLink>>
|
): Flow<PagingData<ShaarliLink>>
|
||||||
|
|
||||||
|
fun getLinkFlow(id: Int): Flow<ShaarliLink?>
|
||||||
|
|
||||||
suspend fun addLink(
|
suspend fun addLink(
|
||||||
url: String,
|
url: String,
|
||||||
title: String?,
|
title: String?,
|
||||||
|
|||||||
@ -6,24 +6,22 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.shaarit.ui.components.GlassCard
|
import coil.compose.AsyncImage
|
||||||
import com.shaarit.ui.components.GradientButton
|
import com.shaarit.ui.components.*
|
||||||
import com.shaarit.ui.components.PremiumTextField
|
|
||||||
import com.shaarit.ui.components.SectionHeader
|
|
||||||
import com.shaarit.ui.components.TagChip
|
|
||||||
import com.shaarit.ui.theme.*
|
import com.shaarit.ui.theme.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -42,13 +40,16 @@ fun AddLinkScreen(
|
|||||||
val availableTags by viewModel.availableTags.collectAsState()
|
val availableTags by viewModel.availableTags.collectAsState()
|
||||||
val isPrivate by viewModel.isPrivate.collectAsState()
|
val isPrivate by viewModel.isPrivate.collectAsState()
|
||||||
val tagSuggestions by viewModel.tagSuggestions.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() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showMarkdownEditor by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(uiState) {
|
LaunchedEffect(uiState) {
|
||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
is AddLinkUiState.Success -> {
|
is AddLinkUiState.Success -> {
|
||||||
// If this was a share intent, finish the activity to return to source app
|
|
||||||
if (onShareSuccess != null) {
|
if (onShareSuccess != null) {
|
||||||
onShareSuccess()
|
onShareSuccess()
|
||||||
} else {
|
} else {
|
||||||
@ -58,9 +59,7 @@ fun AddLinkScreen(
|
|||||||
is AddLinkUiState.Error -> {
|
is AddLinkUiState.Error -> {
|
||||||
snackbarHostState.showSnackbar(state.message)
|
snackbarHostState.showSnackbar(state.message)
|
||||||
}
|
}
|
||||||
is AddLinkUiState.Conflict -> {
|
is AddLinkUiState.Conflict -> {}
|
||||||
// Show conflict dialog - handled in AlertDialog below
|
|
||||||
}
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,32 +70,32 @@ fun AddLinkScreen(
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { viewModel.dismissConflict() },
|
onDismissRequest = { viewModel.dismissConflict() },
|
||||||
title = {
|
title = {
|
||||||
Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary)
|
Text("Lien déjà existant", fontWeight = FontWeight.Bold, color = TextPrimary)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text("A link with this URL already exists:", color = TextSecondary)
|
Text("Un lien avec cette URL existe déjà:", color = TextSecondary)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
conflict.existingTitle ?: "Untitled",
|
conflict.existingTitle ?: "Sans titre",
|
||||||
color = CyanPrimary,
|
color = CyanPrimary,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Would you like to update the existing link instead?",
|
"Voulez-vous mettre à jour le lien existant?",
|
||||||
color = TextSecondary
|
color = TextSecondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
|
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
|
||||||
Text("Update", color = CyanPrimary)
|
Text("Mettre à jour", color = CyanPrimary)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { viewModel.dismissConflict() }) {
|
TextButton(onClick = { viewModel.dismissConflict() }) {
|
||||||
Text("Cancel", color = TextMuted)
|
Text("Annuler", color = TextMuted)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = CardBackground,
|
containerColor = CardBackground,
|
||||||
@ -106,13 +105,10 @@ fun AddLinkScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
brush =
|
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(DeepNavy, DarkNavy)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -121,7 +117,7 @@ fun AddLinkScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"Add Link",
|
"Ajouter un lien",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@ -130,36 +126,33 @@ fun AddLinkScreen(
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ArrowBack,
|
Icons.Default.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Retour",
|
||||||
tint = TextPrimary
|
tint = TextPrimary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||||
titleContentColor = TextPrimary
|
titleContentColor = TextPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
containerColor =
|
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||||
android.graphics.Color.TRANSPARENT.let {
|
|
||||||
androidx.compose.ui.graphics.Color.Transparent
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
) {
|
) {
|
||||||
// URL Section
|
// URL Section avec extraction de métadonnées
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
SectionHeader(title = "URL", subtitle = "Required")
|
SectionHeader(title = "URL", subtitle = "Requis")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
PremiumTextField(
|
PremiumTextField(
|
||||||
value = url,
|
value = url,
|
||||||
onValueChange = { viewModel.url.value = it },
|
onValueChange = { viewModel.url.value = it },
|
||||||
@ -167,12 +160,69 @@ fun AddLinkScreen(
|
|||||||
placeholder = "https://example.com",
|
placeholder = "https://example.com",
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Share,
|
Icons.Default.Link,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = CyanPrimary
|
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,39 +230,73 @@ fun AddLinkScreen(
|
|||||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
title = "Title",
|
title = "Titre",
|
||||||
subtitle = "Optional - auto-fetched if empty"
|
subtitle = "Optionnel - auto-extrait si vide"
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
PremiumTextField(
|
PremiumTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { viewModel.title.value = it },
|
onValueChange = { viewModel.title.value = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = "Page title"
|
placeholder = "Titre de la page"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description Section
|
// Description Section avec MarkdownEditor
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (showMarkdownEditor) {
|
||||||
|
// Éditeur Markdown avancé
|
||||||
|
MarkdownEditor(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { viewModel.description.value = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
mode = EditorMode.SPLIT,
|
||||||
|
minHeight = 200.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Champ texte simple
|
||||||
PremiumTextField(
|
PremiumTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { viewModel.description.value = it },
|
onValueChange = { viewModel.description.value = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = "Add a description...",
|
placeholder = "Ajoutez une description...",
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
minLines = 3
|
minLines = 3
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tags Section
|
// Tags Section
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
SectionHeader(title = "Tags", subtitle = "Organize your links")
|
SectionHeader(title = "Tags", subtitle = "Organisez vos liens")
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
@ -242,7 +326,7 @@ fun AddLinkScreen(
|
|||||||
value = newTagInput,
|
value = newTagInput,
|
||||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = "Add tag..."
|
placeholder = "Ajouter un tag..."
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.addNewTag() },
|
onClick = { viewModel.addNewTag() },
|
||||||
@ -250,10 +334,8 @@ fun AddLinkScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = "Add tag",
|
contentDescription = "Ajouter tag",
|
||||||
tint =
|
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
|
||||||
if (newTagInput.isNotBlank()) CyanPrimary
|
|
||||||
else TextMuted
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -284,11 +366,11 @@ fun AddLinkScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popular tags from existing
|
// Popular tags
|
||||||
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Popular tags",
|
"Tags populaires",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = TextMuted,
|
color = TextMuted,
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
@ -321,13 +403,13 @@ fun AddLinkScreen(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Private",
|
"Privé",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Only you can see this link",
|
"Seul vous pouvez voir ce lien",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TextSecondary
|
color = TextSecondary
|
||||||
)
|
)
|
||||||
@ -335,8 +417,7 @@ fun AddLinkScreen(
|
|||||||
Switch(
|
Switch(
|
||||||
checked = isPrivate,
|
checked = isPrivate,
|
||||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||||
colors =
|
colors = SwitchDefaults.colors(
|
||||||
SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = CyanPrimary,
|
checkedThumbColor = CyanPrimary,
|
||||||
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
|
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
|
||||||
uncheckedThumbColor = TextMuted,
|
uncheckedThumbColor = TextMuted,
|
||||||
@ -350,7 +431,7 @@ fun AddLinkScreen(
|
|||||||
|
|
||||||
// Save Button
|
// Save Button
|
||||||
GradientButton(
|
GradientButton(
|
||||||
text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link",
|
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
|
||||||
onClick = { viewModel.addLink() },
|
onClick = { viewModel.addLink() },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||||
|
|||||||
@ -3,21 +3,29 @@ package com.shaarit.presentation.add
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.data.metadata.LinkMetadataExtractor
|
||||||
import com.shaarit.domain.model.ShaarliTag
|
import com.shaarit.domain.model.ShaarliTag
|
||||||
import com.shaarit.domain.repository.AddLinkResult
|
import com.shaarit.domain.repository.AddLinkResult
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import java.net.URLDecoder
|
import kotlinx.coroutines.FlowPreview
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 kotlinx.coroutines.launch
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AddLinkViewModel
|
class AddLinkViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedStateHandle) :
|
constructor(
|
||||||
ViewModel() {
|
private val linkRepository: LinkRepository,
|
||||||
|
private val metadataExtractor: LinkMetadataExtractor,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
|
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
|
||||||
private val initialUrl: String? = savedStateHandle["url"]
|
private val initialUrl: String? = savedStateHandle["url"]
|
||||||
@ -31,6 +39,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
var description = MutableStateFlow("")
|
var description = MutableStateFlow("")
|
||||||
var isPrivate = MutableStateFlow(false)
|
var isPrivate = MutableStateFlow(false)
|
||||||
|
|
||||||
|
// Extraction state
|
||||||
|
private val _isExtractingMetadata = MutableStateFlow(false)
|
||||||
|
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
|
||||||
|
|
||||||
|
private val _extractedThumbnail = MutableStateFlow<String?>(null)
|
||||||
|
val extractedThumbnail = _extractedThumbnail.asStateFlow()
|
||||||
|
|
||||||
|
private val _contentType = MutableStateFlow<String?>(null)
|
||||||
|
val contentType = _contentType.asStateFlow()
|
||||||
|
|
||||||
// New tag management
|
// New tag management
|
||||||
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
|
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
|
||||||
val selectedTags = _selectedTags.asStateFlow()
|
val selectedTags = _selectedTags.asStateFlow()
|
||||||
@ -49,17 +67,75 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadAvailableTags()
|
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 */
|
/** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */
|
||||||
private fun decodeUrlParam(param: String?): String? {
|
private fun decodeUrlParam(param: String?): String? {
|
||||||
if (param.isNullOrBlank()) return null
|
if (param.isNullOrBlank()) return null
|
||||||
return try {
|
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()
|
URLDecoder.decode(param, "UTF-8").replace("+", " ").trim()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// If decoding fails, just replace + with spaces
|
|
||||||
param.replace("+", " ").trim()
|
param.replace("+", " ").trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,7 +198,6 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = AddLinkUiState.Loading
|
_uiState.value = AddLinkUiState.Loading
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
val currentUrl = url.value
|
val currentUrl = url.value
|
||||||
if (currentUrl.isBlank()) {
|
if (currentUrl.isBlank()) {
|
||||||
_uiState.value = AddLinkUiState.Error("URL is required")
|
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||||
@ -148,8 +223,8 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
conflictLinkId = result.existingLinkId
|
conflictLinkId = result.existingLinkId
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
AddLinkUiState.Conflict(
|
AddLinkUiState.Conflict(
|
||||||
existingLinkId = result.existingLinkId,
|
result.existingLinkId,
|
||||||
existingTitle = result.existingTitle
|
result.existingTitle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is AddLinkResult.Error -> {
|
is AddLinkResult.Error -> {
|
||||||
@ -160,20 +235,24 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun forceUpdateExistingLink() {
|
fun forceUpdateExistingLink() {
|
||||||
val linkId = conflictLinkId ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val currentUrl = url.value
|
||||||
|
if (currentUrl.isBlank()) {
|
||||||
|
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.value = AddLinkUiState.Loading
|
_uiState.value = AddLinkUiState.Loading
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
linkRepository.addOrUpdateLink(
|
linkRepository.addOrUpdateLink(
|
||||||
url = url.value,
|
url = currentUrl,
|
||||||
title = title.value.ifBlank { null },
|
title = title.value.ifBlank { null },
|
||||||
description = description.value.ifBlank { null },
|
description = description.value.ifBlank { null },
|
||||||
tags = _selectedTags.value.ifEmpty { null },
|
tags = _selectedTags.value.ifEmpty { null },
|
||||||
isPrivate = isPrivate.value,
|
isPrivate = isPrivate.value,
|
||||||
forceUpdate = true,
|
forceUpdate = true,
|
||||||
existingLinkId = linkId
|
existingLinkId = conflictLinkId
|
||||||
)
|
)
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
@ -184,19 +263,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
|||||||
_uiState.value = AddLinkUiState.Error(result.message)
|
_uiState.value = AddLinkUiState.Error(result.message)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
_uiState.value = AddLinkUiState.Error("Unexpected error")
|
_uiState.value = AddLinkUiState.Error("Unexpected result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissConflict() {
|
fun dismissConflict() {
|
||||||
conflictLinkId = null
|
|
||||||
_uiState.value = AddLinkUiState.Idle
|
_uiState.value = AddLinkUiState.Idle
|
||||||
|
conflictLinkId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy compatibility for old comma-separated tags input
|
|
||||||
@Deprecated("Use selectedTags instead") var tags = MutableStateFlow("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AddLinkUiState {
|
sealed class AddLinkUiState {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltView
|
|||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
var url by remember { mutableStateOf("") }
|
var url by remember { mutableStateOf(viewModel.getInitialUrl()) }
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var showSecret by remember { mutableStateOf(false) }
|
var showSecret by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.shaarit.presentation.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.data.sync.SyncManager
|
||||||
import com.shaarit.domain.repository.AuthRepository
|
import com.shaarit.domain.repository.AuthRepository
|
||||||
import com.shaarit.domain.usecase.LoginUseCase
|
import com.shaarit.domain.usecase.LoginUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@ -15,7 +16,8 @@ class LoginViewModel
|
|||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val loginUseCase: LoginUseCase,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
||||||
@ -27,6 +29,7 @@ constructor(
|
|||||||
|
|
||||||
private fun checkLoginStatus() {
|
private fun checkLoginStatus() {
|
||||||
if (authRepository.isLoggedIn()) {
|
if (authRepository.isLoggedIn()) {
|
||||||
|
syncManager.syncNow()
|
||||||
_uiState.value = LoginUiState.Success
|
_uiState.value = LoginUiState.Success
|
||||||
} else {
|
} else {
|
||||||
// Pre-fill URL if available
|
// Pre-fill URL if available
|
||||||
@ -42,13 +45,20 @@ constructor(
|
|||||||
_uiState.value = LoginUiState.Loading
|
_uiState.value = LoginUiState.Loading
|
||||||
val result = loginUseCase(url, secret)
|
val result = loginUseCase(url, secret)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { _uiState.value = LoginUiState.Success },
|
onSuccess = {
|
||||||
|
syncManager.syncNow()
|
||||||
|
_uiState.value = LoginUiState.Success
|
||||||
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
_uiState.value = LoginUiState.Error(it.message ?: "Unknown Error")
|
_uiState.value = LoginUiState.Error(it.message ?: "Unknown Error")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getInitialUrl(): String {
|
||||||
|
return authRepository.getBaseUrl() ?: "https://bm.dracodev.net"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class LoginUiState {
|
sealed class LoginUiState {
|
||||||
|
|||||||
@ -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<List<androidx.compose.ui.layout.Placeable>>()
|
||||||
|
val rowWidths = mutableListOf<Int>()
|
||||||
|
val rowHeights = mutableListOf<Int>()
|
||||||
|
|
||||||
|
var currentRow = mutableListOf<androidx.compose.ui.layout.Placeable>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<List<CollectionUiModel>>(emptyList())
|
||||||
|
val collections: StateFlow<List<CollectionUiModel>> = _collections.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ContentType, Int>
|
||||||
|
) {
|
||||||
|
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<TagStat>
|
||||||
|
) {
|
||||||
|
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<ActivityPoint>
|
||||||
|
) {
|
||||||
|
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<ActivityPoint>
|
||||||
|
) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<DashboardStats> = _stats.asStateFlow()
|
||||||
|
|
||||||
|
private val _tagStats = MutableStateFlow<List<TagStat>>(emptyList())
|
||||||
|
val tagStats: StateFlow<List<TagStat>> = _tagStats.asStateFlow()
|
||||||
|
|
||||||
|
private val _contentTypeStats = MutableStateFlow<Map<ContentType, Int>>(emptyMap())
|
||||||
|
val contentTypeStats: StateFlow<Map<ContentType, Int>> = _contentTypeStats.asStateFlow()
|
||||||
|
|
||||||
|
private val _activityData = MutableStateFlow<List<ActivityPoint>>(emptyList())
|
||||||
|
val activityData: StateFlow<List<ActivityPoint>> = _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<String, Int>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,9 @@ fun FeedScreen(
|
|||||||
onNavigateToAdd: () -> Unit,
|
onNavigateToAdd: () -> Unit,
|
||||||
onNavigateToEdit: (Int) -> Unit = {},
|
onNavigateToEdit: (Int) -> Unit = {},
|
||||||
onNavigateToTags: () -> Unit = {},
|
onNavigateToTags: () -> Unit = {},
|
||||||
|
onNavigateToCollections: () -> Unit = {},
|
||||||
|
onNavigateToSettings: () -> Unit = {},
|
||||||
|
onNavigateToRandom: () -> Unit = {},
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
viewModel: FeedViewModel = hiltViewModel()
|
viewModel: FeedViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
@ -54,7 +57,10 @@ fun FeedScreen(
|
|||||||
|
|
||||||
val pullRefreshState = rememberPullRefreshState(
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||||
onRefresh = { pagingItems.refresh() }
|
onRefresh = {
|
||||||
|
viewModel.refresh()
|
||||||
|
pagingItems.refresh()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@ -80,7 +86,10 @@ fun FeedScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Refresh Button
|
// Refresh Button
|
||||||
IconButton(onClick = { pagingItems.refresh() }) {
|
IconButton(onClick = {
|
||||||
|
viewModel.refresh()
|
||||||
|
pagingItems.refresh()
|
||||||
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Refresh,
|
imageVector = Icons.Default.Refresh,
|
||||||
contentDescription = "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
|
// Tags button
|
||||||
TextButton(onClick = onNavigateToTags) {
|
IconButton(onClick = onNavigateToTags) {
|
||||||
Text(
|
Icon(
|
||||||
text = "#",
|
imageVector = Icons.Default.Label,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
contentDescription = "Tags",
|
||||||
fontWeight = FontWeight.Bold,
|
tint = CyanPrimary
|
||||||
color = TealSecondary
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings button
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = "Settings",
|
||||||
|
tint = CyanPrimary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -299,8 +334,15 @@ fun FeedScreen(
|
|||||||
color = ErrorRed
|
color = ErrorRed
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextButton(onClick = { pagingItems.refresh() }) {
|
IconButton(onClick = {
|
||||||
Text("Retry", color = CyanPrimary)
|
viewModel.refresh()
|
||||||
|
pagingItems.refresh()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
tint = CyanPrimary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
|
import com.shaarit.data.sync.SyncManager
|
||||||
import com.shaarit.domain.model.ShaarliLink
|
import com.shaarit.domain.model.ShaarliLink
|
||||||
import com.shaarit.domain.model.ViewStyle
|
import com.shaarit.domain.model.ViewStyle
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
@ -20,7 +21,10 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@HiltViewModel
|
@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("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery = _searchQuery.asStateFlow()
|
val searchQuery = _searchQuery.asStateFlow()
|
||||||
@ -82,6 +86,7 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
syncManager.syncNow()
|
||||||
_refreshTrigger.value++
|
_refreshTrigger.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Edit
|
|||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@ -43,7 +44,8 @@ fun ListViewItem(
|
|||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onViewClick: () -> Unit,
|
onViewClick: () -> Unit,
|
||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit,
|
||||||
|
onTogglePin: (Int) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -85,6 +87,18 @@ fun ListViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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)) {
|
IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Visibility,
|
imageVector = Icons.Default.Visibility,
|
||||||
@ -176,7 +190,8 @@ fun GridViewItem(
|
|||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onViewClick: () -> Unit,
|
onViewClick: () -> Unit,
|
||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit,
|
||||||
|
onTogglePin: (Int) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -202,16 +217,32 @@ fun GridViewItem(
|
|||||||
verticalArrangement = Arrangement.SpaceBetween
|
verticalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Title
|
// Title with pin indicator
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = link.title,
|
text = link.title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = CyanPrimary,
|
color = CyanPrimary,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
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))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// Description with Markdown
|
// Description with Markdown
|
||||||
@ -277,6 +308,18 @@ fun GridViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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(
|
IconButton(
|
||||||
onClick = onViewClick,
|
onClick = onViewClick,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
@ -326,7 +369,8 @@ fun CompactViewItem(
|
|||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
onViewClick: () -> Unit,
|
onViewClick: () -> Unit,
|
||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit,
|
||||||
|
onTogglePin: (Int) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -360,6 +404,15 @@ fun CompactViewItem(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
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) {
|
if (link.isPrivate) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Lock,
|
Icons.Default.Lock,
|
||||||
@ -403,6 +456,17 @@ fun CompactViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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)) {
|
IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Visibility,
|
imageVector = Icons.Default.Visibility,
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
package com.shaarit.presentation.nav
|
package com.shaarit.presentation.nav
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.navigation.navDeepLink
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
sealed class Screen(val route: String) {
|
sealed class Screen(val route: String) {
|
||||||
@ -22,13 +26,17 @@ sealed class Screen(val route: String) {
|
|||||||
fun createRoute(linkId: Int): String = "edit/$linkId"
|
fun createRoute(linkId: Int): String = "edit/$linkId"
|
||||||
}
|
}
|
||||||
object Tags : Screen("tags")
|
object Tags : Screen("tags")
|
||||||
|
object Collections : Screen("collections")
|
||||||
|
object Dashboard : Screen("dashboard")
|
||||||
|
object Settings : Screen("settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavGraph(
|
fun AppNavGraph(
|
||||||
startDestination: String = Screen.Login.route,
|
startDestination: String = Screen.Login.route,
|
||||||
shareUrl: String? = null,
|
shareUrl: String? = null,
|
||||||
shareTitle: String? = null
|
shareTitle: String? = null,
|
||||||
|
initialDeepLink: String? = null
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -47,6 +55,11 @@ fun AppNavGraph(
|
|||||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
||||||
popUpTo(Screen.Login.route) { inclusive = 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 {
|
} else {
|
||||||
navController.navigate(Screen.Feed.createRoute()) {
|
navController.navigate(Screen.Feed.createRoute()) {
|
||||||
popUpTo(Screen.Login.route) { inclusive = true }
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
@ -64,6 +77,10 @@ fun AppNavGraph(
|
|||||||
nullable = true
|
nullable = true
|
||||||
defaultValue = null
|
defaultValue = null
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
deepLinks = listOf(
|
||||||
|
navDeepLink { uriPattern = "shaarit://feed" },
|
||||||
|
navDeepLink { uriPattern = "shaarit://search" }
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val tag = backStackEntry.arguments?.getString("tag")
|
val tag = backStackEntry.arguments?.getString("tag")
|
||||||
@ -73,6 +90,9 @@ fun AppNavGraph(
|
|||||||
navController.navigate(Screen.Edit.createRoute(linkId))
|
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||||
},
|
},
|
||||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||||
|
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
||||||
|
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||||
|
onNavigateToRandom = { },
|
||||||
initialTagFilter = tag
|
initialTagFilter = tag
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -94,6 +114,9 @@ fun AppNavGraph(
|
|||||||
type = NavType.BoolType
|
type = NavType.BoolType
|
||||||
defaultValue = false
|
defaultValue = false
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
deepLinks = listOf(
|
||||||
|
navDeepLink { uriPattern = "shaarit://add" }
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false
|
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(
|
com.shaarit.presentation.tags.TagsScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToFeedWithTag = { tag ->
|
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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _syncStatus = MutableStateFlow<SyncUiStatus>(SyncUiStatus.Synced("Jamais"))
|
||||||
|
val syncStatus: StateFlow<SyncUiStatus> = _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
|
||||||
|
)
|
||||||
44
app/src/main/java/com/shaarit/service/AddLinkTileService.kt
Normal file
44
app/src/main/java/com/shaarit/service/AddLinkTileService.kt
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
383
app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt
Normal file
383
app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt
Normal file
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,11 @@ package com.shaarit.ui.theme
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
@ -84,25 +87,53 @@ private val LightColorScheme =
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShaarItTheme(
|
fun ShaarItTheme(
|
||||||
darkTheme: Boolean = true, // Default to dark theme for premium look
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = false, // Disable dynamic color to maintain brand
|
dynamicColor: Boolean = true, // Enable Material You by default
|
||||||
|
oledMode: Boolean = false, // Pure black for OLED screens
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
val colorScheme =
|
val colorScheme =
|
||||||
when {
|
when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
|
// Material You (Monet) - dynamic colors from wallpaper
|
||||||
else lightColorScheme()
|
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 -> {
|
||||||
|
if (oledMode) {
|
||||||
|
// OLED pure black variant of custom theme
|
||||||
|
DarkColorScheme.copy(
|
||||||
|
background = Color.Black,
|
||||||
|
surface = Color(0xFF0A0A0A),
|
||||||
|
surfaceVariant = Color(0xFF1A1A1A)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DarkColorScheme
|
||||||
|
}
|
||||||
}
|
}
|
||||||
darkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = DeepNavy.toArgb()
|
window.statusBarColor = colorScheme.background.toArgb()
|
||||||
window.navigationBarColor = DeepNavy.toArgb()
|
window.navigationBarColor = colorScheme.background.toArgb()
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt
Normal file
135
app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt
Normal file
101
app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt
Normal file
@ -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<WidgetLinkItem> = 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
|
||||||
|
)
|
||||||
9
app/src/main/res/drawable/widget_background.xml
Normal file
9
app/src/main/res/drawable/widget_background.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#1B2838" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#00D4AA" />
|
||||||
|
</shape>
|
||||||
10
app/src/main/res/drawable/widget_item_background.xml
Normal file
10
app/src/main/res/drawable/widget_item_background.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="#33FFFFFF">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#243447" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</ripple>
|
||||||
43
app/src/main/res/layout/widget_list_item.xml
Normal file
43
app/src/main/res/layout/widget_list_item.xml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_item_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/widget_item_background"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_url"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_tags"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#00D4AA"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
74
app/src/main/res/layout/widget_shaarli.xml
Normal file
74
app/src/main/res/layout/widget_shaarli.xml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_btn_add"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/add_link"
|
||||||
|
android:src="@android:drawable/ic_input_add"
|
||||||
|
android:tint="@android:color/white" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_btn_refresh"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/refresh"
|
||||||
|
android:src="@android:drawable/ic_popup_sync"
|
||||||
|
android:tint="@android:color/white" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_btn_random"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/random"
|
||||||
|
android:src="@android:drawable/ic_menu_sort_by_size"
|
||||||
|
android:tint="@android:color/white" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- List of links -->
|
||||||
|
<ListView
|
||||||
|
android:id="@+id/widget_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:divider="@android:color/transparent"
|
||||||
|
android:dividerHeight="4dp" />
|
||||||
|
|
||||||
|
<!-- Empty view -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_empty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/no_links"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@ -1,4 +1,85 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ShaarIt</string>
|
<string name="app_name">ShaarIt</string>
|
||||||
|
|
||||||
|
<!-- Widget -->
|
||||||
|
<string name="add_link">Ajouter un lien</string>
|
||||||
|
<string name="refresh">Rafraîchir</string>
|
||||||
|
<string name="random">Aléatoire</string>
|
||||||
|
<string name="no_links">Aucun lien</string>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<string name="pin">Épingler</string>
|
||||||
|
<string name="unpin">Désépingler</string>
|
||||||
|
<string name="delete">Supprimer</string>
|
||||||
|
<string name="edit">Modifier</string>
|
||||||
|
<string name="share">Partager</string>
|
||||||
|
<string name="copy_url">Copier l\'URL</string>
|
||||||
|
<string name="open_in_browser">Ouvrir dans le navigateur</string>
|
||||||
|
|
||||||
|
<!-- Editor Modes -->
|
||||||
|
<string name="mode_edit">Éditer</string>
|
||||||
|
<string name="mode_preview">Aperçu</string>
|
||||||
|
<string name="mode_split">Split</string>
|
||||||
|
|
||||||
|
<!-- Sync -->
|
||||||
|
<string name="syncing">Synchronisation…</string>
|
||||||
|
<string name="sync_complete">Synchronisé</string>
|
||||||
|
<string name="sync_error">Erreur de synchronisation</string>
|
||||||
|
<string name="offline_mode">Mode hors-ligne</string>
|
||||||
|
|
||||||
|
<!-- Collections -->
|
||||||
|
<string name="collections">Collections</string>
|
||||||
|
<string name="new_collection">Nouvelle collection</string>
|
||||||
|
<string name="smart_collection">Collection intelligente</string>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<string name="search">Rechercher</string>
|
||||||
|
<string name="search_hint">Rechercher dans les liens…</string>
|
||||||
|
<string name="filter_today">Aujourd\'hui</string>
|
||||||
|
<string name="filter_week">Cette semaine</string>
|
||||||
|
<string name="filter_month">Ce mois</string>
|
||||||
|
<!-- App Shortcuts -->
|
||||||
|
<string name="shortcut_add_link_short">Ajouter</string>
|
||||||
|
<string name="shortcut_add_link_long">Ajouter un lien</string>
|
||||||
|
<string name="shortcut_add_link_disabled">Ajout de lien désactivé</string>
|
||||||
|
|
||||||
|
<string name="shortcut_random_short">Aléatoire</string>
|
||||||
|
<string name="shortcut_random_long">Lien aléatoire</string>
|
||||||
|
<string name="shortcut_random_disabled">Aléatoire désactivé</string>
|
||||||
|
|
||||||
|
<string name="shortcut_search_short">Rechercher</string>
|
||||||
|
<string name="shortcut_search_long">Rechercher des liens</string>
|
||||||
|
<string name="shortcut_search_disabled">Recherche désactivée</string>
|
||||||
|
|
||||||
|
<string name="shortcut_collections_short">Collections</string>
|
||||||
|
<string name="shortcut_collections_long">Voir les collections</string>
|
||||||
|
<string name="shortcut_collections_disabled">Collections désactivées</string>
|
||||||
|
|
||||||
|
<!-- Quick Settings Tile -->
|
||||||
|
<string name="tile_add_link">Ajouter un lien</string>
|
||||||
|
<string name="tile_add_link_desc">Ajouter rapidement un bookmark à ShaarIt</string>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<string name="dashboard">Tableau de bord</string>
|
||||||
|
<string name="total_links">Liens totaux</string>
|
||||||
|
<string name="links_this_week">Cette semaine</string>
|
||||||
|
<string name="links_this_month">Ce mois</string>
|
||||||
|
<string name="most_used_tags">Tags les plus utilisés</string>
|
||||||
|
<string name="reading_stats">Statistiques de lecture</string>
|
||||||
|
<string name="estimated_reading_time">Temps de lecture estimé</string>
|
||||||
|
<string name="links_by_type">Liens par type</string>
|
||||||
|
<string name="activity_overview">Aperçu d\'activité</string>
|
||||||
|
|
||||||
|
<!-- Export/Import -->
|
||||||
|
<string name="export">Exporter</string>
|
||||||
|
<string name="import_bookmarks">Importer</string>
|
||||||
|
<string name="export_json">Exporter en JSON</string>
|
||||||
|
<string name="export_csv">Exporter en CSV</string>
|
||||||
|
<string name="import_html">Importer depuis HTML</string>
|
||||||
|
<string name="import_success">Importation réussie</string>
|
||||||
|
<string name="import_error">Erreur d\'importation</string>
|
||||||
|
<string name="export_success">Exportation réussie</string>
|
||||||
|
<string name="export_error">Erreur d\'exportation</string>
|
||||||
|
<string name="select_file">Sélectionner un fichier</string>
|
||||||
</resources>
|
</resources>
|
||||||
66
app/src/main/res/xml/shortcuts.xml
Normal file
66
app/src/main/res/xml/shortcuts.xml
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Add Link Shortcut -->
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="add_link"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_add_link_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_add_link_long"
|
||||||
|
android:shortcutDisabledMessage="@string/shortcut_add_link_disabled">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="${applicationId}"
|
||||||
|
android:targetClass="com.shaarit.MainActivity"
|
||||||
|
android:data="shaarit://add" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<!-- Random Link Shortcut -->
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="random_link"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_random_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_random_long"
|
||||||
|
android:shortcutDisabledMessage="@string/shortcut_random_disabled">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="${applicationId}"
|
||||||
|
android:targetClass="com.shaarit.MainActivity"
|
||||||
|
android:data="shaarit://random" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<!-- Search Shortcut -->
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="search"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_search_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_search_long"
|
||||||
|
android:shortcutDisabledMessage="@string/shortcut_search_disabled">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="${applicationId}"
|
||||||
|
android:targetClass="com.shaarit.MainActivity"
|
||||||
|
android:data="shaarit://search" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<!-- Collections Shortcut -->
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="collections"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_collections_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_collections_long"
|
||||||
|
android:shortcutDisabledMessage="@string/shortcut_collections_disabled">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="${applicationId}"
|
||||||
|
android:targetClass="com.shaarit.MainActivity"
|
||||||
|
android:data="shaarit://collections" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
</shortcuts>
|
||||||
10
app/src/main/res/xml/widget_info.xml
Normal file
10
app/src/main/res/xml/widget_info.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:initialKeyguardLayout="@layout/widget_shaarli"
|
||||||
|
android:initialLayout="@layout/widget_shaarli"
|
||||||
|
android:minWidth="300dp"
|
||||||
|
android:minHeight="200dp"
|
||||||
|
android:previewImage="@drawable/ic_launcher_foreground"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:widgetCategory="home_screen|keyguard" />
|
||||||
@ -4,4 +4,5 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.hilt) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
moshi.generateAdapter.source=ksp
|
moshi.generateAdapter.source=ksp
|
||||||
|
org.gradle.java.home=C:\\Users\\bruno\\scoop\\apps\\temurin17-jdk\\current
|
||||||
@ -19,6 +19,11 @@ paging = "3.2.1"
|
|||||||
pagingCompose = "3.2.1"
|
pagingCompose = "3.2.1"
|
||||||
material = "1.11.0"
|
material = "1.11.0"
|
||||||
composeMarkdown = "0.4.1"
|
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]
|
[libraries]
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
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" }
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user