From 1deac8850aee78b89f65f0d8e3c7dd79c5b15fda Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 11 Feb 2026 08:54:29 -0500 Subject: [PATCH] feat: Add app widget system with Glance, reader mode, and reading reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 4 + .../6.json | 616 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 52 ++ app/src/main/java/com/shaarit/ShaarItApp.kt | 28 + .../com/shaarit/core/di/DatabaseModule.kt | 7 + .../com/shaarit/data/local/dao/ReminderDao.kt | 93 +++ .../data/local/database/ShaarliDatabase.kt | 39 +- .../shaarit/data/local/entity/LinkEntity.kt | 8 +- .../local/entity/ReadingReminderEntity.kt | 54 ++ .../shaarit/data/reader/ArticleExtractor.kt | 332 ++++++++++ .../shaarit/data/reader/ReaderPreferences.kt | 82 +++ .../data/worker/ReminderNotificationWorker.kt | 113 ++++ .../shaarit/data/worker/ReminderScheduler.kt | 138 ++++ .../shaarit/presentation/feed/FeedScreen.kt | 49 +- .../presentation/feed/LinkItemViews.kt | 83 ++- .../com/shaarit/presentation/nav/NavGraph.kt | 35 + .../presentation/reader/ReaderModeScreen.kt | 496 ++++++++++++++ .../reader/ReaderModeViewModel.kt | 125 ++++ .../reminders/ReminderBottomSheet.kt | 206 ++++++ .../reminders/ReminderViewModel.kt | 131 ++++ .../presentation/reminders/RemindersScreen.kt | 361 ++++++++++ .../presentation/settings/SettingsScreen.kt | 76 +++ .../shaarit/widget/ShaarliWidgetProvider.kt | 54 ++ .../shaarit/widget/ShaarliWidgetService.kt | 50 +- .../com/shaarit/widget/WidgetPreferences.kt | 38 ++ .../shaarit/widget/WidgetSearchActivity.kt | 75 +++ .../shaarit/widget/glance/QuickStatsWidget.kt | 129 ++++ .../widget/glance/RecentLinksWidget.kt | 180 +++++ .../widget/glance/WidgetDataProvider.kt | 92 +++ .../widget/glance/WidgetUpdateWorker.kt | 50 ++ .../res/drawable/widget_search_background.xml | 9 + app/src/main/res/layout/widget_shaarli.xml | 48 +- app/src/main/res/values/strings.xml | 35 + .../main/res/xml/widget_quick_stats_info.xml | 16 + .../main/res/xml/widget_recent_links_info.xml | 16 + 35 files changed, 3885 insertions(+), 35 deletions(-) create mode 100644 app/schemas/com.shaarit.data.local.database.ShaarliDatabase/6.json create mode 100644 app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt create mode 100644 app/src/main/java/com/shaarit/data/local/entity/ReadingReminderEntity.kt create mode 100644 app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt create mode 100644 app/src/main/java/com/shaarit/data/reader/ReaderPreferences.kt create mode 100644 app/src/main/java/com/shaarit/data/worker/ReminderNotificationWorker.kt create mode 100644 app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt create mode 100644 app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt create mode 100644 app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt create mode 100644 app/src/main/java/com/shaarit/widget/WidgetPreferences.kt create mode 100644 app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt create mode 100644 app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt create mode 100644 app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt create mode 100644 app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt create mode 100644 app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt create mode 100644 app/src/main/res/drawable/widget_search_background.xml create mode 100644 app/src/main/res/xml/widget_quick_stats_info.xml create mode 100644 app/src/main/res/xml/widget_recent_links_info.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9211b00..c05ae0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/6.json b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/6.json new file mode 100644 index 0000000..189b505 --- /dev/null +++ b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/6.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8011937..3f8d36d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt index 4de3384..091d83f 100644 --- a/app/src/main/java/com/shaarit/ShaarItApp.kt +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -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" + } } diff --git a/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt index fa4de99..372e9f7 100644 --- a/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt +++ b/app/src/main/java/com/shaarit/core/di/DatabaseModule.kt @@ -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() + } } diff --git a/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt b/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt new file mode 100644 index 0000000..b6826ad --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt @@ -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> + + @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> + + @Query("SELECT * FROM reading_reminders ORDER BY remind_at DESC") + fun getAllReminders(): Flow> + + @Query("SELECT link_id FROM reading_reminders WHERE is_dismissed = 0") + fun getLinkIdsWithActiveReminders(): Flow> + + @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 + + @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> + + @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> +} + +/** + * 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? +) diff --git a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt index b50db97..9a3ee8f 100644 --- a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt +++ b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt @@ -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() } diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt index 1054579..a964ce6 100644 --- a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt +++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt @@ -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 ) /** diff --git a/app/src/main/java/com/shaarit/data/local/entity/ReadingReminderEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/ReadingReminderEntity.kt new file mode 100644 index 0000000..5dba3f4 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/local/entity/ReadingReminderEntity.kt @@ -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() +) diff --git a/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt b/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt new file mode 100644 index 0000000..f41d961 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt @@ -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
et
+ 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 + } + } + } +} diff --git a/app/src/main/java/com/shaarit/data/reader/ReaderPreferences.kt b/app/src/main/java/com/shaarit/data/reader/ReaderPreferences.kt new file mode 100644 index 0000000..3d1a174 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/reader/ReaderPreferences.kt @@ -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 = _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) + } +} diff --git a/app/src/main/java/com/shaarit/data/worker/ReminderNotificationWorker.kt b/app/src/main/java/com/shaarit/data/worker/ReminderNotificationWorker.kt new file mode 100644 index 0000000..f97e070 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/worker/ReminderNotificationWorker.kt @@ -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) + } +} diff --git a/app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt b/app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt new file mode 100644 index 0000000..a9eac1c --- /dev/null +++ b/app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt @@ -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() + .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() + .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 + } +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index 5701313..335d05d 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -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()) } 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 } + ) + } } } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index 19414b0..d4f74d3 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -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 diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index b0316a3..7bd5917 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -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)) + } + ) + } } } diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt new file mode 100644 index 0000000..2bfe018 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt @@ -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 """ + + + + + + + + +

${escapeHtml(article.title)}

+
+ ${article.author?.let { "Par ${escapeHtml(it)} · " } ?: ""}${article.siteName ?: ""} + · ${article.readingTimeMinutes} min de lecture + · ${article.wordCount} mots +
+ ${article.content} + + + """.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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) +} + +@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)) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt new file mode 100644 index 0000000..b941f42 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt @@ -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.Loading) + val readerState: StateFlow = _readerState.asStateFlow() + + val settings: StateFlow = 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 = "

