feat: Add app widget system with Glance, reader mode, and reading reminders

- Add Glance dependencies (glance-appwidget:1.1.0, glance-material3:1.1.0) for Compose-based widgets
- Implement RecentLinksWidget (4×2) and QuickStatsWidget (2×1) with Glance framework
- Add legacy RemoteViews widget (ShaarliWidgetProvider) for backward compatibility
- Create WidgetSearchActivity for widget configuration and WidgetUpdateWorker for periodic updates
- Add reader mode support with readerContent and readerContentFetchedAt
This commit is contained in:
Bruno Charest 2026-02-11 08:54:29 -05:00
parent ec0931134c
commit 1deac8850a
35 changed files with 3885 additions and 35 deletions

View File

@ -153,6 +153,10 @@ dependencies {
// Biometric
implementation(libs.androidx.biometric)
// Glance (App Widgets with Compose)
implementation("androidx.glance:glance-appwidget:1.1.0")
implementation("androidx.glance:glance-material3:1.1.0")
// Google Gemini AI SDK
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")

View File

@ -0,0 +1,616 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "3ff9609708220ab89040ddfc281f0c2e",
"entities": [
{
"tableName": "links",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, `reader_content` TEXT, `reader_content_fetched_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPrivate",
"columnName": "is_private",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "is_pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "syncStatus",
"columnName": "sync_status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localModifiedAt",
"columnName": "local_modified_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "readingTimeMinutes",
"columnName": "reading_time_minutes",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "content_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "siteName",
"columnName": "site_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "excerpt",
"columnName": "excerpt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "linkCheckStatus",
"columnName": "link_check_status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failCount",
"columnName": "fail_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastHealthCheck",
"columnName": "last_health_check",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "excludedFromHealthCheck",
"columnName": "excluded_from_health_check",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "readerContent",
"columnName": "reader_content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "readerContentFetchedAt",
"columnName": "reader_content_fetched_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_links_sync_status",
"unique": false,
"columnNames": [
"sync_status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
},
{
"name": "index_links_is_private",
"unique": false,
"columnNames": [
"is_private"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
},
{
"name": "index_links_created_at",
"unique": false,
"columnNames": [
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
},
{
"name": "index_links_is_pinned",
"unique": false,
"columnNames": [
"is_pinned"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
},
{
"name": "index_links_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_links_content_type",
"unique": false,
"columnNames": [
"content_type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
},
{
"name": "index_links_site_name",
"unique": false,
"columnNames": [
"site_name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
},
{
"name": "index_links_link_check_status",
"unique": false,
"columnNames": [
"link_check_status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
},
{
"name": "index_links_last_health_check",
"unique": false,
"columnNames": [
"last_health_check"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
}
],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "links",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
],
"tableName": "links_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
"fields": [
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "excerpt",
"columnName": "excerpt",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "occurrences",
"columnName": "occurrences",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "last_used_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isFavorite",
"columnName": "is_favorite",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_tags_occurrences",
"unique": false,
"columnNames": [
"occurrences"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
}
],
"foreignKeys": []
},
{
"tableName": "link_tag_cross_ref",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
"fields": [
{
"fieldPath": "linkId",
"columnName": "link_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagName",
"columnName": "tag_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"link_id",
"tag_name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isSmart",
"columnName": "is_smart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_collections_is_smart",
"unique": false,
"columnNames": [
"is_smart"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
},
{
"name": "index_collections_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "collection_links",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
"fields": [
{
"fieldPath": "collectionId",
"columnName": "collection_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "linkId",
"columnName": "link_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedAt",
"columnName": "added_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"collection_id",
"link_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "reading_reminders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `link_id` INTEGER NOT NULL, `remind_at` INTEGER NOT NULL, `repeat_interval` TEXT NOT NULL, `is_dismissed` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "linkId",
"columnName": "link_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remindAt",
"columnName": "remind_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatInterval",
"columnName": "repeat_interval",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDismissed",
"columnName": "is_dismissed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_reading_reminders_link_id",
"unique": false,
"columnNames": [
"link_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `${TABLE_NAME}` (`link_id`)"
},
{
"name": "index_reading_reminders_remind_at",
"unique": false,
"columnNames": [
"remind_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `${TABLE_NAME}` (`remind_at`)"
},
{
"name": "index_reading_reminders_is_dismissed",
"unique": false,
"columnNames": [
"is_dismissed"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `${TABLE_NAME}` (`is_dismissed`)"
}
],
"foreignKeys": [
{
"table": "links",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"link_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ff9609708220ab89040ddfc281f0c2e')"
]
}
}

View File

@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:name=".ShaarItApp"
@ -77,6 +79,56 @@
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- Widget Search Dialog Activity -->
<activity
android:name=".widget.WidgetSearchActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Dialog" />
<!-- Legacy Widget (RemoteViews) -->
<receiver
android:name=".widget.ShaarliWidgetProvider"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
<service
android:name=".widget.ShaarliWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<!-- Glance Widget: Recent Links (4×2) -->
<receiver
android:name=".widget.glance.RecentLinksWidgetReceiver"
android:exported="true"
android:label="@string/widget_recent_links_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent_links_info" />
</receiver>
<!-- Glance Widget: Quick Stats (2×1) -->
<receiver
android:name=".widget.glance.QuickStatsWidgetReceiver"
android:exported="true"
android:label="@string/widget_quick_stats_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_stats_info" />
</receiver>
</application>
</manifest>

View File

@ -1,6 +1,9 @@
package com.shaarit
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
@ -9,6 +12,7 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.shaarit.data.worker.LinkHealthCheckWorker
import com.shaarit.widget.glance.WidgetUpdateWorker
import dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -24,6 +28,8 @@ class ShaarItApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
setupHealthCheckWorker()
setupWidgetUpdateWorker()
setupReminderNotificationChannel()
}
private fun setupHealthCheckWorker() {
@ -45,4 +51,26 @@ class ShaarItApp : Application(), Configuration.Provider {
healthCheckRequest
)
}
private fun setupWidgetUpdateWorker() {
WidgetUpdateWorker.schedule(this)
}
private fun setupReminderNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_REMINDERS,
getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = getString(R.string.reminder_channel_desc)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
companion object {
const val CHANNEL_REMINDERS = "reading_reminders"
}
}

View File

@ -3,6 +3,7 @@ package com.shaarit.core.di
import android.content.Context
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.database.ShaarliDatabase
import dagger.Module
@ -42,4 +43,10 @@ object DatabaseModule {
fun provideCollectionDao(database: ShaarliDatabase): CollectionDao {
return database.collectionDao()
}
@Provides
@Singleton
fun provideReminderDao(database: ShaarliDatabase): ReminderDao {
return database.reminderDao()
}
}

View File

@ -0,0 +1,93 @@
package com.shaarit.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.ReadingReminderEntity
import kotlinx.coroutines.flow.Flow
/**
* Données jointes rappel + lien pour l'affichage
*/
data class ReminderWithLink(
val reminder: ReadingReminderEntity,
val link: LinkEntity
)
@Dao
interface ReminderDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(reminder: ReadingReminderEntity): Long
@Query("SELECT * FROM reading_reminders WHERE id = :id")
suspend fun getById(id: Long): ReadingReminderEntity?
@Query("SELECT * FROM reading_reminders WHERE link_id = :linkId AND is_dismissed = 0 ORDER BY remind_at ASC")
fun getActiveRemindersForLink(linkId: Int): Flow<List<ReadingReminderEntity>>
@Query("SELECT * FROM reading_reminders WHERE link_id = :linkId AND is_dismissed = 0 LIMIT 1")
suspend fun getActiveReminderForLink(linkId: Int): ReadingReminderEntity?
@Query("SELECT * FROM reading_reminders WHERE is_dismissed = 0 ORDER BY remind_at ASC")
fun getAllActiveReminders(): Flow<List<ReadingReminderEntity>>
@Query("SELECT * FROM reading_reminders ORDER BY remind_at DESC")
fun getAllReminders(): Flow<List<ReadingReminderEntity>>
@Query("SELECT link_id FROM reading_reminders WHERE is_dismissed = 0")
fun getLinkIdsWithActiveReminders(): Flow<List<Int>>
@Query("UPDATE reading_reminders SET is_dismissed = 1 WHERE id = :id")
suspend fun markDismissed(id: Long)
@Query("UPDATE reading_reminders SET remind_at = :newTime WHERE id = :id")
suspend fun updateRemindAt(id: Long, newTime: Long)
@Query("DELETE FROM reading_reminders WHERE id = :id")
suspend fun delete(id: Long)
@Query("DELETE FROM reading_reminders WHERE link_id = :linkId")
suspend fun deleteByLinkId(linkId: Int)
@Query("SELECT COUNT(*) FROM reading_reminders WHERE is_dismissed = 0")
fun getActiveReminderCount(): Flow<Int>
@Transaction
@Query("""
SELECT r.*, l.* FROM reading_reminders r
INNER JOIN links l ON r.link_id = l.id
WHERE r.is_dismissed = 0
ORDER BY r.remind_at ASC
""")
fun getActiveRemindersWithLinks(): Flow<List<ReminderWithLinkTuple>>
@Transaction
@Query("""
SELECT r.*, l.* FROM reading_reminders r
INNER JOIN links l ON r.link_id = l.id
ORDER BY r.remind_at DESC
""")
fun getAllRemindersWithLinks(): Flow<List<ReminderWithLinkTuple>>
}
/**
* Tuple pour la jointure rappel + lien (Room @Embedded/@Relation alternative)
*/
data class ReminderWithLinkTuple(
val id: Long,
val link_id: Int,
val remind_at: Long,
val repeat_interval: String,
val is_dismissed: Boolean,
val created_at: Long,
// Link fields
val url: String,
val title: String,
val site_name: String?,
val reading_time_minutes: Int?,
val thumbnail_url: String?
)

View File

@ -10,12 +10,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.shaarit.data.local.converter.Converters
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.entity.CollectionEntity
import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.LinkFtsEntity
import com.shaarit.data.local.entity.LinkTagCrossRef
import com.shaarit.data.local.entity.ReadingReminderEntity
import com.shaarit.data.local.entity.TagEntity
/**
@ -28,9 +30,10 @@ import com.shaarit.data.local.entity.TagEntity
TagEntity::class,
LinkTagCrossRef::class,
CollectionEntity::class,
CollectionLinkCrossRef::class
CollectionLinkCrossRef::class,
ReadingReminderEntity::class
],
version = 5,
version = 6,
exportSchema = true
)
@TypeConverters(Converters::class)
@ -39,6 +42,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
abstract fun linkDao(): LinkDao
abstract fun tagDao(): TagDao
abstract fun collectionDao(): CollectionDao
abstract fun reminderDao(): ReminderDao
companion object {
private const val DATABASE_NAME = "shaarli.db"
@ -59,6 +63,35 @@ abstract class ShaarliDatabase : RoomDatabase() {
}
}
/**
* Migration v5 v6 : Reader Mode + Rappels de Lecture
* - Ajout des colonnes reader_content et reader_content_fetched_at sur links
* - Création de la table reading_reminders
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Colonnes Reader Mode sur links
db.execSQL("ALTER TABLE `links` ADD COLUMN `reader_content` TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE `links` ADD COLUMN `reader_content_fetched_at` INTEGER NOT NULL DEFAULT 0")
// Table rappels de lecture
db.execSQL("""
CREATE TABLE IF NOT EXISTS `reading_reminders` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`link_id` INTEGER NOT NULL,
`remind_at` INTEGER NOT NULL,
`repeat_interval` TEXT NOT NULL DEFAULT 'NONE',
`is_dismissed` INTEGER NOT NULL DEFAULT 0,
`created_at` INTEGER NOT NULL,
FOREIGN KEY(`link_id`) REFERENCES `links`(`id`) ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("CREATE INDEX IF NOT EXISTS `index_reading_reminders_link_id` ON `reading_reminders` (`link_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_reading_reminders_remind_at` ON `reading_reminders` (`remind_at`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_reading_reminders_is_dismissed` ON `reading_reminders` (`is_dismissed`)")
}
}
@Volatile
private var instance: ShaarliDatabase? = null
@ -74,7 +107,7 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.fallbackToDestructiveMigrationFrom(1, 2, 3)
.build()
}

