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
|
||||
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
|
||||
- **Recherche côté serveur** : Recherche par termes et filtrage par tags
|
||||
- **Ajout rapide** : Création de liens privés/publics avec description et tags
|
||||
- **Édition** : Modification complète des liens existants
|
||||
- **Suppression** : Gestion facile des favoris
|
||||
- **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags
|
||||
- **Mode hors-ligne** : Consultation et modification des liens même sans connexion
|
||||
- **Ajout rapide** : Création de liens privés/publics avec description, tags et extraction automatique de métadonnées
|
||||
- **Édition** : Modification complète des liens existants avec éditeur Markdown
|
||||
- **Suppression** : Gestion facile des favoris avec file d'attente de sync
|
||||
- **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour
|
||||
- **Liens épinglés** : Mise en avant des liens importants
|
||||
|
||||
### 🏷️ Gestion des Tags
|
||||
- Vue dédiée pour parcourir tous les tags
|
||||
- Compteur d'utilisation par tag
|
||||
- Filtrage rapide du flux par tag
|
||||
- Tags favoris pour accès rapide
|
||||
|
||||
### 📁 Collections
|
||||
- Organisation des liens en collections
|
||||
- Collections intelligentes avec filtres automatiques
|
||||
- Vue grille adaptative pour les collections
|
||||
|
||||
### 📝 Éditeur Markdown
|
||||
- Édition avec prévisualisation en temps réel
|
||||
- Mode édition/prévisualisation/split
|
||||
- Mode lecture focus sans distraction
|
||||
- Barre d'outils de formatage
|
||||
|
||||
### 🌐 Extraction de Métadonnées
|
||||
- Extraction automatique des OpenGraph (titre, description, image)
|
||||
- Détection du type de contenu (article, vidéo, image, audio, code, etc.)
|
||||
- Estimation du temps de lecture
|
||||
- Extraction du nom du site
|
||||
|
||||
### 📊 Tableau de Bord
|
||||
- Statistiques d'utilisation (liens totaux, cette semaine, ce mois)
|
||||
- Temps de lecture total et moyen
|
||||
- Répartition par type de contenu
|
||||
- Tags les plus utilisés
|
||||
- Graphique d'activité sur 30 jours
|
||||
|
||||
### 💾 Import/Export
|
||||
- Export JSON (format complet avec métadonnées)
|
||||
- Export CSV (compatible Excel)
|
||||
- Export HTML (format Netscape/Chrome bookmarks)
|
||||
- Import depuis JSON (export ShaarIt)
|
||||
- Import depuis HTML (bookmarks Chrome/Firefox)
|
||||
|
||||
### 🔄 Synchronisation
|
||||
- Synchronisation automatique en arrière-plan (WorkManager)
|
||||
- Mode offline-first : modifications en attente
|
||||
- Résolution de conflits intelligente
|
||||
- File d'attente des opérations
|
||||
|
||||
### 🔗 Intégration système
|
||||
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (navigateur, YouTube, etc.) via le menu Partager
|
||||
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app
|
||||
- **App Shortcuts** : Accès rapide via appui long sur l'icône (Ajouter, Aléatoire, Rechercher, Collections)
|
||||
- **Quick Settings Tile** : Tuile pour ajouter rapidement un lien
|
||||
- **Widget** : Widget d'accueil affichant les liens récents
|
||||
- Ouverture des liens dans le navigateur par défaut
|
||||
- Support des URLs partagées avec titre pré-rempli
|
||||
|
||||
### 🎨 Interface Utilisateur
|
||||
- **Design premium** : Thème sombre moderne avec dégradés cyan/bleu
|
||||
- **Material You (Monet)** : Couleurs dynamiques basées sur le fond d'écran (Android 12+)
|
||||
- **Mode OLED** : Noir pur pour les écrans AMOLED
|
||||
- **Design premium** : Thème sombre moderne avec dégradés
|
||||
- **Material Design 3** : Composants UI natifs Android
|
||||
- **Animations fluides** : Transitions et effets visuels
|
||||
- **Deux modes d'affichage** : Liste détaillée ou grille compacte
|
||||
- **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte
|
||||
- **Pull-to-refresh** : Actualisation du flux par glissement
|
||||
|
||||
---
|
||||
@ -47,13 +91,15 @@
|
||||
|
||||
| Catégorie | Technologie |
|
||||
|-----------|-------------|
|
||||
| **Langage** | Kotlin 1.9.20 |
|
||||
| **Langage** | Kotlin 2.0.0 |
|
||||
| **UI** | Jetpack Compose + Material Design 3 |
|
||||
| **Architecture** | Clean Architecture + MVVM |
|
||||
| **Injection de dépendances** | Dagger Hilt 2.48.1 |
|
||||
| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
|
||||
| **Injection de dépendances** | Dagger Hilt 2.51.1 |
|
||||
| **Réseau** | Retrofit 2.11.0 + Moshi 1.15.1 + OkHttp 4.12.0 |
|
||||
| **Base de données locale** | Room 2.6.1 |
|
||||
| **Pagination** | Paging 3 |
|
||||
| **Concurrence** | Coroutines & Flow |
|
||||
| **Background work** | WorkManager 2.9.0 |
|
||||
| **Stockage sécurisé** | AndroidX Security Crypto |
|
||||
| **Navigation** | Navigation Compose |
|
||||
| **Compilation** | Gradle 8.0+ avec KSP |
|
||||
@ -79,274 +125,125 @@ Récupérez le dernier APK depuis la section [Releases](../../releases).
|
||||
|
||||
#### Méthode 2 : Compilation depuis les sources
|
||||
|
||||
##### Prérequis de développement
|
||||
1. **JDK 17** (ou plus récent) installé
|
||||
2. **Android SDK** installé (Platform API 34)
|
||||
3. **Gradle** 8.0+ (si `gradlew` est manquant)
|
||||
|
||||
##### Étapes de compilation
|
||||
|
||||
1. **Cloner le repository**
|
||||
```bash
|
||||
git clone https://github.com/votre-username/ShaarIt.git
|
||||
cd ShaarIt
|
||||
```
|
||||
|
||||
2. **Configurer l'emplacement du SDK Android**
|
||||
|
||||
Si la variable `ANDROID_HOME` n'est pas définie, créez un fichier `local.properties` :
|
||||
```bash
|
||||
# Windows
|
||||
echo sdk.dir=C:\Users\<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
|
||||
|
||||
1. Clonez le repository :
|
||||
```bash
|
||||
# Tests unitaires
|
||||
./gradlew test
|
||||
|
||||
# Tests instrumentés
|
||||
./gradlew connectedAndroidTest
|
||||
git clone https://github.com/votre-username/ShaarIt.git
|
||||
cd ShaarIt
|
||||
```
|
||||
|
||||
### Build de release
|
||||
|
||||
1. **Créer un keystore** (si premier build)
|
||||
```bash
|
||||
keytool -genkey -v -keystore shaarit.keystore -alias shaarit \
|
||||
-keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
2. **Créer `keystore.properties`** dans le répertoire racine :
|
||||
```properties
|
||||
storeFile=shaarit.keystore
|
||||
storePassword=votre_password
|
||||
keyAlias=shaarit
|
||||
keyPassword=votre_password
|
||||
```
|
||||
|
||||
3. **Compiler**
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
4. **L'APK signé se trouve dans** : `app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
### Dépendances principales
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
kotlin = "1.9.20"
|
||||
hilt = "2.48.1"
|
||||
retrofit = "2.9.0"
|
||||
moshi = "1.15.0"
|
||||
okhttp = "4.12.0"
|
||||
paging = "3.2.1"
|
||||
composeBom = "2023.08.00"
|
||||
|
||||
[libraries]
|
||||
# Injection de dépendances
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
|
||||
# Réseau
|
||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
|
||||
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
# Pagination
|
||||
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
|
||||
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
|
||||
|
||||
# Sécurité
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" }
|
||||
2. Compilez l'APK debug :
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
### Contribution
|
||||
L'APK sera généré dans `app/build/outputs/apk/debug/`
|
||||
|
||||
1. Forker le projet
|
||||
2. Créer une branche feature (`git checkout -b feature/amazing-feature`)
|
||||
3. Committer vos changements (`git commit -m 'Add amazing feature'`)
|
||||
4. Pusher sur la branche (`git push origin feature/amazing-feature`)
|
||||
5. Ouvrir une Pull Request
|
||||
3. Ou compilez l'APK release (nécessite une configuration de signature) :
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licence
|
||||
## 📖 Guide d'utilisation
|
||||
|
||||
### Première configuration
|
||||
1. Ouvrez l'application
|
||||
2. Entrez l'URL de votre instance Shaarli
|
||||
3. Entrez votre nom d'utilisateur et mot de passe
|
||||
4. L'application générera automatiquement les tokens API nécessaires
|
||||
|
||||
### Ajouter un lien
|
||||
- **Via l'app** : Appuyez sur le bouton + et entrez l'URL
|
||||
- **Via le partage Android** : Partagez n'importe quelle URL vers ShaarIt depuis n'importe quelle app
|
||||
- **Via Quick Settings** : Ajoutez la tuile ShaarIt dans vos paramètres rapides
|
||||
|
||||
### Organiser vos liens
|
||||
- Utilisez les tags pour catégoriser vos liens
|
||||
- Créez des collections pour regrouper des liens par thème
|
||||
- Épinglez les liens importants pour un accès rapide
|
||||
|
||||
### Mode hors-ligne
|
||||
- Tous les liens sont stockés localement dans la base de données Room
|
||||
- Les modifications sont synchronisées automatiquement quand la connexion est disponible
|
||||
- Consultez vos favoris même sans connexion Internet
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Le projet suit les principes de la **Clean Architecture** avec une séparation claire des couches :
|
||||
|
||||
```
|
||||
├── data/ # Couche de données
|
||||
│ ├── api/ # API Retrofit
|
||||
│ ├── local/ # Base de données Room
|
||||
│ │ ├── dao/ # Data Access Objects
|
||||
│ │ ├── entity/ # Entités Room
|
||||
│ │ └── database/ # Configuration de la DB
|
||||
│ ├── sync/ # Synchronisation
|
||||
│ ├── export/ # Import/Export
|
||||
│ └── repository/ # Implémentations des repositories
|
||||
├── domain/ # Couche domaine
|
||||
│ ├── model/ # Modèles métier
|
||||
│ └── repository/ # Interfaces des repositories
|
||||
├── presentation/ # Couche présentation
|
||||
│ ├── feed/ # Écran principal
|
||||
│ ├── add/ # Ajout de liens
|
||||
│ ├── edit/ # Édition de liens
|
||||
│ ├── tags/ # Gestion des tags
|
||||
│ ├── collections/ # Collections
|
||||
│ ├── dashboard/ # Tableau de bord
|
||||
│ ├── settings/ # Paramètres
|
||||
│ └── nav/ # Navigation
|
||||
└── core/ # Utilitaires
|
||||
├── di/ # Injection de dépendances
|
||||
├── network/ # Configuration réseau
|
||||
└── storage/ # Stockage local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap
|
||||
|
||||
- [x] Synchronisation en arrière-plan avec WorkManager
|
||||
- [x] Mode hors-ligne avec Room
|
||||
- [x] Éditeur Markdown
|
||||
- [x] Extraction de métadonnées OpenGraph
|
||||
- [x] Collections d'organisation
|
||||
- [x] Liens épinglés
|
||||
- [x] Widget d'accueil
|
||||
- [x] App Shortcuts
|
||||
- [x] Quick Settings Tile
|
||||
- [x] Tableau de bord analytique
|
||||
- [x] Import/Export
|
||||
- [x] Material You (Monet)
|
||||
- [ ] Recherche avancée avec filtres multiples
|
||||
- [ ] Suggestions de tags par IA
|
||||
- [ ] Mode lecture sans distraction pour les articles
|
||||
- [ ] Partage de collections
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribution
|
||||
|
||||
Les contributions sont les bienvenues ! N'hésitez pas à :
|
||||
- Ouvrir une issue pour signaler un bug ou suggérer une fonctionnalité
|
||||
- Soumettre une pull request
|
||||
- Améliorer la documentation
|
||||
|
||||
### Signaler un bug
|
||||
1. Vérifiez que le bug n'a pas déjà été signalé
|
||||
2. Ouvrez une issue avec :
|
||||
- Description claire du problème
|
||||
- Étapes pour reproduire
|
||||
- Comportement attendu vs réel
|
||||
- Version Android et de l'app
|
||||
- Logs si disponibles
|
||||
|
||||
---
|
||||
|
||||
## 📝 Licence
|
||||
|
||||
Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails.
|
||||
|
||||
@ -354,12 +251,12 @@ Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de
|
||||
|
||||
## 🙏 Remerciements
|
||||
|
||||
- [Shaarli](https://github.com/shaarli/Shaarli) - Le gestionnaire de favoris auto-hébergé
|
||||
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit moderne Android
|
||||
- [Material Design 3](https://m3.material.io/) - Système de design Google
|
||||
- [Shaarli](https://github.com/shaarli/Shaarli) - Le projet original de gestionnaire de favoris
|
||||
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Framework UI moderne d'Android
|
||||
- La communauté open source pour les excellentes bibliothèques utilisées
|
||||
|
||||
---
|
||||
|
||||
## 📧 Contact
|
||||
|
||||
Pour toute question ou suggestion, n'hésitez pas à ouvrir une [issue](../../issues).
|
||||
<div align="center">
|
||||
<sub>Fait avec ❤️ pour la communauté Shaarli</sub>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
|
||||
|
||||
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@ -96,6 +92,7 @@ dependencies {
|
||||
implementation(libs.androidx.material3)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation(libs.androidx.compose.material)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
@ -119,6 +116,9 @@ dependencies {
|
||||
implementation(libs.moshi)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
// Kotlin Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
@ -127,6 +127,23 @@ dependencies {
|
||||
|
||||
// Markdown
|
||||
implementation(libs.compose.markdown)
|
||||
|
||||
// Room
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.room.paging)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.androidx.work.hilt)
|
||||
ksp(libs.androidx.work.hilt.compiler)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.androidx.datastore)
|
||||
|
||||
// JSoup for HTML parsing (metadata extraction)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@ -16,7 +16,19 @@
|
||||
android:theme="@style/Theme.ShaarIt"
|
||||
android:usesCleartextTraffic="true"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -26,14 +38,31 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
<!-- Share Intent -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- App Shortcuts -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
@ -6,11 +6,8 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.shaarit.presentation.nav.AppNavGraph
|
||||
import com.shaarit.ui.theme.ShaarItTheme
|
||||
@ -30,33 +27,37 @@ class MainActivity : ComponentActivity() {
|
||||
val context = LocalContext.current
|
||||
var shareUrl: String? = null
|
||||
var shareTitle: String? = null
|
||||
var deepLink: String? = null
|
||||
|
||||
val activity = context as? androidx.activity.ComponentActivity
|
||||
val intent = activity?.intent
|
||||
|
||||
// Handle share intent
|
||||
if (intent?.action == android.content.Intent.ACTION_SEND &&
|
||||
intent.type == "text/plain"
|
||||
intent.type == "text/plain"
|
||||
) {
|
||||
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
|
||||
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
|
||||
}
|
||||
|
||||
// Handle deep links from App Shortcuts
|
||||
intent?.data?.let { uri ->
|
||||
if (uri.scheme == "shaarit") {
|
||||
deepLink = uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) { AppNavGraph(shareUrl = shareUrl, shareTitle = shareTitle) }
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavGraph(
|
||||
shareUrl = shareUrl,
|
||||
shareTitle = shareTitle,
|
||||
initialDeepLink = deepLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(text = "Hello $name!", modifier = modifier)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
ShaarItTheme { Greeting("Android") }
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
package com.shaarit
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp class ShaarItApp : Application()
|
||||
@HiltAndroidApp
|
||||
class ShaarItApp : Application(), Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
|
||||
}
|
||||
|
||||
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)
|
||||
data class LinkDto(
|
||||
@Json(name = "id") val id: Int,
|
||||
@Json(name = "url") val url: String,
|
||||
@Json(name = "id") val id: Int?,
|
||||
@Json(name = "url") val url: String?,
|
||||
@Json(name = "shorturl") val shortUrl: String?,
|
||||
@Json(name = "title") val title: String?,
|
||||
@Json(name = "description") val description: String?,
|
||||
@Json(name = "tags") val tags: List<String>?,
|
||||
@Json(name = "private") val isPrivate: Boolean,
|
||||
@Json(name = "private") val isPrivate: Boolean?,
|
||||
@Json(name = "created") val created: 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
|
||||
|
||||
object LinkMapper {
|
||||
fun toDomain(dto: LinkDto): ShaarliLink {
|
||||
fun toDomain(dto: LinkDto): ShaarliLink? {
|
||||
val id = dto.id ?: return null
|
||||
val url = dto.url ?: return null
|
||||
return ShaarliLink(
|
||||
id = dto.id,
|
||||
url = dto.url,
|
||||
title = dto.title ?: dto.url,
|
||||
id = id,
|
||||
url = url,
|
||||
title = dto.title ?: url,
|
||||
description = dto.description ?: "",
|
||||
tags = dto.tags ?: emptyList(),
|
||||
isPrivate = dto.isPrivate,
|
||||
isPrivate = dto.isPrivate ?: false,
|
||||
date = dto.created ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
val links = dtos.map { LinkMapper.toDomain(it) }
|
||||
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
|
||||
|
||||
val nextKey =
|
||||
if (links.isEmpty()) {
|
||||
|
||||
@ -3,93 +3,221 @@ package com.shaarit.data.repository
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
import com.shaarit.data.dto.CreateLinkDto
|
||||
import com.shaarit.data.dto.LinkDto
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.entity.ContentType
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.SyncStatus
|
||||
import com.shaarit.data.local.entity.TagEntity
|
||||
import com.shaarit.data.mapper.LinkMapper
|
||||
import com.shaarit.data.mapper.TagMapper
|
||||
import com.shaarit.data.paging.LinkPagingSource
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.model.ShaarliTag
|
||||
import com.shaarit.domain.repository.AddLinkResult
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import com.squareup.moshi.Moshi
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Implémentation du repository avec architecture offline-first
|
||||
* Utilise Room pour le cache local et l'API Shaarli pour la synchronisation
|
||||
*/
|
||||
@Singleton
|
||||
class LinkRepositoryImpl
|
||||
@Inject
|
||||
constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkRepository {
|
||||
constructor(
|
||||
private val api: ShaarliApi,
|
||||
private val linkDao: LinkDao,
|
||||
private val tagDao: TagDao,
|
||||
private val syncManager: SyncManager,
|
||||
private val moshi: Moshi
|
||||
) : LinkRepository {
|
||||
|
||||
// ====== Lecture (Offline-First) ======
|
||||
|
||||
override fun getLinksStream(
|
||||
searchTerm: String?,
|
||||
searchTags: String?
|
||||
searchTerm: String?,
|
||||
searchTags: String?
|
||||
): Flow<PagingData<ShaarliLink>> {
|
||||
// Utiliser Room pour la pagination locale
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) }
|
||||
)
|
||||
.flow
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
when {
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
|
||||
!searchTags.isNullOrBlank() -> {
|
||||
val tags = searchTags.split(" ").filter { it.isNotBlank() }
|
||||
if (tags.size == 1) {
|
||||
linkDao.getLinksByTag(tags.first())
|
||||
} else {
|
||||
// Pour plusieurs tags, on prend les liens qui ont au moins un des tags
|
||||
linkDao.getLinksByTag(tags.first())
|
||||
}
|
||||
}
|
||||
else -> linkDao.getAllLinksPaged()
|
||||
}
|
||||
}
|
||||
).flow.map { pagingData ->
|
||||
pagingData.map { it.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addLink(
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean
|
||||
): Result<Unit> {
|
||||
|
||||
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 response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
val link = api.getLink(id)
|
||||
val entity = link.toEntity()
|
||||
if (entity != null) {
|
||||
linkDao.insertLink(entity)
|
||||
Result.success(entity.toDomainModel())
|
||||
} else {
|
||||
Result.failure(HttpException(response))
|
||||
Result.failure(Exception("Invalid link data from server"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun getLinkFlow(id: Int): Flow<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(
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean
|
||||
): Result<Unit> {
|
||||
// Créer l'entité locale avec un ID temporaire négatif
|
||||
val tempId = -(System.currentTimeMillis() % 10000000).toInt()
|
||||
val entity = LinkEntity(
|
||||
id = tempId,
|
||||
url = url,
|
||||
title = title ?: url,
|
||||
description = description ?: "",
|
||||
tags = tags ?: emptyList(),
|
||||
isPrivate = isPrivate,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING_CREATE
|
||||
)
|
||||
|
||||
// Sauvegarder localement
|
||||
linkDao.insertLink(entity)
|
||||
|
||||
// Mettre à jour les compteurs de tags
|
||||
tags?.forEach { tag ->
|
||||
val existingTag = tagDao.getTagByName(tag)
|
||||
if (existingTag != null) {
|
||||
tagDao.incrementOccurrences(tag)
|
||||
} else {
|
||||
tagDao.insertTag(TagEntity(tag, 1))
|
||||
}
|
||||
}
|
||||
|
||||
// Déclencher une sync en arrière-plan
|
||||
syncManager.syncNow()
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun addOrUpdateLink(
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean,
|
||||
forceUpdate: Boolean,
|
||||
existingLinkId: Int?
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean,
|
||||
forceUpdate: Boolean,
|
||||
existingLinkId: Int?
|
||||
): AddLinkResult {
|
||||
return try {
|
||||
if (forceUpdate && existingLinkId != null) {
|
||||
// Force update existing link
|
||||
val response =
|
||||
api.updateLink(
|
||||
existingLinkId,
|
||||
CreateLinkDto(url, title, description, tags, isPrivate)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
// Mise à jour forcée
|
||||
val existing = linkDao.getLinkById(existingLinkId)
|
||||
if (existing != null) {
|
||||
val updated = existing.copy(
|
||||
title = title ?: existing.title,
|
||||
description = description ?: existing.description,
|
||||
tags = tags ?: existing.tags,
|
||||
isPrivate = isPrivate,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING_UPDATE
|
||||
)
|
||||
linkDao.updateLink(updated)
|
||||
syncManager.syncNow()
|
||||
AddLinkResult.Success
|
||||
} else {
|
||||
AddLinkResult.Error("Update failed: ${response.code()}")
|
||||
AddLinkResult.Error("Link not found")
|
||||
}
|
||||
} else {
|
||||
// Try to add new link
|
||||
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
if (response.isSuccessful) {
|
||||
AddLinkResult.Success
|
||||
} else if (response.code() == 409) {
|
||||
// Conflict - link already exists
|
||||
// Try to parse the existing link from response body
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val existingLink = parseExistingLink(errorBody)
|
||||
// Vérifier si le lien existe déjà localement
|
||||
val existingByUrl = linkDao.getLinkByUrl(url)
|
||||
if (existingByUrl != null) {
|
||||
AddLinkResult.Conflict(
|
||||
existingLinkId = existingLink?.id ?: 0,
|
||||
existingTitle = existingLink?.title
|
||||
existingLinkId = existingByUrl.id,
|
||||
existingTitle = existingByUrl.title
|
||||
)
|
||||
} else {
|
||||
AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}")
|
||||
// Essayer l'API directement
|
||||
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { serverLink ->
|
||||
serverLink.toEntity()?.let { entity ->
|
||||
linkDao.insertLink(entity)
|
||||
}
|
||||
}
|
||||
AddLinkResult.Success
|
||||
} else if (response.code() == 409) {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val existingLink = parseExistingLink(errorBody)
|
||||
AddLinkResult.Conflict(
|
||||
existingLinkId = existingLink?.id ?: 0,
|
||||
existingTitle = existingLink?.title
|
||||
)
|
||||
} else {
|
||||
// Fallback : créer localement
|
||||
addLink(url, title, description, tags, isPrivate)
|
||||
AddLinkResult.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
@ -97,17 +225,124 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
||||
val errorBody = e.response()?.errorBody()?.string()
|
||||
val existingLink = parseExistingLink(errorBody)
|
||||
AddLinkResult.Conflict(
|
||||
existingLinkId = existingLink?.id ?: 0,
|
||||
existingTitle = existingLink?.title
|
||||
existingLinkId = existingLink?.id ?: 0,
|
||||
existingTitle = existingLink?.title
|
||||
)
|
||||
} else {
|
||||
AddLinkResult.Error(e.message ?: "HTTP Error ${e.code()}")
|
||||
// Fallback offline
|
||||
addLink(url, title, description, tags, isPrivate)
|
||||
AddLinkResult.Success
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AddLinkResult.Error(e.message ?: "Unknown error")
|
||||
// Fallback offline
|
||||
addLink(url, title, description, tags, isPrivate)
|
||||
AddLinkResult.Success
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun updateLink(
|
||||
id: Int,
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean
|
||||
): Result<Unit> {
|
||||
val existing = linkDao.getLinkById(id)
|
||||
?: return Result.failure(Exception("Link not found"))
|
||||
|
||||
val updated = existing.copy(
|
||||
url = url,
|
||||
title = title ?: existing.title,
|
||||
description = description ?: existing.description,
|
||||
tags = tags ?: existing.tags,
|
||||
isPrivate = isPrivate,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||
SyncStatus.PENDING_CREATE
|
||||
} else {
|
||||
SyncStatus.PENDING_UPDATE
|
||||
}
|
||||
)
|
||||
|
||||
linkDao.updateLink(updated)
|
||||
syncManager.syncNow()
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun deleteLink(id: Int): Result<Unit> {
|
||||
val existing = linkDao.getLinkById(id)
|
||||
?: return Result.failure(Exception("Link not found"))
|
||||
|
||||
if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
|
||||
// Si jamais sync, supprimer directement
|
||||
linkDao.deleteLink(id)
|
||||
} else {
|
||||
// Marquer pour suppression
|
||||
linkDao.markForDeletion(id)
|
||||
syncManager.syncNow()
|
||||
}
|
||||
|
||||
// Décrémenter les compteurs de tags
|
||||
existing.tags.forEach { tag ->
|
||||
tagDao.decrementOccurrences(tag)
|
||||
}
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
// ====== Tags ======
|
||||
|
||||
override suspend fun getTags(): Result<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 {
|
||||
val tags = api.getTags(offset = 0, limit = 500)
|
||||
val entities = tags.map { TagMapper.toDomain(it) }.map {
|
||||
TagEntity(it.name, it.occurrences)
|
||||
}
|
||||
tagDao.insertTags(entities)
|
||||
Result.success(tags.map { TagMapper.toDomain(it) })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTagsFlow(): Flow<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 {
|
||||
syncManager.performFullSync()
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Helpers ======
|
||||
|
||||
private fun parseExistingLink(errorBody: String?): LinkDto? {
|
||||
if (errorBody.isNullOrBlank()) return null
|
||||
return try {
|
||||
@ -117,65 +352,46 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateLink(
|
||||
id: Int,
|
||||
url: String,
|
||||
title: String?,
|
||||
description: String?,
|
||||
tags: List<String>?,
|
||||
isPrivate: Boolean
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
val response =
|
||||
api.updateLink(id, CreateLinkDto(url, title, description, tags, isPrivate))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("Update failed: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
private fun LinkEntity.toDomainModel(): ShaarliLink {
|
||||
return ShaarliLink(
|
||||
id = id,
|
||||
url = url,
|
||||
title = title,
|
||||
description = description,
|
||||
tags = tags,
|
||||
isPrivate = isPrivate,
|
||||
date = java.time.Instant.ofEpochMilli(createdAt).toString(),
|
||||
isPinned = isPinned,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
readingTime = readingTimeMinutes,
|
||||
contentType = contentType.name,
|
||||
siteName = siteName
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun deleteLink(id: Int): Result<Unit> {
|
||||
return try {
|
||||
val response = api.deleteLink(id)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("Delete failed: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
private fun LinkDto.toEntity(): LinkEntity? {
|
||||
val linkId = id ?: return null
|
||||
val linkUrl = url ?: return null
|
||||
return LinkEntity(
|
||||
id = linkId,
|
||||
url = linkUrl,
|
||||
title = title ?: linkUrl,
|
||||
description = description ?: "",
|
||||
tags = tags ?: emptyList(),
|
||||
isPrivate = isPrivate ?: false,
|
||||
createdAt = parseDate(created),
|
||||
updatedAt = parseDate(updated),
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getLink(id: Int): Result<ShaarliLink> {
|
||||
|
||||
private fun parseDate(dateString: String?): Long {
|
||||
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
|
||||
return try {
|
||||
val link = api.getLink(id)
|
||||
Result.success(LinkMapper.toDomain(link))
|
||||
java.time.Instant.parse(dateString).toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
||||
return try {
|
||||
val tags = api.getTags(offset = 0, limit = 500)
|
||||
Result.success(tags.map { TagMapper.toDomain(it) })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
|
||||
return try {
|
||||
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
|
||||
Result.success(links.map { LinkMapper.toDomain(it) })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 tags: List<String>,
|
||||
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
|
||||
): Flow<PagingData<ShaarliLink>>
|
||||
|
||||
fun getLinkFlow(id: Int): Flow<ShaarliLink?>
|
||||
|
||||
suspend fun addLink(
|
||||
url: String,
|
||||
title: String?,
|
||||
|
||||
@ -6,30 +6,28 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.ui.components.GlassCard
|
||||
import com.shaarit.ui.components.GradientButton
|
||||
import com.shaarit.ui.components.PremiumTextField
|
||||
import com.shaarit.ui.components.SectionHeader
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import coil.compose.AsyncImage
|
||||
import com.shaarit.ui.components.*
|
||||
import com.shaarit.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddLinkScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onShareSuccess: (() -> Unit)? = null,
|
||||
viewModel: AddLinkViewModel = hiltViewModel()
|
||||
) {
|
||||
@ -42,13 +40,16 @@ fun AddLinkScreen(
|
||||
val availableTags by viewModel.availableTags.collectAsState()
|
||||
val isPrivate by viewModel.isPrivate.collectAsState()
|
||||
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
|
||||
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
|
||||
val extractedThumbnail by viewModel.extractedThumbnail.collectAsState()
|
||||
val contentType by viewModel.contentType.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showMarkdownEditor by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
when (val state = uiState) {
|
||||
is AddLinkUiState.Success -> {
|
||||
// If this was a share intent, finish the activity to return to source app
|
||||
if (onShareSuccess != null) {
|
||||
onShareSuccess()
|
||||
} else {
|
||||
@ -58,9 +59,7 @@ fun AddLinkScreen(
|
||||
is AddLinkUiState.Error -> {
|
||||
snackbarHostState.showSnackbar(state.message)
|
||||
}
|
||||
is AddLinkUiState.Conflict -> {
|
||||
// Show conflict dialog - handled in AlertDialog below
|
||||
}
|
||||
is AddLinkUiState.Conflict -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@ -69,110 +68,161 @@ fun AddLinkScreen(
|
||||
if (uiState is AddLinkUiState.Conflict) {
|
||||
val conflict = uiState as AddLinkUiState.Conflict
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissConflict() },
|
||||
title = {
|
||||
Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text("A link with this URL already exists:", color = TextSecondary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
conflict.existingTitle ?: "Untitled",
|
||||
color = CyanPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Would you like to update the existing link instead?",
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
|
||||
Text("Update", color = CyanPrimary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissConflict() }) {
|
||||
Text("Cancel", color = TextMuted)
|
||||
}
|
||||
},
|
||||
containerColor = CardBackground,
|
||||
titleContentColor = TextPrimary,
|
||||
textContentColor = TextSecondary
|
||||
onDismissRequest = { viewModel.dismissConflict() },
|
||||
title = {
|
||||
Text("Lien déjà existant", fontWeight = FontWeight.Bold, color = TextPrimary)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text("Un lien avec cette URL existe déjà:", color = TextSecondary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
conflict.existingTitle ?: "Sans titre",
|
||||
color = CyanPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Voulez-vous mettre à jour le lien existant?",
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
|
||||
Text("Mettre à jour", color = CyanPrimary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissConflict() }) {
|
||||
Text("Annuler", color = TextMuted)
|
||||
}
|
||||
},
|
||||
containerColor = CardBackground,
|
||||
titleContentColor = TextPrimary,
|
||||
textContentColor = TextSecondary
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Add Link",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor =
|
||||
android.graphics.Color.TRANSPARENT.let {
|
||||
androidx.compose.ui.graphics.Color.Transparent
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Ajouter un lien",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Retour",
|
||||
tint = TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// URL Section
|
||||
// URL Section avec extraction de métadonnées
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "URL", subtitle = "Required")
|
||||
SectionHeader(title = "URL", subtitle = "Requis")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
PremiumTextField(
|
||||
value = url,
|
||||
onValueChange = { viewModel.url.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://example.com",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
value = url,
|
||||
onValueChange = { viewModel.url.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://example.com",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (isExtractingMetadata) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = CyanPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Aperçu des métadonnées extraites
|
||||
AnimatedVisibility(
|
||||
visible = extractedThumbnail != null || contentType != null,
|
||||
enter = expandVertically() + fadeIn()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
// Type de contenu détecté
|
||||
contentType?.let { type ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (type) {
|
||||
"VIDEO" -> Icons.Default.PlayCircle
|
||||
"ARTICLE" -> Icons.Default.Article
|
||||
"PODCAST" -> Icons.Default.Headphones
|
||||
"REPOSITORY" -> Icons.Default.Code
|
||||
else -> Icons.Default.Web
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Type: $type",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail extrait
|
||||
extractedThumbnail?.let { thumbnail ->
|
||||
AsyncImage(
|
||||
model = thumbnail,
|
||||
contentDescription = "Aperçu",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,53 +230,87 @@ fun AddLinkScreen(
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Title",
|
||||
subtitle = "Optional - auto-fetched if empty"
|
||||
title = "Titre",
|
||||
subtitle = "Optionnel - auto-extrait si vide"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
value = title,
|
||||
onValueChange = { viewModel.title.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Page title"
|
||||
value = title,
|
||||
onValueChange = { viewModel.title.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Titre de la page"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description Section
|
||||
// Description Section avec MarkdownEditor
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "Description", subtitle = "Optional")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SectionHeader(title = "Description", subtitle = "Markdown supporté")
|
||||
|
||||
// Toggle pour l'éditeur Markdown
|
||||
TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) {
|
||||
Icon(
|
||||
if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
if (showMarkdownEditor) "Simple" else "Markdown",
|
||||
color = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
|
||||
if (showMarkdownEditor) {
|
||||
// Éditeur Markdown avancé
|
||||
MarkdownEditor(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Add a description...",
|
||||
mode = EditorMode.SPLIT,
|
||||
minHeight = 200.dp
|
||||
)
|
||||
} else {
|
||||
// Champ texte simple
|
||||
PremiumTextField(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Ajoutez une description...",
|
||||
singleLine = false,
|
||||
minLines = 3
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "Tags", subtitle = "Organize your links")
|
||||
SectionHeader(title = "Tags", subtitle = "Organisez vos liens")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Selected tags
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
items(selectedTags) { tag ->
|
||||
TagChip(
|
||||
tag = tag,
|
||||
isSelected = true,
|
||||
onClick = { viewModel.removeTag(tag) }
|
||||
tag = tag,
|
||||
isSelected = true,
|
||||
onClick = { viewModel.removeTag(tag) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -234,76 +318,74 @@ fun AddLinkScreen(
|
||||
|
||||
// New tag input
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PremiumTextField(
|
||||
value = newTagInput,
|
||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Add tag..."
|
||||
value = newTagInput,
|
||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Ajouter un tag..."
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.addNewTag() },
|
||||
enabled = newTagInput.isNotBlank()
|
||||
onClick = { viewModel.addNewTag() },
|
||||
enabled = newTagInput.isNotBlank()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add tag",
|
||||
tint =
|
||||
if (newTagInput.isNotBlank()) CyanPrimary
|
||||
else TextMuted
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Ajouter tag",
|
||||
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
AnimatedVisibility(
|
||||
visible = tagSuggestions.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
visible = tagSuggestions.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Suggestions",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
"Suggestions",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(tagSuggestions.take(10)) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popular tags from existing
|
||||
// Popular tags
|
||||
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Popular tags",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
"Tags populaires",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(
|
||||
availableTags
|
||||
.filter { it.name !in selectedTags }
|
||||
.take(10)
|
||||
availableTags
|
||||
.filter { it.name !in selectedTags }
|
||||
.take(10)
|
||||
) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -315,33 +397,32 @@ fun AddLinkScreen(
|
||||
// Privacy Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Private",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
"Privé",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
"Only you can see this link",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
"Seul vous pouvez voir ce lien",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = CyanPrimary,
|
||||
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
|
||||
uncheckedThumbColor = TextMuted,
|
||||
uncheckedTrackColor = SurfaceVariant
|
||||
)
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CyanPrimary,
|
||||
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
|
||||
uncheckedThumbColor = TextMuted,
|
||||
uncheckedTrackColor = SurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -350,17 +431,17 @@ fun AddLinkScreen(
|
||||
|
||||
// Save Button
|
||||
GradientButton(
|
||||
text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link",
|
||||
onClick = { viewModel.addLink() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
|
||||
onClick = { viewModel.addLink() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||
)
|
||||
|
||||
if (uiState is AddLinkUiState.Loading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = CyanPrimary,
|
||||
trackColor = SurfaceVariant
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = CyanPrimary,
|
||||
trackColor = SurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,21 +3,29 @@ package com.shaarit.presentation.add
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.data.metadata.LinkMetadataExtractor
|
||||
import com.shaarit.domain.model.ShaarliTag
|
||||
import com.shaarit.domain.repository.AddLinkResult
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.net.URLDecoder
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AddLinkViewModel
|
||||
@Inject
|
||||
constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedStateHandle) :
|
||||
ViewModel() {
|
||||
constructor(
|
||||
private val linkRepository: LinkRepository,
|
||||
private val metadataExtractor: LinkMetadataExtractor,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
|
||||
private val initialUrl: String? = savedStateHandle["url"]
|
||||
@ -30,6 +38,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
|
||||
var description = MutableStateFlow("")
|
||||
var isPrivate = MutableStateFlow(false)
|
||||
|
||||
// Extraction state
|
||||
private val _isExtractingMetadata = MutableStateFlow(false)
|
||||
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
|
||||
|
||||
private val _extractedThumbnail = MutableStateFlow<String?>(null)
|
||||
val extractedThumbnail = _extractedThumbnail.asStateFlow()
|
||||
|
||||
private val _contentType = MutableStateFlow<String?>(null)
|
||||
val contentType = _contentType.asStateFlow()
|
||||
|
||||
// New tag management
|
||||
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
|
||||
@ -49,17 +67,75 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
|
||||
init {
|
||||
loadAvailableTags()
|
||||
setupUrlMetadataExtraction()
|
||||
|
||||
// Si une URL initiale est fournie, extraire les métadonnées
|
||||
if (!initialUrl.isNullOrBlank()) {
|
||||
extractMetadata(initialUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun setupUrlMetadataExtraction() {
|
||||
url
|
||||
.debounce(1000) // Attendre 1s après la fin de la saisie
|
||||
.onEach { urlString ->
|
||||
if (urlString.isNotBlank() && urlString.startsWith("http")) {
|
||||
extractMetadata(urlString)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun extractMetadata(urlString: String) {
|
||||
viewModelScope.launch {
|
||||
_isExtractingMetadata.value = true
|
||||
try {
|
||||
val metadata = metadataExtractor.extract(urlString)
|
||||
|
||||
// Auto-remplir si les champs sont vides
|
||||
if (title.value.isBlank() && !metadata.title.isNullOrBlank()) {
|
||||
title.value = metadata.title
|
||||
}
|
||||
if (description.value.isBlank() && !metadata.description.isNullOrBlank()) {
|
||||
description.value = metadata.description
|
||||
}
|
||||
|
||||
_extractedThumbnail.value = metadata.thumbnailUrl
|
||||
_contentType.value = metadata.contentType.name
|
||||
|
||||
// Suggérer des tags basés sur le site
|
||||
metadata.siteName?.let { site ->
|
||||
suggestTagFromSite(site)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignorer silencieusement les erreurs d'extraction
|
||||
} finally {
|
||||
_isExtractingMetadata.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun suggestTagFromSite(siteName: String) {
|
||||
val siteTag = siteName.lowercase().replace(" ", "-").replace(".", "")
|
||||
if (siteTag !in _selectedTags.value) {
|
||||
// Ajouter automatiquement certains tags connus
|
||||
when (siteTag) {
|
||||
"youtube", "vimeo" -> addTag("video")
|
||||
"github", "gitlab" -> addTag("dev")
|
||||
"twitter", "x" -> addTag("social")
|
||||
"reddit" -> addTag("reddit")
|
||||
"medium", "devto" -> addTag("article")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */
|
||||
private fun decodeUrlParam(param: String?): String? {
|
||||
if (param.isNullOrBlank()) return null
|
||||
return try {
|
||||
// First decode URL encoding, then replace + with spaces
|
||||
// The + signs appear because URLEncoder uses + for spaces
|
||||
URLDecoder.decode(param, "UTF-8").replace("+", " ").trim()
|
||||
} catch (e: Exception) {
|
||||
// If decoding fails, just replace + with spaces
|
||||
param.replace("+", " ").trim()
|
||||
}
|
||||
}
|
||||
@ -67,15 +143,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
private fun loadAvailableTags() {
|
||||
viewModelScope.launch {
|
||||
linkRepository
|
||||
.getTags()
|
||||
.fold(
|
||||
onSuccess = { tags ->
|
||||
_availableTags.value = tags.sortedByDescending { it.occurrences }
|
||||
},
|
||||
onFailure = {
|
||||
// Silently fail - tags are optional
|
||||
}
|
||||
)
|
||||
.getTags()
|
||||
.fold(
|
||||
onSuccess = { tags ->
|
||||
_availableTags.value = tags.sortedByDescending { it.occurrences }
|
||||
},
|
||||
onFailure = {
|
||||
// Silently fail - tags are optional
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,13 +168,13 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
|
||||
val queryLower = query.lowercase()
|
||||
_tagSuggestions.value =
|
||||
_availableTags
|
||||
.value
|
||||
.filter {
|
||||
it.name.lowercase().contains(queryLower) &&
|
||||
it.name !in _selectedTags.value
|
||||
}
|
||||
.take(10)
|
||||
_availableTags
|
||||
.value
|
||||
.filter {
|
||||
it.name.lowercase().contains(queryLower) &&
|
||||
it.name !in _selectedTags.value
|
||||
}
|
||||
.take(10)
|
||||
}
|
||||
|
||||
fun addTag(tag: String) {
|
||||
@ -122,7 +198,6 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
viewModelScope.launch {
|
||||
_uiState.value = AddLinkUiState.Loading
|
||||
|
||||
// Basic validation
|
||||
val currentUrl = url.value
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||
@ -130,15 +205,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
}
|
||||
|
||||
val result =
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value,
|
||||
forceUpdate = false,
|
||||
existingLinkId = null
|
||||
)
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value,
|
||||
forceUpdate = false,
|
||||
existingLinkId = null
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is AddLinkResult.Success -> {
|
||||
@ -147,10 +222,10 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
is AddLinkResult.Conflict -> {
|
||||
conflictLinkId = result.existingLinkId
|
||||
_uiState.value =
|
||||
AddLinkUiState.Conflict(
|
||||
existingLinkId = result.existingLinkId,
|
||||
existingTitle = result.existingTitle
|
||||
)
|
||||
AddLinkUiState.Conflict(
|
||||
result.existingLinkId,
|
||||
result.existingTitle
|
||||
)
|
||||
}
|
||||
is AddLinkResult.Error -> {
|
||||
_uiState.value = AddLinkUiState.Error(result.message)
|
||||
@ -160,21 +235,25 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
}
|
||||
|
||||
fun forceUpdateExistingLink() {
|
||||
val linkId = conflictLinkId ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
val currentUrl = url.value
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.value = AddLinkUiState.Loading
|
||||
|
||||
val result =
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = url.value,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value,
|
||||
forceUpdate = true,
|
||||
existingLinkId = linkId
|
||||
)
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value,
|
||||
forceUpdate = true,
|
||||
existingLinkId = conflictLinkId
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is AddLinkResult.Success -> {
|
||||
@ -184,19 +263,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
|
||||
_uiState.value = AddLinkUiState.Error(result.message)
|
||||
}
|
||||
else -> {
|
||||
_uiState.value = AddLinkUiState.Error("Unexpected error")
|
||||
_uiState.value = AddLinkUiState.Error("Unexpected result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissConflict() {
|
||||
conflictLinkId = null
|
||||
_uiState.value = AddLinkUiState.Idle
|
||||
conflictLinkId = null
|
||||
}
|
||||
|
||||
// Legacy compatibility for old comma-separated tags input
|
||||
@Deprecated("Use selectedTags instead") var tags = MutableStateFlow("")
|
||||
}
|
||||
|
||||
sealed class AddLinkUiState {
|
||||
|
||||
@ -26,7 +26,7 @@ fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltView
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var url by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf(viewModel.getInitialUrl()) }
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var showSecret by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package com.shaarit.presentation.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.domain.repository.AuthRepository
|
||||
import com.shaarit.domain.usecase.LoginUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@ -15,7 +16,8 @@ class LoginViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val authRepository: AuthRepository // To check login state
|
||||
private val authRepository: AuthRepository, // To check login state
|
||||
private val syncManager: SyncManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
||||
@ -27,6 +29,7 @@ constructor(
|
||||
|
||||
private fun checkLoginStatus() {
|
||||
if (authRepository.isLoggedIn()) {
|
||||
syncManager.syncNow()
|
||||
_uiState.value = LoginUiState.Success
|
||||
} else {
|
||||
// Pre-fill URL if available
|
||||
@ -42,13 +45,20 @@ constructor(
|
||||
_uiState.value = LoginUiState.Loading
|
||||
val result = loginUseCase(url, secret)
|
||||
result.fold(
|
||||
onSuccess = { _uiState.value = LoginUiState.Success },
|
||||
onSuccess = {
|
||||
syncManager.syncNow()
|
||||
_uiState.value = LoginUiState.Success
|
||||
},
|
||||
onFailure = {
|
||||
_uiState.value = LoginUiState.Error(it.message ?: "Unknown Error")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInitialUrl(): String {
|
||||
return authRepository.getBaseUrl() ?: "https://bm.dracodev.net"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class LoginUiState {
|
||||
|
||||
@ -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,
|
||||
onNavigateToEdit: (Int) -> Unit = {},
|
||||
onNavigateToTags: () -> Unit = {},
|
||||
onNavigateToCollections: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
onNavigateToRandom: () -> Unit = {},
|
||||
initialTagFilter: String? = null,
|
||||
viewModel: FeedViewModel = hiltViewModel()
|
||||
) {
|
||||
@ -54,7 +57,10 @@ fun FeedScreen(
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||
onRefresh = { pagingItems.refresh() }
|
||||
onRefresh = {
|
||||
viewModel.refresh()
|
||||
pagingItems.refresh()
|
||||
}
|
||||
)
|
||||
|
||||
Box(
|
||||
@ -80,7 +86,10 @@ fun FeedScreen(
|
||||
},
|
||||
actions = {
|
||||
// Refresh Button
|
||||
IconButton(onClick = { pagingItems.refresh() }) {
|
||||
IconButton(onClick = {
|
||||
viewModel.refresh()
|
||||
pagingItems.refresh()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
@ -176,13 +185,39 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Random button
|
||||
IconButton(onClick = onNavigateToRandom) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Shuffle,
|
||||
contentDescription = "Random link",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
|
||||
// Collections button
|
||||
IconButton(onClick = onNavigateToCollections) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Folder,
|
||||
contentDescription = "Collections",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
|
||||
// Tags button
|
||||
TextButton(onClick = onNavigateToTags) {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TealSecondary
|
||||
IconButton(onClick = onNavigateToTags) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Label,
|
||||
contentDescription = "Tags",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
|
||||
// Settings button
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
@ -299,8 +334,15 @@ fun FeedScreen(
|
||||
color = ErrorRed
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { pagingItems.refresh() }) {
|
||||
Text("Retry", color = CyanPrimary)
|
||||
IconButton(onClick = {
|
||||
viewModel.refresh()
|
||||
pagingItems.refresh()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
@ -20,7 +21,10 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class FeedViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() {
|
||||
class FeedViewModel @Inject constructor(
|
||||
private val linkRepository: LinkRepository,
|
||||
private val syncManager: SyncManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery = _searchQuery.asStateFlow()
|
||||
@ -82,6 +86,7 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
syncManager.syncNow()
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@ -43,7 +44,8 @@ fun ListViewItem(
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@ -85,6 +87,18 @@ fun ListViewItem(
|
||||
}
|
||||
|
||||
Row {
|
||||
// Pin button
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
|
||||
tint = if (link.isPinned) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
@ -176,7 +190,8 @@ fun GridViewItem(
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@ -202,15 +217,31 @@ fun GridViewItem(
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
// Title
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = CyanPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Title with pin indicator
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = CyanPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (link.isPinned) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = "Épinglé",
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@ -277,6 +308,18 @@ fun GridViewItem(
|
||||
}
|
||||
|
||||
Row {
|
||||
// Pin button
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
|
||||
tint = if (link.isPinned) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onViewClick,
|
||||
modifier = Modifier.size(24.dp)
|
||||
@ -326,7 +369,8 @@ fun CompactViewItem(
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@ -360,6 +404,15 @@ fun CompactViewItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (link.isPinned) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = "Épinglé",
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (link.isPrivate) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
@ -403,6 +456,17 @@ fun CompactViewItem(
|
||||
}
|
||||
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
|
||||
tint = if (link.isPinned) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
package com.shaarit.presentation.nav
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import java.net.URLEncoder
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
@ -22,13 +26,17 @@ sealed class Screen(val route: String) {
|
||||
fun createRoute(linkId: Int): String = "edit/$linkId"
|
||||
}
|
||||
object Tags : Screen("tags")
|
||||
object Collections : Screen("collections")
|
||||
object Dashboard : Screen("dashboard")
|
||||
object Settings : Screen("settings")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavGraph(
|
||||
startDestination: String = Screen.Login.route,
|
||||
shareUrl: String? = null,
|
||||
shareTitle: String? = null
|
||||
shareTitle: String? = null,
|
||||
initialDeepLink: String? = null
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
@ -47,6 +55,11 @@ fun AppNavGraph(
|
||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else if (initialDeepLink != null) {
|
||||
// Handle deep link after login
|
||||
navController.navigate(initialDeepLink) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(Screen.Feed.createRoute()) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
@ -64,6 +77,10 @@ fun AppNavGraph(
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
),
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://feed" },
|
||||
navDeepLink { uriPattern = "shaarit://search" }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val tag = backStackEntry.arguments?.getString("tag")
|
||||
@ -73,6 +90,9 @@ fun AppNavGraph(
|
||||
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||
},
|
||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||
onNavigateToRandom = { },
|
||||
initialTagFilter = tag
|
||||
)
|
||||
}
|
||||
@ -94,6 +114,9 @@ fun AppNavGraph(
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
}
|
||||
),
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://add" }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false
|
||||
@ -118,7 +141,12 @@ fun AppNavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Tags.route) {
|
||||
composable(
|
||||
route = Screen.Tags.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://tags" }
|
||||
)
|
||||
) {
|
||||
com.shaarit.presentation.tags.TagsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToFeedWithTag = { tag ->
|
||||
@ -128,5 +156,42 @@ fun AppNavGraph(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Collections.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://collections" }
|
||||
)
|
||||
) {
|
||||
com.shaarit.presentation.collections.CollectionsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onCollectionClick = { collectionId ->
|
||||
// Naviguer vers le feed avec le filtre de collection
|
||||
navController.navigate(Screen.Feed.createRoute()) {
|
||||
popUpTo(Screen.Collections.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Dashboard.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "shaarit://dashboard" }
|
||||
)
|
||||
) {
|
||||
com.shaarit.presentation.dashboard.DashboardScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Settings.route
|
||||
) {
|
||||
com.shaarit.presentation.settings.SettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
@ -84,25 +87,53 @@ private val LightColorScheme =
|
||||
|
||||
@Composable
|
||||
fun ShaarItTheme(
|
||||
darkTheme: Boolean = true, // Default to dark theme for premium look
|
||||
dynamicColor: Boolean = false, // Disable dynamic color to maintain brand
|
||||
content: @Composable () -> Unit
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true, // Enable Material You by default
|
||||
oledMode: Boolean = false, // Pure black for OLED screens
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme =
|
||||
when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
|
||||
else lightColorScheme()
|
||||
when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
// Material You (Monet) - dynamic colors from wallpaper
|
||||
if (darkTheme) {
|
||||
if (oledMode) {
|
||||
// OLED pure black variant
|
||||
dynamicDarkColorScheme(context).copy(
|
||||
background = Color.Black,
|
||||
surface = Color(0xFF0A0A0A),
|
||||
surfaceVariant = Color(0xFF1A1A1A)
|
||||
)
|
||||
} else {
|
||||
dynamicDarkColorScheme(context)
|
||||
}
|
||||
} else {
|
||||
dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
darkTheme -> {
|
||||
if (oledMode) {
|
||||
// OLED pure black variant of custom theme
|
||||
DarkColorScheme.copy(
|
||||
background = Color.Black,
|
||||
surface = Color(0xFF0A0A0A),
|
||||
surfaceVariant = Color(0xFF1A1A1A)
|
||||
)
|
||||
} else {
|
||||
DarkColorScheme
|
||||
}
|
||||
}
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = DeepNavy.toArgb()
|
||||
window.navigationBarColor = DeepNavy.toArgb()
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
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"?>
|
||||
<resources>
|
||||
<string name="app_name">ShaarIt</string>
|
||||
</resources>
|
||||
|
||||
<!-- 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>
|
||||
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.hilt) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=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"
|
||||
material = "1.11.0"
|
||||
composeMarkdown = "0.4.1"
|
||||
room = "2.6.1"
|
||||
workManager = "2.9.0"
|
||||
dataStore = "1.0.0"
|
||||
kotlinxSerialization = "1.6.2"
|
||||
coil = "2.6.0"
|
||||
|
||||
[libraries]
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
@ -55,8 +60,32 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" }
|
||||
|
||||
# Room
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" }
|
||||
|
||||
# WorkManager
|
||||
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||
androidx-work-hilt = { group = "androidx.hilt", name = "hilt-work", version = "1.1.0" }
|
||||
androidx-work-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.1.0" }
|
||||
|
||||
# DataStore
|
||||
androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" }
|
||||
|
||||
# JSoup for HTML parsing
|
||||
jsoup = { group = "org.jsoup", name = "jsoup", version = "1.17.1" }
|
||||
|
||||
# Coil (images in Compose)
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# Kotlin Serialization
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user