${link.description}

", + 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) +} diff --git a/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt b/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt new file mode 100644 index 0000000..5734266 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt @@ -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)) +} diff --git a/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt b/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt new file mode 100644 index 0000000..1503cb7 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt @@ -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 = try { + reminderDao.getActiveReminderCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + } catch (e: Exception) { + MutableStateFlow(0) + } + + val linkIdsWithReminders: StateFlow> = try { + reminderDao.getLinkIdsWithActiveReminders() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + } catch (e: Exception) { + MutableStateFlow(emptyList()) + } + + private val _remindersWithLinks = MutableStateFlow>(emptyList()) + val remindersWithLinks: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt b/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt new file mode 100644 index 0000000..ba2ace6 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt @@ -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 -> "" + } +} diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt index 6fbb9d2..484dec2 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt index 3375995..57e0b84 100644 --- a/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt +++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt @@ -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) diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt index 89b77f3..55c49f7 100644 --- a/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt +++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt @@ -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 = 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 diff --git a/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt b/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt new file mode 100644 index 0000000..4b92cc0 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt @@ -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() + } +} diff --git a/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt b/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt new file mode 100644 index 0000000..d35c7d2 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt b/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt new file mode 100644 index 0000000..b906c95 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt @@ -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()) + ) { + 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() +} diff --git a/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt b/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt new file mode 100644 index 0000000..fa336d7 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt @@ -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) { + 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()), + 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()), + 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()) + ) { + // 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() +} diff --git a/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt b/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt new file mode 100644 index 0000000..f2d07b1 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt @@ -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, + val createdAt: Long + ) + + data class WidgetStats( + val totalLinks: Int, + val linksThisWeek: Int, + val totalReadingTimeMinutes: Int + ) + + suspend fun getRecentLinks(context: Context, limit: Int? = null): List { + 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 + } + } +} diff --git a/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt b/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt new file mode 100644 index 0000000..f30aab1 --- /dev/null +++ b/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt @@ -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( + 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() + } + } +} diff --git a/app/src/main/res/drawable/widget_search_background.xml b/app/src/main/res/drawable/widget_search_background.xml new file mode 100644 index 0000000..da235bc --- /dev/null +++ b/app/src/main/res/drawable/widget_search_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_shaarli.xml b/app/src/main/res/layout/widget_shaarli.xml index eda4fae..416ef72 100644 --- a/app/src/main/res/layout/widget_shaarli.xml +++ b/app/src/main/res/layout/widget_shaarli.xml @@ -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" /> + + + + + + + + + + Exportation réussie Erreur d\'exportation Sélectionner un fichier + + + ShaarIt — Récents + Affiche les derniers liens ajoutés + ShaarIt — Stats + Statistiques rapides de vos liens + + + Mode Lecture + Extraction de l\'article… + Impossible d\'extraire l\'article + Réessayer + Sans-serif + Serif + Monospace + Sombre + Sépia + Clair + Auto + + + Rappel de lecture + Rappels de lecture + Notifications pour les rappels de lecture planifiés + Dans 1 heure + Ce soir (20h) + Demain matin (9h) + Ce week-end + La semaine prochaine + Date personnalisée… + Rappeler de lire + Lu + Rappeler dans 1h + Mes rappels + Aucun rappel planifié \ No newline at end of file diff --git a/app/src/main/res/xml/widget_quick_stats_info.xml b/app/src/main/res/xml/widget_quick_stats_info.xml new file mode 100644 index 0000000..d473150 --- /dev/null +++ b/app/src/main/res/xml/widget_quick_stats_info.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/widget_recent_links_info.xml b/app/src/main/res/xml/widget_recent_links_info.xml new file mode 100644 index 0000000..5b66f5e --- /dev/null +++ b/app/src/main/res/xml/widget_recent_links_info.xml @@ -0,0 +1,16 @@ + +