View File

@ -85,7 +85,13 @@ data class LinkEntity(
val lastHealthCheck: Long = 0,
@ColumnInfo(name = "excluded_from_health_check")
val excludedFromHealthCheck: Boolean = false
val excludedFromHealthCheck: Boolean = false,
@ColumnInfo(name = "reader_content")
val readerContent: String? = null,
@ColumnInfo(name = "reader_content_fetched_at")
val readerContentFetchedAt: Long = 0
)
/**

View File

@ -0,0 +1,54 @@
package com.shaarit.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Intervalle de répétition pour les rappels de lecture
*/
enum class RepeatInterval {
NONE,
DAILY,
WEEKLY,
MONTHLY
}
/**
* Entité Room pour les rappels de lecture (« Lire plus tard »)
*/
@Entity(
tableName = "reading_reminders",
foreignKeys = [ForeignKey(
entity = LinkEntity::class,
parentColumns = ["id"],
childColumns = ["link_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [
Index(value = ["link_id"]),
Index(value = ["remind_at"]),
Index(value = ["is_dismissed"])
]
)
data class ReadingReminderEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "link_id")
val linkId: Int,
@ColumnInfo(name = "remind_at")
val remindAt: Long,
@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()
)

View File

@ -0,0 +1,332 @@
package com.shaarit.data.reader
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.safety.Safelist
import javax.inject.Inject
import javax.inject.Singleton
/**
* Modèle d'article lisible extrait d'une page web
*/
data class ReadableArticle(
val title: String,
val author: String?,
val siteName: String?,
val content: String,
val leadImage: String?,
val readingTimeMinutes: Int,
val wordCount: Int
)
/**
* Extracteur d'articles style Readability basé sur JSoup.
* Extrait le contenu principal d'une page web en supprimant navigation, pubs, sidebars, etc.
*/
@Singleton
class ArticleExtractor @Inject constructor() {
companion object {
private const val TAG = "ArticleExtractor"
private const val TIMEOUT_MS = 15000
private const val WORDS_PER_MINUTE = 200
// Éléments à supprimer systématiquement
private val REMOVE_SELECTORS = listOf(
"script", "style", "noscript", "iframe", "object", "embed",
"nav", "header:not(article header)", "footer:not(article footer)",
".sidebar", "#sidebar", ".widget", ".ad", ".ads", ".advert",
".advertisement", "[class*=advert]", "[id*=advert]",
".social-share", ".share-buttons", ".sharing",
".comments", "#comments", ".comment-section",
".related-posts", ".related-articles", ".recommended",
".newsletter", ".subscribe", ".popup", ".modal",
".cookie-banner", ".cookie-notice", ".gdpr",
".breadcrumb", ".breadcrumbs", ".pagination",
".menu", ".navigation", "#navigation",
"[role=navigation]", "[role=banner]", "[role=complementary]",
".toc", "#toc", ".table-of-contents"
)
// Sélecteurs pour trouver le contenu principal (ordre de priorité)
private val CONTENT_SELECTORS = listOf(
"article",
"[role=main]",
"main",
".post-content",
".article-content",
".entry-content",
".content-body",
".article-body",
".post-body",
".story-body",
"#article-body",
"#content",
".content",
".post",
".article"
)
// Safelist HTML permise dans le contenu nettoyé
private val READER_SAFELIST = Safelist.relaxed()
.addTags("figure", "figcaption", "picture", "source", "video", "audio")
.addAttributes("img", "src", "alt", "width", "height", "loading")
.addAttributes("a", "href", "title")
.addAttributes("pre", "class")
.addAttributes("code", "class")
.addAttributes("source", "src", "type", "srcset")
.addAttributes("video", "src", "controls", "poster")
.addAttributes("audio", "src", "controls")
}
/**
* Extrait le contenu lisible d'une URL
*/
suspend fun extract(url: String): ReadableArticle? = withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(url)
.timeout(TIMEOUT_MS)
.userAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
.followRedirects(true)
.maxBodySize(5 * 1024 * 1024) // 5 MB max
.get()
extractFromDocument(doc, url)
} catch (e: Exception) {
Log.e(TAG, "Erreur extraction article pour $url", e)
null
}
}
/**
* Extrait le contenu lisible depuis un document JSoup déjà chargé
*/
fun extractFromDocument(doc: Document, baseUrl: String): ReadableArticle? {
return try {
// Extraire les métadonnées
val title = extractTitle(doc)
val author = extractAuthor(doc)
val siteName = extractSiteName(doc, baseUrl)
val leadImage = extractLeadImage(doc, baseUrl)
// Nettoyer le document
val cleanDoc = doc.clone()
removeUnwantedElements(cleanDoc)
// Trouver le contenu principal
val mainContent = findMainContent(cleanDoc)
?: return null
// Nettoyer le HTML du contenu principal
val cleanHtml = Jsoup.clean(
mainContent.html(),
baseUrl,
READER_SAFELIST
)
// Calculer les stats
val textContent = Jsoup.parse(cleanHtml).text()
val wordCount = textContent.split(Regex("\\s+")).filter { it.isNotBlank() }.size
val readingTime = maxOf(1, wordCount / WORDS_PER_MINUTE)
if (wordCount < 50) {
// Trop peu de contenu, probablement pas un article
return null
}
ReadableArticle(
title = title ?: "Sans titre",
author = author,
siteName = siteName,
content = cleanHtml,
leadImage = leadImage,
readingTimeMinutes = readingTime,
wordCount = wordCount
)
} catch (e: Exception) {
Log.e(TAG, "Erreur extraction contenu", e)
null
}
}
private fun extractTitle(doc: Document): String? {
// Priorité: og:title > title tag > h1
val ogTitle = doc.select("meta[property=og:title]").attr("content")
if (ogTitle.isNotBlank()) return ogTitle.trim()
val titleTag = doc.select("title").text()
if (titleTag.isNotBlank()) return titleTag.trim()
val h1 = doc.select("h1").first()?.text()
if (!h1.isNullOrBlank()) return h1.trim()
return null
}
private fun extractAuthor(doc: Document): String? {
val ogAuthor = doc.select("meta[name=author]").attr("content")
if (ogAuthor.isNotBlank()) return ogAuthor.trim()
val articleAuthor = doc.select("meta[property=article:author]").attr("content")
if (articleAuthor.isNotBlank()) return articleAuthor.trim()
val ldJson = doc.select("script[type=application/ld+json]").html()
val authorMatch = Regex("\"author\"\\s*:\\s*\\{[^}]*\"name\"\\s*:\\s*\"([^\"]+)\"").find(ldJson)
if (authorMatch != null) return authorMatch.groupValues[1]
// Chercher des éléments courants
val authorSelectors = listOf(
".author", ".byline", "[rel=author]", ".post-author",
"[itemprop=author]", ".entry-author"
)
for (selector in authorSelectors) {
val el = doc.select(selector).first()
if (el != null && el.text().isNotBlank() && el.text().length < 100) {
return el.text().trim()
}
}
return null
}
private fun extractSiteName(doc: Document, baseUrl: String): String? {
val ogSiteName = doc.select("meta[property=og:site_name]").attr("content")
if (ogSiteName.isNotBlank()) return ogSiteName.trim()
return try {
java.net.URL(baseUrl).host.removePrefix("www.")
} catch (e: Exception) {
null
}
}
private fun extractLeadImage(doc: Document, baseUrl: String): String? {
val ogImage = doc.select("meta[property=og:image]").attr("content")
if (ogImage.isNotBlank()) return resolveUrl(ogImage, baseUrl)
val twitterImage = doc.select("meta[name=twitter:image]").attr("content")
if (twitterImage.isNotBlank()) return resolveUrl(twitterImage, baseUrl)
return null
}
private fun removeUnwantedElements(doc: Document) {
for (selector in REMOVE_SELECTORS) {
try {
doc.select(selector).remove()
} catch (_: Exception) {
// Ignorer les erreurs de sélecteur
}
}
}
/**
* Trouve le contenu principal en utilisant des heuristiques.
* Essaie d'abord les sélecteurs connus, puis scoring par densité de texte.
*/
private fun findMainContent(doc: Document): Element? {
// 1. Essayer les sélecteurs connus
for (selector in CONTENT_SELECTORS) {
val candidates = doc.select(selector)
if (candidates.isNotEmpty()) {
// Prendre le candidat avec le plus de texte
val best = candidates.maxByOrNull { it.text().length }
if (best != null && best.text().length > 200) {
return best
}
}
}
// 2. Scoring par densité de texte sur les <div> et <section>
val candidates = doc.select("div, section")
if (candidates.isEmpty()) return doc.body()
var bestElement: Element? = null
var bestScore = 0.0
for (element in candidates) {
val score = scoreElement(element)
if (score > bestScore) {
bestScore = score
bestElement = element
}
}
return bestElement ?: doc.body()
}
/**
* Score un élément selon sa probabilité de contenir le contenu principal.
* Inspiré de l'algorithme Readability de Mozilla.
*/
private fun scoreElement(element: Element): Double {
var score = 0.0
// Texte direct (pas dans les enfants)
val text = element.ownText()
val textLength = text.length
// Plus de texte = plus probable
score += textLength * 0.1
// Nombre de paragraphes
val paragraphs = element.select("> p, > div > p")
score += paragraphs.size * 10.0
// Nombre de balises de contenu (images, code, etc.)
score += element.select("img").size * 3.0
score += element.select("pre, code").size * 5.0
score += element.select("blockquote").size * 3.0
score += element.select("h2, h3, h4").size * 5.0
// Pénalité pour les liens (haute densité = probablement navigation)
val links = element.select("a")
val linkTextLength = links.sumOf { it.text().length }
val totalTextLength = element.text().length
if (totalTextLength > 0) {
val linkDensity = linkTextLength.toDouble() / totalTextLength
if (linkDensity > 0.5) {
score *= 0.2 // Forte pénalité
} else if (linkDensity > 0.3) {
score *= 0.5
}
}
// Pénalité pour les éléments trop courts
if (totalTextLength < 100) {
score *= 0.1
}
// Bonus pour les classes/ids évocateurs
val classId = "${element.className()} ${element.id()}".lowercase()
if (classId.contains("article") || classId.contains("content") ||
classId.contains("post") || classId.contains("entry") ||
classId.contains("text") || classId.contains("body")) {
score *= 1.5
}
if (classId.contains("comment") || classId.contains("sidebar") ||
classId.contains("footer") || classId.contains("header") ||
classId.contains("nav") || classId.contains("menu") ||
classId.contains("ad") || classId.contains("widget")) {
score *= 0.2
}
return score
}
private fun resolveUrl(url: String, baseUrl: String): String {
return when {
url.startsWith("http") -> url
url.startsWith("//") -> "https:$url"
else -> try {
java.net.URL(java.net.URL(baseUrl), url).toString()
} catch (e: Exception) {
url
}
}
}
}

