- 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
47 KiB
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
- Mode Lecture sans Distraction (Reader Mode)
- Widget d'Accueil Interactif (Glance)
- Partage de Collections entre Utilisateurs
- Support Multi-Instances Shaarli
- Verrouillage Biométrique
- Rappels de Lecture (« Lire plus tard »)
- Voice Input (Recherche et Ajout)
- Adaptive Layouts (Tablettes et Foldables)
- Thème Clair et Material You (Monet)
- 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èquecompose-markdown 0.4.1) - JSoup est déjà intégré pour l'extraction de métadonnées (
LinkMetadataExtractor) - Le champ
excerptexiste dansLinkEntitymais ne contient qu'un résumé court - Un
readingTimeMinutesest 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)
// 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 | 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
// 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
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.ReaderdansNavGraph.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)
// 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)
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
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
descriptiond'un lien Shaarli privé, tagshaarit_config) - Import/Export déjà supporté (JSON, CSV, HTML)
3.3 Modes de Partage
A. Partage via Lien Public (sans authentification)
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 :
// 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
CollectionDaopour ajouter des requêtes liées au partage - Modifier
SyncManagerpour 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
TokenManagerstocke un seul jeu de credentials (token, baseUrl, apiSecret)HostSelectionInterceptorpermet déjà de changer dynamiquement le host de l'APIAuthInterceptorgère le JWT automatiquement- La base Room est unique (une seule
ShaarliDatabase)
4.3 Architecture Proposée
A. Stockage Multi-Comptes
// 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é) :
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 :
// 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)
// 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à
EncryptedSharedPreferencespour stocker les tokens TokenManagergè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
implementation("androidx.biometric:biometric:1.2.0-alpha05")
B. Gestion des Préférences
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
@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
// 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_pinnedsurLinkEntityoffre un mécanisme rudimentaire de « favoris »
6.3 Architecture Proposée
A. Modèle de Données
// 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
@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
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
<!-- 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_NOTIFICATIONSau 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
AddLinkScreenavec champ URL et titres - Aucune intégration vocale actuelle
7.3 Architecture Proposée
A. Service de Reconnaissance Vocale
@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
@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
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
<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
NavHostmono-pane - Edge-to-Edge déjà activé
8.3 Architecture Proposée
A. Dépendances
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
@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
@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
@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
// 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 undarkColorScheme) AppThemeenum dansAppTheme.ktavecThemePreferences(SharedPreferences)ShaarItThemecomposable applique le thème sélectionné- La variable
isEffectivelyDarkest hardcodée àtrue - Aucun
lightColorSchemedéfini
9.3 Architecture Proposée
A. Extension de l'Enum AppTheme
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
}
// 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 :
// ── 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)
@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
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