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:
Bruno Charest 2026-01-29 13:14:47 -05:00
parent a9475c16b1
commit 7277342d4a
55 changed files with 6255 additions and 697 deletions

426
ANALYSE_ET_AMELIORATIONS.md Normal file
View 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
View File

@ -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>

View File

@ -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)
@ -128,6 +128,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)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -17,6 +17,18 @@
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"
@ -33,7 +45,24 @@
<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>

View File

@ -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,9 +27,12 @@ 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"
) {
@ -40,23 +40,24 @@ class MainActivity : ComponentActivity() {
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) }
) {
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") }
}

View File

@ -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()
}

View 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()
}
}

View File

@ -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?
)

View 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
}
@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
)

View 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?
)
}

View File

@ -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
}
}
}

View File

@ -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
}

View 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
)

View 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>
}

View File

@ -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()
}
}
}

View File

@ -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()
)

View 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?
)

View 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
)

View File

@ -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 ?: ""
)
}

View File

@ -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?
)

View File

@ -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()) {

View File

@ -3,35 +3,122 @@ 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?
): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) }
)
.flow
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 getLink(id: Int): Result<ShaarliLink> {
// Essayer d'abord le cache local
val localLink = linkDao.getLinkById(id)
if (localLink != null) {
return Result.success(localLink.toDomainModel())
}
// Fallback vers l'API
return try {
val link = api.getLink(id)
val entity = link.toEntity()
if (entity != null) {
linkDao.insertLink(entity)
Result.success(entity.toDomainModel())
} else {
Result.failure(Exception("Invalid link data from server"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override fun getLinkFlow(id: Int): Flow<ShaarliLink?> {
return linkDao.getLinkByIdFlow(id).map { it?.toDomainModel() }
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
// Lire depuis le cache local
return try {
// Si nous avons des données locales, les retourner
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) }
if (filtered.isNotEmpty()) {
return Result.success(filtered.map { it.toDomainModel() })
}
}
// Fallback vers l'API
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
// ====== Écriture (avec file d'attente de sync) ======
override suspend fun addLink(
url: String,
@ -40,16 +127,37 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
return try {
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
Result.success(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 {
Result.failure(HttpException(response))
tagDao.insertTag(TagEntity(tag, 1))
}
} catch (e: Exception) {
Result.failure(e)
}
// Déclencher une sync en arrière-plan
syncManager.syncNow()
return Result.success(Unit)
}
override suspend fun addOrUpdateLink(
@ -63,25 +171,42 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
): AddLinkResult {
return try {
if (forceUpdate && existingLinkId != null) {
// Force update existing link
val response =
api.updateLink(
existingLinkId,
CreateLinkDto(url, title, description, tags, isPrivate)
// 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
)
if (response.isSuccessful) {
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
// Vérifier si le lien existe déjà localement
val existingByUrl = linkDao.getLinkByUrl(url)
if (existingByUrl != null) {
AddLinkResult.Conflict(
existingLinkId = existingByUrl.id,
existingTitle = existingByUrl.title
)
} else {
// Essayer l'API directement
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
response.body()?.let { serverLink ->
serverLink.toEntity()?.let { entity ->
linkDao.insertLink(entity)
}
}
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)
AddLinkResult.Conflict(
@ -89,7 +214,10 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
existingTitle = existingLink?.title
)
} else {
AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}")
// Fallback : créer localement
addLink(url, title, description, tags, isPrivate)
AddLinkResult.Success
}
}
}
} catch (e: HttpException) {
@ -101,20 +229,14 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
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")
}
}
private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null
return try {
val adapter = moshi.adapter(LinkDto::class.java)
adapter.fromJson(errorBody)
} catch (e: Exception) {
null
// Fallback offline
addLink(url, title, description, tags, isPrivate)
AddLinkResult.Success
}
}
@ -126,56 +248,150 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
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)
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 {
Result.failure(Exception("Update failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
SyncStatus.PENDING_UPDATE
}
)
linkDao.updateLink(updated)
syncManager.syncNow()
return Result.success(Unit)
}
override suspend fun deleteLink(id: Int): Result<Unit> {
return try {
val response = api.deleteLink(id)
if (response.isSuccessful) {
Result.success(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 {
Result.failure(Exception("Delete failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
// Marquer pour suppression
linkDao.markForDeletion(id)
syncManager.syncNow()
}
override suspend fun getLink(id: Int): Result<ShaarliLink> {
return try {
val link = api.getLink(id)
Result.success(LinkMapper.toDomain(link))
} catch (e: Exception) {
Result.failure(e)
// 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)
}
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
fun getTagsFlow(): Flow<List<ShaarliTag>> {
return tagDao.getAllTags().map { tags ->
tags.map { ShaarliTag(it.name, it.occurrences) }
}
}
// ====== Actions supplémentaires ======
suspend fun togglePin(id: Int): Result<Boolean> {
val link = linkDao.getLinkById(id)
?: return Result.failure(Exception("Link not found"))
val newPinState = !link.isPinned
linkDao.updatePinStatus(id, newPinState)
return Result.success(newPinState)
}
suspend fun refreshFromServer(): Result<Unit> {
return try {
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.map { LinkMapper.toDomain(it) })
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 {
val adapter = moshi.adapter(LinkDto::class.java)
adapter.fromJson(errorBody)
} catch (e: Exception) {
null
}
}
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
id = id,
url = url,
title = title,
description = description,
tags = tags,
isPrivate = isPrivate,
date = java.time.Instant.ofEpochMilli(createdAt).toString(),
isPinned = isPinned,
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
contentType = contentType.name,
siteName = siteName
)
}
private fun LinkDto.toEntity(): LinkEntity? {
val linkId = id ?: return null
val linkUrl = url ?: return null
return LinkEntity(
id = linkId,
url = linkUrl,
title = title ?: linkUrl,
description = description ?: "",
tags = tags ?: emptyList(),
isPrivate = isPrivate ?: false,
createdAt = parseDate(created),
updatedAt = parseDate(updated),
syncStatus = SyncStatus.SYNCED
)
}
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
java.time.Instant.parse(dateString).toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}

View 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()
}

View 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()
}

View File

@ -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
)

View File

@ -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?,

View File

@ -6,24 +6,22 @@ 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)
@ -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 -> {}
}
}
@ -71,32 +70,32 @@ fun AddLinkScreen(
AlertDialog(
onDismissRequest = { viewModel.dismissConflict() },
title = {
Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary)
Text("Lien déjà existant", fontWeight = FontWeight.Bold, color = TextPrimary)
},
text = {
Column {
Text("A link with this URL already exists:", color = TextSecondary)
Text("Un lien avec cette URL existe déjà:", color = TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Text(
conflict.existingTitle ?: "Untitled",
conflict.existingTitle ?: "Sans titre",
color = CyanPrimary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Would you like to update the existing link instead?",
"Voulez-vous mettre à jour le lien existant?",
color = TextSecondary
)
}
},
confirmButton = {
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
Text("Update", color = CyanPrimary)
Text("Mettre à jour", color = CyanPrimary)
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissConflict() }) {
Text("Cancel", color = TextMuted)
Text("Annuler", color = TextMuted)
}
},
containerColor = CardBackground,
@ -106,13 +105,10 @@ fun AddLinkScreen(
}
Box(
modifier =
Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
Scaffold(
@ -121,7 +117,7 @@ fun AddLinkScreen(
TopAppBar(
title = {
Text(
"Add Link",
"Ajouter un lien",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
@ -130,36 +126,33 @@ fun AddLinkScreen(
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
contentDescription = "Retour",
tint = TextPrimary
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor =
android.graphics.Color.TRANSPARENT.let {
androidx.compose.ui.graphics.Color.Transparent
}
containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
Column(
modifier =
Modifier.padding(paddingValues)
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 },
@ -167,12 +160,69 @@ fun AddLinkScreen(
placeholder = "https://example.com",
leadingIcon = {
Icon(
Icons.Default.Share,
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,39 +230,73 @@ 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"
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))
if (showMarkdownEditor) {
// Éditeur Markdown avancé
MarkdownEditor(
value = description,
onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(),
mode = EditorMode.SPLIT,
minHeight = 200.dp
)
} else {
// Champ texte simple
PremiumTextField(
value = description,
onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Add a description...",
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))
@ -242,7 +326,7 @@ fun AddLinkScreen(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f),
placeholder = "Add tag..."
placeholder = "Ajouter un tag..."
)
IconButton(
onClick = { viewModel.addNewTag() },
@ -250,10 +334,8 @@ fun AddLinkScreen(
) {
Icon(
Icons.Default.Add,
contentDescription = "Add tag",
tint =
if (newTagInput.isNotBlank()) CyanPrimary
else TextMuted
contentDescription = "Ajouter tag",
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
}
@ -284,11 +366,11 @@ fun AddLinkScreen(
}
}
// Popular tags from existing
// Popular tags
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Popular tags",
"Tags populaires",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
@ -321,13 +403,13 @@ fun AddLinkScreen(
) {
Column {
Text(
"Private",
"Privé",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
Text(
"Only you can see this link",
"Seul vous pouvez voir ce lien",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
@ -335,8 +417,7 @@ fun AddLinkScreen(
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
colors =
SwitchDefaults.colors(
colors = SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
uncheckedThumbColor = TextMuted,
@ -350,7 +431,7 @@ fun AddLinkScreen(
// Save Button
GradientButton(
text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link",
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
onClick = { viewModel.addLink() },
modifier = Modifier.fillMaxWidth(),
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading

View File

@ -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"]
@ -31,6 +39,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
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())
val selectedTags = _selectedTags.asStateFlow()
@ -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()
}
}
@ -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")
@ -148,8 +223,8 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
conflictLinkId = result.existingLinkId
_uiState.value =
AddLinkUiState.Conflict(
existingLinkId = result.existingLinkId,
existingTitle = result.existingTitle
result.existingLinkId,
result.existingTitle
)
}
is AddLinkResult.Error -> {
@ -160,20 +235,24 @@ 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,
url = currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = true,
existingLinkId = linkId
existingLinkId = conflictLinkId
)
when (result) {
@ -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 {

View File

@ -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) }

View File

@ -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 {

View File

@ -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
}
}
}
}

View File

@ -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
)
}
}

View File

@ -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"
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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++
}

View File

@ -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,16 +217,32 @@ fun GridViewItem(
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
// Title
// 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
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))
// Description with Markdown
@ -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,

View File

@ -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) }
)
}
}
}

