docs: Update comprehensive documentation with AI features, health checks, and architecture improvements
- Document automatic token verification on startup and note:// prefix for Markdown notes - Add extensive AI capabilities section (Gemini integration, auto-tagging, content classification, multi-model fallback) - Document link health monitoring system with dead link detection and exclusion features - Add file sharing support (Markdown/text files) and deep links documentation - Update technology
This commit is contained in:
parent
c8a9e6653b
commit
ec0931134c
195
README.md
195
README.md
@ -15,16 +15,19 @@
|
||||
- Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1)
|
||||
- Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences`
|
||||
- Génération automatique de tokens JWT avec algorithme HS512
|
||||
- Vérification automatique du token au démarrage (skip login si valide)
|
||||
|
||||
### 📚 Gestion des Favoris
|
||||
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
|
||||
- **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
|
||||
- **Notes Markdown** : Création de notes enrichies (pas uniquement des URLs) avec le préfixe `note://`
|
||||
- **É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
|
||||
- **Liens épinglés** : Mise en avant des liens importants avec écran dédié
|
||||
- **Vérification santé des liens** : Détection automatique des liens morts (Dead Links) en arrière-plan
|
||||
|
||||
### 🏷️ Gestion des Tags
|
||||
- Vue dédiée pour parcourir tous les tags
|
||||
@ -33,8 +36,9 @@
|
||||
- Tags favoris pour accès rapide
|
||||
|
||||
### 📁 Collections
|
||||
- Organisation des liens en collections
|
||||
- Collections intelligentes avec filtres automatiques
|
||||
- Organisation des liens en collections manuelles ou intelligentes
|
||||
- Collections intelligentes avec filtres automatiques (basées sur les tags)
|
||||
- Synchronisation des collections via le serveur Shaarli
|
||||
- Vue grille adaptative pour les collections
|
||||
|
||||
### 📝 Éditeur Markdown
|
||||
@ -43,11 +47,19 @@
|
||||
- Mode lecture focus sans distraction
|
||||
- Barre d'outils de formatage
|
||||
|
||||
### 🤖 Intelligence Artificielle (Google Gemini)
|
||||
- **Analyse IA d'URL** : Extraction intelligente du titre, description et tags via Gemini
|
||||
- **Suggestions de tags IA** : Génération automatique de tags pertinents
|
||||
- **Classification de contenu** : Détection automatique du type (Article, Vidéo, Tutorial, Repository)
|
||||
- **Fallback multi-modèles** : Essai automatique de plusieurs modèles Gemini (2.5 Flash Lite → 1.5 Flash)
|
||||
- **Classification en lot** : Scan et classification de tous les bookmarks existants
|
||||
|
||||
### 🌐 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.)
|
||||
- Extraction automatique des OpenGraph (titre, description, image) via JSoup
|
||||
- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
|
||||
- Estimation du temps de lecture
|
||||
- Extraction du nom du site
|
||||
- Extraction du nom du site et du favicon
|
||||
- Suggestion automatique de tags basée sur le domaine
|
||||
|
||||
### 📊 Tableau de Bord
|
||||
- Statistiques d'utilisation (liens totaux, cette semaine, ce mois)
|
||||
@ -56,7 +68,15 @@
|
||||
- Tags les plus utilisés
|
||||
- Graphique d'activité sur 30 jours
|
||||
|
||||
### 💾 Import/Export
|
||||
### <20> Vérification des Liens (Health Check)
|
||||
- Détection automatique des liens morts via WorkManager (toutes les 12h)
|
||||
- Système de seuil : 3 échecs consécutifs avant de marquer un lien comme « mort »
|
||||
- Filtrage intelligent (exclusion des notes, réseau local, IPs privées)
|
||||
- Vérification manuelle à la demande depuis les paramètres
|
||||
- Écran dédié de gestion des liens morts
|
||||
- Exclusion de liens spécifiques du health check
|
||||
|
||||
### <20>💾 Import/Export
|
||||
- Export JSON (format complet avec métadonnées)
|
||||
- Export CSV (compatible Excel)
|
||||
- Export HTML (format Netscape/Chrome bookmarks)
|
||||
@ -70,20 +90,21 @@
|
||||
- File d'attente des opérations
|
||||
|
||||
### 🔗 Intégration système
|
||||
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app
|
||||
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (URLs et fichiers texte/Markdown)
|
||||
- **Partage de fichiers** : Import de fichiers `.md`, `.txt` directement comme notes Shaarli
|
||||
- **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
|
||||
- **Deep Links** : Navigation directe via `shaarit://feed`, `shaarit://add`, `shaarit://search`, etc.
|
||||
- Ouverture des liens dans le navigateur par défaut
|
||||
|
||||
### 🎨 Interface Utilisateur
|
||||
- **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
|
||||
- **15 thèmes sombres** : ShaarIt, GitHub, Linear, Spotify, Notion, Discord, Dracula, One Dark Pro, Tokyo Night, Nord, Night Owl, Anthracite, Cyberpunk, Navy Élégance, Tons Terreux
|
||||
- **Design premium** : Composants glassmorphism avec effets visuels
|
||||
- **Material Design 3** : Composants UI natifs Jetpack Compose
|
||||
- **Skeleton Loading** : Chargement élégant avec shimmer effect
|
||||
- **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte
|
||||
- **Pull-to-refresh** : Actualisation du flux par glissement
|
||||
- **Edge-to-Edge** : Support des insets système (barre de statut, navigation)
|
||||
|
||||
---
|
||||
|
||||
@ -91,24 +112,30 @@
|
||||
|
||||
| Catégorie | Technologie |
|
||||
|-----------|-------------|
|
||||
| **Langage** | Kotlin 2.0.0 |
|
||||
| **UI** | Jetpack Compose + Material Design 3 |
|
||||
| **Langage** | Kotlin 1.9.20 |
|
||||
| **UI** | Jetpack Compose (BOM 2023.08) + Material Design 3 |
|
||||
| **Architecture** | Clean Architecture + MVVM |
|
||||
| **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 |
|
||||
| **Injection de dépendances** | Dagger Hilt 2.48.1 |
|
||||
| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
|
||||
| **Base de données locale** | Room 2.6.1 (avec FTS4) |
|
||||
| **Pagination** | Paging 3 (3.2.1) |
|
||||
| **Concurrence** | Coroutines & Flow |
|
||||
| **Background work** | WorkManager 2.9.0 |
|
||||
| **Stockage sécurisé** | AndroidX Security Crypto |
|
||||
| **Navigation** | Navigation Compose |
|
||||
| **Compilation** | Gradle 8.0+ avec KSP |
|
||||
| **Navigation** | Navigation Compose 2.7.6 |
|
||||
| **Images** | Coil 2.6.0 |
|
||||
| **Markdown** | compose-markdown 0.4.1 |
|
||||
| **HTML Parsing** | JSoup 1.17.1 |
|
||||
| **IA** | Google Gemini AI SDK 0.9.0 |
|
||||
| **Sérialisation** | Kotlin Serialization 1.6.2 |
|
||||
| **Compilation** | AGP 8.13.2 + KSP 1.9.20 |
|
||||
|
||||
### Compatibilité
|
||||
- **minSdk** : 24 (Android 7.0)
|
||||
- **targetSdk** : 34 (Android 14)
|
||||
- **compileSdk** : 34
|
||||
- **JDK requis** : 17+
|
||||
- **Compose Compiler** : 1.5.4
|
||||
|
||||
---
|
||||
|
||||
@ -149,14 +176,27 @@ L'APK sera généré dans `app/build/outputs/apk/debug/`
|
||||
|
||||
### Première configuration
|
||||
1. Ouvrez l'application
|
||||
2. Entrez l'URL de votre instance Shaarli
|
||||
2. Entrez l'URL de votre instance Shaarli (ex: `https://monserveur.com/shaarli`)
|
||||
3. Entrez votre nom d'utilisateur et mot de passe
|
||||
4. L'application générera automatiquement les tokens API nécessaires
|
||||
5. *(Optionnel)* Configurez votre clé API Gemini dans Paramètres pour l'auto-tagging IA
|
||||
|
||||
### 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
|
||||
- **Fichier Markdown** : Partagez un fichier `.md` ou `.txt` pour l'importer comme note
|
||||
|
||||
### Créer une note
|
||||
- Sur l'écran d'ajout, basculez en mode **Note** (au lieu de Bookmark)
|
||||
- Rédigez le titre et le contenu en Markdown
|
||||
- La note sera sauvegardée sur votre instance Shaarli avec un identifiant unique
|
||||
|
||||
### Intelligence Artificielle
|
||||
1. Obtenez une clé API sur [Google AI Studio](https://aistudio.google.com/)
|
||||
2. Allez dans **Paramètres** → **Clé API Gemini** et entrez votre clé
|
||||
3. Lors de l'ajout d'un lien, appuyez sur le bouton IA pour analyser automatiquement l'URL
|
||||
4. L'IA remplira le titre, la description et suggérera des tags pertinents
|
||||
|
||||
### Organiser vos liens
|
||||
- Utilisez les tags pour catégoriser vos liens
|
||||
@ -176,52 +216,98 @@ Le projet suit les principes de la **Clean Architecture** avec une séparation c
|
||||
|
||||
```
|
||||
├── data/ # Couche de données
|
||||
│ ├── api/ # API Retrofit
|
||||
│ ├── api/ # API Retrofit (ShaarliApi)
|
||||
│ ├── dto/ # Data Transfer Objects (Moshi)
|
||||
│ ├── local/ # Base de données Room
|
||||
│ │ ├── dao/ # Data Access Objects
|
||||
│ │ ├── entity/ # Entités Room
|
||||
│ │ ├── dao/ # Data Access Objects (Link, Tag, Collection)
|
||||
│ │ ├── entity/ # Entités Room + FTS4
|
||||
│ │ ├── converter/ # Type converters Room
|
||||
│ │ └── database/ # Configuration de la DB
|
||||
│ ├── sync/ # Synchronisation
|
||||
│ ├── export/ # Import/Export
|
||||
│ └── repository/ # Implémentations des repositories
|
||||
│ ├── mapper/ # Mappers DTO ↔ Entity ↔ Domain
|
||||
│ ├── metadata/ # Extraction métadonnées (JSoup/OpenGraph)
|
||||
│ ├── paging/ # PagingSource réseau
|
||||
│ ├── sync/ # SyncManager + ConflictResolver + SyncWorker
|
||||
│ ├── worker/ # LinkHealthCheckWorker
|
||||
│ ├── export/ # BookmarkExporter (JSON, CSV, HTML)
|
||||
│ └── repository/ # Implémentations (Link, Auth, Gemini)
|
||||
├── domain/ # Couche domaine
|
||||
│ ├── model/ # Modèles métier
|
||||
│ └── repository/ # Interfaces des repositories
|
||||
│ ├── model/ # Modèles métier (ShaarliLink, ShaarliTag, etc.)
|
||||
│ ├── repository/ # Interfaces des repositories
|
||||
│ └── usecase/ # Use cases (AnalyzeUrl, GenerateTags, Classify, Login)
|
||||
├── presentation/ # Couche présentation
|
||||
│ ├── feed/ # Écran principal
|
||||
│ ├── add/ # Ajout de liens
|
||||
│ ├── feed/ # Écran principal + vues (List, Grid, Compact)
|
||||
│ ├── add/ # Ajout de liens / notes
|
||||
│ ├── edit/ # Édition de liens
|
||||
│ ├── auth/ # Écran de connexion
|
||||
│ ├── tags/ # Gestion des tags
|
||||
│ ├── collections/ # Collections
|
||||
│ ├── dashboard/ # Tableau de bord
|
||||
│ ├── settings/ # Paramètres
|
||||
│ └── nav/ # Navigation
|
||||
│ ├── dashboard/ # Tableau de bord / statistiques
|
||||
│ ├── deadlinks/ # Gestion des liens morts
|
||||
│ ├── pinned/ # Liens épinglés
|
||||
│ ├── settings/ # Paramètres (sync, export, IA, thèmes)
|
||||
│ ├── help/ # Écran d'aide
|
||||
│ └── nav/ # Navigation (NavGraph + Deep Links)
|
||||
├── ui/ # Composants UI réutilisables
|
||||
│ ├── components/ # GlassCard, TagChip, MarkdownEditor, SkeletonLoader
|
||||
│ └── theme/ # 15 thèmes + typographie + préférences
|
||||
├── service/ # AddLinkTileService (Quick Settings)
|
||||
└── core/ # Utilitaires
|
||||
├── di/ # Injection de dépendances
|
||||
├── network/ # Configuration réseau
|
||||
└── storage/ # Stockage local
|
||||
├── di/ # Modules Hilt (App, Database, Network, Repository)
|
||||
├── network/ # AuthInterceptor + HostSelectionInterceptor
|
||||
├── storage/ # TokenManager (EncryptedSharedPreferences)
|
||||
└── util/ # JwtGenerator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap
|
||||
|
||||
### Complété ✅
|
||||
- [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] Mode hors-ligne avec Room + architecture offline-first
|
||||
- [x] Éditeur Markdown (édition + prévisualisation + split)
|
||||
- [x] Extraction de métadonnées OpenGraph (JSoup)
|
||||
- [x] Collections manuelles et intelligentes
|
||||
- [x] Liens épinglés avec écran dédié
|
||||
- [x] App Shortcuts + Deep Links (`shaarit://`)
|
||||
- [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
|
||||
- [x] Tableau de bord analytique (statistiques, graphiques)
|
||||
- [x] Import/Export (JSON, CSV, HTML Netscape)
|
||||
- [x] 15 thèmes sombres premium
|
||||
- [x] Intelligence Artificielle (Google Gemini) — auto-tagging, analyse d'URL, classification
|
||||
- [x] Recherche FTS4 (full-text search) + filtres multi-tags
|
||||
- [x] Détection et gestion des liens morts (Health Check)
|
||||
- [x] Support des notes Markdown (pas uniquement des URLs)
|
||||
- [x] Partage de fichiers `.md` / `.txt` via Share Intent
|
||||
- [x] Skeleton Loading (shimmer effect)
|
||||
- [x] Résolution de conflits de synchronisation
|
||||
- [x] Synchronisation des collections via le serveur Shaarli
|
||||
- [x] Optimisations de performance — R8/ProGuard, Baseline Profiles, sync incrémentale
|
||||
- [x] Recherche FTS4 activée dans le repository — Moteur FTS4 intégré dans `LinkRepositoryImpl`
|
||||
- [x] Migrations Room explicites — `MIGRATION_4_5` explicite + fallback ciblé pour v1-v3
|
||||
|
||||
### Prochaines étapes 🔜
|
||||
- [ ] Mode lecture sans distraction (Reader Mode) pour les articles
|
||||
- [ ] Widget d'accueil interactif (Glance)
|
||||
- [ ] Partage de collections entre utilisateurs
|
||||
- [ ] Support multi-instances Shaarli
|
||||
- [ ] Verrouillage biométrique
|
||||
- [ ] Rappels de lecture (« Lire plus tard » avec notifications)
|
||||
- [ ] Voice Input dans la recherche et l'ajout de lien
|
||||
- [ ] Adaptive Layouts pour tablettes et foldables
|
||||
- [ ] Thème clair et Material You (Monet) dynamique sur Android 12+
|
||||
|
||||
---
|
||||
|
||||
## 📄 Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Performance & UX Optimale](docs/PERFORMANCE_ET_UX_OPTIMALE.md) | Analyse de performance complète, 24 optimisations identifiées, plan d'action priorisé |
|
||||
| [Analyse et Améliorations](docs/ANALYSE_ET_AMELIORATIONS.md) | Propositions d'améliorations fonctionnelles et architecturales |
|
||||
| [Rapport d'Audit v2](docs/RAPPORT_AMELIORATION_v2.md) | Audit UX « Next Level » — micro-interactions, IA, ergonomie |
|
||||
| [Instructions de Build Release](docs/RELEASE_BUILD.md) | Guide de création du keystore, signature et build release |
|
||||
| [Roadmap Prochaines Fonctionnalités](docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md) | Analyse détaillée des 9 fonctionnalités à implémenter (Reader Mode, Widget, Biométrie, Material You…) |
|
||||
|
||||
---
|
||||
|
||||
@ -253,6 +339,9 @@ Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de
|
||||
|
||||
- [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
|
||||
- [Google Gemini](https://ai.google.dev/) - IA générative pour l'enrichissement intelligent
|
||||
- [JSoup](https://jsoup.org/) - Parsing HTML pour l'extraction de métadonnées
|
||||
- [Coil](https://coil-kt.github.io/coil/) - Chargement d'images optimisé pour Compose
|
||||
- La communauté open source pour les excellentes bibliothèques utilisées
|
||||
|
||||
---
|
||||
|
||||
@ -44,8 +44,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
@ -67,6 +67,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
@ -78,6 +79,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@ -145,9 +150,15 @@ dependencies {
|
||||
// JSoup for HTML parsing (metadata extraction)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// Biometric
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
// Google Gemini AI SDK
|
||||
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
|
||||
|
||||
// Baseline Profiles
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
@ -155,4 +166,5 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
|
||||
}
|
||||
|
||||
8
app/proguard-rules.pro
vendored
8
app/proguard-rules.pro
vendored
@ -104,9 +104,11 @@
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Keep ALL Application classes to prevent runtime crashes
|
||||
-keep class com.shaarit.** { *; }
|
||||
-keepclassmembers class com.shaarit.** { *; }
|
||||
# Keep only what is strictly needed for serialization and Room
|
||||
-keep class com.shaarit.data.dto.** { *; }
|
||||
-keep class com.shaarit.data.local.entity.** { *; }
|
||||
-keep class com.shaarit.domain.model.** { *; }
|
||||
-keep class com.shaarit.data.export.Exported* { *; }
|
||||
|
||||
# Keep Hilt generated classes
|
||||
-keep class com.shaarit.Hilt_* { *; }
|
||||
|
||||
@ -0,0 +1,514 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "5f96db8ef034b3e68703165267fd3696",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPrivate",
|
||||
"columnName": "is_private",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPinned",
|
||||
"columnName": "is_pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncStatus",
|
||||
"columnName": "sync_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localModifiedAt",
|
||||
"columnName": "local_modified_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "readingTimeMinutes",
|
||||
"columnName": "reading_time_minutes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentType",
|
||||
"columnName": "content_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "siteName",
|
||||
"columnName": "site_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkCheckStatus",
|
||||
"columnName": "link_check_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "failCount",
|
||||
"columnName": "fail_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastHealthCheck",
|
||||
"columnName": "last_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excludedFromHealthCheck",
|
||||
"columnName": "excluded_from_health_check",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_links_sync_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sync_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_private",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_private"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_created_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"created_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_is_pinned",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_pinned"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_content_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"content_type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_site_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"site_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_link_check_status",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"link_check_status"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
|
||||
},
|
||||
{
|
||||
"name": "index_links_last_health_check",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_health_check"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "links",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
|
||||
],
|
||||
"tableName": "links_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "excerpt",
|
||||
"columnName": "excerpt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "tags",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "occurrences",
|
||||
"columnName": "occurrences",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedAt",
|
||||
"columnName": "last_used_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isFavorite",
|
||||
"columnName": "is_favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_tags_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_tags_occurrences",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"occurrences"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "link_tag_cross_ref",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tagName",
|
||||
"columnName": "tag_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"link_id",
|
||||
"tag_name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSmart",
|
||||
"columnName": "is_smart",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_is_smart",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_smart"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collections_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "collection_links",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collection_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "linkId",
|
||||
"columnName": "link_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"collection_id",
|
||||
"link_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f96db8ef034b3e68703165267fd3696')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".ShaarItApp"
|
||||
|
||||
15
app/src/main/baseline-prof.txt
Normal file
15
app/src/main/baseline-prof.txt
Normal file
@ -0,0 +1,15 @@
|
||||
HSPLandroidx/compose/runtime/ComposerImpl;->startRestartGroup(I)Landroidx/compose/runtime/Composer;
|
||||
HSPLandroidx/compose/runtime/ComposerImpl;->endRestartGroup()Landroidx/compose/runtime/ScopeUpdateScope;
|
||||
HSPLandroidx/compose/material3/**;->**(**)**
|
||||
HSPLandroidx/compose/foundation/**;->**(**)**
|
||||
HSPLandroidx/compose/ui/**;->**(**)**
|
||||
HSPLandroidx/compose/animation/**;->**(**)**
|
||||
HSPLandroidx/navigation/**;->**(**)**
|
||||
HSPLandroidx/paging/**;->**(**)**
|
||||
HSPLandroidx/room/**;->**(**)**
|
||||
HSPLcoil/**;->**(**)**
|
||||
HSPLcom/shaarit/presentation/**;->**(**)**
|
||||
HSPLcom/shaarit/ui/**;->**(**)**
|
||||
HSPLcom/shaarit/core/**;->**(**)**
|
||||
HSPLcom/shaarit/data/**;->**(**)**
|
||||
HSPLcom/shaarit/domain/**;->**(**)**
|
||||
@ -2,18 +2,23 @@ package com.shaarit
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.shaarit.core.storage.BiometricAuthManager
|
||||
import com.shaarit.core.storage.SecurityPreferences
|
||||
import com.shaarit.presentation.auth.LockScreen
|
||||
import com.shaarit.presentation.nav.AppNavGraph
|
||||
import com.shaarit.ui.theme.ShaarItTheme
|
||||
import com.shaarit.ui.theme.ThemePreferences
|
||||
@ -23,9 +28,17 @@ import java.io.InputStreamReader
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject lateinit var themePreferences: ThemePreferences
|
||||
@Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager
|
||||
@Inject lateinit var securityPreferences: SecurityPreferences
|
||||
@Inject lateinit var biometricAuthManager: BiometricAuthManager
|
||||
|
||||
// Start as authenticated — lock only triggers after app goes to background
|
||||
private var isAuthenticated by mutableStateOf(true)
|
||||
private var lastBackgroundTime: Long = 0L
|
||||
private var hasBeenBackgrounded = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install splash screen before super.onCreate
|
||||
@ -37,65 +50,126 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
// On cold start, require auth if biometric + requireOnStartup are enabled
|
||||
if (securityPreferences.isBiometricEnabled.value && securityPreferences.requireOnStartup.value) {
|
||||
isAuthenticated = false
|
||||
}
|
||||
|
||||
// Parse intent ONCE, before composition (avoids re-execution on recomposition)
|
||||
val shareData = parseShareIntent(intent)
|
||||
|
||||
// Check if user is already logged in to skip login screen for share intents
|
||||
val hasValidToken = tokenManager.getToken() != null && tokenManager.getBaseUrl() != null
|
||||
val startDestination = if (hasValidToken) {
|
||||
com.shaarit.presentation.nav.Screen.Feed.createRoute()
|
||||
} else {
|
||||
com.shaarit.presentation.nav.Screen.Login.route
|
||||
}
|
||||
|
||||
setContent {
|
||||
val currentTheme by themePreferences.currentTheme.collectAsState()
|
||||
ShaarItTheme(appTheme = currentTheme) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
val context = LocalContext.current
|
||||
val currentThemeMode by themePreferences.themeMode.collectAsState()
|
||||
val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
|
||||
|
||||
val needsAuth = biometricEnabled && !isAuthenticated
|
||||
|
||||
ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
if (needsAuth) {
|
||||
LockScreen(
|
||||
activity = this@MainActivity,
|
||||
biometricAuthManager = biometricAuthManager,
|
||||
onAuthenticated = { isAuthenticated = true }
|
||||
)
|
||||
} else {
|
||||
AppNavGraph(
|
||||
startDestination = startDestination,
|
||||
shareUrl = shareData.url,
|
||||
shareTitle = shareData.title,
|
||||
shareDescription = shareData.description,
|
||||
shareTags = shareData.tags,
|
||||
isFileShare = shareData.isFileShare,
|
||||
initialDeepLink = shareData.deepLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
lastBackgroundTime = System.currentTimeMillis()
|
||||
hasBeenBackgrounded = true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!hasBeenBackgrounded) return
|
||||
if (securityPreferences.isBiometricEnabled.value) {
|
||||
val elapsed = System.currentTimeMillis() - lastBackgroundTime
|
||||
val timeout = securityPreferences.lockTimeout.value.delayMs
|
||||
val shouldLock = if (securityPreferences.requireOnResume.value) {
|
||||
lastBackgroundTime > 0 && elapsed > timeout
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (shouldLock) {
|
||||
isAuthenticated = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ShareData(
|
||||
val url: String? = null,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val tags: List<String>? = null,
|
||||
val isFileShare: Boolean = false,
|
||||
val deepLink: String? = null
|
||||
)
|
||||
|
||||
private fun parseShareIntent(intent: android.content.Intent?): ShareData {
|
||||
if (intent == null) return ShareData()
|
||||
|
||||
var shareUrl: String? = null
|
||||
var shareTitle: String? = null
|
||||
var shareDescription: String? = null
|
||||
var shareTags: List<String>? = null
|
||||
var deepLink: String? = null
|
||||
var isFileShare = false
|
||||
|
||||
val activity = context as? androidx.activity.ComponentActivity
|
||||
val intent = activity?.intent
|
||||
var deepLink: String? = null
|
||||
|
||||
// Handle share intent
|
||||
if (intent?.action == android.content.Intent.ACTION_SEND) {
|
||||
if (intent.action == android.content.Intent.ACTION_SEND) {
|
||||
val mimeType = intent.type ?: ""
|
||||
|
||||
// Check if this is a file share (markdown or text file)
|
||||
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
|
||||
|
||||
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
|
||||
// Handle file sharing - use filename as title, content as description
|
||||
isFileShare = true
|
||||
val fileInfo = readFileContent(fileUri)
|
||||
shareTitle = fileInfo.first // filename without extension
|
||||
shareDescription = fileInfo.second // file content
|
||||
shareTitle = fileInfo.first
|
||||
shareDescription = fileInfo.second
|
||||
shareTags = listOf("note", "fichier")
|
||||
shareUrl = null // No URL for file shares
|
||||
shareUrl = null
|
||||
} else if (mimeType == "text/plain") {
|
||||
// Regular text share (URL)
|
||||
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
|
||||
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep links from App Shortcuts
|
||||
intent?.data?.let { uri ->
|
||||
intent.data?.let { uri ->
|
||||
if (uri.scheme == "shaarit") {
|
||||
deepLink = uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavGraph(
|
||||
shareUrl = shareUrl,
|
||||
shareTitle = shareTitle,
|
||||
shareDescription = shareDescription,
|
||||
shareTags = shareTags,
|
||||
isFileShare = isFileShare,
|
||||
initialDeepLink = deepLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.shaarit.core.di
|
||||
|
||||
import android.content.Context
|
||||
import com.shaarit.core.network.AuthInterceptor
|
||||
import com.shaarit.core.network.HostSelectionInterceptor
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
@ -7,8 +8,12 @@ import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
@ -23,11 +28,24 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
@ApplicationContext context: Context,
|
||||
authInterceptor: AuthInterceptor,
|
||||
hostSelectionInterceptor: HostSelectionInterceptor
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (com.shaarit.BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
val cacheSize = 10L * 1024 * 1024 // 10 MB
|
||||
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.addInterceptor(hostSelectionInterceptor) // Host selection first
|
||||
.addInterceptor(authInterceptor) // Auth header second
|
||||
.addInterceptor(logging)
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
package com.shaarit.core.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class BiometricAvailability {
|
||||
AVAILABLE,
|
||||
NO_HARDWARE,
|
||||
NOT_ENROLLED,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class BiometricAuthManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val biometricManager = BiometricManager.from(context)
|
||||
|
||||
/**
|
||||
* Returns the best available authenticator flags, trying from strongest to weakest:
|
||||
* 1. BIOMETRIC_STRONG | DEVICE_CREDENTIAL
|
||||
* 2. BIOMETRIC_WEAK | DEVICE_CREDENTIAL
|
||||
* 3. DEVICE_CREDENTIAL alone
|
||||
* 4. BIOMETRIC_STRONG alone
|
||||
* 5. BIOMETRIC_WEAK alone
|
||||
* Returns 0 if nothing is available.
|
||||
*/
|
||||
private fun resolveAuthenticators(): Int {
|
||||
val candidates = listOf(
|
||||
BIOMETRIC_STRONG or DEVICE_CREDENTIAL,
|
||||
BIOMETRIC_WEAK or DEVICE_CREDENTIAL,
|
||||
DEVICE_CREDENTIAL,
|
||||
BIOMETRIC_STRONG,
|
||||
BIOMETRIC_WEAK
|
||||
)
|
||||
for (auth in candidates) {
|
||||
if (biometricManager.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun canAuthenticate(): BiometricAvailability {
|
||||
if (resolveAuthenticators() != 0) return BiometricAvailability.AVAILABLE
|
||||
|
||||
val result = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
return when (result) {
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NO_HARDWARE
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NOT_ENROLLED
|
||||
else -> BiometricAvailability.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val authenticators = resolveAuthenticators()
|
||||
if (authenticators == 0) {
|
||||
onError("Aucune méthode d'authentification disponible")
|
||||
return
|
||||
}
|
||||
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
|
||||
val builder = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Déverrouiller ShaarIt")
|
||||
.setSubtitle("Utilisez votre empreinte, visage ou code PIN")
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
|
||||
// setNegativeButtonText is required when DEVICE_CREDENTIAL is NOT included
|
||||
if (authenticators and DEVICE_CREDENTIAL == 0) {
|
||||
builder.setNegativeButtonText("Annuler")
|
||||
}
|
||||
|
||||
val promptInfo = builder.build()
|
||||
|
||||
val biometricPrompt = BiometricPrompt(activity, executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
|
||||
errorCode != BiometricPrompt.ERROR_CANCELED
|
||||
) {
|
||||
onError(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
// Individual attempt failed, prompt stays open for retry
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.shaarit.core.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class LockTimeout(val displayName: String, val delayMs: Long) {
|
||||
IMMEDIATE("Immédiat", 0),
|
||||
AFTER_1_MIN("Après 1 minute", 60_000),
|
||||
AFTER_5_MIN("Après 5 minutes", 300_000),
|
||||
AFTER_15_MIN("Après 15 minutes", 900_000)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SecurityPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("security_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _isBiometricEnabled = MutableStateFlow(loadBiometricEnabled())
|
||||
val isBiometricEnabled: StateFlow<Boolean> = _isBiometricEnabled.asStateFlow()
|
||||
|
||||
private val _lockTimeout = MutableStateFlow(loadLockTimeout())
|
||||
val lockTimeout: StateFlow<LockTimeout> = _lockTimeout.asStateFlow()
|
||||
|
||||
private val _requireOnStartup = MutableStateFlow(loadRequireOnStartup())
|
||||
val requireOnStartup: StateFlow<Boolean> = _requireOnStartup.asStateFlow()
|
||||
|
||||
private val _requireOnResume = MutableStateFlow(loadRequireOnResume())
|
||||
val requireOnResume: StateFlow<Boolean> = _requireOnResume.asStateFlow()
|
||||
|
||||
private fun loadBiometricEnabled(): Boolean =
|
||||
prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
||||
|
||||
private fun loadLockTimeout(): LockTimeout {
|
||||
val name = prefs.getString(KEY_LOCK_TIMEOUT, LockTimeout.IMMEDIATE.name)
|
||||
?: LockTimeout.IMMEDIATE.name
|
||||
return try { LockTimeout.valueOf(name) } catch (_: Exception) { LockTimeout.IMMEDIATE }
|
||||
}
|
||||
|
||||
private fun loadRequireOnStartup(): Boolean =
|
||||
prefs.getBoolean(KEY_REQUIRE_ON_STARTUP, true)
|
||||
|
||||
private fun loadRequireOnResume(): Boolean =
|
||||
prefs.getBoolean(KEY_REQUIRE_ON_RESUME, false)
|
||||
|
||||
fun setBiometricEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply()
|
||||
_isBiometricEnabled.value = enabled
|
||||
}
|
||||
|
||||
fun setLockTimeout(timeout: LockTimeout) {
|
||||
prefs.edit().putString(KEY_LOCK_TIMEOUT, timeout.name).apply()
|
||||
_lockTimeout.value = timeout
|
||||
}
|
||||
|
||||
fun setRequireOnStartup(require: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_REQUIRE_ON_STARTUP, require).apply()
|
||||
_requireOnStartup.value = require
|
||||
}
|
||||
|
||||
fun setRequireOnResume(require: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_REQUIRE_ON_RESUME, require).apply()
|
||||
_requireOnResume.value = require
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
|
||||
private const val KEY_LOCK_TIMEOUT = "lock_timeout"
|
||||
private const val KEY_REQUIRE_ON_STARTUP = "require_on_startup"
|
||||
private const val KEY_REQUIRE_ON_RESUME = "require_on_resume"
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,9 @@ interface TokenManager {
|
||||
fun saveGeminiApiKey(apiKey: String)
|
||||
fun getGeminiApiKey(): String?
|
||||
fun clearGeminiApiKey()
|
||||
|
||||
fun saveLastSyncTimestamp(timestamp: Long)
|
||||
fun getLastSyncTimestamp(): Long
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@ -128,6 +131,14 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
||||
sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply()
|
||||
}
|
||||
|
||||
override fun saveLastSyncTimestamp(timestamp: Long) {
|
||||
sharedPreferences.edit().putLong(KEY_LAST_SYNC_TIMESTAMP, timestamp).apply()
|
||||
}
|
||||
|
||||
override fun getLastSyncTimestamp(): Long {
|
||||
return sharedPreferences.getLong(KEY_LAST_SYNC_TIMESTAMP, 0L)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TOKEN = "jwt_token"
|
||||
private const val KEY_BASE_URL = "base_url"
|
||||
@ -136,5 +147,6 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
||||
private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty"
|
||||
private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id"
|
||||
private const val KEY_GEMINI_API_KEY = "gemini_api_key"
|
||||
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,9 @@ interface LinkDao {
|
||||
@RawQuery(observedEntities = [LinkEntity::class])
|
||||
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
|
||||
|
||||
@RawQuery(observedEntities = [LinkEntity::class])
|
||||
suspend fun getLinksRawQuery(query: SupportSQLiteQuery): List<LinkEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT links.* FROM links
|
||||
INNER JOIN collection_links ON links.id = collection_links.link_id
|
||||
|
||||
@ -5,6 +5,8 @@ import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.shaarit.data.local.converter.Converters
|
||||
import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
@ -28,8 +30,8 @@ import com.shaarit.data.local.entity.TagEntity
|
||||
CollectionEntity::class,
|
||||
CollectionLinkCrossRef::class
|
||||
],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
version = 5,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class ShaarliDatabase : RoomDatabase() {
|
||||
@ -41,6 +43,22 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "shaarli.db"
|
||||
|
||||
/**
|
||||
* Migration v4 → v5 : Ajout des index manquants pour les colonnes fréquemment filtrées
|
||||
* - content_type : utilisé par getContentTypeDistribution()
|
||||
* - site_name : utilisé par getTopSites() et getAllSites()
|
||||
* - link_check_status : utilisé par getDeadLinks(), getDeadLinksCount(), getPendingLinksCount()
|
||||
* - last_health_check : utilisé par getLinksForHealthCheck()
|
||||
*/
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `links` (`content_type`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `links` (`site_name`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `links` (`link_check_status`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `links` (`last_health_check`)")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var instance: ShaarliDatabase? = null
|
||||
|
||||
@ -56,7 +74,8 @@ abstract class ShaarliDatabase : RoomDatabase() {
|
||||
ShaarliDatabase::class.java,
|
||||
DATABASE_NAME
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(MIGRATION_4_5)
|
||||
.fallbackToDestructiveMigrationFrom(1, 2, 3)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,11 @@ import androidx.room.PrimaryKey
|
||||
Index(value = ["is_private"]),
|
||||
Index(value = ["created_at"]),
|
||||
Index(value = ["is_pinned"]),
|
||||
Index(value = ["url"], unique = true)
|
||||
Index(value = ["url"], unique = true),
|
||||
Index(value = ["content_type"]),
|
||||
Index(value = ["site_name"]),
|
||||
Index(value = ["link_check_status"]),
|
||||
Index(value = ["last_health_check"])
|
||||
]
|
||||
)
|
||||
data class LinkEntity(
|
||||
|
||||
@ -21,6 +21,16 @@ class LinkMetadataExtractor @Inject constructor() {
|
||||
private const val TAG = "LinkMetadataExtractor"
|
||||
private const val TIMEOUT_MS = 10000
|
||||
private const val MAX_DESCRIPTION_LENGTH = 300
|
||||
|
||||
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion|youtu\\.be")
|
||||
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|gitlab|bitbucket")
|
||||
private val DOCUMENT_PATTERN = Regex("docs\\.google\\.com|docs\\.google|notion\\.so|confluence")
|
||||
private val SOCIAL_PATTERN = Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")
|
||||
private val SHOPPING_PATTERN = Regex("amazon|ebay|shopify")
|
||||
private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp")
|
||||
private val MUSIC_PATTERN = Regex("spotify|deezer|soundcloud|bandcamp")
|
||||
private val NEWS_PATTERN = Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")
|
||||
private val PODCAST_PATTERN = Regex("podcast|anchor")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -172,16 +182,16 @@ class LinkMetadataExtractor @Inject constructor() {
|
||||
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 == "video" || url.contains(VIDEO_PATTERN) -> 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("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
|
||||
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
|
||||
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
|
||||
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
|
||||
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
|
||||
doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST
|
||||
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
|
||||
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
|
||||
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
||||
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
||||
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
||||
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
|
||||
url.contains(NEWS_PATTERN) -> ContentType.NEWS
|
||||
doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST
|
||||
url.endsWith(".pdf") -> ContentType.PDF
|
||||
else -> ContentType.UNKNOWN
|
||||
}
|
||||
@ -192,14 +202,14 @@ class LinkMetadataExtractor @Inject constructor() {
|
||||
*/
|
||||
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|bitbucket")) -> ContentType.REPOSITORY
|
||||
url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT
|
||||
url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
|
||||
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
|
||||
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
|
||||
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
|
||||
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
|
||||
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
|
||||
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
|
||||
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
|
||||
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
||||
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
||||
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
||||
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
|
||||
url.contains(NEWS_PATTERN) -> ContentType.NEWS
|
||||
url.endsWith(".pdf") -> ContentType.PDF
|
||||
else -> ContentType.UNKNOWN
|
||||
}
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
package com.shaarit.data.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
import com.shaarit.data.mapper.LinkMapper
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import java.io.IOException
|
||||
import retrofit2.HttpException
|
||||
|
||||
class LinkPagingSource(
|
||||
private val api: ShaarliApi,
|
||||
private val searchTerm: String? = null,
|
||||
private val searchTags: String? = null
|
||||
) : PagingSource<Int, ShaarliLink>() {
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, ShaarliLink>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
|
||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ShaarliLink> {
|
||||
val position = params.key ?: 0
|
||||
// Shaarli V1 API uses offset and limit
|
||||
// Assuming we pass page index as key? No, offset is better for generic APIs, but here we
|
||||
// can manage offset.
|
||||
// Wait, params.key is usually page index if we set it so.
|
||||
// Or we can use offset as the key.
|
||||
// Let's use Offset as Key. Initial key = 0.
|
||||
|
||||
val offset = position
|
||||
val limit = params.loadSize
|
||||
|
||||
return try {
|
||||
val dtos =
|
||||
api.getLinks(
|
||||
offset = offset,
|
||||
limit = limit,
|
||||
searchTerm = searchTerm,
|
||||
searchTags = searchTags
|
||||
)
|
||||
|
||||
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
|
||||
|
||||
val nextKey =
|
||||
if (links.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
// If we got less than requested, we are at the end?
|
||||
// Shaarli doesn't return total count easily in v1 (maybe headers).
|
||||
// If detailed list is empty or < limit, next is null.
|
||||
if (links.size < limit) null else offset + limit
|
||||
}
|
||||
|
||||
LoadResult.Page(
|
||||
data = links,
|
||||
prevKey = if (offset == 0) null else offset - limit,
|
||||
nextKey = nextKey
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
LoadResult.Error(exception)
|
||||
} catch (exception: HttpException) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import android.util.LruCache
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@ -22,11 +23,11 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient
|
||||
) : GeminiRepository {
|
||||
|
||||
// Cache pour les tags
|
||||
private val tagsCache = mutableMapOf<String, List<String>>()
|
||||
// Cache pour les tags (borné à 50 entrées max)
|
||||
private val tagsCache = LruCache<String, List<String>>(50)
|
||||
|
||||
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
|
||||
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
|
||||
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max)
|
||||
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
|
||||
|
||||
override fun isApiKeyConfigured(): Boolean {
|
||||
return !tokenManager.getGeminiApiKey().isNullOrBlank()
|
||||
@ -34,8 +35,8 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun generateTags(title: String, description: String): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
val cacheKey = "$title|$description"
|
||||
if (tagsCache.containsKey(cacheKey)) {
|
||||
return@withContext Result.success(tagsCache[cacheKey]!!)
|
||||
tagsCache.get(cacheKey)?.let {
|
||||
return@withContext Result.success(it)
|
||||
}
|
||||
|
||||
try {
|
||||
@ -56,7 +57,7 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
for (modelName in modelsToTry) {
|
||||
try {
|
||||
val tags = generateTagsWithModel(apiKey, modelName, title, description)
|
||||
tagsCache[cacheKey] = tags
|
||||
tagsCache.put(cacheKey, tags)
|
||||
return@withContext Result.success(tags)
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
@ -114,8 +115,8 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
|
||||
// Vérifier le cache d'abord
|
||||
if (analysisCache.containsKey(url)) {
|
||||
return@withContext Result.success(analysisCache[url]!!)
|
||||
analysisCache.get(url)?.let {
|
||||
return@withContext Result.success(it)
|
||||
}
|
||||
|
||||
try {
|
||||
@ -146,7 +147,7 @@ class GeminiRepositoryImpl @Inject constructor(
|
||||
try {
|
||||
val result = generateWithModel(apiKey, modelName, url)
|
||||
// Mettre en cache le résultat réussi
|
||||
analysisCache[url] = result
|
||||
analysisCache.put(url, result)
|
||||
return@withContext Result.success(result)
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
|
||||
@ -55,11 +55,16 @@ constructor(
|
||||
): Flow<PagingData<ShaarliLink>> {
|
||||
// Utiliser Room pour la pagination locale
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
config = PagingConfig(
|
||||
pageSize = 20,
|
||||
prefetchDistance = 10,
|
||||
initialLoadSize = 40,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
pagingSourceFactory = {
|
||||
when {
|
||||
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
|
||||
!searchTags.isNullOrBlank() -> {
|
||||
val tags =
|
||||
searchTags
|
||||
@ -114,18 +119,15 @@ constructor(
|
||||
}
|
||||
|
||||
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()
|
||||
// Requête SQL directe via Room au lieu de charger toute la base en mémoire
|
||||
val sql = "SELECT * FROM links WHERE tags LIKE ? AND sync_status != 'PENDING_DELETE' ORDER BY is_pinned DESC, created_at DESC LIMIT 100"
|
||||
val localLinks = linkDao.getLinksRawQuery(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
|
||||
if (localLinks.isNotEmpty()) {
|
||||
val filtered = localLinks.filter { it.tags.contains(tag) }
|
||||
if (filtered.isNotEmpty()) {
|
||||
return Result.success(filtered.map { it.toDomainModel() })
|
||||
}
|
||||
return Result.success(localLinks.map { it.toDomainModel() })
|
||||
}
|
||||
|
||||
// Fallback vers l'API
|
||||
// Fallback vers l'API si aucun résultat local
|
||||
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
|
||||
Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
|
||||
} catch (e: Exception) {
|
||||
@ -507,7 +509,12 @@ constructor(
|
||||
|
||||
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
config = PagingConfig(
|
||||
pageSize = 20,
|
||||
prefetchDistance = 10,
|
||||
initialLoadSize = 40,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
pagingSourceFactory = { linkDao.getDeadLinks() }
|
||||
).flow.map { pagingData ->
|
||||
pagingData.map { it.toDomainModel() }
|
||||
@ -516,7 +523,12 @@ constructor(
|
||||
|
||||
override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> {
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
config = PagingConfig(
|
||||
pageSize = 20,
|
||||
prefetchDistance = 10,
|
||||
initialLoadSize = 40,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
pagingSourceFactory = { linkDao.getPinnedLinksPaged() }
|
||||
).flow.map { pagingData ->
|
||||
pagingData.map { it.toDomainModel() }
|
||||
@ -533,6 +545,10 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
|
||||
}
|
||||
|
||||
private fun LinkEntity.toDomainModel(): ShaarliLink {
|
||||
return ShaarliLink(
|
||||
id = id,
|
||||
@ -541,7 +557,7 @@ constructor(
|
||||
description = description,
|
||||
tags = tags,
|
||||
isPrivate = isPrivate,
|
||||
date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)),
|
||||
date = DATE_FORMAT.format(java.util.Date(createdAt)),
|
||||
isPinned = isPinned,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
readingTime = readingTimeMinutes,
|
||||
@ -580,8 +596,7 @@ constructor(
|
||||
private fun parseDate(dateString: String?): Long {
|
||||
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
|
||||
return try {
|
||||
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
|
||||
format.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
DATE_FORMAT.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@ -370,12 +370,20 @@ class SyncManager @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les données depuis le serveur
|
||||
* Récupère les données depuis le serveur (sync incrémentale)
|
||||
* S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync)
|
||||
*/
|
||||
private suspend fun pullFromServer() {
|
||||
var offset = 0
|
||||
val limit = 100
|
||||
var hasMore = true
|
||||
val lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
|
||||
val syncStartTime = System.currentTimeMillis()
|
||||
val isFirstSync = lastSyncTimestamp == 0L
|
||||
var unchangedStreakCount = 0
|
||||
val unchangedStreakThreshold = 2 // Stop after 2 consecutive pages of unchanged links
|
||||
|
||||
Log.d(TAG, "Sync incrémentale: lastSync=${if (isFirstSync) "jamais" else java.time.Instant.ofEpochMilli(lastSyncTimestamp)}")
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
@ -395,9 +403,21 @@ class SyncManager @Inject constructor(
|
||||
}
|
||||
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
|
||||
|
||||
var newOrUpdatedCount = 0
|
||||
|
||||
val entities = validLinks.mapNotNull { dto ->
|
||||
try {
|
||||
val existing = linkDao.getLinkById(dto.id!!)
|
||||
val serverUpdatedAt = parseDate(dto.updated)
|
||||
|
||||
// Check if this link has been modified since last sync
|
||||
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
|
||||
// Link unchanged since last sync — skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
newOrUpdatedCount++
|
||||
|
||||
LinkEntity(
|
||||
id = dto.id,
|
||||
url = dto.url!!,
|
||||
@ -407,7 +427,7 @@ class SyncManager @Inject constructor(
|
||||
isPrivate = dto.isPrivate ?: false,
|
||||
isPinned = existing?.isPinned ?: false,
|
||||
createdAt = parseDate(dto.created),
|
||||
updatedAt = parseDate(dto.updated),
|
||||
updatedAt = serverUpdatedAt,
|
||||
syncStatus = SyncStatus.SYNCED,
|
||||
thumbnailUrl = existing?.thumbnailUrl,
|
||||
readingTimeMinutes = existing?.readingTimeMinutes,
|
||||
@ -428,6 +448,20 @@ class SyncManager @Inject constructor(
|
||||
if (entities.isNotEmpty()) {
|
||||
linkDao.insertLinks(entities)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
|
||||
|
||||
// Incremental sync: stop early if we encounter pages with no changes
|
||||
if (!isFirstSync && newOrUpdatedCount == 0) {
|
||||
unchangedStreakCount++
|
||||
if (unchangedStreakCount >= unchangedStreakThreshold) {
|
||||
Log.d(TAG, "Sync incrémentale: $unchangedStreakThreshold pages consécutives sans changement, arrêt anticipé")
|
||||
hasMore = false
|
||||
}
|
||||
} else {
|
||||
unchangedStreakCount = 0
|
||||
}
|
||||
|
||||
offset += links.size
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -436,6 +470,9 @@ class SyncManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Save sync timestamp on success
|
||||
tokenManager.saveLastSyncTimestamp(syncStartTime)
|
||||
|
||||
// Synchroniser les tags
|
||||
try {
|
||||
val tags = api.getTags(limit = 1000)
|
||||
|
||||
@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.shaarit.ui.components.*
|
||||
import com.shaarit.ui.components.VoiceInputButton
|
||||
import com.shaarit.ui.theme.Typography
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ -60,6 +61,7 @@ fun AddLinkScreen(
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
|
||||
var showMarkdownPreview by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@ -75,6 +77,7 @@ fun AddLinkScreen(
|
||||
LaunchedEffect(uiState) {
|
||||
when (val state = uiState) {
|
||||
is AddLinkUiState.Success -> {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
if (onShareSuccess != null) {
|
||||
onShareSuccess()
|
||||
} else {
|
||||
@ -335,7 +338,13 @@ fun AddLinkScreen(
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
trailingIcon = {
|
||||
VoiceInputButton(
|
||||
onResult = { viewModel.title.value = it },
|
||||
contentDescription = "Dicter le titre"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
import javax.inject.Inject
|
||||
@ -122,28 +123,46 @@ constructor(
|
||||
private fun extractMetadata(urlString: String) {
|
||||
viewModelScope.launch {
|
||||
_isExtractingMetadata.value = true
|
||||
try {
|
||||
val metadata = metadataExtractor.extract(urlString)
|
||||
|
||||
// Auto-remplir si les champs sont vides
|
||||
// Launch JSoup metadata extraction and AI analysis in parallel
|
||||
val metadataDeferred = async {
|
||||
try {
|
||||
metadataExtractor.extract(urlString)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) {
|
||||
_aiEnrichmentState.value = AiEnrichmentState.Loading
|
||||
async {
|
||||
analyzeUrlWithAiUseCase(urlString)
|
||||
}
|
||||
} else null
|
||||
|
||||
// Apply JSoup metadata as soon as it arrives
|
||||
val metadata = metadataDeferred.await()
|
||||
if (metadata != null) {
|
||||
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)
|
||||
metadata.siteName?.let { site -> suggestTagFromSite(site) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignorer silencieusement les erreurs d'extraction
|
||||
} finally {
|
||||
_isExtractingMetadata.value = false
|
||||
|
||||
// Complete with AI enrichment
|
||||
aiDeferred?.await()
|
||||
?.onSuccess { result ->
|
||||
applyAiEnrichment(result)
|
||||
_aiEnrichmentState.value = AiEnrichmentState.Success
|
||||
}
|
||||
?.onFailure {
|
||||
_aiEnrichmentState.value = AiEnrichmentState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt
Normal file
155
app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt
Normal file
@ -0,0 +1,155 @@
|
||||
package com.shaarit.presentation.auth
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.shaarit.core.storage.BiometricAuthManager
|
||||
import com.shaarit.core.storage.BiometricAvailability
|
||||
|
||||
@Composable
|
||||
fun LockScreen(
|
||||
activity: FragmentActivity,
|
||||
biometricAuthManager: BiometricAuthManager,
|
||||
onAuthenticated: () -> Unit
|
||||
) {
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var hasTriedOnce by remember { mutableStateOf(false) }
|
||||
|
||||
// Auto-trigger biometric prompt only when activity lifecycle reaches RESUMED
|
||||
DisposableEffect(activity) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !hasTriedOnce) {
|
||||
hasTriedOnce = true
|
||||
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
|
||||
try {
|
||||
biometricAuthManager.authenticate(
|
||||
activity = activity,
|
||||
onSuccess = onAuthenticated,
|
||||
onError = { errorMessage = it }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message ?: "Erreur d'authentification"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.lifecycle.addObserver(observer)
|
||||
onDispose { activity.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Pulsing animation for the lock icon
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "lock_pulse")
|
||||
val pulseAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.6f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = EaseInOutCubic),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse_alpha"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.alpha(pulseAlpha)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "ShaarIt est verrouillé",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Authentifiez-vous pour accéder à vos favoris",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
errorMessage?.let { msg ->
|
||||
Text(
|
||||
text = msg,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
errorMessage = null
|
||||
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
|
||||
biometricAuthManager.authenticate(
|
||||
activity = activity,
|
||||
onSuccess = onAuthenticated,
|
||||
onError = { errorMessage = it }
|
||||
)
|
||||
} else {
|
||||
errorMessage = "Authentification biométrique non disponible"
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Déverrouiller",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,7 @@ import com.shaarit.domain.model.TagFilter
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.ui.components.PremiumTextField
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import com.shaarit.ui.components.VoiceInputButton
|
||||
import com.shaarit.ui.theme.Typography
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@ -1314,6 +1315,7 @@ fun FeedScreen(
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Row {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.onSearchQueryChanged("") }
|
||||
@ -1325,6 +1327,10 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceInputButton(
|
||||
onResult = { viewModel.onSearchQueryChanged(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -1444,7 +1450,8 @@ fun FeedScreen(
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1504,7 +1511,8 @@ fun FeedScreen(
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1565,7 +1573,8 @@ fun FeedScreen(
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import com.shaarit.domain.model.TimeFilter
|
||||
import com.shaarit.domain.model.VisibilityFilter
|
||||
import com.shaarit.domain.model.TagFilter
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
@ -44,7 +45,8 @@ class FeedViewModel @Inject constructor(
|
||||
private val syncManager: SyncManager,
|
||||
private val collectionDao: CollectionDao,
|
||||
private val tagDao: TagDao,
|
||||
private val tokenManager: TokenManager
|
||||
private val tokenManager: TokenManager,
|
||||
private val linkDao: LinkDao
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
@ -75,11 +77,14 @@ class FeedViewModel @Inject constructor(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val pagedLinks: Flow<PagingData<ShaarliLink>> =
|
||||
combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
|
||||
val pagedLinks: Flow<PagingData<ShaarliLink>> = run {
|
||||
val debouncedSearch = _searchQuery.debounce(300)
|
||||
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { tags, collectionId, bookmarkFilter, _ ->
|
||||
Triple(tags, collectionId, bookmarkFilter)
|
||||
}
|
||||
combine(debouncedSearch, instantFilters) { query, (tags, collectionId, bookmarkFilter) ->
|
||||
Quadruple(query, tags, collectionId, bookmarkFilter)
|
||||
}
|
||||
.debounce(300) // Debounce for 300ms
|
||||
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
|
||||
linkRepository.getLinksStream(
|
||||
searchTerm = if (query.isBlank()) null else query,
|
||||
@ -89,6 +94,7 @@ class FeedViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun setTagFilter(tags: String?) {
|
||||
_collectionId.value = null
|
||||
@ -185,8 +191,19 @@ class FeedViewModel @Inject constructor(
|
||||
_bookmarkFilter.value = filter
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
syncManager.syncNow()
|
||||
fun togglePin(id: Int) {
|
||||
viewModelScope.launch {
|
||||
linkDao.getLinkById(id)?.let { link ->
|
||||
linkDao.updatePinStatus(id, !link.isPinned)
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
syncManager.performFullSync()
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -40,6 +42,7 @@ import com.shaarit.ui.components.TagChip
|
||||
import com.shaarit.ui.theme.Typography
|
||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@ -61,12 +64,14 @@ fun ListViewItem(
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.displayTitle,
|
||||
onConfirm = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
@ -89,7 +94,12 @@ fun ListViewItem(
|
||||
// Thumbnail (List View)
|
||||
if (!link.thumbnailUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = link.thumbnailUrl,
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(link.thumbnailUrl)
|
||||
.size(200)
|
||||
.crossfade(true)
|
||||
.memoryCacheKey(link.thumbnailUrl)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
@ -137,7 +147,10 @@ fun ListViewItem(
|
||||
}
|
||||
// Pin button
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onTogglePin(link.id)
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
@ -176,10 +189,12 @@ fun ListViewItem(
|
||||
|
||||
if (link.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MarkdownText(
|
||||
markdown = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
Text(
|
||||
text = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@ -245,12 +260,14 @@ fun GridViewItem(
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.displayTitle,
|
||||
onConfirm = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
@ -274,7 +291,12 @@ fun GridViewItem(
|
||||
// Thumbnail (Grid View)
|
||||
if (!link.thumbnailUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = link.thumbnailUrl,
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(link.thumbnailUrl)
|
||||
.size(400, 280)
|
||||
.crossfade(true)
|
||||
.memoryCacheKey(link.thumbnailUrl)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
@ -322,12 +344,14 @@ fun GridViewItem(
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Description with Markdown
|
||||
// Description (plain text for scroll performance)
|
||||
if (link.description.isNotBlank()) {
|
||||
MarkdownText(
|
||||
markdown = link.description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
Text(
|
||||
text = link.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@ -393,7 +417,10 @@ fun GridViewItem(
|
||||
Row {
|
||||
// Pin button
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onTogglePin(link.id)
|
||||
},
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
@ -460,12 +487,14 @@ fun CompactViewItem(
|
||||
onDeleteClick: () -> Unit,
|
||||
onTogglePin: (Int) -> Unit = {}
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.displayTitle,
|
||||
onConfirm = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
@ -565,7 +594,10 @@ fun CompactViewItem(
|
||||
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onTogglePin(link.id)
|
||||
},
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
@ -713,7 +745,12 @@ fun LinkDetailsView(
|
||||
// Hero Image in Details
|
||||
if (!link.thumbnailUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = link.thumbnailUrl,
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(link.thumbnailUrl)
|
||||
.size(800, 400)
|
||||
.crossfade(true)
|
||||
.memoryCacheKey(link.thumbnailUrl)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
|
||||
@ -59,6 +59,29 @@ fun AppNavGraph(
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
|
||||
// If user is already logged in and has share data, navigate directly to Add screen
|
||||
val hasShareData = shareUrl != null || (isFileShare && shareTitle != null)
|
||||
androidx.compose.runtime.LaunchedEffect(hasShareData) {
|
||||
if (hasShareData && startDestination != Screen.Login.route) {
|
||||
val route = if (isFileShare && shareTitle != null) {
|
||||
val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8")
|
||||
val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else ""
|
||||
val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: ""
|
||||
"add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags"
|
||||
} else if (shareUrl != null) {
|
||||
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
|
||||
val encodedTitle = if (shareTitle != null) URLEncoder.encode(shareTitle, "UTF-8") else ""
|
||||
"add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags="
|
||||
} else null
|
||||
|
||||
route?.let {
|
||||
navController.navigate(it) {
|
||||
popUpTo(startDestination) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable(Screen.Login.route) {
|
||||
com.shaarit.presentation.auth.LoginScreen(
|
||||
|
||||
@ -8,7 +8,10 @@ import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ -18,14 +21,19 @@ class PinnedViewModel @Inject constructor(
|
||||
private val linkDao: LinkDao
|
||||
) : ViewModel() {
|
||||
|
||||
private val _refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> =
|
||||
_refreshTrigger.flatMapLatest {
|
||||
linkRepository.getPinnedLinksStream()
|
||||
.cachedIn(viewModelScope)
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
fun togglePin(id: Int) {
|
||||
viewModelScope.launch {
|
||||
linkDao.getLinkById(id)?.let { link ->
|
||||
linkDao.updatePinStatus(id, !link.isPinned)
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,10 +33,16 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.core.storage.BiometricAuthManager
|
||||
import com.shaarit.core.storage.BiometricAvailability
|
||||
import com.shaarit.core.storage.LockTimeout
|
||||
import com.shaarit.core.storage.SecurityPreferences
|
||||
import com.shaarit.data.export.BookmarkImporter
|
||||
import com.shaarit.ui.theme.AppTheme
|
||||
import com.shaarit.ui.theme.ThemeMode
|
||||
import com.shaarit.ui.theme.ThemePreferences
|
||||
import com.shaarit.ui.theme.getColorSchemeForTheme
|
||||
import com.shaarit.ui.theme.getLightColorSchemeForTheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@ -51,6 +57,7 @@ fun SettingsScreen(
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val currentTheme by themePreferences.currentTheme.collectAsState()
|
||||
val currentThemeMode by themePreferences.themeMode.collectAsState()
|
||||
|
||||
// Export JSON
|
||||
val exportJsonLauncher = rememberLauncherForActivityResult(
|
||||
@ -122,7 +129,22 @@ fun SettingsScreen(
|
||||
item {
|
||||
ThemePickerItem(
|
||||
currentTheme = currentTheme,
|
||||
onThemeSelected = { themePreferences.setTheme(it) }
|
||||
currentThemeMode = currentThemeMode,
|
||||
onThemeSelected = { themePreferences.setTheme(it) },
|
||||
onThemeModeSelected = { themePreferences.setThemeMode(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// Security Section
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsSection(title = "Sécurité")
|
||||
}
|
||||
|
||||
item {
|
||||
SecuritySettingsItem(
|
||||
securityPreferences = viewModel.securityPreferences,
|
||||
biometricAuthManager = viewModel.biometricAuthManager
|
||||
)
|
||||
}
|
||||
|
||||
@ -695,11 +717,19 @@ private fun HealthCheckStatusItem(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ThemePickerItem(
|
||||
currentTheme: AppTheme,
|
||||
onThemeSelected: (AppTheme) -> Unit
|
||||
currentThemeMode: ThemeMode,
|
||||
onThemeSelected: (AppTheme) -> Unit,
|
||||
onThemeModeSelected: (ThemeMode) -> Unit
|
||||
) {
|
||||
val isDynamic = currentThemeMode in listOf(
|
||||
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
|
||||
)
|
||||
val isDynamicAvailable = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@ -724,7 +754,7 @@ private fun ThemePickerItem(
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = currentTheme.displayName,
|
||||
text = "${currentTheme.displayName} \u2022 ${currentThemeMode.displayName}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@ -733,6 +763,116 @@ private fun ThemePickerItem(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Theme Mode selector (Dark / Light / System)
|
||||
Text(
|
||||
text = "Mode",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val baseMode = if (isDynamic) {
|
||||
when (currentThemeMode) {
|
||||
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
|
||||
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
|
||||
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
|
||||
else -> currentThemeMode
|
||||
}
|
||||
} else currentThemeMode
|
||||
|
||||
listOf(
|
||||
ThemeMode.DARK to Icons.Default.DarkMode,
|
||||
ThemeMode.LIGHT to Icons.Default.LightMode,
|
||||
ThemeMode.SYSTEM to Icons.Default.BrightnessAuto
|
||||
).forEach { (mode, icon) ->
|
||||
val isSelected = baseMode == mode
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
val newMode = if (isDynamic) {
|
||||
when (mode) {
|
||||
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
|
||||
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
|
||||
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
|
||||
else -> mode
|
||||
}
|
||||
} else mode
|
||||
onThemeModeSelected(newMode)
|
||||
},
|
||||
label = { Text(mode.displayName, style = MaterialTheme.typography.labelSmall) },
|
||||
leadingIcon = {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Material You toggle (Android 12+)
|
||||
if (isDynamicAvailable) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = if (isDynamic) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Material You",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Couleurs extraites du fond d'\u00e9cran",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isDynamic,
|
||||
onCheckedChange = { enabled ->
|
||||
val baseMode2 = when (currentThemeMode) {
|
||||
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
|
||||
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
|
||||
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
|
||||
ThemeMode.DARK -> ThemeMode.DARK
|
||||
ThemeMode.LIGHT -> ThemeMode.LIGHT
|
||||
ThemeMode.SYSTEM -> ThemeMode.SYSTEM
|
||||
}
|
||||
val newMode = if (enabled) {
|
||||
when (baseMode2) {
|
||||
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
|
||||
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
|
||||
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
|
||||
else -> ThemeMode.DYNAMIC_SYSTEM
|
||||
}
|
||||
} else baseMode2
|
||||
onThemeModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Theme color picker (hidden when Material You is active)
|
||||
if (!isDynamic) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Palette de couleurs",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
@ -740,6 +880,7 @@ private fun ThemePickerItem(
|
||||
ThemePreviewCard(
|
||||
theme = theme,
|
||||
isSelected = theme == currentTheme,
|
||||
isDarkPreview = currentThemeMode != ThemeMode.LIGHT,
|
||||
onClick = { onThemeSelected(theme) }
|
||||
)
|
||||
}
|
||||
@ -747,14 +888,16 @@ private fun ThemePickerItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePreviewCard(
|
||||
theme: AppTheme,
|
||||
isSelected: Boolean,
|
||||
isDarkPreview: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colors = getColorSchemeForTheme(theme)
|
||||
val colors = if (isDarkPreview) getColorSchemeForTheme(theme) else getLightColorSchemeForTheme(theme)
|
||||
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else colors.outline
|
||||
|
||||
Column(
|
||||
@ -844,3 +987,170 @@ private fun ThemePreviewCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecuritySettingsItem(
|
||||
securityPreferences: SecurityPreferences,
|
||||
biometricAuthManager: BiometricAuthManager
|
||||
) {
|
||||
val isBiometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
|
||||
val lockTimeout by securityPreferences.lockTimeout.collectAsState()
|
||||
val requireOnStartup by securityPreferences.requireOnStartup.collectAsState()
|
||||
val requireOnResume by securityPreferences.requireOnResume.collectAsState()
|
||||
val biometricAvailability = remember { biometricAuthManager.canAuthenticate() }
|
||||
var showTimeoutMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
tint = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Verrouillage biométrique",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = when (biometricAvailability) {
|
||||
BiometricAvailability.AVAILABLE -> if (isBiometricEnabled) "Activé" else "Désactivé"
|
||||
BiometricAvailability.NO_HARDWARE -> "Matériel non disponible"
|
||||
BiometricAvailability.NOT_ENROLLED -> "Aucune empreinte enregistrée"
|
||||
BiometricAvailability.UNAVAILABLE -> "Non disponible"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isBiometricEnabled,
|
||||
onCheckedChange = { securityPreferences.setBiometricEnabled(it) },
|
||||
enabled = biometricAvailability == BiometricAvailability.AVAILABLE
|
||||
)
|
||||
}
|
||||
|
||||
if (isBiometricEnabled) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Lock timeout
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showTimeoutMenu = true },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Timer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Délai de verrouillage",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = lockTimeout.displayName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showTimeoutMenu,
|
||||
onDismissRequest = { showTimeoutMenu = false }
|
||||
) {
|
||||
LockTimeout.entries.forEach { timeout ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(timeout.displayName) },
|
||||
onClick = {
|
||||
securityPreferences.setLockTimeout(timeout)
|
||||
showTimeoutMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
if (timeout == lockTimeout) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Require on startup
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PowerSettingsNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = "Verrouiller au démarrage",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Checkbox(
|
||||
checked = requireOnStartup,
|
||||
onCheckedChange = { securityPreferences.setRequireOnStartup(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// Require on resume
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhonelinkLock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = "Verrouiller en arrière-plan",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Checkbox(
|
||||
checked = requireOnResume,
|
||||
onCheckedChange = { securityPreferences.setRequireOnResume(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ package com.shaarit.presentation.settings
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.core.storage.BiometricAuthManager
|
||||
import com.shaarit.core.storage.SecurityPreferences
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.data.export.BookmarkExporter
|
||||
import com.shaarit.data.export.BookmarkImporter
|
||||
@ -31,7 +33,9 @@ class SettingsViewModel @Inject constructor(
|
||||
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
|
||||
private val tokenManager: TokenManager,
|
||||
private val workManager: WorkManager,
|
||||
val themePreferences: ThemePreferences
|
||||
val themePreferences: ThemePreferences,
|
||||
val securityPreferences: SecurityPreferences,
|
||||
val biometricAuthManager: BiometricAuthManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
package com.shaarit.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Composable
|
||||
fun VoiceInputButton(
|
||||
onResult: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
language: String = "fr-FR",
|
||||
contentDescription: String = "Recherche vocale"
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
|
||||
|
||||
val speechLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val matches = result.data
|
||||
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
matches?.firstOrNull()?.let { onResult(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
speechLauncher.launch(createRecognitionIntent(language))
|
||||
} else {
|
||||
Toast.makeText(context, "Permission micro requise pour la saisie vocale", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!isAvailable) {
|
||||
Toast.makeText(context, "Reconnaissance vocale non disponible", Toast.LENGTH_SHORT).show()
|
||||
return@IconButton
|
||||
}
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasPermission) {
|
||||
speechLauncher.launch(createRecognitionIntent(language))
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
enabled = isAvailable
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAvailable) Icons.Default.Mic else Icons.Default.MicOff,
|
||||
contentDescription = contentDescription,
|
||||
tint = if (isAvailable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecognitionIntent(language: String): Intent {
|
||||
return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, language)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
|
||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Parlez maintenant...")
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,21 @@ enum class AppTheme(
|
||||
}
|
||||
}
|
||||
|
||||
enum class ThemeMode(val displayName: String) {
|
||||
DARK("Sombre"),
|
||||
LIGHT("Clair"),
|
||||
SYSTEM("Système"),
|
||||
DYNAMIC_DARK("Material You Sombre"),
|
||||
DYNAMIC_LIGHT("Material You Clair"),
|
||||
DYNAMIC_SYSTEM("Material You Auto");
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): ThemeMode {
|
||||
return entries.find { it.name == name } ?: DARK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ThemePreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@ -46,17 +61,31 @@ class ThemePreferences @Inject constructor(
|
||||
private val _currentTheme = MutableStateFlow(loadTheme())
|
||||
val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(loadThemeMode())
|
||||
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||
|
||||
private fun loadTheme(): AppTheme {
|
||||
val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name
|
||||
return AppTheme.fromName(name)
|
||||
}
|
||||
|
||||
private fun loadThemeMode(): ThemeMode {
|
||||
val name = prefs.getString(KEY_THEME_MODE, ThemeMode.DARK.name) ?: ThemeMode.DARK.name
|
||||
return ThemeMode.fromName(name)
|
||||
}
|
||||
|
||||
fun setTheme(theme: AppTheme) {
|
||||
prefs.edit().putString(KEY_THEME, theme.name).apply()
|
||||
_currentTheme.value = theme
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
|
||||
_themeMode.value = mode
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_THEME = "selected_theme"
|
||||
private const val KEY_THEME_MODE = "theme_mode"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
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
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@ -466,16 +472,429 @@ fun getColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.Color
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ══════════════════ LIGHT COLOR SCHEMES ══════════════════
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
private val DefaultLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF006B5A),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFB2F5E6),
|
||||
onPrimaryContainer = Color(0xFF00201A),
|
||||
secondary = Color(0xFF0077B6),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD0E8FF),
|
||||
onSecondaryContainer = Color(0xFF001E36),
|
||||
tertiary = Color(0xFF00897B),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF8FAFA),
|
||||
onBackground = Color(0xFF1A1C1E),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1C1E),
|
||||
surfaceVariant = Color(0xFFE7F0EE),
|
||||
onSurfaceVariant = Color(0xFF404944),
|
||||
outline = Color(0xFF707974),
|
||||
outlineVariant = Color(0xFFC0C9C4),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002)
|
||||
)
|
||||
|
||||
private val GitHubLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF0969DA),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDDF4FF),
|
||||
onPrimaryContainer = Color(0xFF0A3069),
|
||||
secondary = Color(0xFF1A7F37),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFDAFBE1),
|
||||
onSecondaryContainer = Color(0xFF0E4F1F),
|
||||
tertiary = Color(0xFF8250DF),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFFFFFF),
|
||||
onBackground = Color(0xFF1F2328),
|
||||
surface = Color(0xFFF6F8FA),
|
||||
onSurface = Color(0xFF1F2328),
|
||||
surfaceVariant = Color(0xFFEAEEF2),
|
||||
onSurfaceVariant = Color(0xFF656D76),
|
||||
outline = Color(0xFFD0D7DE),
|
||||
outlineVariant = Color(0xFFE1E4E8),
|
||||
error = Color(0xFFCF222E),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFEBE9),
|
||||
onErrorContainer = Color(0xFF82071E)
|
||||
)
|
||||
|
||||
private val LinearLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5E6AD2),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE0E1FF),
|
||||
onPrimaryContainer = Color(0xFF1B1F5E),
|
||||
secondary = Color(0xFF2B6CB0),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD0E4FF),
|
||||
onSecondaryContainer = Color(0xFF0A2E52),
|
||||
tertiary = Color(0xFFBF7B3F),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFCFCFD),
|
||||
onBackground = Color(0xFF1B1C24),
|
||||
surface = Color(0xFFF5F5F7),
|
||||
onSurface = Color(0xFF1B1C24),
|
||||
surfaceVariant = Color(0xFFECECF0),
|
||||
onSurfaceVariant = Color(0xFF5A5B66),
|
||||
outline = Color(0xFFCCCDD4),
|
||||
outlineVariant = Color(0xFFDDDDE2),
|
||||
error = Color(0xFFD32F2F),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val SpotifyLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF1DB954),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD4F5E0),
|
||||
onPrimaryContainer = Color(0xFF0B3D1E),
|
||||
secondary = Color(0xFF1DB954),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD4F5E0),
|
||||
onSecondaryContainer = Color(0xFF0B3D1E),
|
||||
tertiary = Color(0xFF535353),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF8F8F8),
|
||||
onBackground = Color(0xFF191414),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF191414),
|
||||
surfaceVariant = Color(0xFFF0F0F0),
|
||||
onSurfaceVariant = Color(0xFF535353),
|
||||
outline = Color(0xFFB3B3B3),
|
||||
outlineVariant = Color(0xFFD9D9D9),
|
||||
error = Color(0xFFE22134),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val NotionLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF2F7ABA),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD6EAFF),
|
||||
onPrimaryContainer = Color(0xFF0A2E52),
|
||||
secondary = Color(0xFFCB5A3C),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFFFDDD3),
|
||||
onSecondaryContainer = Color(0xFF4D1A0D),
|
||||
tertiary = Color(0xFF4D8B6F),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFFFFFF),
|
||||
onBackground = Color(0xFF37352F),
|
||||
surface = Color(0xFFF7F6F3),
|
||||
onSurface = Color(0xFF37352F),
|
||||
surfaceVariant = Color(0xFFEFEEEB),
|
||||
onSurfaceVariant = Color(0xFF787774),
|
||||
outline = Color(0xFFD3D1CB),
|
||||
outlineVariant = Color(0xFFE3E2DE),
|
||||
error = Color(0xFFEB5757),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val DiscordLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5865F2),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE0E3FF),
|
||||
onPrimaryContainer = Color(0xFF1E2175),
|
||||
secondary = Color(0xFF248046),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD4F5E0),
|
||||
onSecondaryContainer = Color(0xFF0B3D1E),
|
||||
tertiary = Color(0xFFC4960C),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFFFFFF),
|
||||
onBackground = Color(0xFF313338),
|
||||
surface = Color(0xFFF2F3F5),
|
||||
onSurface = Color(0xFF313338),
|
||||
surfaceVariant = Color(0xFFEBEDEF),
|
||||
onSurfaceVariant = Color(0xFF5C5E66),
|
||||
outline = Color(0xFFD1D3D8),
|
||||
outlineVariant = Color(0xFFE1E3E8),
|
||||
error = Color(0xFFED4245),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val DraculaLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF7C3AED),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFEDE0FF),
|
||||
onPrimaryContainer = Color(0xFF2D0A6E),
|
||||
secondary = Color(0xFF16A34A),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD4F5E0),
|
||||
onSecondaryContainer = Color(0xFF0B3D1E),
|
||||
tertiary = Color(0xFFDB2777),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFCFAFF),
|
||||
onBackground = Color(0xFF1E1E2E),
|
||||
surface = Color(0xFFF5F3FA),
|
||||
onSurface = Color(0xFF1E1E2E),
|
||||
surfaceVariant = Color(0xFFEDE9F5),
|
||||
onSurfaceVariant = Color(0xFF5C5670),
|
||||
outline = Color(0xFFCBC4D8),
|
||||
outlineVariant = Color(0xFFDDD8E8),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val OneDarkProLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF2563EB),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDBEAFE),
|
||||
onPrimaryContainer = Color(0xFF0A2E6E),
|
||||
secondary = Color(0xFF4D7C0F),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE2F5C5),
|
||||
onSecondaryContainer = Color(0xFF1A3305),
|
||||
tertiary = Color(0xFFCA8A04),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFAFAFB),
|
||||
onBackground = Color(0xFF282C34),
|
||||
surface = Color(0xFFF3F4F6),
|
||||
onSurface = Color(0xFF282C34),
|
||||
surfaceVariant = Color(0xFFECEDF0),
|
||||
onSurfaceVariant = Color(0xFF5A5D66),
|
||||
outline = Color(0xFFCCCED4),
|
||||
outlineVariant = Color(0xFFDDDEE2),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val TokyoNightLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF3B5FD9),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDDE3FF),
|
||||
onPrimaryContainer = Color(0xFF0F1E5E),
|
||||
secondary = Color(0xFF5A8A1E),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE2F5C5),
|
||||
onSecondaryContainer = Color(0xFF1A3305),
|
||||
tertiary = Color(0xFF7C3AED),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF8F9FC),
|
||||
onBackground = Color(0xFF1A1B26),
|
||||
surface = Color(0xFFF0F2F8),
|
||||
onSurface = Color(0xFF1A1B26),
|
||||
surfaceVariant = Color(0xFFE8EAF2),
|
||||
onSurfaceVariant = Color(0xFF545770),
|
||||
outline = Color(0xFFC4C7D8),
|
||||
outlineVariant = Color(0xFFD8DAE8),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val NordLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5E81AC),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD8E6F3),
|
||||
onPrimaryContainer = Color(0xFF1E3450),
|
||||
secondary = Color(0xFF6B8F71),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFDAEEDC),
|
||||
onSecondaryContainer = Color(0xFF233828),
|
||||
tertiary = Color(0xFFBF8B2E),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF5F7FA),
|
||||
onBackground = Color(0xFF2E3440),
|
||||
surface = Color(0xFFECEFF4),
|
||||
onSurface = Color(0xFF2E3440),
|
||||
surfaceVariant = Color(0xFFE5E9F0),
|
||||
onSurfaceVariant = Color(0xFF4C566A),
|
||||
outline = Color(0xFFBCC3CE),
|
||||
outlineVariant = Color(0xFFD8DEE9),
|
||||
error = Color(0xFFBF616A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val NightOwlLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF0C7B93),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFCCEFF5),
|
||||
onPrimaryContainer = Color(0xFF003540),
|
||||
secondary = Color(0xFF4D7C0F),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE2F5C5),
|
||||
onSecondaryContainer = Color(0xFF1A3305),
|
||||
tertiary = Color(0xFF9333EA),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFBFCFD),
|
||||
onBackground = Color(0xFF011627),
|
||||
surface = Color(0xFFF0F4F8),
|
||||
onSurface = Color(0xFF011627),
|
||||
surfaceVariant = Color(0xFFE5ECF2),
|
||||
onSurfaceVariant = Color(0xFF4A5568),
|
||||
outline = Color(0xFFBCC8D6),
|
||||
outlineVariant = Color(0xFFD4DEE8),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val AnthraciteLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF6750A4),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFEADDFF),
|
||||
onPrimaryContainer = Color(0xFF21005D),
|
||||
secondary = Color(0xFF00897B),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFB2F5E6),
|
||||
onSecondaryContainer = Color(0xFF00201A),
|
||||
tertiary = Color(0xFFC2185B),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFFFBFE),
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
surface = Color(0xFFF4F0F5),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
surfaceVariant = Color(0xFFE7E0EC),
|
||||
onSurfaceVariant = Color(0xFF49454F),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCAC4D0),
|
||||
error = Color(0xFFB3261E),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF410E0B)
|
||||
)
|
||||
|
||||
private val CyberpunkLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF0097A7),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFCCF2F6),
|
||||
onPrimaryContainer = Color(0xFF003840),
|
||||
secondary = Color(0xFFC2185B),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFFFD9E3),
|
||||
onSecondaryContainer = Color(0xFF4D0620),
|
||||
tertiary = Color(0xFFC6A700),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF8F8FC),
|
||||
onBackground = Color(0xFF0A0A14),
|
||||
surface = Color(0xFFF0F0F8),
|
||||
onSurface = Color(0xFF0A0A14),
|
||||
surfaceVariant = Color(0xFFE8E8F2),
|
||||
onSurfaceVariant = Color(0xFF4A4A60),
|
||||
outline = Color(0xFFB8B8CC),
|
||||
outlineVariant = Color(0xFFD4D4E2),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val NavyEleganceLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFFB8860B),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFFFF0C8),
|
||||
onPrimaryContainer = Color(0xFF3D2E00),
|
||||
secondary = Color(0xFF6B7280),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE5E7EB),
|
||||
onSecondaryContainer = Color(0xFF1F2937),
|
||||
tertiary = Color(0xFF3B82F6),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFAFBFD),
|
||||
onBackground = Color(0xFF0B1929),
|
||||
surface = Color(0xFFF0F3F8),
|
||||
onSurface = Color(0xFF0B1929),
|
||||
surfaceVariant = Color(0xFFE8ECF2),
|
||||
onSurfaceVariant = Color(0xFF4A5568),
|
||||
outline = Color(0xFFBCC5D2),
|
||||
outlineVariant = Color(0xFFD4DAE4),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
private val EarthyLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF2E7D32),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD4EDDA),
|
||||
onPrimaryContainer = Color(0xFF0B3D0E),
|
||||
secondary = Color(0xFF8D6E4C),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFF5E6D3),
|
||||
onSecondaryContainer = Color(0xFF3D2B18),
|
||||
tertiary = Color(0xFF558B2F),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFFAF8F5),
|
||||
onBackground = Color(0xFF1A1510),
|
||||
surface = Color(0xFFF5F2ED),
|
||||
onSurface = Color(0xFF1A1510),
|
||||
surfaceVariant = Color(0xFFEDE8E0),
|
||||
onSurfaceVariant = Color(0xFF5A5248),
|
||||
outline = Color(0xFFC4BAA8),
|
||||
outlineVariant = Color(0xFFD8D0C2),
|
||||
error = Color(0xFFDC2626),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF690005)
|
||||
)
|
||||
|
||||
fun getLightColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.ColorScheme {
|
||||
return when (appTheme) {
|
||||
AppTheme.DEFAULT -> DefaultLightColorScheme
|
||||
AppTheme.GITHUB -> GitHubLightColorScheme
|
||||
AppTheme.LINEAR -> LinearLightColorScheme
|
||||
AppTheme.SPOTIFY -> SpotifyLightColorScheme
|
||||
AppTheme.NOTION -> NotionLightColorScheme
|
||||
AppTheme.DISCORD -> DiscordLightColorScheme
|
||||
AppTheme.DRACULA -> DraculaLightColorScheme
|
||||
AppTheme.ONE_DARK_PRO -> OneDarkProLightColorScheme
|
||||
AppTheme.TOKYO_NIGHT -> TokyoNightLightColorScheme
|
||||
AppTheme.NORD -> NordLightColorScheme
|
||||
AppTheme.NIGHT_OWL -> NightOwlLightColorScheme
|
||||
AppTheme.ANTHRACITE -> AnthraciteLightColorScheme
|
||||
AppTheme.CYBERPUNK -> CyberpunkLightColorScheme
|
||||
AppTheme.NAVY_ELEGANCE -> NavyEleganceLightColorScheme
|
||||
AppTheme.EARTHY -> EarthyLightColorScheme
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShaarItTheme(
|
||||
appTheme: AppTheme = AppTheme.DEFAULT,
|
||||
themeMode: ThemeMode = ThemeMode.DARK,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// Always use the explicit color scheme for the selected theme
|
||||
val colorScheme = getColorSchemeForTheme(appTheme)
|
||||
val isDarkTheme = when (themeMode) {
|
||||
ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true
|
||||
ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false
|
||||
ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
// All custom themes are dark
|
||||
val isEffectivelyDark = true
|
||||
val useDynamicColors = themeMode in listOf(
|
||||
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
|
||||
)
|
||||
|
||||
val colorScheme = when {
|
||||
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
isDarkTheme -> getColorSchemeForTheme(appTheme)
|
||||
else -> getLightColorSchemeForTheme(appTheme)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
@ -483,7 +902,7 @@ fun ShaarItTheme(
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isEffectivelyDark
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
benchmark/build.gradle.kts
Normal file
48
benchmark/build.gradle.kts
Normal file
@ -0,0 +1,48 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
id("com.android.test")
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.shaarit.benchmark"
|
||||
compileSdk = 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
create("benchmark") {
|
||||
isDebuggable = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
matchingFallbacks += listOf("release")
|
||||
}
|
||||
}
|
||||
|
||||
targetProjectPath = ":app"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.test.ext:junit:1.1.5")
|
||||
implementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
||||
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.3")
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
beforeVariants(selector().all()) {
|
||||
it.enable = it.buildType == "benchmark"
|
||||
}
|
||||
}
|
||||
3
benchmark/src/main/AndroidManifest.xml
Normal file
3
benchmark/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@ -0,0 +1,54 @@
|
||||
package com.shaarit.benchmark
|
||||
|
||||
import androidx.benchmark.macro.junit4.BaselineProfileRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Generates Baseline Profiles for ShaarIt.
|
||||
*
|
||||
* Baseline Profiles pre-compile critical user journeys (cold start, navigation, scroll)
|
||||
* using AOT compilation, avoiding JIT compilation at runtime.
|
||||
*
|
||||
* Typical gain: 30-50% reduction in cold start time for Compose apps.
|
||||
*
|
||||
* To generate the profile, run:
|
||||
* ./gradlew :benchmark:pixel6Api31BenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
|
||||
*
|
||||
* Then copy the generated baseline-prof.txt to app/src/main/baseline-prof.txt
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class BaselineProfileGenerator {
|
||||
|
||||
@get:Rule
|
||||
val rule = BaselineProfileRule()
|
||||
|
||||
@Test
|
||||
fun generateProfile() {
|
||||
rule.collect(
|
||||
packageName = "com.shaarit",
|
||||
includeInStartupProfile = true
|
||||
) {
|
||||
// Cold start — launch the main activity
|
||||
startActivityAndWait()
|
||||
|
||||
// Wait for the feed to load
|
||||
device.waitForIdle()
|
||||
|
||||
// Scroll the feed to capture scroll-related code paths
|
||||
val feedList = device.findObject(
|
||||
androidx.test.uiautomator.By.scrollable(true)
|
||||
)
|
||||
feedList?.let {
|
||||
it.scroll(androidx.test.uiautomator.Direction.DOWN, 2.0f)
|
||||
device.waitForIdle()
|
||||
it.scroll(androidx.test.uiautomator.Direction.UP, 2.0f)
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
869
docs/PERFORMANCE_ET_UX_OPTIMALE.md
Normal file
869
docs/PERFORMANCE_ET_UX_OPTIMALE.md
Normal file
@ -0,0 +1,869 @@
|
||||
# ShaarIt — Analyse de Performance & Expérience Utilisateur Optimale
|
||||
|
||||
**Date de l'audit** : 9 février 2026
|
||||
**Version analysée** : v1.0 (47 fichiers Kotlin, ~8 500 lignes de code)
|
||||
**Objectif** : Identifier et corriger tous les points bloquants pour offrir une expérience instantanée, sans délai ni attente perceptible.
|
||||
|
||||
---
|
||||
|
||||
## Table des Matières
|
||||
|
||||
1. [Résumé Exécutif](#1-résumé-exécutif)
|
||||
2. [Problèmes Critiques de Performance](#2-problèmes-critiques-de-performance)
|
||||
3. [Optimisations du Démarrage (Cold Start)](#3-optimisations-du-démarrage-cold-start)
|
||||
4. [Optimisations Réseau & Synchronisation](#4-optimisations-réseau--synchronisation)
|
||||
5. [Optimisations Base de Données (Room)](#5-optimisations-base-de-données-room)
|
||||
6. [Optimisations UI/Compose](#6-optimisations-uicompose)
|
||||
7. [Optimisations Mémoire](#7-optimisations-mémoire)
|
||||
8. [Optimisations du Build Release](#8-optimisations-du-build-release)
|
||||
9. [Expérience Utilisateur — Réduction des Temps d'Attente](#9-expérience-utilisateur--réduction-des-temps-dattente)
|
||||
10. [Plan d'Action Priorisé](#10-plan-daction-priorisé)
|
||||
11. [Métriques de Suivi](#11-métriques-de-suivi)
|
||||
|
||||
---
|
||||
|
||||
## 1. Résumé Exécutif
|
||||
|
||||
L'application ShaarIt repose sur une architecture solide (Clean Architecture + MVVM, Hilt, Room, Compose). Cependant, l'analyse approfondie du code révèle **24 problèmes de performance** concrets, allant de critiques (R8 désactivé, requêtes N+1 en mémoire) à modérés (regex recompilées, caches non bornés). Corrigés, ces points transformeront l'expérience utilisateur en la rendant **instantanée** même avec des bibliothèques de plusieurs milliers de liens.
|
||||
|
||||
### Impact Attendu
|
||||
|
||||
| Métrique | Actuel (estimé) | Après optimisations |
|
||||
|----------|-----------------|---------------------|
|
||||
| **Taille APK** | ~15-20 MB | ~5-8 MB (-60%) |
|
||||
| **Cold start** | ~1.5-2s | ~400-600ms (-70%) |
|
||||
| **Affichage du flux** | ~500ms | <100ms (instantané) |
|
||||
| **Sync complète (1000 liens)** | ~30s+ | ~3-5s (-85%) |
|
||||
| **Recherche** | ~200-500ms (LIKE) | <50ms (FTS4) |
|
||||
| **Mémoire au repos** | ~80-120 MB | ~40-60 MB (-50%) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Problèmes Critiques de Performance
|
||||
|
||||
### 2.1 R8/ProGuard Désactivé en Release
|
||||
|
||||
**Fichier** : `app/build.gradle.kts` (lignes 47-48)
|
||||
|
||||
```kotlin
|
||||
release {
|
||||
isMinifyEnabled = false // ← CRITIQUE
|
||||
isShrinkResources = false // ← CRITIQUE
|
||||
}
|
||||
```
|
||||
|
||||
**Impact** :
|
||||
- APK 2-3x plus gros que nécessaire
|
||||
- Aucune optimisation du bytecode (inlining, dead code elimination)
|
||||
- Aucune suppression des ressources inutilisées
|
||||
- Pas d'obfuscation (sécurité réduite)
|
||||
|
||||
**Solution** :
|
||||
```kotlin
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> **Note** : Les règles ProGuard actuelles (`proguard-rules.pro`) sont bien configurées et couvrent Retrofit, Moshi, Hilt, et les DTOs. L'activation est donc sûre. Cependant, la règle `-keep class com.shaarit.** { *; }` (ligne 108) est trop large et doit être affinée pour bénéficier pleinement de R8.
|
||||
|
||||
**Priorité** : 🔴 CRITIQUE
|
||||
**Effort** : Faible (2 lignes + tests de non-régression)
|
||||
**Gain** : -60% taille APK, +15-20% vitesse d'exécution
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `SimpleDateFormat` recréé à chaque item
|
||||
|
||||
**Fichier** : `data/repository/LinkRepositoryImpl.kt` (ligne 544)
|
||||
|
||||
```kotlin
|
||||
private fun LinkEntity.toDomainModel(): ShaarliLink {
|
||||
return ShaarliLink(
|
||||
// ...
|
||||
date = java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
java.util.Locale.getDefault()
|
||||
).format(java.util.Date(createdAt)),
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Impact** : `SimpleDateFormat` est coûteux à instancier. Cette méthode est appelée pour **chaque lien affiché** dans le flux paginé. Avec 20 liens par page, cela crée 20 instances inutiles à chaque chargement.
|
||||
|
||||
**Solution** : Utiliser un `companion object` avec un formatter thread-safe :
|
||||
```kotlin
|
||||
companion object {
|
||||
private val dateFormatter = java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US
|
||||
)
|
||||
}
|
||||
```
|
||||
Ou mieux, utiliser `java.time.Instant` + `DateTimeFormatter` (déjà utilisé dans `SyncManager`).
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Réduction des allocations GC à chaque scroll
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `getLinksByTag()` charge TOUTE la base en mémoire
|
||||
|
||||
**Fichier** : `data/repository/LinkRepositoryImpl.kt` (lignes 116-134)
|
||||
|
||||
```kotlin
|
||||
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
|
||||
return try {
|
||||
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() // ← CHARGE TOUT
|
||||
if (localLinks.isNotEmpty()) {
|
||||
val filtered = localLinks.filter { it.tags.contains(tag) } // ← FILTRE EN MÉMOIRE
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impact** : Pour une bibliothèque de 5 000 liens, cette opération :
|
||||
1. Charge ~5 000 entités complètes en mémoire (~5-10 MB)
|
||||
2. Itère sur chacune pour un filtre que Room pourrait faire en SQL
|
||||
3. Crée une copie filtrée
|
||||
|
||||
**Solution** : Utiliser une requête Room dédiée (le DAO `getLinksByTag(tag)` existe déjà !) :
|
||||
```kotlin
|
||||
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
|
||||
return try {
|
||||
val sql = "SELECT * FROM links WHERE tags LIKE ? ORDER BY created_at DESC LIMIT 100"
|
||||
val links = linkDao.getLinksByTagDirect(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
|
||||
Result.success(links.map { it.toDomainModel() })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Faible
|
||||
**Gain** : -95% mémoire pour les requêtes par tag
|
||||
|
||||
---
|
||||
|
||||
### 2.4 FTS4 défini mais non utilisé pour la recherche
|
||||
|
||||
**Fichier** : `data/local/dao/LinkDao.kt`
|
||||
|
||||
L'entité FTS4 `LinkFtsEntity` est définie (ligne 129 de `LinkEntity.kt`) et la méthode `searchLinksFullText()` existe dans le DAO (ligne 51-57), mais le repository utilise `searchLinks()` avec des `LIKE '%query%'` à la place :
|
||||
|
||||
```kotlin
|
||||
// Utilisé actuellement (LENT) :
|
||||
fun searchLinks(query: String): PagingSource<Int, LinkEntity>
|
||||
// WHERE title LIKE '%query%' OR description LIKE '%query%' OR url LIKE '%query%'
|
||||
|
||||
// Disponible mais NON UTILISÉ (RAPIDE) :
|
||||
fun searchLinksFullText(query: String): PagingSource<Int, LinkEntity>
|
||||
// WHERE links_fts MATCH :query ← Utilise l'index FTS4
|
||||
```
|
||||
|
||||
**Impact** : Les requêtes `LIKE '%...%'` nécessitent un full table scan (O(n)). FTS4 utilise un index inversé (O(log n)). Pour 10 000 liens, la différence est de **10-100x** en vitesse.
|
||||
|
||||
**Solution** : Dans `LinkRepositoryImpl.getLinksStream()`, remplacer :
|
||||
```kotlin
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
|
||||
```
|
||||
par :
|
||||
```kotlin
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : 1 ligne
|
||||
**Gain** : Recherche 10-100x plus rapide
|
||||
|
||||
---
|
||||
|
||||
## 3. Optimisations du Démarrage (Cold Start)
|
||||
|
||||
### 3.1 Absence de Baseline Profiles
|
||||
|
||||
Les Baseline Profiles pré-compilent les chemins critiques (cold start, navigation, scroll) en AOT, évitant la compilation JIT au runtime.
|
||||
|
||||
**Impact** : Jetpack Compose bénéficie massivement des Baseline Profiles car le framework génère beaucoup de bytecode dynamique. Gain typique : **30-50% de réduction du temps de démarrage**.
|
||||
|
||||
**Solution** :
|
||||
1. Ajouter le module `:benchmark` avec la dépendance `androidx.benchmark:benchmark-macro-junit4`
|
||||
2. Créer un `BaselineProfileGenerator` qui navigue dans les écrans principaux
|
||||
3. Générer le profil et l'inclure dans `src/main/baseline-prof.txt`
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BaselineProfileGenerator {
|
||||
@get:Rule
|
||||
val rule = BaselineProfileRule()
|
||||
|
||||
@Test
|
||||
fun generateProfile() {
|
||||
rule.collect("com.shaarit") {
|
||||
startActivityAndWait()
|
||||
// Scroll feed, search, add link...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Moyen (1-2h de configuration)
|
||||
**Gain** : -30-50% cold start
|
||||
|
||||
### 3.2 Traitement du Share Intent pendant la composition
|
||||
|
||||
**Fichier** : `MainActivity.kt` (lignes 56-82)
|
||||
|
||||
Le parsing de l'intent (extraction du Share Intent, deep links) se fait **dans le bloc `setContent {}`**, ce qui signifie qu'il est ré-exécuté à chaque recomposition.
|
||||
|
||||
**Solution** : Extraire le parsing dans `onCreate()` avant `setContent {}` et passer les valeurs en tant que paramètres stables :
|
||||
```kotlin
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Parse intent ONCE, before composition
|
||||
val shareData = parseShareIntent(intent)
|
||||
|
||||
setContent {
|
||||
// Use shareData directly (stable, no recomposition)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Faible
|
||||
**Gain** : Évite les recompositions inutiles au démarrage
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimisations Réseau & Synchronisation
|
||||
|
||||
### 4.1 Logging Interceptor en mode BODY en production
|
||||
|
||||
**Fichier** : `core/di/NetworkModule.kt` (ligne 29)
|
||||
|
||||
```kotlin
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY // ← TOUJOURS BODY
|
||||
}
|
||||
```
|
||||
|
||||
**Impact** : Log chaque requête et réponse complète (headers + body). Pour un sync de 1 000 liens en JSON, cela :
|
||||
- Génère des méga-octets de logs
|
||||
- Ralentit le I/O
|
||||
- Consomme de la mémoire pour la sérialisation des logs
|
||||
|
||||
**Solution** :
|
||||
```kotlin
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Significatif en release (réseau + mémoire)
|
||||
|
||||
### 4.2 Pas de timeouts explicites sur le client OkHttp principal
|
||||
|
||||
**Fichier** : `core/di/NetworkModule.kt`
|
||||
|
||||
Le `OkHttpClient` principal n'a aucun timeout configuré. Les défauts OkHttp sont de 10s pour connect, 10s pour read, 10s pour write — ce qui peut causer des attentes longues si le serveur Shaarli est lent.
|
||||
|
||||
**Solution** :
|
||||
```kotlin
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Très faible
|
||||
|
||||
### 4.3 Sync complète sans delta/incrémental
|
||||
|
||||
**Fichier** : `data/sync/SyncManager.kt` — `pullFromServer()` (lignes 375-437)
|
||||
|
||||
Chaque sync récupère **tous les liens** du serveur, page par page (100 par page). Pour une bibliothèque de 5 000 liens, cela nécessite 50 requêtes HTTP séquentielles.
|
||||
|
||||
**Solution** : Sync incrémentale basée sur la date de dernière synchronisation :
|
||||
1. Stocker le timestamp de dernière sync réussie dans `TokenManager`
|
||||
2. Utiliser le paramètre `searchterm` ou un header `If-Modified-Since` si supporté par Shaarli
|
||||
3. En fallback, comparer les `updatedAt` locaux vs serveur pour ne traiter que les changements
|
||||
|
||||
```kotlin
|
||||
private suspend fun pullFromServer() {
|
||||
val lastSyncTime = tokenManager.getLastSyncTimestamp()
|
||||
// Ne récupérer que les liens modifiés depuis lastSyncTime
|
||||
// ...
|
||||
tokenManager.saveLastSyncTimestamp(System.currentTimeMillis())
|
||||
}
|
||||
```
|
||||
|
||||
> **Note** : L'API Shaarli v1 ne supporte pas nativement le filtrage par date de modification. Il faudra soit implémenter un checksum côté client, soit paginer intelligemment (s'arrêter quand on rencontre des liens déjà synchés).
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Élevé (refactoring majeur)
|
||||
**Gain** : -85% temps de sync pour les syncs régulières
|
||||
|
||||
### 4.4 `HttpURLConnection` dans `LinkHealthCheckWorker`
|
||||
|
||||
**Fichier** : `data/worker/LinkHealthCheckWorker.kt` (lignes 151-191)
|
||||
|
||||
Le health check utilise `HttpURLConnection` legacy au lieu du `OkHttpClient` déjà disponible via Hilt. Cela empêche le **connection pooling** et le **HTTP/2 multiplexing** d'OkHttp.
|
||||
|
||||
**Solution** : Injecter `OkHttpClient` dans le Worker (via `@AssistedInject`) et utiliser un client dédié (comme fait dans `GeminiRepositoryImpl`) :
|
||||
```kotlin
|
||||
private val healthCheckClient by lazy {
|
||||
okHttpClient.newBuilder()
|
||||
.connectTimeout(7, TimeUnit.SECONDS)
|
||||
.readTimeout(7, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Faible
|
||||
**Gain** : Meilleur connection pooling, HTTP/2
|
||||
|
||||
### 4.5 Pas de cache HTTP disque
|
||||
|
||||
**Fichier** : `core/di/NetworkModule.kt`
|
||||
|
||||
Aucun `Cache` OkHttp n'est configuré. Les réponses API ne sont jamais mises en cache sur disque.
|
||||
|
||||
**Solution** :
|
||||
```kotlin
|
||||
val cacheSize = 10L * 1024 * 1024 // 10 MB
|
||||
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
// ...
|
||||
.build()
|
||||
```
|
||||
|
||||
**Priorité** : 🟢 FAIBLE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Réduit la bande passante, accélère les requêtes répétées
|
||||
|
||||
---
|
||||
|
||||
## 5. Optimisations Base de Données (Room)
|
||||
|
||||
### 5.1 `getAllLinksForStats()` charge tout en mémoire
|
||||
|
||||
**Fichier** : `data/local/dao/LinkDao.kt` (ligne 234)
|
||||
|
||||
```kotlin
|
||||
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
|
||||
suspend fun getAllLinksForStats(): List<LinkEntity>
|
||||
```
|
||||
|
||||
Utilisé par `BookmarkExporter` (3 fonctions d'export) et `LinkRepositoryImpl.getAllLinks()`. Charge **toutes les colonnes** de **tous les liens** en mémoire.
|
||||
|
||||
**Solutions** :
|
||||
1. **Pour l'export** : Utiliser un `Cursor` ou un `Flow<List<LinkEntity>>` avec traitement par batch
|
||||
2. **Pour les stats** : Créer des requêtes d'agrégation SQL dédiées au lieu de charger les entités :
|
||||
```kotlin
|
||||
@Query("SELECT COUNT(*) FROM links WHERE sync_status != 'PENDING_DELETE'")
|
||||
suspend fun getTotalLinksCount(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM links WHERE created_at >= :since")
|
||||
suspend fun getLinksCountSince(since: Long): Int
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Moyen
|
||||
**Gain** : -90% mémoire pour les stats et exports
|
||||
|
||||
### 5.2 Pas d'index sur les colonnes fréquemment filtrées
|
||||
|
||||
**Fichier** : `data/local/entity/LinkEntity.kt` (lignes 13-21)
|
||||
|
||||
Les index existants sont bons (`sync_status`, `is_private`, `created_at`, `is_pinned`, `url`), mais il manque :
|
||||
- **`content_type`** — utilisé par `getContentTypeDistribution()`
|
||||
- **`site_name`** — utilisé par `getTopSites()` et `getAllSites()`
|
||||
- **`link_check_status`** — utilisé par `getDeadLinks()`, `getDeadLinksCount()`, `getPendingLinksCount()`
|
||||
|
||||
**Solution** :
|
||||
```kotlin
|
||||
@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),
|
||||
Index(value = ["content_type"]), // NOUVEAU
|
||||
Index(value = ["site_name"]), // NOUVEAU
|
||||
Index(value = ["link_check_status"]), // NOUVEAU
|
||||
Index(value = ["last_health_check"]) // NOUVEAU
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Très faible (+ migration de schéma)
|
||||
**Gain** : Requêtes de filtrage/stats 5-10x plus rapides
|
||||
|
||||
### 5.3 `fallbackToDestructiveMigration()` — Perte de données silencieuse
|
||||
|
||||
**Fichier** : `data/local/database/ShaarliDatabase.kt` (ligne 59)
|
||||
|
||||
```kotlin
|
||||
.fallbackToDestructiveMigration()
|
||||
```
|
||||
|
||||
**Impact UX** : À chaque changement de schéma (version de DB), toutes les données locales sont **silencieusement supprimées**. L'utilisateur perd son cache offline, ses épingles, ses métadonnées enrichies, sans aucun avertissement.
|
||||
|
||||
**Solution** : Écrire des migrations explicites :
|
||||
```kotlin
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE links ADD COLUMN new_column TEXT DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
Room.databaseBuilder(context, ShaarliDatabase::class.java, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_4_5)
|
||||
.build()
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE (UX critique)
|
||||
**Effort** : Moyen (pour chaque future migration)
|
||||
**Gain** : Zéro perte de données utilisateur
|
||||
|
||||
### 5.4 `exportSchema = false`
|
||||
|
||||
**Fichier** : `data/local/database/ShaarliDatabase.kt` (ligne 32)
|
||||
|
||||
L'export de schéma est désactivé, ce qui empêche la vérification automatique des migrations Room et la détection des erreurs de migration en compile-time.
|
||||
|
||||
**Solution** : Activer l'export de schéma :
|
||||
```kotlin
|
||||
@Database(
|
||||
// ...
|
||||
exportSchema = true
|
||||
)
|
||||
```
|
||||
Et ajouter dans `build.gradle.kts` :
|
||||
```kotlin
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Très faible
|
||||
|
||||
---
|
||||
|
||||
## 6. Optimisations UI/Compose
|
||||
|
||||
### 6.1 Images non redimensionnées par Coil
|
||||
|
||||
**Fichier** : `presentation/feed/LinkItemViews.kt`
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = link.thumbnailUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
```
|
||||
|
||||
**Impact** : Coil télécharge et décode l'image en résolution originale (potentiellement 4K), puis Compose la redimensionne visuellement. Cela gaspille de la mémoire (un bitmap 1920x1080 = ~8 MB en ARGB_8888).
|
||||
|
||||
**Solution** : Utiliser le builder de requête Coil pour limiter la taille en mémoire :
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(link.thumbnailUrl)
|
||||
.size(200) // Taille cible en pixels
|
||||
.crossfade(true)
|
||||
.memoryCacheKey(link.thumbnailUrl)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Faible
|
||||
**Gain** : -80% mémoire par image, scroll plus fluide
|
||||
|
||||
### 6.2 `MarkdownText` dans chaque item de liste
|
||||
|
||||
**Fichier** : `presentation/feed/LinkItemViews.kt` (lignes 179-184, 327-332)
|
||||
|
||||
Chaque item du flux rend du Markdown via `MarkdownText`. Le parsing Markdown est coûteux et se fait pour **chaque item visible**.
|
||||
|
||||
**Solutions** :
|
||||
1. **Limiter l'affichage** : Dans les vues liste/grille, afficher un `Text()` simple avec le texte brut tronqué. Réserver `MarkdownText` pour la vue détail.
|
||||
2. **Pré-parser** : Stocker le texte HTML pré-rendu dans la base de données (colonne `excerpt`) lors de la sync.
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Faible
|
||||
**Gain** : Scroll plus fluide, moins de jank
|
||||
|
||||
### 6.3 Debounce de 300ms sur le flux principal
|
||||
|
||||
**Fichier** : `presentation/feed/FeedViewModel.kt` (ligne 82)
|
||||
|
||||
```kotlin
|
||||
.debounce(300)
|
||||
```
|
||||
|
||||
Ce debounce de 300ms s'applique à **tous les changements** (recherche, tags, filtres, refresh), ce qui ajoute un délai perceptible même quand l'utilisateur tape sur un filtre.
|
||||
|
||||
**Solution** : Appliquer le debounce uniquement à la recherche textuelle :
|
||||
```kotlin
|
||||
val debouncedSearch = _searchQuery.debounce(300)
|
||||
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { ... }
|
||||
|
||||
val pagedLinks = combine(debouncedSearch, instantFilters) { query, filters ->
|
||||
// ...
|
||||
}.flatMapLatest { ... }
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Faible
|
||||
**Gain** : Filtres instantanés au lieu de 300ms de délai
|
||||
|
||||
---
|
||||
|
||||
## 7. Optimisations Mémoire
|
||||
|
||||
### 7.1 Caches en mémoire non bornés (Gemini)
|
||||
|
||||
**Fichier** : `data/repository/GeminiRepositoryImpl.kt` (lignes 26-29)
|
||||
|
||||
```kotlin
|
||||
private val tagsCache = mutableMapOf<String, List<String>>()
|
||||
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
|
||||
```
|
||||
|
||||
Ces caches croissent indéfiniment durant la session. Si l'utilisateur analyse 100 URLs, le cache contient 100 résultats sans jamais les libérer.
|
||||
|
||||
**Solution** : Utiliser un `LruCache` avec une taille maximale :
|
||||
```kotlin
|
||||
private val analysisCache = object : LinkedHashMap<String, AiEnrichmentResult>(50, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, AiEnrichmentResult>): Boolean {
|
||||
return size > 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ou mieux, utiliser `LruCache` d'Android :
|
||||
```kotlin
|
||||
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
|
||||
```
|
||||
|
||||
**Priorité** : 🟢 FAIBLE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Prévient les fuites mémoire en session longue
|
||||
|
||||
### 7.2 `GenerativeModel` recréé à chaque appel Gemini
|
||||
|
||||
**Fichier** : `data/repository/GeminiRepositoryImpl.kt` (lignes 78, 193)
|
||||
|
||||
```kotlin
|
||||
private suspend fun generateWithModel(apiKey: String, modelName: String, url: String): AiEnrichmentResult {
|
||||
val generativeModel = GenerativeModel( // ← Recréé à chaque appel
|
||||
modelName = modelName,
|
||||
apiKey = apiKey,
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Solution** : Mettre en cache les instances par nom de modèle :
|
||||
```kotlin
|
||||
private val modelCache = mutableMapOf<String, GenerativeModel>()
|
||||
|
||||
private fun getOrCreateModel(apiKey: String, modelName: String): GenerativeModel {
|
||||
return modelCache.getOrPut(modelName) {
|
||||
GenerativeModel(modelName = modelName, apiKey = apiKey, ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟢 FAIBLE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Réduit les allocations lors d'appels AI successifs
|
||||
|
||||
### 7.3 Regex compilées à chaque appel
|
||||
|
||||
**Fichier** : `data/metadata/LinkMetadataExtractor.kt` (lignes 175-206)
|
||||
|
||||
```kotlin
|
||||
url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) // ← Nouvelle Regex à chaque appel
|
||||
```
|
||||
|
||||
Plus de **15 patterns Regex** sont compilés inline dans `detectContentType()` et `detectContentTypeFromUrl()`.
|
||||
|
||||
**Solution** : Pré-compiler en `companion object` :
|
||||
```kotlin
|
||||
companion object {
|
||||
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion")
|
||||
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|bitbucket")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Réduit les allocations, accélère la détection de type
|
||||
|
||||
---
|
||||
|
||||
## 8. Optimisations du Build Release
|
||||
|
||||
### 8.1 ProGuard trop permissif
|
||||
|
||||
**Fichier** : `proguard-rules.pro` (lignes 107-109)
|
||||
|
||||
```
|
||||
-keep class com.shaarit.** { *; }
|
||||
-keepclassmembers class com.shaarit.** { *; }
|
||||
```
|
||||
|
||||
Ces règles **empêchent R8 d'optimiser toute la codebase**. Aucun inlining, aucune suppression de code mort, aucune obfuscation.
|
||||
|
||||
**Solution** : Remplacer par des règles ciblées :
|
||||
```
|
||||
# Keep only what is strictly needed
|
||||
-keep class com.shaarit.data.dto.** { *; }
|
||||
-keep class com.shaarit.data.local.entity.** { *; }
|
||||
-keep class com.shaarit.domain.model.** { *; }
|
||||
-keep class com.shaarit.data.export.Exported* { *; }
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Moyen (tests de non-régression requis)
|
||||
**Gain** : R8 peut enfin optimiser le code
|
||||
|
||||
### 8.2 Ajout recommandé de la bibliothèque LeakCanary (Debug)
|
||||
|
||||
Pour détecter les fuites mémoire durant le développement :
|
||||
```kotlin
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : 1 ligne
|
||||
|
||||
---
|
||||
|
||||
## 9. Expérience Utilisateur — Réduction des Temps d'Attente
|
||||
|
||||
### 9.1 Skeleton Loading déjà implémenté ✅
|
||||
|
||||
Le composant `SkeletonLinkCard` existe dans `ui/components/SkeletonLoader.kt`. **S'assurer qu'il est utilisé systématiquement** comme état de chargement initial au lieu d'un spinner circulaire.
|
||||
|
||||
### 9.2 Extraction de métadonnées — Feedback immédiat
|
||||
|
||||
**Fichier** : `presentation/add/AddLinkViewModel.kt`
|
||||
|
||||
L'extraction de métadonnées (JSoup, 10s timeout) et l'analyse Gemini se font **séquentiellement**. L'utilisateur attend sans feedback visuel clair.
|
||||
|
||||
**Solutions** :
|
||||
1. **Optimistic UI** : Permettre la sauvegarde immédiate du lien, enrichir les métadonnées en arrière-plan
|
||||
2. **Paralléliser** : Lancer JSoup et Gemini en parallèle si les deux sont disponibles
|
||||
3. **Progressive loading** : Afficher le titre dès qu'il est extrait, puis la description, puis les tags AI
|
||||
|
||||
```kotlin
|
||||
// Paralléliser extraction JSoup + analyse Gemini
|
||||
viewModelScope.launch {
|
||||
val metadataDeferred = async { metadataExtractor.extract(url) }
|
||||
val aiDeferred = async { analyzeUrlWithAiUseCase(url) }
|
||||
|
||||
// Afficher les métadonnées JSoup dès qu'elles arrivent
|
||||
metadataDeferred.await().let { metadata -> ... }
|
||||
|
||||
// Compléter avec l'AI
|
||||
aiDeferred.await().onSuccess { result -> ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🔴 HAUTE
|
||||
**Effort** : Moyen
|
||||
**Gain** : UX perçue comme instantanée
|
||||
|
||||
### 9.3 Share Intent — Temps d'apparition de l'écran d'ajout
|
||||
|
||||
Quand l'utilisateur partage un lien depuis Chrome, le flux est :
|
||||
1. Splash Screen → 2. Login check → 3. Navigation → 4. AddLinkScreen → 5. Extraction métadonnées
|
||||
|
||||
**Optimisations** :
|
||||
- Vérifier le token **dans le splash screen** pour éviter l'écran de login
|
||||
- Naviguer directement vers AddLinkScreen si le token est valide
|
||||
- Commencer l'extraction des métadonnées **pendant la navigation**
|
||||
|
||||
### 9.4 Pull-to-Refresh — Feedback de sync
|
||||
|
||||
Le `refresh()` du `FeedViewModel` déclenche `syncManager.syncNow()` qui planifie un Worker. L'utilisateur ne voit pas immédiatement les résultats car :
|
||||
1. Le Worker est planifié (pas exécuté immédiatement)
|
||||
2. La pagination Room ne se rafraîchit pas automatiquement
|
||||
|
||||
**Solution** : Effectuer un `performFullSync()` directement dans un coroutine scope, puis invalider le PagingSource :
|
||||
```kotlin
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
syncManager.performFullSync()
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Faible
|
||||
**Gain** : Refresh perçu comme immédiat
|
||||
|
||||
### 9.5 Haptic Feedback sur les actions clés
|
||||
|
||||
Ajouter du retour haptique pour :
|
||||
- Toggle pin (long press feel)
|
||||
- Ajout de lien réussi
|
||||
- Suppression confirmée
|
||||
- Pull-to-refresh
|
||||
|
||||
```kotlin
|
||||
val haptic = LocalHapticFeedback.current
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
```
|
||||
|
||||
**Priorité** : 🟢 FAIBLE
|
||||
**Effort** : Très faible
|
||||
**Gain** : UX premium, feedback instantané
|
||||
|
||||
### 9.6 Préchargement intelligent du flux
|
||||
|
||||
Configurer Paging 3 pour précharger plus de données en avance :
|
||||
```kotlin
|
||||
PagingConfig(
|
||||
pageSize = 20,
|
||||
prefetchDistance = 10, // Commence à charger 10 items avant la fin
|
||||
initialLoadSize = 40, // Charge 40 items au démarrage
|
||||
enablePlaceholders = false
|
||||
)
|
||||
```
|
||||
|
||||
**Priorité** : 🟡 MOYENNE
|
||||
**Effort** : Très faible
|
||||
**Gain** : Scroll continu sans "loading" visible
|
||||
|
||||
---
|
||||
|
||||
## 10. Plan d'Action Priorisé
|
||||
|
||||
### Phase 1 — Quick Wins Critiques (1-2 jours)
|
||||
|
||||
| # | Action | Fichier | Effort | Impact |
|
||||
|---|--------|---------|--------|--------|
|
||||
| 1 | Activer R8 (`isMinifyEnabled = true`) | `build.gradle.kts` | 2 lignes | 🔴🔴🔴 |
|
||||
| 2 | Activer FTS4 pour la recherche | `LinkRepositoryImpl.kt` | 1 ligne | 🔴🔴🔴 |
|
||||
| 3 | Corriger `getLinksByTag()` (requête SQL) | `LinkRepositoryImpl.kt` | 10 lignes | 🔴🔴 |
|
||||
| 4 | Désactiver les logs réseau en release | `NetworkModule.kt` | 5 lignes | 🔴🔴 |
|
||||
| 5 | Singleton `SimpleDateFormat` | `LinkRepositoryImpl.kt` | 5 lignes | 🔴 |
|
||||
| 6 | Pré-compiler les Regex | `LinkMetadataExtractor.kt` | 15 lignes | 🟡 |
|
||||
|
||||
### Phase 2 — Optimisations Structurelles (3-5 jours)
|
||||
|
||||
| # | Action | Fichier | Effort | Impact |
|
||||
|---|--------|---------|--------|--------|
|
||||
| 7 | Baseline Profiles | Nouveau module | 2h | 🔴🔴🔴 |
|
||||
| 8 | Migrations Room explicites | `ShaarliDatabase.kt` | 2h | 🔴🔴 |
|
||||
| 9 | Images Coil avec taille cible | `LinkItemViews.kt` | 30min | 🔴🔴 |
|
||||
| 10 | Affiner les règles ProGuard | `proguard-rules.pro` | 1h | 🔴🔴 |
|
||||
| 11 | Ajouter les index manquants | `LinkEntity.kt` | 15min | 🟡 |
|
||||
| 12 | Texte brut dans la liste, Markdown en détail | `LinkItemViews.kt` | 1h | 🟡 |
|
||||
|
||||
### Phase 3 — Améliorations UX (1 semaine)
|
||||
|
||||
| # | Action | Fichier | Effort | Impact |
|
||||
|---|--------|---------|--------|--------|
|
||||
| 13 | Sync incrémentale (delta) | `SyncManager.kt` | 4h | 🔴🔴🔴 |
|
||||
| 14 | Extraction métadonnées parallèle | `AddLinkViewModel.kt` | 2h | 🔴🔴 |
|
||||
| 15 | Debounce uniquement sur la recherche | `FeedViewModel.kt` | 1h | 🟡 |
|
||||
| 16 | Préchargement Paging amélioré | `LinkRepositoryImpl.kt` | 15min | 🟡 |
|
||||
| 17 | Share Intent optimisé (skip login) | `MainActivity.kt` | 2h | 🟡 |
|
||||
| 18 | Haptic feedback | Composables | 30min | 🟢 |
|
||||
|
||||
### Phase 4 — Maintenance Continue
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 19 | LeakCanary en debug | 1 ligne | 🟡 |
|
||||
| 20 | Borner les caches Gemini | 15min | 🟢 |
|
||||
| 21 | Cache HTTP OkHttp | 15min | 🟢 |
|
||||
| 22 | Export schéma Room | 15min | 🟢 |
|
||||
| 23 | Supprimer `LinkPagingSource` (code mort) | Suppression | 🟢 |
|
||||
|
||||
---
|
||||
|
||||
## 11. Métriques de Suivi
|
||||
|
||||
Pour mesurer l'impact des optimisations, instrumenter les métriques suivantes :
|
||||
|
||||
### Métriques de Démarrage
|
||||
- **Time to First Frame** (TTFF) : Temps jusqu'au premier frame visible
|
||||
- **Time to Interactive** (TTI) : Temps jusqu'à ce que le flux soit scrollable
|
||||
- Outil : `adb shell am start -W com.shaarit/.MainActivity`
|
||||
|
||||
### Métriques de Rendu
|
||||
- **Jank frames** : Frames dépassant 16ms
|
||||
- **Frame drop rate** : % de frames perdues pendant le scroll
|
||||
- Outil : Android Studio Profiler → Frame Rendering
|
||||
|
||||
### Métriques de Sync
|
||||
- **Sync duration** : Temps total d'une sync complète
|
||||
- **Items/second** : Débit de sync
|
||||
- Instrumenter avec `System.currentTimeMillis()` dans `SyncManager`
|
||||
|
||||
### Métriques Mémoire
|
||||
- **Heap size** au repos et sous charge
|
||||
- **GC frequency** pendant le scroll
|
||||
- Outil : Android Studio Profiler → Memory
|
||||
|
||||
### Recommandation : Firebase Performance Monitoring
|
||||
```kotlin
|
||||
implementation("com.google.firebase:firebase-perf-ktx")
|
||||
```
|
||||
Permet de tracer automatiquement les requêtes réseau, le temps de démarrage, et les traces custom en production.
|
||||
|
||||
---
|
||||
|
||||
## Annexe : Code Mort Identifié
|
||||
|
||||
| Fichier | Description |
|
||||
|---------|-------------|
|
||||
| `data/paging/LinkPagingSource.kt` | Ancien PagingSource réseau, remplacé par Room Paging. Peut être supprimé. |
|
||||
| `data/sync/ConflictResolver.kt` | Défini mais jamais appelé par `SyncManager`. La résolution de conflits n'est pas implémentée. |
|
||||
|
||||
---
|
||||
|
||||
*Document généré le 9 février 2026 — Analyse basée sur l'intégralité du code source ShaarIt v1.0 (47 fichiers Kotlin)*
|
||||
1352
docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md
Normal file
1352
docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ workManager = "2.9.0"
|
||||
dataStore = "1.0.0"
|
||||
kotlinxSerialization = "1.6.2"
|
||||
coil = "2.6.0"
|
||||
biometric = "1.1.0"
|
||||
|
||||
[libraries]
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
@ -83,6 +84,9 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
|
||||
# Kotlin Serialization
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
|
||||
# Biometric
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
@ -18,3 +18,4 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "ShaarIt"
|
||||
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user