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

47 KiB
Raw Blame History

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)
  2. Widget d'Accueil Interactif (Glance)
  3. Partage de Collections entre Utilisateurs
  4. Support Multi-Instances Shaarli
  5. Verrouillage Biométrique
  6. Rappels de Lecture (« Lire plus tard »)
  7. Voice Input (Recherche et Ajout)
  8. Adaptive Layouts (Tablettes et Foldables)
  9. Thème Clair et Material You (Monet)
  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)

// 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.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)

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

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

// 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à 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

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_pinned sur LinkEntity offre 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_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

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

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