View File

@ -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()
}

View File

@ -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
)

View 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()
}
}
}

View 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()
)
}
}
}
}

View File

@ -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
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()
// 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 -> {
if (oledMode) {
// OLED pure black variant of custom theme
DarkColorScheme.copy(
background = Color.Black,
surface = Color(0xFF0A0A0A),
surfaceVariant = Color(0xFF1A1A1A)
)
} else {
DarkColorScheme
}
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
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
}
}

View 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)
}
}

View 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
)

View 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>

View 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>

View 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>

View 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>

View File

@ -1,4 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ShaarIt</string>
<!-- Widget -->
<string name="add_link">Ajouter un lien</string>
<string name="refresh">Rafraîchir</string>
<string name="random">Aléatoire</string>
<string name="no_links">Aucun lien</string>
<!-- Actions -->
<string name="pin">Épingler</string>
<string name="unpin">Désépingler</string>
<string name="delete">Supprimer</string>
<string name="edit">Modifier</string>
<string name="share">Partager</string>
<string name="copy_url">Copier l\'URL</string>
<string name="open_in_browser">Ouvrir dans le navigateur</string>
<!-- Editor Modes -->
<string name="mode_edit">Éditer</string>
<string name="mode_preview">Aperçu</string>
<string name="mode_split">Split</string>
<!-- Sync -->
<string name="syncing">Synchronisation…</string>
<string name="sync_complete">Synchronisé</string>
<string name="sync_error">Erreur de synchronisation</string>
<string name="offline_mode">Mode hors-ligne</string>
<!-- Collections -->
<string name="collections">Collections</string>
<string name="new_collection">Nouvelle collection</string>
<string name="smart_collection">Collection intelligente</string>
<!-- Search -->
<string name="search">Rechercher</string>
<string name="search_hint">Rechercher dans les liens…</string>
<string name="filter_today">Aujourd\'hui</string>
<string name="filter_week">Cette semaine</string>
<string name="filter_month">Ce mois</string>
<!-- App Shortcuts -->
<string name="shortcut_add_link_short">Ajouter</string>
<string name="shortcut_add_link_long">Ajouter un lien</string>
<string name="shortcut_add_link_disabled">Ajout de lien désactivé</string>
<string name="shortcut_random_short">Aléatoire</string>
<string name="shortcut_random_long">Lien aléatoire</string>
<string name="shortcut_random_disabled">Aléatoire désactivé</string>
<string name="shortcut_search_short">Rechercher</string>
<string name="shortcut_search_long">Rechercher des liens</string>
<string name="shortcut_search_disabled">Recherche désactivée</string>
<string name="shortcut_collections_short">Collections</string>
<string name="shortcut_collections_long">Voir les collections</string>
<string name="shortcut_collections_disabled">Collections désactivées</string>
<!-- Quick Settings Tile -->
<string name="tile_add_link">Ajouter un lien</string>
<string name="tile_add_link_desc">Ajouter rapidement un bookmark à ShaarIt</string>
<!-- Dashboard -->
<string name="dashboard">Tableau de bord</string>
<string name="total_links">Liens totaux</string>
<string name="links_this_week">Cette semaine</string>
<string name="links_this_month">Ce mois</string>
<string name="most_used_tags">Tags les plus utilisés</string>
<string name="reading_stats">Statistiques de lecture</string>
<string name="estimated_reading_time">Temps de lecture estimé</string>
<string name="links_by_type">Liens par type</string>
<string name="activity_overview">Aperçu d\'activité</string>
<!-- Export/Import -->
<string name="export">Exporter</string>
<string name="import_bookmarks">Importer</string>
<string name="export_json">Exporter en JSON</string>
<string name="export_csv">Exporter en CSV</string>
<string name="import_html">Importer depuis HTML</string>
<string name="import_success">Importation réussie</string>
<string name="import_error">Erreur d\'importation</string>
<string name="export_success">Exportation réussie</string>
<string name="export_error">Erreur d\'exportation</string>
<string name="select_file">Sélectionner un fichier</string>
</resources>

View 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>

View 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" />

View File

@ -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
}

View File

@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
moshi.generateAdapter.source=ksp
org.gradle.java.home=C:\\Users\\bruno\\scoop\\apps\\temurin17-jdk\\current

View File

@ -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" }