View File

@ -0,0 +1,82 @@
package com.shaarit.data.reader
import android.content.Context
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class ReaderFont(val displayName: String) {
SANS_SERIF("Sans-serif"),
SERIF("Serif"),
MONOSPACE("Monospace")
}
enum class ReaderTheme(val displayName: String) {
DARK("Sombre"),
SEPIA("Sépia"),
LIGHT("Clair"),
AUTO("Auto")
}
data class ReaderSettings(
val fontFamily: ReaderFont = ReaderFont.SANS_SERIF,
val fontSize: TextUnit = 18.sp,
val lineSpacing: Float = 1.5f,
val theme: ReaderTheme = ReaderTheme.AUTO,
val textAlign: TextAlign = TextAlign.Start
)
@Singleton
class ReaderPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
private val _settings = MutableStateFlow(loadSettings())
val settings: StateFlow<ReaderSettings> = _settings.asStateFlow()
private fun loadSettings(): ReaderSettings {
return ReaderSettings(
fontFamily = ReaderFont.valueOf(prefs.getString("font_family", ReaderFont.SANS_SERIF.name) ?: ReaderFont.SANS_SERIF.name),
fontSize = prefs.getFloat("font_size", 18f).sp,
lineSpacing = prefs.getFloat("line_spacing", 1.5f),
theme = ReaderTheme.valueOf(prefs.getString("theme", ReaderTheme.AUTO.name) ?: ReaderTheme.AUTO.name),
textAlign = when (prefs.getString("text_align", "START")) {
"JUSTIFY" -> TextAlign.Justify
else -> TextAlign.Start
}
)
}
fun updateFont(font: ReaderFont) {
prefs.edit().putString("font_family", font.name).apply()
_settings.value = _settings.value.copy(fontFamily = font)
}
fun updateFontSize(size: Float) {
prefs.edit().putFloat("font_size", size).apply()
_settings.value = _settings.value.copy(fontSize = size.sp)
}
fun updateLineSpacing(spacing: Float) {
prefs.edit().putFloat("line_spacing", spacing).apply()
_settings.value = _settings.value.copy(lineSpacing = spacing)
}
fun updateTheme(theme: ReaderTheme) {
prefs.edit().putString("theme", theme.name).apply()
_settings.value = _settings.value.copy(theme = theme)
}
fun updateTextAlign(align: TextAlign) {
val key = if (align == TextAlign.Justify) "JUSTIFY" else "START"
prefs.edit().putString("text_align", key).apply()
_settings.value = _settings.value.copy(textAlign = align)
}
}

View File

@ -0,0 +1,113 @@
package com.shaarit.data.worker
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.shaarit.MainActivity
import com.shaarit.R
import com.shaarit.ShaarItApp
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.entity.RepeatInterval
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
/**
* Worker qui affiche une notification de rappel de lecture
*/
@HiltWorker
class ReminderNotificationWorker @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted workerParams: WorkerParameters,
private val linkDao: LinkDao,
private val reminderDao: ReminderDao,
private val reminderScheduler: ReminderScheduler
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val reminderId = inputData.getLong("reminder_id", -1)
if (reminderId == -1L) return Result.failure()
val reminder = reminderDao.getById(reminderId) ?: return Result.failure()
if (reminder.isDismissed) return Result.success()
val link = linkDao.getLinkById(reminder.linkId) ?: return Result.failure()
showNotification(
reminderId = reminder.id,
linkId = link.id,
title = link.title,
siteName = link.siteName
)
// Handle recurring reminders
if (reminder.repeatInterval != RepeatInterval.NONE) {
val nextTime = reminderScheduler.computeNextRepeatTime(reminder)
if (nextTime > 0) {
val nextReminder = reminder.copy(remindAt = nextTime)
reminderDao.updateRemindAt(reminder.id, nextTime)
reminderScheduler.schedule(nextReminder)
}
} else {
reminderDao.markDismissed(reminderId)
}
return Result.success()
}
private fun showNotification(
reminderId: Long,
linkId: Int,
title: String,
siteName: String?
) {
// Check notification permission on API 33+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
appContext,
android.Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
}
// Deep link to Reader Mode
val contentIntent = Intent(appContext, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse("shaarit://reader/$linkId")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentPendingIntent = PendingIntent.getActivity(
appContext,
reminderId.toInt(),
contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val subtitle = buildString {
siteName?.let { append(it) }
append(" · Rappel de lecture")
}
val notification = NotificationCompat.Builder(appContext, ShaarItApp.CHANNEL_REMINDERS)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(subtitle)
.setContentIntent(contentPendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
NotificationManagerCompat.from(appContext).notify(reminderId.toInt(), notification)
}
}

View File

@ -0,0 +1,138 @@
package com.shaarit.data.worker
import android.content.Context
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.shaarit.data.local.entity.ReadingReminderEntity
import com.shaarit.data.local.entity.RepeatInterval
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Calendar
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Raccourcis rapides pour programmer un rappel
*/
enum class QuickReminder(val displayName: String) {
IN_1_HOUR("Dans 1 heure"),
TONIGHT("Ce soir (20h)"),
TOMORROW("Demain matin (9h)"),
THIS_WEEKEND("Ce week-end"),
NEXT_WEEK("La semaine prochaine"),
CUSTOM("Date personnalisée…");
fun computeTimestamp(): Long {
val cal = Calendar.getInstance()
return when (this) {
IN_1_HOUR -> System.currentTimeMillis() + 3_600_000
TONIGHT -> {
cal.set(Calendar.HOUR_OF_DAY, 20)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_YEAR, 1)
}
cal.timeInMillis
}
TOMORROW -> {
cal.add(Calendar.DAY_OF_YEAR, 1)
cal.set(Calendar.HOUR_OF_DAY, 9)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.timeInMillis
}
THIS_WEEKEND -> {
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
val daysUntilSaturday = if (dayOfWeek == Calendar.SATURDAY) 0
else if (dayOfWeek == Calendar.SUNDAY) 0
else (Calendar.SATURDAY - dayOfWeek)
cal.add(Calendar.DAY_OF_YEAR, if (daysUntilSaturday == 0) 0 else daysUntilSaturday)
cal.set(Calendar.HOUR_OF_DAY, 10)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_YEAR, 7)
}
cal.timeInMillis
}
NEXT_WEEK -> {
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
val daysUntilMonday = if (dayOfWeek == Calendar.MONDAY) 7
else (Calendar.SATURDAY - dayOfWeek + 2) % 7
cal.add(Calendar.DAY_OF_YEAR, if (daysUntilMonday == 0) 7 else daysUntilMonday)
cal.set(Calendar.HOUR_OF_DAY, 9)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.timeInMillis
}
CUSTOM -> System.currentTimeMillis() // Placeholder
}
}
}
/**
* Planifie et annule les rappels de lecture via WorkManager
*/
@Singleton
class ReminderScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
private val workManager = WorkManager.getInstance(context)
fun schedule(reminder: ReadingReminderEntity) {
val delay = reminder.remindAt - System.currentTimeMillis()
if (delay <= 0) return
val inputData = Data.Builder()
.putLong("reminder_id", reminder.id)
.build()
val workRequest = OneTimeWorkRequestBuilder<ReminderNotificationWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("reminder_${reminder.id}")
.build()
workManager.enqueueUniqueWork(
"reminder_${reminder.id}",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
fun cancel(reminderId: Long) {
workManager.cancelUniqueWork("reminder_$reminderId")
}
fun scheduleSnooze(reminderId: Long, delayMs: Long = 3_600_000) {
val inputData = Data.Builder()
.putLong("reminder_id", reminderId)
.build()
val workRequest = OneTimeWorkRequestBuilder<ReminderNotificationWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("reminder_${reminderId}")
.build()
workManager.enqueueUniqueWork(
"reminder_$reminderId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
fun computeNextRepeatTime(reminder: ReadingReminderEntity): Long {
val cal = Calendar.getInstance().apply { timeInMillis = reminder.remindAt }
when (reminder.repeatInterval) {
RepeatInterval.DAILY -> cal.add(Calendar.DAY_OF_YEAR, 1)
RepeatInterval.WEEKLY -> cal.add(Calendar.WEEK_OF_YEAR, 1)
RepeatInterval.MONTHLY -> cal.add(Calendar.MONTH, 1)
RepeatInterval.NONE -> return 0
}
return cal.timeInMillis
}
}

View File

@ -279,6 +279,8 @@ fun FeedScreen(
onNavigateToHelp: () -> Unit = {},
onNavigateToDeadLinks: () -> Unit = {},
onNavigateToPinned: () -> Unit = {},
onNavigateToReader: (Int) -> Unit = {},
onNavigateToReminders: () -> Unit = {},
initialTagFilter: String? = null,
initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel()
@ -305,6 +307,13 @@ fun FeedScreen(
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
var showAddToCollectionDialog by remember { mutableStateOf(false) }
// Reminder bottom sheet state
var showReminderSheet by remember { mutableStateOf(false) }
var reminderTargetLinkId by remember { mutableIntStateOf(-1) }
var reminderTargetLinkTitle by remember { mutableStateOf("") }
val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel()
val linkIdsWithReminders by reminderViewModel.linkIdsWithReminders.collectAsState()
// États des accordéons du drawer
var mainMenuExpanded by remember { mutableStateOf(true) }
var collectionsExpanded by remember { mutableStateOf(true) }
@ -460,6 +469,15 @@ fun FeedScreen(
onNavigateToDeadLinks()
}
)
DrawerNavigationItem(
icon = Icons.Default.Alarm,
label = "Rappels de lecture",
onClick = {
scope.launch { drawerState.close() }
onNavigateToReminders()
}
)
}
}
@ -1451,7 +1469,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
onTogglePin = { id -> viewModel.togglePin(id) },
hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@ -1512,7 +1531,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
onTogglePin = { id -> viewModel.togglePin(id) },
hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@ -1574,7 +1594,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
onTogglePin = { id -> viewModel.togglePin(id) },
hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@ -1623,6 +1644,14 @@ fun FeedScreen(
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onReadClick = { linkId ->
onNavigateToReader(linkId)
},
onReminderClick = { linkId ->
reminderTargetLinkId = linkId
reminderTargetLinkTitle = link.title
showReminderSheet = true
}
)
}
@ -1666,6 +1695,20 @@ fun FeedScreen(
}
)
}
// Reminder Bottom Sheet
if (showReminderSheet && reminderTargetLinkId > 0) {
com.shaarit.presentation.reminders.ReminderBottomSheet(
linkTitle = reminderTargetLinkTitle,
onQuickReminderSelected = { quickReminder ->
reminderViewModel.scheduleReminder(reminderTargetLinkId, quickReminder)
},
onCustomTimeSelected = { timestamp ->
reminderViewModel.scheduleReminderAt(reminderTargetLinkId, timestamp)
},
onDismiss = { showReminderSheet = false }
)
}
}
}
}

