# 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) { LazyColumn { items(links) { link -> Row( modifier = GlanceModifier .fillMaxWidth() .clickable(actionStartActivity(/* 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` : `` + `` ### 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, 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 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> { 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 ``` - 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 ``` - 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) { 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 = _currentTheme.asStateFlow() // Nouveau private val _themeMode = MutableStateFlow(loadThemeMode()) val themeMode: StateFlow = _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*