Sharrit/docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md
Bruno Charest ec0931134c 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
2026-02-10 21:15:30 -05:00

1353 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ShaarIt — Analyse Détaillée des Prochaines Fonctionnalités
**Date** : 9 février 2026
**Version de référence** : v1.0 (47 fichiers Kotlin, ~8 500 lignes)
**Objectif** : Spécification technique et fonctionnelle des 9 fonctionnalités de la roadmap « Prochaines étapes »
---
## Table des Matières
1. [Mode Lecture sans Distraction (Reader Mode)](#1-mode-lecture-sans-distraction-reader-mode)
2. [Widget d'Accueil Interactif (Glance)](#2-widget-daccueil-interactif-glance)
3. [Partage de Collections entre Utilisateurs](#3-partage-de-collections-entre-utilisateurs)
4. [Support Multi-Instances Shaarli](#4-support-multi-instances-shaarli)
5. [Verrouillage Biométrique](#5-verrouillage-biométrique)
6. [Rappels de Lecture (« Lire plus tard »)](#6-rappels-de-lecture--lire-plus-tard-)
7. [Voice Input (Recherche et Ajout)](#7-voice-input-recherche-et-ajout)
8. [Adaptive Layouts (Tablettes et Foldables)](#8-adaptive-layouts-tablettes-et-foldables)
9. [Thème Clair et Material You (Monet)](#9-thème-clair-et-material-you-monet)
10. [Roadmap Priorisée et Dépendances](#10-roadmap-priorisée-et-dépendances)
---
## 1. Mode Lecture sans Distraction (Reader Mode)
### 1.1 Objectif
Proposer un mode lecture immersif qui extrait et affiche le contenu principal d'un article web (texte, images, code) dans une vue Compose épurée, sans publicités, menus ni distractions. Similaire à Firefox Reader View ou Safari Reader Mode.
### 1.2 Contexte Existant
- L'app dispose déjà d'un éditeur/lecteur Markdown (`MarkdownEditor`, bibliothèque `compose-markdown 0.4.1`)
- JSoup est déjà intégré pour l'extraction de métadonnées (`LinkMetadataExtractor`)
- Le champ `excerpt` existe dans `LinkEntity` mais ne contient qu'un résumé court
- Un `readingTimeMinutes` est déjà calculé et stocké
### 1.3 Architecture Proposée
```
┌──────────────────────────────────────────────────┐
│ ReaderModeScreen │
│ ┌────────────────────────────────────────────┐ │
│ │ TopBar : titre, source, temps de lecture │ │
│ ├────────────────────────────────────────────┤ │
│ │ │ │
│ │ Contenu article (Markdown/HTML rendu) │ │
│ │ - Typographie optimisée lecture │ │
│ │ - Images inline redimensionnées │ │
│ │ - Blocs de code avec coloration │ │
│ │ - Scroll fluide sans jank │ │
│ │ │ │
│ ├────────────────────────────────────────────┤ │
│ │ BottomBar : police, taille, thème lecture │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### 1.4 Composants Techniques
#### A. Extraction du contenu (Readability)
```kotlin
// Nouveau service dans data/reader/
interface ArticleExtractor {
suspend fun extract(url: String): ReadableArticle?
}
data class ReadableArticle(
val title: String,
val author: String?,
val siteName: String?,
val content: String, // HTML nettoyé ou Markdown
val leadImage: String?, // Image principale
val readingTimeMinutes: Int,
val wordCount: Int
)
```
**Options d'implémentation** :
| Option | Bibliothèque | Avantage | Inconvénient |
|--------|-------------|----------|--------------|
| **A1** | JSoup + heuristiques maison | Déjà intégré, pas de dépendance | Qualité variable |
| **A2** | [Readability4J](https://github.com/nicola-moro/readability4j) | Port Java de Mozilla Readability | Dépendance supplémentaire (~50KB) |
| **A3** | API serveur Shaarli (si disponible) | Extraction côté serveur | Nécessite connexion |
**Recommandation** : Option A2 (Readability4J) pour la meilleure qualité d'extraction. Fallback vers JSoup si l'article est déjà en Markdown (notes Shaarli).
#### B. Cache offline du contenu
```kotlin
// Nouvelle colonne dans LinkEntity (migration v5 → v6)
@ColumnInfo(name = "reader_content")
val readerContent: String? = null,
@ColumnInfo(name = "reader_content_fetched_at")
val readerContentFetchedAt: Long = 0
```
- Contenu extrait et stocké localement pour consultation offline
- TTL configurable (ex : 7 jours) avant re-extraction
- WorkManager pour pré-extraction en arrière-plan des liens non lus
#### C. Personnalisation de la lecture
```kotlin
data class ReaderPreferences(
val fontFamily: ReaderFont, // SANS_SERIF, SERIF, MONOSPACE
val fontSize: TextUnit, // 14sp → 24sp
val lineSpacing: Float, // 1.2 → 2.0
val theme: ReaderTheme, // DARK, SEPIA, LIGHT, AUTO
val textAlign: TextAlign // START, JUSTIFY
)
enum class ReaderFont(val displayName: String) {
SANS_SERIF("Sans-serif"),
SERIF("Serif (lecture longue)"),
MONOSPACE("Monospace (code)")
}
```
### 1.5 Navigation
- Nouveau `Screen.Reader` dans `NavGraph.kt` : `reader/{linkId}`
- Accessible depuis le bouton « Lire » sur chaque lien dans le feed, l'écran d'édition, et l'écran épinglés
- Deep link : `shaarit://reader/{linkId}`
### 1.6 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| `ArticleExtractor` + Readability4J | 4h | Haute |
| `ReaderModeScreen` (Compose) | 6h | Haute |
| Personnalisation (police, taille, thème) | 3h | Moyenne |
| Cache offline + migration Room v6 | 2h | Moyenne |
| Pré-extraction WorkManager | 2h | Basse |
| **Total** | **~17h** | |
---
## 2. Widget d'Accueil Interactif (Glance)
### 2.1 Objectif
Offrir un widget Android sur l'écran d'accueil permettant de :
- Voir les derniers liens ajoutés
- Ajouter rapidement un lien (quick-add)
- Accéder à un lien aléatoire
- Voir les statistiques rapides
### 2.2 Contexte Existant
- Quick Settings Tile (`AddLinkTileService`) déjà implémenté
- App Shortcuts déjà configurés (Ajouter, Aléatoire, Rechercher, Collections)
- Deep links fonctionnels (`shaarit://add`, `shaarit://feed`, etc.)
### 2.3 Architecture Technique
**Framework** : Jetpack Glance (basé sur Compose pour widgets)
```kotlin
// Nouvelle dépendance
implementation("androidx.glance:glance-appwidget:1.1.0")
implementation("androidx.glance:glance-material3:1.1.0")
```
#### A. Widget « Derniers Liens » (4×2)
```kotlin
class RecentLinksWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val links = getRecentLinks(context) // Room query directe
provideContent {
GlanceTheme {
RecentLinksContent(links)
}
}
}
}
@Composable
private fun RecentLinksContent(links: List<WidgetLink>) {
LazyColumn {
items(links) { link ->
Row(
modifier = GlanceModifier
.fillMaxWidth()
.clickable(actionStartActivity<MainActivity>(/* deep link */))
) {
Text(link.title, style = TextStyle(fontSize = 14.sp))
Text(link.siteName ?: "", style = TextStyle(color = ColorProvider(Color.Gray)))
}
}
}
}
```
**Mockup** :
```
┌─────────────────────────────────────────────┐
│ 🔖 ShaarIt — Récents [+] [🔀] │
├─────────────────────────────────────────────┤
│ 📄 Kotlin Coroutines Deep Dive │
│ medium.com · il y a 2h │
├─────────────────────────────────────────────┤
│ 📹 Understanding Compose State │
│ youtube.com · il y a 5h │
├─────────────────────────────────────────────┤
│ 🛠️ Architecture hexagonale en pratique │
│ blog.octo.com · hier │
└─────────────────────────────────────────────┘
```
#### B. Widget « Quick Stats » (2×1)
```
┌─────────────────────┐
│ 📊 ShaarIt │
│ 1,247 liens │
│ 42 cette semaine │
│ 📚 3h de lecture │
└─────────────────────┘
```
#### C. Mise à jour du widget
```kotlin
class WidgetUpdateWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
RecentLinksWidget().updateAll(applicationContext)
return Result.success()
}
}
```
- Mise à jour automatique toutes les 30 min via WorkManager
- Mise à jour immédiate après chaque ajout/sync via `SyncManager`
### 2.4 Fichiers à Créer
```
presentation/widget/
├── RecentLinksWidget.kt # Widget liens récents
├── QuickStatsWidget.kt # Widget statistiques
├── WidgetReceiver.kt # GlanceAppWidgetReceiver
├── WidgetDataProvider.kt # Accès Room pour le widget
└── WidgetUpdateWorker.kt # Mise à jour périodique
```
- Déclaration dans `AndroidManifest.xml` : `<receiver>` + `<appwidget-provider>`
### 2.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| Setup Glance + widget receiver | 2h | Haute |
| Widget « Derniers Liens » (4×2) | 4h | Haute |
| Widget « Quick Stats » (2×1) | 2h | Moyenne |
| Mise à jour auto (WorkManager) | 1h | Haute |
| Configurations multiples (tailles) | 2h | Basse |
| **Total** | **~11h** | |
---
## 3. Partage de Collections entre Utilisateurs
### 3.1 Objectif
Permettre de partager une collection (liste de liens organisée) avec d'autres utilisateurs Shaarli ou via un lien public.
### 3.2 Contexte Existant
- Collections manuelles et intelligentes déjà implémentées (`CollectionEntity`, `CollectionDao`, `CollectionLinkCrossRef`)
- Synchronisation des collections via un bookmark serveur (format JSON dans le champ `description` d'un lien Shaarli privé, tag `shaarit_config`)
- Import/Export déjà supporté (JSON, CSV, HTML)
### 3.3 Modes de Partage
#### A. Partage via Lien Public (sans authentification)
```kotlin
data class SharedCollection(
val id: String, // UUID court (ex: "abc123")
val name: String,
val description: String?,
val links: List<SharedLink>,
val createdBy: String, // URL de l'instance Shaarli
val sharedAt: Long,
val expiresAt: Long? // Optionnel : expiration
)
```
**Mécanisme** : Stocker la collection partagée comme un bookmark Shaarli **public** avec un tag spécial :
```kotlin
// URL unique pour le partage
val shareUrl = "https://shaarit.app/shared/$collectionUuid"
// Bookmark Shaarli public contenant le JSON de la collection
val sharedBookmark = CreateLinkDto(
url = shareUrl,
title = "[Shared] $collectionName",
description = sharedCollectionJson, // JSON sérialisé
tags = listOf("shaarit_shared"),
isPrivate = false // Public pour être accessible
)
```
#### B. Partage Inter-Instances (via API Shaarli)
```
Instance A Instance B
┌──────────┐ ┌──────────┐
│ Exporter │ ──── JSON ────▶ │ Importer │
│ Collection│ │ Collection│
└──────────┘ └──────────┘
│ │
└── Bookmark public ──── API fetch ──┘
```
#### C. Partage Local (Share Intent Android)
- Export de la collection en JSON/HTML via le système de partage Android
- Import depuis un fichier JSON reçu (déjà partiellement supporté par `BookmarkImporter`)
### 3.4 Interface Utilisateur
```
┌──────────────────────────────────────────┐
│ 📁 Ma Collection "Dev Android" │
│ │
│ [🔗 Copier le lien] [📤 Exporter] │
│ [👥 Partager via…] [⏰ Expiration: 7j] │
│ │
│ ── Liens partagés (12) ── │
│ ✅ Kotlin Coroutines Guide │
│ ✅ Compose Navigation Deep Dive │
│ ✅ Room Database Best Practices │
│ ... │
└──────────────────────────────────────────┘
```
### 3.5 Fichiers à Créer / Modifier
```
data/share/
├── CollectionShareManager.kt # Logique de création/lecture de partages
├── SharedCollectionDto.kt # DTOs pour sérialisation
domain/model/
├── SharedCollection.kt # Modèle domaine
presentation/collections/
├── ShareCollectionSheet.kt # Bottom sheet de partage
├── ImportCollectionScreen.kt # Écran d'import
```
- Modifier `CollectionDao` pour ajouter des requêtes liées au partage
- Modifier `SyncManager` pour synchroniser les collections partagées
### 3.6 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| `CollectionShareManager` | 4h | Haute |
| DTOs et sérialisation | 2h | Haute |
| UI partage (bottom sheet) | 3h | Haute |
| Import depuis lien/fichier | 3h | Moyenne |
| Expiration des partages | 2h | Basse |
| **Total** | **~14h** | |
### 3.7 Limitations
- L'API Shaarli v1 ne propose pas de mécanisme de partage natif entre instances
- Le partage repose donc sur des bookmarks publics comme vecteur de transport
- La collaboration temps réel n'est pas possible sans serveur intermédiaire
---
## 4. Support Multi-Instances Shaarli
### 4.1 Objectif
Permettre de gérer plusieurs instances Shaarli (perso, travail, projet) avec switch rapide et vue unifiée optionnelle.
### 4.2 Contexte Existant
- `TokenManager` stocke un seul jeu de credentials (token, baseUrl, apiSecret)
- `HostSelectionInterceptor` permet déjà de changer dynamiquement le host de l'API
- `AuthInterceptor` gère le JWT automatiquement
- La base Room est unique (une seule `ShaarliDatabase`)
### 4.3 Architecture Proposée
#### A. Stockage Multi-Comptes
```kotlin
// Nouvelle entité pour stocker les instances
data class ShaarliInstance(
val id: String, // UUID
val name: String, // Ex: "Perso", "Travail"
val baseUrl: String,
val username: String,
val icon: String?, // Emoji ou URL
val color: Long?, // Couleur d'identification
val isActive: Boolean,
val lastSyncAt: Long
)
// Extension de TokenManager
interface MultiInstanceTokenManager {
fun getInstances(): List<ShaarliInstance>
fun getActiveInstance(): ShaarliInstance?
fun switchToInstance(instanceId: String)
fun addInstance(instance: ShaarliInstance, token: String, apiSecret: String)
fun removeInstance(instanceId: String)
// Credentials par instance (chiffrées séparément)
fun getTokenForInstance(instanceId: String): String?
fun getApiSecretForInstance(instanceId: String): String?
}
```
#### B. Isolation des Données
**Option 1 — Base de données séparée par instance** (recommandé) :
```kotlin
fun getDatabaseForInstance(context: Context, instanceId: String): ShaarliDatabase {
return Room.databaseBuilder(
context,
ShaarliDatabase::class.java,
"shaarli_$instanceId.db" // DB unique par instance
)
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigrationFrom(1, 2, 3)
.build()
}
```
**Option 2 — Colonne `instance_id` dans chaque table** :
```kotlin
// Plus simple mais requiert migration lourde de toutes les tables
@ColumnInfo(name = "instance_id")
val instanceId: String
```
**Recommandation** : Option 1 — DB séparées. Plus propre, pas de migration complexe, isolation parfaite des données.
#### C. Switch Rapide
```
┌──────────────────────────────────┐
│ 🏠 Perso (shaarli.maison.fr) │ ← Active
│ 💼 Travail (links.corp.com) │
│ 🧪 Lab (test.shaarli.org) │
│ │
│ [+ Ajouter une instance] │
└──────────────────────────────────┘
```
- Drawer ou bottom sheet accessible depuis le header du feed
- Indicateur visuel permanent de l'instance active (couleur, icône)
- Switch = changement de DB + mise à jour du `HostSelectionInterceptor`
#### D. Vue Unifiée (optionnelle)
```kotlin
// Agrégation cross-DB en lecture seule
suspend fun getUnifiedFeed(): Flow<List<ShaarliLink>> {
val instances = tokenManager.getInstances()
return instances.map { instance ->
getDatabaseForInstance(context, instance.id)
.linkDao()
.getRecentLinks(limit = 50)
.map { links -> links.map { it.toDomainModel(instance) } }
}.merge() // kotlinx.coroutines.flow.merge
}
```
### 4.4 Impact sur le Code Existant
| Fichier | Modification |
|---------|-------------|
| `TokenManager.kt` | Refactoring vers `MultiInstanceTokenManager` |
| `DatabaseModule.kt` | Fournir la DB basée sur l'instance active |
| `NetworkModule.kt` | `HostSelectionInterceptor` piloté par l'instance active |
| `SyncManager.kt` | Sync par instance, pas global |
| `NavGraph.kt` | Nouveau `Screen.InstancePicker` |
| `FeedScreen.kt` | Afficher l'instance active + switch |
### 4.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| `MultiInstanceTokenManager` | 4h | Haute |
| DB par instance (`DatabaseModule` refactoring) | 4h | Haute |
| UI picker d'instances | 3h | Haute |
| Switch dynamique (network + DB) | 3h | Haute |
| Vue unifiée cross-DB | 4h | Basse |
| Migration des données existantes | 2h | Haute |
| **Total** | **~20h** | |
---
## 5. Verrouillage Biométrique
### 5.1 Objectif
Protéger l'accès à l'application par empreinte digitale, reconnaissance faciale ou code PIN, en utilisant l'API BiometricPrompt d'AndroidX.
### 5.2 Contexte Existant
- L'app utilise déjà `EncryptedSharedPreferences` pour stocker les tokens
- `TokenManager` gère les secrets sensibles
- Le minSdk est 24 (Android 7.0) — BiometricPrompt requiert SDK 28+ mais AndroidX fournit un compat
### 5.3 Architecture Proposée
#### A. Dépendances
```kotlin
implementation("androidx.biometric:biometric:1.2.0-alpha05")
```
#### B. Gestion des Préférences
```kotlin
data class SecurityPreferences(
val isBiometricEnabled: Boolean = false,
val lockTimeout: LockTimeout = LockTimeout.IMMEDIATE,
val requireOnStartup: Boolean = true,
val requireOnResume: Boolean = false // Après mise en arrière-plan
)
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)
}
```
#### C. Service Biométrique
```kotlin
@Singleton
class BiometricAuthManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val biometricManager = BiometricManager.from(context)
fun canAuthenticate(): BiometricAvailability {
return when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.AVAILABLE
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 promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Déverrouiller ShaarIt")
.setSubtitle("Utilisez votre empreinte ou votre visage")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
val biometricPrompt = BiometricPrompt(activity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}
```
#### D. Flux d'Authentification
```
App Launch
├── Biometric disabled? ──▶ Normal flow (Login/Feed)
└── Biometric enabled?
├── Show LockScreen
│ │
│ ├── Auth success ──▶ Normal flow
│ ├── Auth failed ──▶ Retry ou message d'erreur
│ └── Fallback ──▶ PIN/Pattern système
└── Check timeout (si reprise après background)
├── Timeout pas expiré ──▶ Normal flow
└── Timeout expiré ──▶ Show LockScreen
```
#### E. Intégration dans MainActivity
```kotlin
// Dans MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
val needsAuth = securityPreferences.isBiometricEnabled && !isAuthenticated
setContent {
if (needsAuth) {
LockScreen(onAuthenticated = { isAuthenticated = true })
} else {
// AppNavGraph normal...
}
}
}
override fun onStop() {
super.onStop()
lastBackgroundTime = System.currentTimeMillis()
}
override fun onResume() {
super.onResume()
if (securityPreferences.isBiometricEnabled) {
val elapsed = System.currentTimeMillis() - lastBackgroundTime
if (elapsed > securityPreferences.lockTimeout.delayMs) {
isAuthenticated = false
}
}
}
```
### 5.4 UI Paramètres
```
┌──────────────────────────────────────────┐
│ 🔒 Sécurité │
├──────────────────────────────────────────┤
│ Verrouillage biométrique [Toggle ON] │
│ Délai de verrouillage [Immédiat ▾]│
│ Verrouiller au démarrage [✓] │
│ Verrouiller en arrière-plan [✓] │
└──────────────────────────────────────────┘
```
### 5.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| `BiometricAuthManager` | 2h | Haute |
| `LockScreen` (Compose) | 2h | Haute |
| Intégration `MainActivity` (lifecycle) | 2h | Haute |
| Préférences de sécurité (Settings) | 2h | Moyenne |
| Gestion du timeout background | 1h | Moyenne |
| **Total** | **~9h** | |
---
## 6. Rappels de Lecture (« Lire plus tard »)
### 6.1 Objectif
Permettre à l'utilisateur de programmer des rappels pour relire des liens sauvegardés, avec notifications push à l'heure souhaitée.
### 6.2 Contexte Existant
- WorkManager déjà intégré et utilisé (sync, health check)
- Notifications non implémentées actuellement
- Le champ `is_pinned` sur `LinkEntity` offre un mécanisme rudimentaire de « favoris »
### 6.3 Architecture Proposée
#### A. Modèle de Données
```kotlin
// Nouvelle entité Room
@Entity(
tableName = "reading_reminders",
foreignKeys = [ForeignKey(
entity = LinkEntity::class,
parentColumns = ["id"],
childColumns = ["link_id"],
onDelete = ForeignKey.CASCADE
)]
)
data class ReadingReminderEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "link_id")
val linkId: Int,
@ColumnInfo(name = "remind_at")
val remindAt: Long, // Timestamp du rappel
@ColumnInfo(name = "repeat_interval")
val repeatInterval: RepeatInterval = RepeatInterval.NONE,
@ColumnInfo(name = "is_dismissed")
val isDismissed: Boolean = false,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis()
)
enum class RepeatInterval {
NONE,
DAILY,
WEEKLY,
MONTHLY
}
```
- Migration Room v6 (ou v7 selon la fonctionnalité Reader Mode) pour ajouter la table
#### B. Notification Worker
```kotlin
@HiltWorker
class ReminderNotificationWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val linkDao: LinkDao,
private val reminderDao: ReminderDao
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val reminderId = inputData.getLong("reminder_id", -1)
val reminder = reminderDao.getById(reminderId) ?: return Result.failure()
val link = linkDao.getLinkById(reminder.linkId) ?: return Result.failure()
showNotification(link, reminder)
if (reminder.repeatInterval != RepeatInterval.NONE) {
scheduleNextReminder(reminder)
} else {
reminderDao.markDismissed(reminderId)
}
return Result.success()
}
private fun showNotification(link: LinkEntity, reminder: ReadingReminderEntity) {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_REMINDERS)
.setSmallIcon(R.drawable.ic_bookmark)
.setContentTitle("📖 Rappel de lecture")
.setContentText(link.title)
.setContentIntent(createDeepLinkPendingIntent(link.id))
.addAction(R.drawable.ic_check, "Lu", createDismissIntent(reminder.id))
.addAction(R.drawable.ic_snooze, "Rappeler dans 1h", createSnoozeIntent(reminder.id))
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(applicationContext).notify(reminder.id.toInt(), notification)
}
}
```
#### C. Raccourcis Rapides de Rappel
```kotlin
enum class QuickReminder(val displayName: String, val delayMs: Long) {
IN_1_HOUR("Dans 1 heure", 3_600_000),
TONIGHT("Ce soir (20h)", /* calculé */),
TOMORROW("Demain matin (9h)", /* calculé */),
THIS_WEEKEND("Ce week-end", /* calculé */),
NEXT_WEEK("La semaine prochaine", /* calculé */),
CUSTOM("Date personnalisée…", 0)
}
```
### 6.4 Interface Utilisateur
```
Long press sur un lien dans le feed :
┌──────────────────────────────────────┐
│ ⏰ Rappeler de lire │
├──────────────────────────────────────┤
│ 🕐 Dans 1 heure │
│ 🌙 Ce soir (20h00) │
│ ☀️ Demain matin (9h00) │
│ 📅 Ce week-end │
│ 📆 La semaine prochaine │
│ 📝 Date personnalisée… │
└──────────────────────────────────────┘
```
- Indicateur visuel sur les liens avec rappel actif (icône ⏰ dans le feed)
- Section dédiée dans Settings : « Mes rappels » avec liste des rappels planifiés
### 6.5 Permissions
```xml
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- API 33+ -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <!-- API 31+ -->
```
- Demander la permission `POST_NOTIFICATIONS` au runtime sur Android 13+
### 6.6 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| Entité `ReadingReminderEntity` + DAO + migration | 2h | Haute |
| `ReminderNotificationWorker` | 3h | Haute |
| Notification channel + permissions | 1h | Haute |
| Bottom sheet de sélection rapide | 2h | Haute |
| Date picker personnalisé | 2h | Moyenne |
| Écran « Mes rappels » | 3h | Moyenne |
| Rappels récurrents | 2h | Basse |
| **Total** | **~15h** | |
---
## 7. Voice Input (Recherche et Ajout)
### 7.1 Objectif
Permettre l'ajout de liens et la recherche vocale via `SpeechRecognizer` ou l'API Material Search avec entrée vocale.
### 7.2 Contexte Existant
- Barre de recherche dans `FeedScreen` (champ texte avec debounce 300ms)
- Écran `AddLinkScreen` avec champ URL et titres
- Aucune intégration vocale actuelle
### 7.3 Architecture Proposée
#### A. Service de Reconnaissance Vocale
```kotlin
@Singleton
class VoiceInputManager @Inject constructor(
@ApplicationContext private val context: Context
) {
fun isAvailable(): Boolean {
return SpeechRecognizer.isRecognitionAvailable(context)
}
fun createRecognitionIntent(language: String = "fr-FR"): 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)
}
}
}
```
#### B. Composable Réutilisable
```kotlin
@Composable
fun VoiceInputButton(
onResult: (String) -> Unit,
modifier: Modifier = Modifier
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val matches = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
matches?.firstOrNull()?.let { onResult(it) }
}
}
IconButton(
onClick = { launcher.launch(voiceInputManager.createRecognitionIntent()) },
modifier = modifier
) {
Icon(Icons.Default.Mic, contentDescription = "Recherche vocale")
}
}
```
#### C. Points d'Intégration
| Écran | Usage | Détail |
|-------|-------|--------|
| **FeedScreen** | Recherche vocale | Bouton 🎤 à côté de la barre de recherche |
| **AddLinkScreen** | Dictée du titre | Bouton 🎤 à côté du champ titre |
| **AddLinkScreen** | Dictée des tags | Bouton 🎤 — reconnaissance + split par virgule |
| **AddLinkScreen** | Dictée de la description | Mode dictée pour le champ description |
#### D. Intelligence de Parsing
```kotlin
fun parseVoiceInput(text: String): VoiceParsedInput {
val urlPattern = Regex("https?://\\S+")
val url = urlPattern.find(text)?.value
return if (url != null) {
VoiceParsedInput.Url(url)
} else {
VoiceParsedInput.SearchQuery(text)
}
}
sealed class VoiceParsedInput {
data class Url(val url: String) : VoiceParsedInput()
data class SearchQuery(val query: String) : VoiceParsedInput()
}
```
### 7.4 Permissions
```xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```
- Demander la permission au runtime avant la première utilisation
- Afficher un message explicatif si refusée
### 7.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| `VoiceInputManager` | 1h | Haute |
| `VoiceInputButton` composable | 2h | Haute |
| Intégration FeedScreen (recherche) | 1h | Haute |
| Intégration AddLinkScreen (titre, tags) | 2h | Moyenne |
| Parsing intelligent (URL vs texte) | 1h | Moyenne |
| Gestion des permissions audio | 1h | Haute |
| **Total** | **~8h** | |
---
## 8. Adaptive Layouts (Tablettes et Foldables)
### 8.1 Objectif
Adapter l'interface de ShaarIt pour les tablettes, foldables et écrans larges en utilisant les WindowSizeClass de Material 3 et la navigation adaptative.
### 8.2 Contexte Existant
- UI actuellement optimisée pour téléphone uniquement (single-pane)
- Trois modes d'affichage existants : Liste, Grille, Compact (via `ViewStyle`)
- Navigation Compose avec `NavHost` mono-pane
- Edge-to-Edge déjà activé
### 8.3 Architecture Proposée
#### A. Dépendances
```kotlin
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.0.0")
implementation("androidx.window:window:1.2.0")
```
#### B. Détection de la Taille d'Écran
```kotlin
@Composable
fun ShaarItAdaptiveLayout() {
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Téléphone : navigation bottom/drawer actuelle
SinglePaneLayout()
}
WindowWidthSizeClass.Medium -> {
// Tablette portrait / foldable déplié : list-detail
ListDetailLayout()
}
WindowWidthSizeClass.Expanded -> {
// Tablette paysage / grand écran : three-pane
ThreePaneLayout()
}
}
}
```
#### C. Layout List-Detail (Medium)
```
┌─────────────────┬──────────────────────────────┐
│ │ │
│ Feed (liste) │ Détail du lien sélectionné │
│ │ (édition ou Reader Mode) │
│ ┌────────────┐ │ │
│ │ Lien 1 │ │ Titre: Kotlin Coroutines │
│ │ Lien 2 ◀──┼─┤ URL: medium.com/... │
│ │ Lien 3 │ │ Description: ... │
│ │ Lien 4 │ │ Tags: [kotlin] [coroutines]│
│ └────────────┘ │ │
│ │ │
└─────────────────┴──────────────────────────────┘
```
#### D. Navigation Adaptative
```kotlin
@Composable
fun AdaptiveNavigation(
windowSizeClass: WindowSizeClass,
content: @Composable () -> Unit
) {
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Bottom navigation bar
Scaffold(bottomBar = { BottomNavBar() }) { content() }
}
WindowWidthSizeClass.Medium -> {
// Navigation rail (barre latérale fine)
Row {
NavigationRail { /* Feed, Tags, Collections, Settings */ }
content()
}
}
WindowWidthSizeClass.Expanded -> {
// Navigation drawer permanent
PermanentNavigationDrawer(
drawerContent = { PermanentDrawerSheet { /* Menu complet */ } }
) { content() }
}
}
}
```
#### E. Grille Adaptative
```kotlin
@Composable
fun AdaptiveGrid(links: LazyPagingItems<ShaarliLink>) {
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
val columns = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> 1 // ou 2 en grille
WindowWidthSizeClass.Medium -> 2
WindowWidthSizeClass.Expanded -> 3
else -> 1
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp)
) {
items(links.itemCount) { index ->
links[index]?.let { LinkCard(it) }
}
}
}
```
### 8.4 Support Foldables
```kotlin
// Détection de la posture du foldable
val foldingFeatures = WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context)
.collectAsState()
// Adapter le layout selon la charnière
foldingFeatures.value.displayFeatures.forEach { feature ->
if (feature is FoldingFeature) {
when (feature.state) {
FoldingFeature.State.HALF_OPENED -> {
// Mode table-top : contenu en haut, contrôles en bas
TableTopLayout()
}
FoldingFeature.State.FLAT -> {
// Déplié : utiliser tout l'espace
ExpandedLayout()
}
}
}
}
```
### 8.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| WindowSizeClass + layout adaptatif | 3h | Haute |
| Navigation adaptative (rail/drawer) | 4h | Haute |
| Layout List-Detail (tablettes) | 6h | Haute |
| Grille adaptative multi-colonnes | 2h | Moyenne |
| Support foldables (posture) | 3h | Basse |
| Tests sur émulateurs (tablette, fold) | 2h | Haute |
| **Total** | **~20h** | |
---
## 9. Thème Clair et Material You (Monet)
### 9.1 Objectif
Ajouter un thème clair et le support de Material You (couleurs dynamiques basées sur le fond d'écran) sur Android 12+, tout en conservant les 15 thèmes sombres existants.
### 9.2 Contexte Existant
- 15 thèmes sombres définis dans `Theme.kt` (492 lignes, chacun un `darkColorScheme`)
- `AppTheme` enum dans `AppTheme.kt` avec `ThemePreferences` (SharedPreferences)
- `ShaarItTheme` composable applique le thème sélectionné
- La variable `isEffectivelyDark` est hardcodée à `true`
- Aucun `lightColorScheme` défini
### 9.3 Architecture Proposée
#### A. Extension de l'Enum AppTheme
```kotlin
enum class ThemeMode(val displayName: String) {
DARK("Sombre"),
LIGHT("Clair"),
SYSTEM("Système"), // Suit le paramètre Android
DYNAMIC_DARK("Material You Sombre"), // Android 12+ dynamic dark
DYNAMIC_LIGHT("Material You Clair"), // Android 12+ dynamic light
DYNAMIC_SYSTEM("Material You Auto") // Android 12+ dynamic + suit système
}
```
```kotlin
// Extension de ThemePreferences
@Singleton
class ThemePreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
// Existant
private val _currentTheme = MutableStateFlow(loadTheme())
val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow()
// Nouveau
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
}
```
#### B. Schémas de Couleurs Claires
Pour chaque thème sombre existant, créer un pendant clair :
```kotlin
// ── Default Light (ShaarIt) ──
private val DefaultLightColorScheme = lightColorScheme(
primary = Color(0xFF006B5A), // CyanPrimary assombri
onPrimary = Color.White,
primaryContainer = Color(0xFFB2F5E6),
onPrimaryContainer = Color(0xFF00201A),
secondary = Color(0xFF0077B6), // TealSecondary assombri
onSecondary = Color.White,
background = Color(0xFFF8FAFA),
onBackground = Color(0xFF1A1C1E),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1C1E),
surfaceVariant = Color(0xFFE7F0EE),
onSurfaceVariant = Color(0xFF404944),
outline = Color(0xFF707974),
error = Color(0xFFBA1A1A),
onError = Color.White
)
// Générer pour : GitHub Light, Linear Light, Nord Light, etc.
```
#### C. Material You (Dynamic Colors)
```kotlin
@Composable
fun ShaarItTheme(
appTheme: AppTheme = AppTheme.DEFAULT,
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit
) {
val isDarkTheme = when (themeMode) {
ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true
ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false
ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme()
}
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 -> {
if (isDarkTheme) {
dynamicDarkColorScheme(LocalContext.current)
} else {
dynamicLightColorScheme(LocalContext.current)
}
}
isDarkTheme -> getColorSchemeForTheme(appTheme) // Existant
else -> getLightColorSchemeForTheme(appTheme) // Nouveau
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view)
.isAppearanceLightStatusBars = !isDarkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}
```
#### D. UI Paramètres (mise à jour)
```
┌──────────────────────────────────────────┐
│ 🎨 Apparence │
├──────────────────────────────────────────┤
│ Mode │
│ ○ Sombre ○ Clair ○ Système │
│ │
│ Material You (Android 12+) [Toggle] │
│ ↳ Couleurs extraites du fond d'écran │
│ │
│ Thème (si Material You désactivé) │
│ [ShaarIt] [GitHub] [Spotify] [Dracula]… │
│ │
│ Prévisualisation │
│ ┌──────────────────────────────────┐ │
│ │ Aperçu du thème sélectionné │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────┘
```
### 9.4 Impact sur les Composants Existants
- **GlassCard** et composants glassmorphism : adapter les effets de transparence pour le mode clair
- **Skeleton Loading** : les couleurs shimmer doivent être adaptées
- **TagChip** : les couleurs de fond et texte doivent s'inverser
- **Icônes de statut** : certaines couleurs (SuccessGreen, WarningAmber) nécessitent un ajustement en mode clair pour le contraste
### 9.5 Estimation
| Tâche | Effort | Priorité |
|-------|--------|----------|
| Créer `lightColorScheme` pour les 15 thèmes | 4h | Haute |
| `ThemeMode` enum + préférences | 1h | Haute |
| Material You (dynamic colors API 31+) | 2h | Haute |
| Modifier `ShaarItTheme` composable | 2h | Haute |
| Adapter les composants custom (glass, skeleton, etc.) | 4h | Haute |
| UI paramètres (sélecteur mode + preview) | 3h | Moyenne |
| Tests visuels (clair/sombre/dynamic × 15 thèmes) | 3h | Haute |
| **Total** | **~19h** | |
---
## 10. Roadmap Priorisée et Dépendances
### 10.1 Matrice Effort / Impact
```
Impact Utilisateur ▲
Élevé │ 🏆 Reader Mode 🏆 Material You
│ 🏆 Biométrique 🏆 Widget Glance
Moyen │ ★ Rappels Lecture ★ Adaptive Layout
│ ★ Voice Input
Faible │ ◆ Multi-Instances ◆ Partage Collections
└──────────────────────────────────────▶
Faible Moyen Élevé
Effort de Dev
```
### 10.2 Graphe de Dépendances
```mermaid
graph TD
A[Thème Clair / Material You] --> |"Indépendant"| Z[Prêt]
B[Verrouillage Biométrique] --> |"Indépendant"| Z
C[Voice Input] --> |"Indépendant"| Z
D[Widget Glance] --> |"Indépendant"| Z
E[Mode Lecture] --> |"Migration Room"| F[Rappels de Lecture]
G[Multi-Instances] --> |"Requis pour"| H[Partage Collections]
I[Adaptive Layouts] --> |"Améliore"| E
style A fill:#81C784
style B fill:#81C784
style C fill:#81C784
style D fill:#81C784
style E fill:#FFB74D
style F fill:#FFB74D
style G fill:#EF5350
style H fill:#EF5350
style I fill:#FFB74D
```
### 10.3 Plan de Livraison Recommandé
#### Phase 1 — Quick Wins Indépendants (2-3 semaines)
| # | Fonctionnalité | Effort | Justification |
|---|----------------|--------|---------------|
| 1 | **Verrouillage Biométrique** | ~9h | Indépendant, forte demande sécurité, impact immédiat |
| 2 | **Voice Input** | ~8h | Indépendant, facile à implémenter, améliore l'accessibilité |
| 3 | **Thème Clair + Material You** | ~19h | Indépendant, impact visuel majeur, demande fréquente |
#### Phase 2 — Expérience Enrichie (3-4 semaines)
| # | Fonctionnalité | Effort | Justification |
|---|----------------|--------|---------------|
| 4 | **Widget Glance** | ~11h | Visibilité app sur l'écran d'accueil, engagement quotidien |
| 5 | **Mode Lecture** | ~17h | Différenciateur clé, exploite JSoup/Markdown existants |
| 6 | **Rappels de Lecture** | ~15h | Complète le Reader Mode, utilise WorkManager existant |
#### Phase 3 — Adaptation et Collaboration (4-5 semaines)
| # | Fonctionnalité | Effort | Justification |
|---|----------------|--------|---------------|
| 7 | **Adaptive Layouts** | ~20h | Ouvre le marché tablettes/foldables, améliore le Reader Mode |
| 8 | **Multi-Instances** | ~20h | Refactoring profond, prérequis au partage |
| 9 | **Partage de Collections** | ~14h | Nécessite multi-instances, fonctionnalité sociale |
### 10.4 Résumé Global
| Fonctionnalité | Effort | Priorité | Dépendances | Migration Room |
|----------------|--------|----------|-------------|----------------|
| Verrouillage Biométrique | ~9h | 🔴 Haute | Aucune | Non |
| Voice Input | ~8h | 🔴 Haute | Aucune | Non |
| Thème Clair + Material You | ~19h | 🔴 Haute | Aucune | Non |
| Widget Glance | ~11h | 🔴 Haute | Aucune | Non |
| Mode Lecture | ~17h | 🟡 Moyenne | Aucune | Oui (v6) |
| Rappels de Lecture | ~15h | 🟡 Moyenne | Reader Mode (migration) | Oui (v6/v7) |
| Adaptive Layouts | ~20h | 🟡 Moyenne | Aucune | Non |
| Multi-Instances | ~20h | 🟠 Élevé | Aucune | Non (DB séparées) |
| Partage Collections | ~14h | 🟠 Élevé | Multi-Instances | Non |
**Effort total estimé** : **~133 heures** (~3.5 semaines à temps plein)
---
*Document généré le 9 février 2026 — Analyse basée sur le code source ShaarIt v1.0*