View File

@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@ -62,7 +64,8 @@ fun ListViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
onTogglePin: (Int) -> Unit = {},
hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@ -215,11 +218,24 @@ fun ListViewItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = link.date,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = link.date,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
if (hasReminder) {
Icon(
Icons.Default.Alarm,
contentDescription = "Rappel programmé",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(14.dp)
)
}
}
if (link.isPrivate) {
Row(
@ -258,7 +274,8 @@ fun GridViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
onTogglePin: (Int) -> Unit = {},
hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@ -412,6 +429,14 @@ fun GridViewItem(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
if (hasReminder) {
Icon(
Icons.Default.Alarm,
contentDescription = "Rappel programmé",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(12.dp)
)
}
}
Row {
@ -485,7 +510,8 @@ fun CompactViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
onTogglePin: (Int) -> Unit = {},
hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@ -578,6 +604,14 @@ fun CompactViewItem(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
if (hasReminder) {
Icon(
Icons.Default.Alarm,
contentDescription = "Rappel programmé",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(12.dp)
)
}
if (link.tags.isNotEmpty()) {
Text(
@ -691,7 +725,9 @@ fun DeleteConfirmationDialog(
fun LinkDetailsView(
link: ShaarliLink,
onDismiss: () -> Unit,
onLinkClick: (String) -> Unit
onLinkClick: (String) -> Unit,
onReadClick: ((Int) -> Unit)? = null,
onReminderClick: ((Int) -> Unit)? = null
) {
Box(
modifier = Modifier
@ -777,6 +813,35 @@ fun LinkDetailsView(
.padding(vertical = 4.dp)
)
Spacer(modifier = Modifier.height(12.dp))
// Reader Mode & Reminder actions
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (onReadClick != null && !link.url.startsWith("note://")) {
OutlinedButton(
onClick = {
onReadClick(link.id)
onDismiss()
}
) {
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Lire", style = MaterialTheme.typography.labelMedium)
}
}
if (onReminderClick != null) {
OutlinedButton(
onClick = { onReminderClick(link.id) }
) {
Icon(Icons.Default.Alarm, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Rappel", style = MaterialTheme.typography.labelMedium)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Tags

View File

@ -44,6 +44,10 @@ sealed class Screen(val route: String) {
object Help : Screen("help")
object DeadLinks : Screen("dead_links")
object Pinned : Screen("pinned")
object Reader : Screen("reader/{linkId}") {
fun createRoute(linkId: Int): String = "reader/$linkId"
}
object Reminders : Screen("reminders")
}
@Composable
@ -153,6 +157,10 @@ fun AppNavGraph(
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
onNavigateToPinned = { navController.navigate(Screen.Pinned.route) },
onNavigateToReader = { linkId ->
navController.navigate(Screen.Reader.createRoute(linkId))
},
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
initialTagFilter = tag,
initialCollectionId = collectionId
)
@ -306,5 +314,32 @@ fun AppNavGraph(
}
)
}
composable(
route = "reader/{linkId}",
arguments = listOf(
navArgument("linkId") {
type = NavType.IntType
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://reader/{linkId}" }
)
) {
com.shaarit.presentation.reader.ReaderModeScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.Reminders.route
) {
com.shaarit.presentation.reminders.RemindersScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToReader = { linkId ->
navController.navigate(Screen.Reader.createRoute(linkId))
}
)
}
}
}

View File

@ -0,0 +1,496 @@
package com.shaarit.presentation.reader
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FormatSize
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.data.reader.ReaderFont
import com.shaarit.data.reader.ReaderSettings
import com.shaarit.data.reader.ReaderTheme
import com.shaarit.data.reader.ReadableArticle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderModeScreen(
onNavigateBack: () -> Unit,
viewModel: ReaderModeViewModel = hiltViewModel()
) {
val readerState by viewModel.readerState.collectAsState()
val settings by viewModel.settings.collectAsState()
val context = LocalContext.current
var showSettingsSheet by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
when (val state = readerState) {
is ReaderState.Success -> {
Column {
Text(
text = state.article.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subtitle = buildString {
state.article.siteName?.let { append(it) }
append(" · ${state.article.readingTimeMinutes} min")
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> Text("Mode Lecture")
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Retour")
}
},
actions = {
IconButton(onClick = { showSettingsSheet = true }) {
Icon(Icons.Default.FormatSize, contentDescription = "Paramètres")
}
if (readerState is ReaderState.Success) {
val link = (readerState as ReaderState.Success).link
IconButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent)
}) {
Icon(Icons.Default.OpenInBrowser, contentDescription = "Ouvrir")
}
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, link.url)
putExtra(Intent.EXTRA_SUBJECT, link.title)
}
context.startActivity(Intent.createChooser(shareIntent, "Partager"))
}) {
Icon(Icons.Default.Share, contentDescription = "Partager")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
when (val state = readerState) {
is ReaderState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Extraction de l'article...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
is ReaderState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = { viewModel.loadArticle() }) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Réessayer")
}
}
}
}
is ReaderState.Success -> {
ReaderContent(
article = state.article,
settings = settings,
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
}
}
}
if (showSettingsSheet) {
ReaderSettingsSheet(
settings = settings,
onDismiss = { showSettingsSheet = false },
onFontChange = viewModel::updateFont,
onFontSizeChange = viewModel::updateFontSize,
onLineSpacingChange = viewModel::updateLineSpacing,
onThemeChange = viewModel::updateTheme
)
}
}
@Composable
private fun ReaderContent(
article: ReadableArticle,
settings: ReaderSettings,
modifier: Modifier = Modifier
) {
val isDark = isSystemInDarkTheme()
val bgColor = when (settings.theme) {
ReaderTheme.DARK -> Color(0xFF1A1A1A)
ReaderTheme.SEPIA -> Color(0xFFF4ECD8)
ReaderTheme.LIGHT -> Color(0xFFFAFAFA)
ReaderTheme.AUTO -> if (isDark) Color(0xFF1A1A1A) else Color(0xFFFAFAFA)
}
val textColor = when (settings.theme) {
ReaderTheme.DARK -> Color(0xFFE0E0E0)
ReaderTheme.SEPIA -> Color(0xFF5B4636)
ReaderTheme.LIGHT -> Color(0xFF1A1A1A)
ReaderTheme.AUTO -> if (isDark) Color(0xFFE0E0E0) else Color(0xFF1A1A1A)
}
val linkColor = when (settings.theme) {
ReaderTheme.DARK -> Color(0xFF64B5F6)
ReaderTheme.SEPIA -> Color(0xFF8B6914)
ReaderTheme.LIGHT -> Color(0xFF1565C0)
ReaderTheme.AUTO -> if (isDark) Color(0xFF64B5F6) else Color(0xFF1565C0)
}
val fontFamily = when (settings.fontFamily) {
ReaderFont.SANS_SERIF -> "sans-serif"
ReaderFont.SERIF -> "serif"
ReaderFont.MONOSPACE -> "monospace"
}
val htmlContent = buildReaderHtml(
article = article,
bgColor = bgColor,
textColor = textColor,
linkColor = linkColor,
fontFamily = fontFamily,
fontSize = settings.fontSize.value,
lineSpacing = settings.lineSpacing
)
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
webViewClient = WebViewClient()
getSettings().javaScriptEnabled = false
getSettings().loadWithOverviewMode = true
getSettings().useWideViewPort = true
setBackgroundColor(bgColor.toArgb())
loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
}
},
update = { webView ->
webView.setBackgroundColor(bgColor.toArgb())
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
},
modifier = modifier.background(bgColor)
)
}
private fun buildReaderHtml(
article: ReadableArticle,
bgColor: Color,
textColor: Color,
linkColor: Color,
fontFamily: String,
fontSize: Float,
lineSpacing: Float
): String {
val bgHex = colorToHex(bgColor)
val textHex = colorToHex(textColor)
val linkHex = colorToHex(linkColor)
val mutedHex = colorToHex(textColor.copy(alpha = 0.6f))
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: $bgHex;
color: $textHex;
font-family: $fontFamily;
font-size: ${fontSize}px;
line-height: $lineSpacing;
padding: 16px 20px 40px 20px;
word-wrap: break-word;
overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
}
h1 {
font-size: 1.6em;
font-weight: 700;
margin-bottom: 8px;
line-height: 1.3;
}
.meta {
color: $mutedHex;
font-size: 0.85em;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid ${mutedHex}33;
}
h2 { font-size: 1.35em; margin-top: 28px; margin-bottom: 12px; }
h3 { font-size: 1.15em; margin-top: 24px; margin-bottom: 10px; }
h4, h5, h6 { font-size: 1.05em; margin-top: 20px; margin-bottom: 8px; }
p { margin-bottom: 16px; }
a { color: $linkHex; text-decoration: none; }
a:hover { text-decoration: underline; }
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
display: block;
}
pre {
background: ${textHex}11;
padding: 12px 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85em;
margin: 16px 0;
line-height: 1.5;
}
code {
font-family: monospace;
background: ${textHex}11;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
pre code { background: none; padding: 0; }
blockquote {
border-left: 3px solid $linkHex;
padding-left: 16px;
margin: 16px 0;
color: $mutedHex;
font-style: italic;
}
ul, ol { padding-left: 24px; margin-bottom: 16px; }
li { margin-bottom: 6px; }
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 0.9em;
}
th, td {
border: 1px solid ${textHex}22;
padding: 8px 12px;
text-align: left;
}
th { font-weight: 600; background: ${textHex}08; }
figure { margin: 16px 0; }
figcaption { font-size: 0.85em; color: $mutedHex; text-align: center; margin-top: 8px; }
hr { border: none; border-top: 1px solid ${textHex}22; margin: 24px 0; }
</style>
</head>
<body>
<h1>${escapeHtml(article.title)}</h1>
<div class="meta">
${article.author?.let { "Par ${escapeHtml(it)} · " } ?: ""}${article.siteName ?: ""}
· ${article.readingTimeMinutes} min de lecture
· ${article.wordCount} mots
</div>
${article.content}
</body>
</html>
""".trimIndent()
}
private fun colorToHex(color: Color): String {
val r = (color.red * 255).toInt()
val g = (color.green * 255).toInt()
val b = (color.blue * 255).toInt()
return String.format("#%02X%02X%02X", r, g, b)
}
private fun escapeHtml(text: String): String {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReaderSettingsSheet(
settings: ReaderSettings,
onDismiss: () -> Unit,
onFontChange: (ReaderFont) -> Unit,
onFontSizeChange: (Float) -> Unit,
onLineSpacingChange: (Float) -> Unit,
onThemeChange: (ReaderTheme) -> Unit
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.windowInsetsPadding(WindowInsets.navigationBars)
) {
Text(
text = "Paramètres de lecture",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(20.dp))
// Font family
Text("Police", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ReaderFont.entries.forEach { font ->
FilterChip(
selected = settings.fontFamily == font,
onClick = { onFontChange(font) },
label = { Text(font.displayName, fontSize = 13.sp) }
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Font size
Text(
"Taille du texte: ${settings.fontSize.value.toInt()}sp",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = settings.fontSize.value,
onValueChange = onFontSizeChange,
valueRange = 14f..28f,
steps = 6
)
Spacer(modifier = Modifier.height(12.dp))
// Line spacing
Text(
"Interligne: ${String.format("%.1f", settings.lineSpacing)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = settings.lineSpacing,
onValueChange = onLineSpacingChange,
valueRange = 1.2f..2.0f,
steps = 3
)
Spacer(modifier = Modifier.height(20.dp))
// Theme
Text("Thème", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ReaderTheme.entries.forEach { theme ->
FilterChip(
selected = settings.theme == theme,
onClick = { onThemeChange(theme) },
label = { Text(theme.displayName, fontSize = 13.sp) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View File

@ -0,0 +1,125 @@
package com.shaarit.presentation.reader
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.reader.ArticleExtractor
import com.shaarit.data.reader.ReadableArticle
import com.shaarit.data.reader.ReaderPreferences
import com.shaarit.data.reader.ReaderSettings
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class ReaderState {
object Loading : ReaderState()
data class Success(val article: ReadableArticle, val link: LinkEntity) : ReaderState()
data class Error(val message: String) : ReaderState()
}
@HiltViewModel
class ReaderModeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val linkDao: LinkDao,
private val articleExtractor: ArticleExtractor,
private val readerPreferences: ReaderPreferences
) : ViewModel() {
private val linkId: Int = savedStateHandle["linkId"] ?: -1
private val _readerState = MutableStateFlow<ReaderState>(ReaderState.Loading)
val readerState: StateFlow<ReaderState> = _readerState.asStateFlow()
val settings: StateFlow<ReaderSettings> = readerPreferences.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), readerPreferences.settings.value)
companion object {
private const val CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
}
init {
loadArticle()
}
fun loadArticle() {
viewModelScope.launch {
_readerState.value = ReaderState.Loading
try {
val link = linkDao.getLinkById(linkId)
if (link == null) {
_readerState.value = ReaderState.Error("Lien introuvable")
return@launch
}
// Check cached content
val cachedContent = link.readerContent
val cacheAge = System.currentTimeMillis() - link.readerContentFetchedAt
if (!cachedContent.isNullOrBlank() && cacheAge < CACHE_TTL_MS) {
_readerState.value = ReaderState.Success(
article = ReadableArticle(
title = link.title,
author = null,
siteName = link.siteName,
content = cachedContent,
leadImage = link.thumbnailUrl,
readingTimeMinutes = link.readingTimeMinutes ?: 1,
wordCount = cachedContent.split(Regex("\\s+")).size
),
link = link
)
return@launch
}
// Check if it's a note (no URL to extract from)
if (link.url.startsWith("note://")) {
// Use the description as content for notes
_readerState.value = ReaderState.Success(
article = ReadableArticle(
title = link.title,
author = null,
siteName = "Note",
content = "<p>${link.description}</p>",
leadImage = null,
readingTimeMinutes = link.readingTimeMinutes ?: 1,
wordCount = link.description.split(Regex("\\s+")).size
),
link = link
)
return@launch
}
// Extract article from URL
val article = articleExtractor.extract(link.url)
if (article != null) {
// Cache the content
linkDao.updateLink(
link.copy(
readerContent = article.content,
readerContentFetchedAt = System.currentTimeMillis()
)
)
_readerState.value = ReaderState.Success(
article = article,
link = link
)
} else {
_readerState.value = ReaderState.Error("Impossible d'extraire l'article")
}
} catch (e: Exception) {
_readerState.value = ReaderState.Error(e.message ?: "Erreur inconnue")
}
}
}
fun updateFont(font: com.shaarit.data.reader.ReaderFont) = readerPreferences.updateFont(font)
fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size)
fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing)
fun updateTheme(theme: com.shaarit.data.reader.ReaderTheme) = readerPreferences.updateTheme(theme)
}

View File

@ -0,0 +1,206 @@
package com.shaarit.presentation.reminders
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material.icons.filled.Weekend
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.shaarit.data.worker.QuickReminder
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReminderBottomSheet(
linkTitle: String,
onQuickReminderSelected: (QuickReminder) -> Unit,
onCustomTimeSelected: (Long) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.windowInsetsPadding(WindowInsets.navigationBars)
) {
Text(
text = "Rappeler de lire",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = linkTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
Spacer(modifier = Modifier.height(16.dp))
// Quick reminder options
ReminderOption(
icon = Icons.Default.AccessTime,
label = QuickReminder.IN_1_HOUR.displayName,
subtitle = formatTime(QuickReminder.IN_1_HOUR.computeTimestamp()),
onClick = {
onQuickReminderSelected(QuickReminder.IN_1_HOUR)
onDismiss()
}
)
ReminderOption(
icon = Icons.Default.DarkMode,
label = QuickReminder.TONIGHT.displayName,
subtitle = formatTime(QuickReminder.TONIGHT.computeTimestamp()),
onClick = {
onQuickReminderSelected(QuickReminder.TONIGHT)
onDismiss()
}
)
ReminderOption(
icon = Icons.Default.WbSunny,
label = QuickReminder.TOMORROW.displayName,
subtitle = formatDate(QuickReminder.TOMORROW.computeTimestamp()),
onClick = {
onQuickReminderSelected(QuickReminder.TOMORROW)
onDismiss()
}
)
ReminderOption(
icon = Icons.Default.Weekend,
label = QuickReminder.THIS_WEEKEND.displayName,
subtitle = formatDate(QuickReminder.THIS_WEEKEND.computeTimestamp()),
onClick = {
onQuickReminderSelected(QuickReminder.THIS_WEEKEND)
onDismiss()
}
)
ReminderOption(
icon = Icons.Default.DateRange,
label = QuickReminder.NEXT_WEEK.displayName,
subtitle = formatDate(QuickReminder.NEXT_WEEK.computeTimestamp()),
onClick = {
onQuickReminderSelected(QuickReminder.NEXT_WEEK)
onDismiss()
}
)
ReminderOption(
icon = Icons.Default.CalendarMonth,
label = QuickReminder.CUSTOM.displayName,
subtitle = null,
onClick = {
val cal = Calendar.getInstance()
DatePickerDialog(
context,
{ _, year, month, day ->
TimePickerDialog(
context,
{ _, hour, minute ->
val selectedCal = Calendar.getInstance().apply {
set(year, month, day, hour, minute, 0)
}
onCustomTimeSelected(selectedCal.timeInMillis)
onDismiss()
},
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
true
).show()
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun ReminderOption(
icon: ImageVector,
label: String,
subtitle: String?,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private fun formatTime(timestamp: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("EEE d MMM, HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View File

@ -0,0 +1,131 @@
package com.shaarit.presentation.reminders
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.ReminderDao
import com.shaarit.data.local.entity.ReadingReminderEntity
import com.shaarit.data.local.entity.RepeatInterval
import com.shaarit.data.worker.QuickReminder
import com.shaarit.data.worker.ReminderScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ReminderWithLinkInfo(
val reminder: ReadingReminderEntity,
val linkTitle: String,
val linkUrl: String,
val siteName: String?,
val readingTime: Int?
)
@HiltViewModel
class ReminderViewModel @Inject constructor(
private val reminderDao: ReminderDao,
private val linkDao: LinkDao,
private val reminderScheduler: ReminderScheduler
) : ViewModel() {
val activeReminderCount: StateFlow<Int> = try {
reminderDao.getActiveReminderCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
} catch (e: Exception) {
MutableStateFlow(0)
}
val linkIdsWithReminders: StateFlow<List<Int>> = try {
reminderDao.getLinkIdsWithActiveReminders()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
} catch (e: Exception) {
MutableStateFlow(emptyList())
}
private val _remindersWithLinks = MutableStateFlow<List<ReminderWithLinkInfo>>(emptyList())
val remindersWithLinks: StateFlow<List<ReminderWithLinkInfo>> = _remindersWithLinks.asStateFlow()
init {
loadRemindersWithLinks()
}
private fun loadRemindersWithLinks() {
viewModelScope.launch {
try {
reminderDao.getAllReminders().collect { reminders ->
val result = reminders.mapNotNull { reminder ->
try {
val link = linkDao.getLinkById(reminder.linkId)
if (link != null) {
ReminderWithLinkInfo(
reminder = reminder,
linkTitle = link.title,
linkUrl = link.url,
siteName = link.siteName,
readingTime = link.readingTimeMinutes
)
} else null
} catch (e: Exception) {
null
}
}
_remindersWithLinks.value = result
}
} catch (e: Exception) {
_remindersWithLinks.value = emptyList()
}
}
}
fun scheduleReminder(linkId: Int, quickReminder: QuickReminder) {
viewModelScope.launch {
val remindAt = quickReminder.computeTimestamp()
val reminder = ReadingReminderEntity(
linkId = linkId,
remindAt = remindAt,
repeatInterval = RepeatInterval.NONE
)
val id = reminderDao.insert(reminder)
reminderScheduler.schedule(reminder.copy(id = id))
}
}
fun scheduleReminderAt(linkId: Int, timestamp: Long, repeatInterval: RepeatInterval = RepeatInterval.NONE) {
viewModelScope.launch {
val reminder = ReadingReminderEntity(
linkId = linkId,
remindAt = timestamp,
repeatInterval = repeatInterval
)
val id = reminderDao.insert(reminder)
reminderScheduler.schedule(reminder.copy(id = id))
}
}
fun dismissReminder(reminderId: Long) {
viewModelScope.launch {
reminderDao.markDismissed(reminderId)
reminderScheduler.cancel(reminderId)
}
}
fun deleteReminder(reminderId: Long) {
viewModelScope.launch {
reminderScheduler.cancel(reminderId)
reminderDao.delete(reminderId)
}
}
fun snoozeReminder(reminderId: Long) {
viewModelScope.launch {
val newTime = System.currentTimeMillis() + 3_600_000 // +1h
reminderDao.updateRemindAt(reminderId, newTime)
reminderScheduler.scheduleSnooze(reminderId)
}
}
}

View File

@ -0,0 +1,361 @@
package com.shaarit.presentation.reminders
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.AlarmOff
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Snooze
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemindersScreen(
onNavigateBack: () -> Unit,
onNavigateToReader: (Int) -> Unit,
viewModel: ReminderViewModel = hiltViewModel()
) {
val reminders by viewModel.remindersWithLinks.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Alarm,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Mes rappels")
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Retour")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
if (reminders.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.AlarmOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Aucun rappel planifié",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Maintenez un lien dans le feed\npour programmer un rappel",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Active reminders
val active = reminders.filter { !it.reminder.isDismissed }
val dismissed = reminders.filter { it.reminder.isDismissed }
if (active.isNotEmpty()) {
item {
Text(
text = "Actifs (${active.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(active, key = { it.reminder.id }) { reminderInfo ->
ReminderCard(
reminderInfo = reminderInfo,
onReadClick = { onNavigateToReader(reminderInfo.reminder.linkId) },
onDismissClick = { viewModel.dismissReminder(reminderInfo.reminder.id) },
onSnoozeClick = { viewModel.snoozeReminder(reminderInfo.reminder.id) },
onDeleteClick = { viewModel.deleteReminder(reminderInfo.reminder.id) }
)
}
}
if (dismissed.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Terminés (${dismissed.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(dismissed.take(20), key = { it.reminder.id }) { reminderInfo ->
ReminderCard(
reminderInfo = reminderInfo,
onReadClick = { onNavigateToReader(reminderInfo.reminder.linkId) },
onDismissClick = null,
onSnoozeClick = null,
onDeleteClick = { viewModel.deleteReminder(reminderInfo.reminder.id) },
isDismissed = true
)
}
}
}
}
}
}
@Composable
private fun ReminderCard(
reminderInfo: ReminderWithLinkInfo,
onReadClick: () -> Unit,
onDismissClick: (() -> Unit)?,
onSnoozeClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
isDismissed: Boolean = false
) {
val reminder = reminderInfo.reminder
val isPast = reminder.remindAt < System.currentTimeMillis()
Card(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onReadClick),
colors = CardDefaults.cardColors(
containerColor = if (isDismissed) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = reminderInfo.linkTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (isDismissed) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (reminderInfo.siteName != null) {
Text(
text = reminderInfo.siteName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = " · ",
color = MaterialTheme.colorScheme.outline
)
}
reminderInfo.readingTime?.let {
Text(
text = "${it}min",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Reminder time
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isDismissed) Icons.Default.Check else Icons.Default.Alarm,
contentDescription = null,
tint = when {
isDismissed -> MaterialTheme.colorScheme.outline
isPast -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
},
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = formatReminderTime(reminder.remindAt),
style = MaterialTheme.typography.labelMedium,
color = when {
isDismissed -> MaterialTheme.colorScheme.outline
isPast -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
)
if (reminder.repeatInterval != com.shaarit.data.local.entity.RepeatInterval.NONE) {
Text(
text = " · ${repeatLabel(reminder.repeatInterval)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Actions
if (!isDismissed) {
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onReadClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.MenuBook,
contentDescription = "Lire",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
if (onDismissClick != null) {
IconButton(onClick = onDismissClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Check,
contentDescription = "Marquer comme lu",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(18.dp)
)
}
}
if (onSnoozeClick != null) {
IconButton(onClick = onSnoozeClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Snooze,
contentDescription = "Rappeler dans 1h",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(18.dp)
)
}
}
IconButton(onClick = onDeleteClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Supprimer",
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
private fun formatReminderTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = timestamp - now
return when {
diff < 0 -> {
val absDiff = -diff
val minutes = absDiff / 60_000
val hours = absDiff / 3_600_000
val days = absDiff / 86_400_000
when {
minutes < 60 -> "Il y a ${minutes}min"
hours < 24 -> "Il y a ${hours}h"
else -> "Il y a ${days}j"
}
}
diff < 3_600_000 -> "Dans ${diff / 60_000}min"
diff < 86_400_000 -> {
val sdf = SimpleDateFormat("'Aujourd\\'hui à' HH:mm", Locale.FRANCE)
sdf.format(Date(timestamp))
}
diff < 172_800_000 -> {
val sdf = SimpleDateFormat("'Demain à' HH:mm", Locale.FRANCE)
sdf.format(Date(timestamp))
}
else -> {
val sdf = SimpleDateFormat("EEE d MMM 'à' HH:mm", Locale.FRANCE)
sdf.format(Date(timestamp))
}
}
}
private fun repeatLabel(interval: com.shaarit.data.local.entity.RepeatInterval): String {
return when (interval) {
com.shaarit.data.local.entity.RepeatInterval.DAILY -> "Quotidien"
com.shaarit.data.local.entity.RepeatInterval.WEEKLY -> "Hebdomadaire"
com.shaarit.data.local.entity.RepeatInterval.MONTHLY -> "Mensuel"
com.shaarit.data.local.entity.RepeatInterval.NONE -> ""
}
}

View File

@ -163,6 +163,16 @@ fun SettingsScreen(
)
}
// Widget Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Widgets")
}
item {
WidgetLinkCountItem()
}
// Analytics Section
item {
Spacer(modifier = Modifier.height(16.dp))
@ -1154,3 +1164,69 @@ private fun SecuritySettingsItem(
}
}
}
@Composable
private fun WidgetLinkCountItem() {
val context = LocalContext.current
var linkCount by remember { mutableIntStateOf(com.shaarit.widget.WidgetPreferences.getWidgetLinkCount(context)) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Widgets,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Liens dans le widget",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Nombre de liens affichés dans le widget Récents",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "3",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Slider(
value = linkCount.toFloat(),
onValueChange = { linkCount = it.toInt() },
onValueChangeFinished = {
com.shaarit.widget.WidgetPreferences.setWidgetLinkCount(context, linkCount)
},
valueRange = 3f..20f,
steps = 16,
modifier = Modifier.weight(1f)
)
Text(
text = "20",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "$linkCount",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(32.dp)
)
}
}
}
}

View File

@ -19,6 +19,8 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
const val ACTION_ADD_LINK = "com.shaarit.widget.ACTION_ADD_LINK"
const val ACTION_REFRESH = "com.shaarit.widget.ACTION_REFRESH"
const val ACTION_RANDOM = "com.shaarit.widget.ACTION_RANDOM"
const val ACTION_SEARCH = "com.shaarit.widget.ACTION_SEARCH"
const val ACTION_CLEAR_SEARCH = "com.shaarit.widget.ACTION_CLEAR_SEARCH"
const val EXTRA_LINK_URL = "link_url"
}
@ -61,6 +63,22 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
}
context.startActivity(mainIntent)
}
ACTION_SEARCH -> {
// Ouvrir le dialogue de recherche
val searchIntent = Intent(context, WidgetSearchActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(searchIntent)
}
ACTION_CLEAR_SEARCH -> {
// Effacer la recherche et rafraîchir
WidgetPreferences.clearSearchQuery(context)
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = android.content.ComponentName(context, ShaarliWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list)
onUpdate(context, appWidgetManager, appWidgetIds)
}
}
}
@ -110,6 +128,42 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
)
views.setOnClickPendingIntent(R.id.widget_btn_random, randomPendingIntent)
// Barre de recherche — clic ouvre le dialogue
val searchIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
action = ACTION_SEARCH
}
val searchPendingIntent = PendingIntent.getBroadcast(
context,
3,
searchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_search_bar, searchPendingIntent)
// Bouton effacer la recherche
val clearSearchIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
action = ACTION_CLEAR_SEARCH
}
val clearSearchPendingIntent = PendingIntent.getBroadcast(
context,
4,
clearSearchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_btn_clear_search, clearSearchPendingIntent)
// Afficher la requête active ou le placeholder
val currentQuery = WidgetPreferences.getSearchQuery(context)
if (currentQuery.isNotBlank()) {
views.setTextViewText(R.id.widget_search_text, currentQuery)
views.setTextColor(R.id.widget_search_text, 0xFFFFFFFF.toInt())
views.setViewVisibility(R.id.widget_btn_clear_search, android.view.View.VISIBLE)
} else {
views.setTextViewText(R.id.widget_search_text, "Rechercher…")
views.setTextColor(R.id.widget_search_text, 0xFF94A3B8.toInt())
views.setViewVisibility(R.id.widget_btn_clear_search, android.view.View.GONE)
}
// Configuration de la liste (utilise un RemoteViewsService)
val serviceIntent = Intent(context, ShaarliWidgetService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)

View File

@ -5,7 +5,6 @@ import android.content.Intent
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import com.shaarit.R
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.database.ShaarliDatabase
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@ -26,9 +25,6 @@ class ShaarliWidgetItemFactory(
) : RemoteViewsService.RemoteViewsFactory {
private var links: List<WidgetLinkItem> = emptyList()
private val linkDao: LinkDao by lazy {
ShaarliDatabase.getInstance(context).linkDao()
}
override fun onCreate() {
// Initialisation
@ -38,18 +34,31 @@ class ShaarliWidgetItemFactory(
// Charger les liens depuis la base de données
links = runBlocking {
try {
linkDao.getAllLinks()
.firstOrNull()
?.take(10) // Limiter à 10 liens
?.map { link ->
WidgetLinkItem(
id = link.id,
title = link.title,
url = link.url,
tags = link.tags.take(3).joinToString(", ") // Max 3 tags
)
} ?: emptyList()
val db = ShaarliDatabase.getInstance(context)
val linkDao = db.linkDao()
val count = WidgetPreferences.getWidgetLinkCount(context)
val query = WidgetPreferences.getSearchQuery(context).trim().lowercase()
val allLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
val filtered = if (query.isNotBlank()) {
allLinks.filter { link ->
link.title.lowercase().contains(query) ||
link.url.lowercase().contains(query) ||
link.description.lowercase().contains(query) ||
link.tags.any { it.lowercase().contains(query) }
}
} else {
allLinks
}
filtered.take(count).map { link ->
WidgetLinkItem(
id = link.id,
title = link.title,
url = link.url,
tags = link.tags.take(3).joinToString(", ") // Max 3 tags
)
}
} catch (e: Exception) {
android.util.Log.e("ShaarliWidget", "Error loading links", e)
emptyList()
}
}
@ -62,6 +71,9 @@ class ShaarliWidgetItemFactory(
override fun getCount(): Int = links.size
override fun getViewAt(position: Int): RemoteViews {
if (position < 0 || position >= links.size) {
return getLoadingView()
}
val link = links[position]
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
@ -84,7 +96,13 @@ class ShaarliWidgetItemFactory(
}
}
override fun getLoadingView(): RemoteViews? = null
override fun getLoadingView(): RemoteViews {
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
setTextViewText(R.id.item_title, "Chargement…")
setTextViewText(R.id.item_url, "")
setViewVisibility(R.id.item_tags, android.view.View.GONE)
}
}
override fun getViewTypeCount(): Int = 1

View File

@ -0,0 +1,38 @@
package com.shaarit.widget
import android.content.Context
import android.content.SharedPreferences
/**
* Préférences pour les widgets (nombre de liens à afficher, etc.)
*/
object WidgetPreferences {
private const val PREFS_NAME = "shaarit_widget_prefs"
private const val KEY_WIDGET_LINK_COUNT = "widget_link_count"
private const val KEY_SEARCH_QUERY = "widget_search_query"
private const val DEFAULT_LINK_COUNT = 5
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getWidgetLinkCount(context: Context): Int {
return getPrefs(context).getInt(KEY_WIDGET_LINK_COUNT, DEFAULT_LINK_COUNT)
}
fun setWidgetLinkCount(context: Context, count: Int) {
getPrefs(context).edit().putInt(KEY_WIDGET_LINK_COUNT, count.coerceIn(3, 20)).apply()
}
fun getSearchQuery(context: Context): String {
return getPrefs(context).getString(KEY_SEARCH_QUERY, "") ?: ""
}
fun setSearchQuery(context: Context, query: String) {
getPrefs(context).edit().putString(KEY_SEARCH_QUERY, query).apply()
}
fun clearSearchQuery(context: Context) {
getPrefs(context).edit().remove(KEY_SEARCH_QUERY).apply()
}
}

View File

@ -0,0 +1,75 @@
package com.shaarit.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.shaarit.R
/**
* Activité transparente qui affiche un dialogue de recherche pour le widget ShaarIt.
* Quand l'utilisateur tape une requête, elle est sauvegardée dans WidgetPreferences
* et le widget est rafraîchi avec les résultats filtrés.
*/
class WidgetSearchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val currentQuery = WidgetPreferences.getSearchQuery(this)
val editText = EditText(this).apply {
hint = "Rechercher dans les bookmarks…"
setText(currentQuery)
setSingleLine(true)
requestFocus()
}
val container = LinearLayout(this).apply {
setPadding(48, 32, 48, 0)
addView(editText, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
))
}
AlertDialog.Builder(this)
.setTitle("Rechercher")
.setView(container)
.setPositiveButton("Rechercher") { _, _ ->
val query = editText.text.toString().trim()
WidgetPreferences.setSearchQuery(this, query)
refreshWidget()
finish()
}
.setNegativeButton("Annuler") { _, _ ->
finish()
}
.setNeutralButton("Effacer") { _, _ ->
WidgetPreferences.clearSearchQuery(this)
refreshWidget()
finish()
}
.setOnCancelListener {
finish()
}
.show()
}
private fun refreshWidget() {
val appWidgetManager = AppWidgetManager.getInstance(this)
val componentName = ComponentName(this, ShaarliWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list)
val updateIntent = Intent(this, ShaarliWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
sendBroadcast(updateIntent)
}
}

View File

@ -0,0 +1,129 @@
package com.shaarit.widget.glance
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.shaarit.MainActivity
/**
* Widget Glance affichant les statistiques rapides (2×1)
*/
class QuickStatsWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stats = WidgetDataProvider.getStats(context)
provideContent {
GlanceTheme {
QuickStatsContent(stats)
}
}
}
}
@Composable
private fun QuickStatsContent(stats: WidgetDataProvider.WidgetStats) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(12.dp)
.cornerRadius(16.dp)
.clickable(actionStartActivity<MainActivity>())
) {
Text(
text = "\uD83D\uDCCA ShaarIt",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.height(6.dp))
// Total links
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatCount(stats.totalLinks),
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 22.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = " liens",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 13.sp
)
)
}
// This week
Text(
text = "${stats.linksThisWeek} cette semaine",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp
)
)
// Reading time
if (stats.totalReadingTimeMinutes > 0) {
val hours = stats.totalReadingTimeMinutes / 60
val readingText = if (hours > 0) {
"\uD83D\uDCDA ${hours}h de lecture"
} else {
"\uD83D\uDCDA ${stats.totalReadingTimeMinutes}min de lecture"
}
Text(
text = readingText,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp
)
)
}
}
}
private fun formatCount(count: Int): String {
return when {
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
else -> count.toString()
}
}
/**
* Receiver pour le widget Quick Stats
*/
class QuickStatsWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = QuickStatsWidget()
}

View File

@ -0,0 +1,180 @@
package com.shaarit.widget.glance
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.shaarit.MainActivity
/**
* Widget Glance affichant les liens récents (4×2)
*/
class RecentLinksWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val links = WidgetDataProvider.getRecentLinks(context)
provideContent {
GlanceTheme {
RecentLinksContent(links)
}
}
}
}
@Composable
private fun RecentLinksContent(links: List<WidgetDataProvider.WidgetLink>) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(12.dp)
.cornerRadius(16.dp)
) {
// Header
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDD16 ShaarIt — Récents",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
modifier = GlanceModifier.defaultWeight()
)
// Add button
Box(
modifier = GlanceModifier
.size(28.dp)
.clickable(actionStartActivity<MainActivity>()),
contentAlignment = Alignment.Center
) {
Text(
text = "+",
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
)
}
Spacer(modifier = GlanceModifier.width(4.dp))
// Random button
Box(
modifier = GlanceModifier
.size(28.dp)
.clickable(actionStartActivity<MainActivity>()),
contentAlignment = Alignment.Center
) {
Text(
text = "\uD83D\uDD00",
style = TextStyle(fontSize = 16.sp)
)
}
}
Spacer(modifier = GlanceModifier.height(8.dp))
if (links.isEmpty()) {
Box(
modifier = GlanceModifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Aucun lien",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp
)
)
}
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(links, itemId = { it.id.toLong() }) { link ->
LinkItem(link)
}
}
}
}
}
@Composable
private fun LinkItem(link: WidgetDataProvider.WidgetLink) {
Column(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 2.dp)
.clickable(actionStartActivity<MainActivity>())
) {
// Content type emoji + title
val emoji = getContentEmoji(link.url)
Text(
text = "$emoji ${link.title}",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
),
maxLines = 1
)
// Site name + relative time
val domain = link.siteName ?: WidgetDataProvider.extractDomain(link.url)
val relativeTime = WidgetDataProvider.formatRelativeTime(link.createdAt)
Text(
text = "$domain · $relativeTime",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp
),
maxLines = 1
)
}
}
private fun getContentEmoji(url: String): String {
val lower = url.lowercase()
return when {
lower.contains("youtube.com") || lower.contains("youtu.be") || lower.contains("vimeo") -> "\uD83D\uDCF9"
lower.contains("github.com") || lower.contains("gitlab.com") -> "\uD83D\uDEE0\uFE0F"
lower.contains("spotify") || lower.contains("deezer") -> "\uD83C\uDFB5"
lower.contains("twitter.com") || lower.contains("x.com") || lower.contains("mastodon") -> "\uD83D\uDCAC"
lower.endsWith(".pdf") -> "\uD83D\uDCC4"
else -> "\uD83D\uDCC4"
}
}
/**
* Receiver pour le widget Liens Récents
*/
class RecentLinksWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = RecentLinksWidget()
}

View File

@ -0,0 +1,92 @@
package com.shaarit.widget.glance
import android.content.Context
import com.shaarit.data.local.database.ShaarliDatabase
import com.shaarit.data.local.entity.LinkEntity
import kotlinx.coroutines.flow.firstOrNull
/**
* Fournit les données depuis Room pour les widgets Glance
*/
object WidgetDataProvider {
data class WidgetLink(
val id: Int,
val title: String,
val url: String,
val siteName: String?,
val tags: List<String>,
val createdAt: Long
)
data class WidgetStats(
val totalLinks: Int,
val linksThisWeek: Int,
val totalReadingTimeMinutes: Int
)
suspend fun getRecentLinks(context: Context, limit: Int? = null): List<WidgetLink> {
return try {
val count = limit ?: com.shaarit.widget.WidgetPreferences.getWidgetLinkCount(context)
val db = ShaarliDatabase.getInstance(context)
val links = db.linkDao().getAllLinks().firstOrNull() ?: emptyList()
links.take(count).map { it.toWidgetLink() }
} catch (e: Exception) {
emptyList()
}
}
suspend fun getStats(context: Context): WidgetStats {
return try {
val db = ShaarliDatabase.getInstance(context)
val linkDao = db.linkDao()
val totalLinks = linkDao.getAllLinks().firstOrNull()?.size ?: 0
val oneWeekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
val linksThisWeek = linkDao.getCountSince(oneWeekAgo)
val allLinks = linkDao.getAllLinksForStats()
val totalReadingTime = allLinks.sumOf { it.readingTimeMinutes ?: 0 }
WidgetStats(
totalLinks = totalLinks,
linksThisWeek = linksThisWeek,
totalReadingTimeMinutes = totalReadingTime
)
} catch (e: Exception) {
WidgetStats(0, 0, 0)
}
}
private fun LinkEntity.toWidgetLink(): WidgetLink {
return WidgetLink(
id = id,
title = title,
url = url,
siteName = siteName,
tags = tags,
createdAt = createdAt
)
}
fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
val minutes = diff / 60_000
val hours = diff / 3_600_000
val days = diff / 86_400_000
return when {
minutes < 1 -> "à l'instant"
minutes < 60 -> "il y a ${minutes}min"
hours < 24 -> "il y a ${hours}h"
days < 7 -> "il y a ${days}j"
else -> "il y a ${days / 7}sem"
}
}
fun extractDomain(url: String): String {
return try {
java.net.URL(url).host.removePrefix("www.")
} catch (e: Exception) {
url
}
}
}

View File

@ -0,0 +1,50 @@
package com.shaarit.widget.glance
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* Worker pour mettre à jour périodiquement les widgets Glance
*/
@HiltWorker
class WidgetUpdateWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val WORK_NAME = "widget_update_work"
private const val UPDATE_INTERVAL_MINUTES = 30L
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
UPDATE_INTERVAL_MINUTES, TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}
override suspend fun doWork(): Result {
return try {
RecentLinksWidget().updateAll(applicationContext)
QuickStatsWidget().updateAll(applicationContext)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0F1923" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#2A3A4A" />
</shape>

View File

@ -27,7 +27,7 @@
android:id="@+id/widget_btn_add"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="@android:color/transparent"
android:contentDescription="@string/add_link"
android:src="@android:drawable/ic_input_add"
android:tint="@android:color/white" />
@ -36,7 +36,7 @@
android:id="@+id/widget_btn_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="@android:color/transparent"
android:contentDescription="@string/refresh"
android:src="@android:drawable/ic_popup_sync"
android:tint="@android:color/white" />
@ -45,12 +45,54 @@
android:id="@+id/widget_btn_random"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:background="@android:color/transparent"
android:contentDescription="@string/random"
android:src="@android:drawable/ic_menu_sort_by_size"
android:tint="@android:color/white" />
</LinearLayout>
<!-- Search bar (clickable, opens search dialog) -->
<LinearLayout
android:id="@+id/widget_search_bar"
android:layout_width="match_parent"
android:layout_height="36dp"
android:layout_marginTop="8dp"
android:background="@drawable/widget_search_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="4dp">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:contentDescription="@string/search"
android:src="@android:drawable/ic_menu_search"
android:tint="#94A3B8" />
<TextView
android:id="@+id/widget_search_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="Rechercher…"
android:textColor="#94A3B8"
android:textSize="13sp" />
<ImageButton
android:id="@+id/widget_btn_clear_search"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@android:color/transparent"
android:contentDescription="Effacer la recherche"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:tint="#94A3B8"
android:visibility="gone" />
</LinearLayout>
<!-- List of links -->
<ListView
android:id="@+id/widget_list"

View File

@ -82,4 +82,39 @@
<string name="export_success">Exportation réussie</string>
<string name="export_error">Erreur d\'exportation</string>
<string name="select_file">Sélectionner un fichier</string>
<!-- Glance Widgets -->
<string name="widget_recent_links_name">ShaarIt — Récents</string>
<string name="widget_recent_links_desc">Affiche les derniers liens ajoutés</string>
<string name="widget_quick_stats_name">ShaarIt — Stats</string>
<string name="widget_quick_stats_desc">Statistiques rapides de vos liens</string>
<!-- Reader Mode -->
<string name="reader_mode">Mode Lecture</string>
<string name="reader_loading">Extraction de l\'article…</string>
<string name="reader_error">Impossible d\'extraire l\'article</string>
<string name="reader_retry">Réessayer</string>
<string name="reader_font_sans">Sans-serif</string>
<string name="reader_font_serif">Serif</string>
<string name="reader_font_mono">Monospace</string>
<string name="reader_theme_dark">Sombre</string>
<string name="reader_theme_sepia">Sépia</string>
<string name="reader_theme_light">Clair</string>
<string name="reader_theme_auto">Auto</string>
<!-- Reading Reminders -->
<string name="reminder_title">Rappel de lecture</string>
<string name="reminder_channel_name">Rappels de lecture</string>
<string name="reminder_channel_desc">Notifications pour les rappels de lecture planifiés</string>
<string name="reminder_in_1_hour">Dans 1 heure</string>
<string name="reminder_tonight">Ce soir (20h)</string>
<string name="reminder_tomorrow">Demain matin (9h)</string>
<string name="reminder_this_weekend">Ce week-end</string>
<string name="reminder_next_week">La semaine prochaine</string>
<string name="reminder_custom">Date personnalisée…</string>
<string name="reminder_set">Rappeler de lire</string>
<string name="reminder_dismiss">Lu</string>
<string name="reminder_snooze">Rappeler dans 1h</string>
<string name="my_reminders">Mes rappels</string>
<string name="no_reminders">Aucun rappel planifié</string>
</resources>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_shaarli"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="80dp"
android:maxResizeWidth="300dp"
android:maxResizeHeight="200dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:previewImage="@drawable/ic_launcher_foreground"
android:description="@string/widget_quick_stats_desc"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_shaarli"
android:minWidth="250dp"
android:minHeight="180dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="400dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:previewImage="@drawable/ic_launcher_foreground"
android:description="@string/widget_recent_links_desc"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />