feat: Add app widget system with Glance, reader mode, and reading reminders
- Add Glance dependencies (glance-appwidget:1.1.0, glance-material3:1.1.0) for Compose-based widgets - Implement RecentLinksWidget (4×2) and QuickStatsWidget (2×1) with Glance framework - Add legacy RemoteViews widget (ShaarliWidgetProvider) for backward compatibility - Create WidgetSearchActivity for widget configuration and WidgetUpdateWorker for periodic updates - Add reader mode support with readerContent and readerContentFetchedAt
This commit is contained in:
parent
ec0931134c
commit
1deac8850a
@ -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")
|
||||
|
||||
|
||||
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<application
|
||||
android:name=".ShaarItApp"
|
||||
@ -77,6 +79,56 @@
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Widget Search Dialog Activity -->
|
||||
<activity
|
||||
android:name=".widget.WidgetSearchActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.Dialog" />
|
||||
|
||||
<!-- Legacy Widget (RemoteViews) -->
|
||||
<receiver
|
||||
android:name=".widget.ShaarliWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".widget.ShaarliWidgetService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
<!-- Glance Widget: Recent Links (4×2) -->
|
||||
<receiver
|
||||
android:name=".widget.glance.RecentLinksWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_recent_links_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_recent_links_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Glance Widget: Quick Stats (2×1) -->
|
||||
<receiver
|
||||
android:name=".widget.glance.QuickStatsWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_quick_stats_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_quick_stats_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
93
app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt
Normal file
93
app/src/main/java/com/shaarit/data/local/dao/ReminderDao.kt
Normal file
@ -0,0 +1,93 @@
|
||||
package com.shaarit.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.ReadingReminderEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Données jointes rappel + lien pour l'affichage
|
||||
*/
|
||||
data class ReminderWithLink(
|
||||
val reminder: ReadingReminderEntity,
|
||||
val link: LinkEntity
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ReminderDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(reminder: ReadingReminderEntity): Long
|
||||
|
||||
@Query("SELECT * FROM reading_reminders WHERE id = :id")
|
||||
suspend fun getById(id: Long): ReadingReminderEntity?
|
||||
|
||||
@Query("SELECT * FROM reading_reminders WHERE link_id = :linkId AND is_dismissed = 0 ORDER BY remind_at ASC")
|
||||
fun getActiveRemindersForLink(linkId: Int): Flow<List<ReadingReminderEntity>>
|
||||
|
||||
@Query("SELECT * FROM reading_reminders WHERE link_id = :linkId AND is_dismissed = 0 LIMIT 1")
|
||||
suspend fun getActiveReminderForLink(linkId: Int): ReadingReminderEntity?
|
||||
|
||||
@Query("SELECT * FROM reading_reminders WHERE is_dismissed = 0 ORDER BY remind_at ASC")
|
||||
fun getAllActiveReminders(): Flow<List<ReadingReminderEntity>>
|
||||
|
||||
@Query("SELECT * FROM reading_reminders ORDER BY remind_at DESC")
|
||||
fun getAllReminders(): Flow<List<ReadingReminderEntity>>
|
||||
|
||||
@Query("SELECT link_id FROM reading_reminders WHERE is_dismissed = 0")
|
||||
fun getLinkIdsWithActiveReminders(): Flow<List<Int>>
|
||||
|
||||
@Query("UPDATE reading_reminders SET is_dismissed = 1 WHERE id = :id")
|
||||
suspend fun markDismissed(id: Long)
|
||||
|
||||
@Query("UPDATE reading_reminders SET remind_at = :newTime WHERE id = :id")
|
||||
suspend fun updateRemindAt(id: Long, newTime: Long)
|
||||
|
||||
@Query("DELETE FROM reading_reminders WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("DELETE FROM reading_reminders WHERE link_id = :linkId")
|
||||
suspend fun deleteByLinkId(linkId: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM reading_reminders WHERE is_dismissed = 0")
|
||||
fun getActiveReminderCount(): Flow<Int>
|
||||
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT r.*, l.* FROM reading_reminders r
|
||||
INNER JOIN links l ON r.link_id = l.id
|
||||
WHERE r.is_dismissed = 0
|
||||
ORDER BY r.remind_at ASC
|
||||
""")
|
||||
fun getActiveRemindersWithLinks(): Flow<List<ReminderWithLinkTuple>>
|
||||
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT r.*, l.* FROM reading_reminders r
|
||||
INNER JOIN links l ON r.link_id = l.id
|
||||
ORDER BY r.remind_at DESC
|
||||
""")
|
||||
fun getAllRemindersWithLinks(): Flow<List<ReminderWithLinkTuple>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuple pour la jointure rappel + lien (Room @Embedded/@Relation alternative)
|
||||
*/
|
||||
data class ReminderWithLinkTuple(
|
||||
val id: Long,
|
||||
val link_id: Int,
|
||||
val remind_at: Long,
|
||||
val repeat_interval: String,
|
||||
val is_dismissed: Boolean,
|
||||
val created_at: Long,
|
||||
// Link fields
|
||||
val url: String,
|
||||
val title: String,
|
||||
val site_name: String?,
|
||||
val reading_time_minutes: Int?,
|
||||
val thumbnail_url: String?
|
||||
)
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
)
|
||||
332
app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt
Normal file
332
app/src/main/java/com/shaarit/data/reader/ArticleExtractor.kt
Normal file
@ -0,0 +1,332 @@
|
||||
package com.shaarit.data.reader
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.safety.Safelist
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Modèle d'article lisible extrait d'une page web
|
||||
*/
|
||||
data class ReadableArticle(
|
||||
val title: String,
|
||||
val author: String?,
|
||||
val siteName: String?,
|
||||
val content: String,
|
||||
val leadImage: String?,
|
||||
val readingTimeMinutes: Int,
|
||||
val wordCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracteur d'articles style Readability basé sur JSoup.
|
||||
* Extrait le contenu principal d'une page web en supprimant navigation, pubs, sidebars, etc.
|
||||
*/
|
||||
@Singleton
|
||||
class ArticleExtractor @Inject constructor() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ArticleExtractor"
|
||||
private const val TIMEOUT_MS = 15000
|
||||
private const val WORDS_PER_MINUTE = 200
|
||||
|
||||
// Éléments à supprimer systématiquement
|
||||
private val REMOVE_SELECTORS = listOf(
|
||||
"script", "style", "noscript", "iframe", "object", "embed",
|
||||
"nav", "header:not(article header)", "footer:not(article footer)",
|
||||
".sidebar", "#sidebar", ".widget", ".ad", ".ads", ".advert",
|
||||
".advertisement", "[class*=advert]", "[id*=advert]",
|
||||
".social-share", ".share-buttons", ".sharing",
|
||||
".comments", "#comments", ".comment-section",
|
||||
".related-posts", ".related-articles", ".recommended",
|
||||
".newsletter", ".subscribe", ".popup", ".modal",
|
||||
".cookie-banner", ".cookie-notice", ".gdpr",
|
||||
".breadcrumb", ".breadcrumbs", ".pagination",
|
||||
".menu", ".navigation", "#navigation",
|
||||
"[role=navigation]", "[role=banner]", "[role=complementary]",
|
||||
".toc", "#toc", ".table-of-contents"
|
||||
)
|
||||
|
||||
// Sélecteurs pour trouver le contenu principal (ordre de priorité)
|
||||
private val CONTENT_SELECTORS = listOf(
|
||||
"article",
|
||||
"[role=main]",
|
||||
"main",
|
||||
".post-content",
|
||||
".article-content",
|
||||
".entry-content",
|
||||
".content-body",
|
||||
".article-body",
|
||||
".post-body",
|
||||
".story-body",
|
||||
"#article-body",
|
||||
"#content",
|
||||
".content",
|
||||
".post",
|
||||
".article"
|
||||
)
|
||||
|
||||
// Safelist HTML permise dans le contenu nettoyé
|
||||
private val READER_SAFELIST = Safelist.relaxed()
|
||||
.addTags("figure", "figcaption", "picture", "source", "video", "audio")
|
||||
.addAttributes("img", "src", "alt", "width", "height", "loading")
|
||||
.addAttributes("a", "href", "title")
|
||||
.addAttributes("pre", "class")
|
||||
.addAttributes("code", "class")
|
||||
.addAttributes("source", "src", "type", "srcset")
|
||||
.addAttributes("video", "src", "controls", "poster")
|
||||
.addAttributes("audio", "src", "controls")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le contenu lisible d'une URL
|
||||
*/
|
||||
suspend fun extract(url: String): ReadableArticle? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val doc = Jsoup.connect(url)
|
||||
.timeout(TIMEOUT_MS)
|
||||
.userAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
|
||||
.followRedirects(true)
|
||||
.maxBodySize(5 * 1024 * 1024) // 5 MB max
|
||||
.get()
|
||||
|
||||
extractFromDocument(doc, url)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur extraction article pour $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le contenu lisible depuis un document JSoup déjà chargé
|
||||
*/
|
||||
fun extractFromDocument(doc: Document, baseUrl: String): ReadableArticle? {
|
||||
return try {
|
||||
// Extraire les métadonnées
|
||||
val title = extractTitle(doc)
|
||||
val author = extractAuthor(doc)
|
||||
val siteName = extractSiteName(doc, baseUrl)
|
||||
val leadImage = extractLeadImage(doc, baseUrl)
|
||||
|
||||
// Nettoyer le document
|
||||
val cleanDoc = doc.clone()
|
||||
removeUnwantedElements(cleanDoc)
|
||||
|
||||
// Trouver le contenu principal
|
||||
val mainContent = findMainContent(cleanDoc)
|
||||
?: return null
|
||||
|
||||
// Nettoyer le HTML du contenu principal
|
||||
val cleanHtml = Jsoup.clean(
|
||||
mainContent.html(),
|
||||
baseUrl,
|
||||
READER_SAFELIST
|
||||
)
|
||||
|
||||
// Calculer les stats
|
||||
val textContent = Jsoup.parse(cleanHtml).text()
|
||||
val wordCount = textContent.split(Regex("\\s+")).filter { it.isNotBlank() }.size
|
||||
val readingTime = maxOf(1, wordCount / WORDS_PER_MINUTE)
|
||||
|
||||
if (wordCount < 50) {
|
||||
// Trop peu de contenu, probablement pas un article
|
||||
return null
|
||||
}
|
||||
|
||||
ReadableArticle(
|
||||
title = title ?: "Sans titre",
|
||||
author = author,
|
||||
siteName = siteName,
|
||||
content = cleanHtml,
|
||||
leadImage = leadImage,
|
||||
readingTimeMinutes = readingTime,
|
||||
wordCount = wordCount
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur extraction contenu", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractTitle(doc: Document): String? {
|
||||
// Priorité: og:title > title tag > h1
|
||||
val ogTitle = doc.select("meta[property=og:title]").attr("content")
|
||||
if (ogTitle.isNotBlank()) return ogTitle.trim()
|
||||
|
||||
val titleTag = doc.select("title").text()
|
||||
if (titleTag.isNotBlank()) return titleTag.trim()
|
||||
|
||||
val h1 = doc.select("h1").first()?.text()
|
||||
if (!h1.isNullOrBlank()) return h1.trim()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractAuthor(doc: Document): String? {
|
||||
val ogAuthor = doc.select("meta[name=author]").attr("content")
|
||||
if (ogAuthor.isNotBlank()) return ogAuthor.trim()
|
||||
|
||||
val articleAuthor = doc.select("meta[property=article:author]").attr("content")
|
||||
if (articleAuthor.isNotBlank()) return articleAuthor.trim()
|
||||
|
||||
val ldJson = doc.select("script[type=application/ld+json]").html()
|
||||
val authorMatch = Regex("\"author\"\\s*:\\s*\\{[^}]*\"name\"\\s*:\\s*\"([^\"]+)\"").find(ldJson)
|
||||
if (authorMatch != null) return authorMatch.groupValues[1]
|
||||
|
||||
// Chercher des éléments courants
|
||||
val authorSelectors = listOf(
|
||||
".author", ".byline", "[rel=author]", ".post-author",
|
||||
"[itemprop=author]", ".entry-author"
|
||||
)
|
||||
for (selector in authorSelectors) {
|
||||
val el = doc.select(selector).first()
|
||||
if (el != null && el.text().isNotBlank() && el.text().length < 100) {
|
||||
return el.text().trim()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractSiteName(doc: Document, baseUrl: String): String? {
|
||||
val ogSiteName = doc.select("meta[property=og:site_name]").attr("content")
|
||||
if (ogSiteName.isNotBlank()) return ogSiteName.trim()
|
||||
|
||||
return try {
|
||||
java.net.URL(baseUrl).host.removePrefix("www.")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractLeadImage(doc: Document, baseUrl: String): String? {
|
||||
val ogImage = doc.select("meta[property=og:image]").attr("content")
|
||||
if (ogImage.isNotBlank()) return resolveUrl(ogImage, baseUrl)
|
||||
|
||||
val twitterImage = doc.select("meta[name=twitter:image]").attr("content")
|
||||
if (twitterImage.isNotBlank()) return resolveUrl(twitterImage, baseUrl)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun removeUnwantedElements(doc: Document) {
|
||||
for (selector in REMOVE_SELECTORS) {
|
||||
try {
|
||||
doc.select(selector).remove()
|
||||
} catch (_: Exception) {
|
||||
// Ignorer les erreurs de sélecteur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le contenu principal en utilisant des heuristiques.
|
||||
* Essaie d'abord les sélecteurs connus, puis scoring par densité de texte.
|
||||
*/
|
||||
private fun findMainContent(doc: Document): Element? {
|
||||
// 1. Essayer les sélecteurs connus
|
||||
for (selector in CONTENT_SELECTORS) {
|
||||
val candidates = doc.select(selector)
|
||||
if (candidates.isNotEmpty()) {
|
||||
// Prendre le candidat avec le plus de texte
|
||||
val best = candidates.maxByOrNull { it.text().length }
|
||||
if (best != null && best.text().length > 200) {
|
||||
return best
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scoring par densité de texte sur les <div> et <section>
|
||||
val candidates = doc.select("div, section")
|
||||
if (candidates.isEmpty()) return doc.body()
|
||||
|
||||
var bestElement: Element? = null
|
||||
var bestScore = 0.0
|
||||
|
||||
for (element in candidates) {
|
||||
val score = scoreElement(element)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestElement = element
|
||||
}
|
||||
}
|
||||
|
||||
return bestElement ?: doc.body()
|
||||
}
|
||||
|
||||
/**
|
||||
* Score un élément selon sa probabilité de contenir le contenu principal.
|
||||
* Inspiré de l'algorithme Readability de Mozilla.
|
||||
*/
|
||||
private fun scoreElement(element: Element): Double {
|
||||
var score = 0.0
|
||||
|
||||
// Texte direct (pas dans les enfants)
|
||||
val text = element.ownText()
|
||||
val textLength = text.length
|
||||
|
||||
// Plus de texte = plus probable
|
||||
score += textLength * 0.1
|
||||
|
||||
// Nombre de paragraphes
|
||||
val paragraphs = element.select("> p, > div > p")
|
||||
score += paragraphs.size * 10.0
|
||||
|
||||
// Nombre de balises de contenu (images, code, etc.)
|
||||
score += element.select("img").size * 3.0
|
||||
score += element.select("pre, code").size * 5.0
|
||||
score += element.select("blockquote").size * 3.0
|
||||
score += element.select("h2, h3, h4").size * 5.0
|
||||
|
||||
// Pénalité pour les liens (haute densité = probablement navigation)
|
||||
val links = element.select("a")
|
||||
val linkTextLength = links.sumOf { it.text().length }
|
||||
val totalTextLength = element.text().length
|
||||
if (totalTextLength > 0) {
|
||||
val linkDensity = linkTextLength.toDouble() / totalTextLength
|
||||
if (linkDensity > 0.5) {
|
||||
score *= 0.2 // Forte pénalité
|
||||
} else if (linkDensity > 0.3) {
|
||||
score *= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// Pénalité pour les éléments trop courts
|
||||
if (totalTextLength < 100) {
|
||||
score *= 0.1
|
||||
}
|
||||
|
||||
// Bonus pour les classes/ids évocateurs
|
||||
val classId = "${element.className()} ${element.id()}".lowercase()
|
||||
if (classId.contains("article") || classId.contains("content") ||
|
||||
classId.contains("post") || classId.contains("entry") ||
|
||||
classId.contains("text") || classId.contains("body")) {
|
||||
score *= 1.5
|
||||
}
|
||||
if (classId.contains("comment") || classId.contains("sidebar") ||
|
||||
classId.contains("footer") || classId.contains("header") ||
|
||||
classId.contains("nav") || classId.contains("menu") ||
|
||||
classId.contains("ad") || classId.contains("widget")) {
|
||||
score *= 0.2
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private fun resolveUrl(url: String, baseUrl: String): String {
|
||||
return when {
|
||||
url.startsWith("http") -> url
|
||||
url.startsWith("//") -> "https:$url"
|
||||
else -> try {
|
||||
java.net.URL(java.net.URL(baseUrl), url).toString()
|
||||
} catch (e: Exception) {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package com.shaarit.data.reader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ReaderFont(val displayName: String) {
|
||||
SANS_SERIF("Sans-serif"),
|
||||
SERIF("Serif"),
|
||||
MONOSPACE("Monospace")
|
||||
}
|
||||
|
||||
enum class ReaderTheme(val displayName: String) {
|
||||
DARK("Sombre"),
|
||||
SEPIA("Sépia"),
|
||||
LIGHT("Clair"),
|
||||
AUTO("Auto")
|
||||
}
|
||||
|
||||
data class ReaderSettings(
|
||||
val fontFamily: ReaderFont = ReaderFont.SANS_SERIF,
|
||||
val fontSize: TextUnit = 18.sp,
|
||||
val lineSpacing: Float = 1.5f,
|
||||
val theme: ReaderTheme = ReaderTheme.AUTO,
|
||||
val textAlign: TextAlign = TextAlign.Start
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class ReaderPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _settings = MutableStateFlow(loadSettings())
|
||||
val settings: StateFlow<ReaderSettings> = _settings.asStateFlow()
|
||||
|
||||
private fun loadSettings(): ReaderSettings {
|
||||
return ReaderSettings(
|
||||
fontFamily = ReaderFont.valueOf(prefs.getString("font_family", ReaderFont.SANS_SERIF.name) ?: ReaderFont.SANS_SERIF.name),
|
||||
fontSize = prefs.getFloat("font_size", 18f).sp,
|
||||
lineSpacing = prefs.getFloat("line_spacing", 1.5f),
|
||||
theme = ReaderTheme.valueOf(prefs.getString("theme", ReaderTheme.AUTO.name) ?: ReaderTheme.AUTO.name),
|
||||
textAlign = when (prefs.getString("text_align", "START")) {
|
||||
"JUSTIFY" -> TextAlign.Justify
|
||||
else -> TextAlign.Start
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFont(font: ReaderFont) {
|
||||
prefs.edit().putString("font_family", font.name).apply()
|
||||
_settings.value = _settings.value.copy(fontFamily = font)
|
||||
}
|
||||
|
||||
fun updateFontSize(size: Float) {
|
||||
prefs.edit().putFloat("font_size", size).apply()
|
||||
_settings.value = _settings.value.copy(fontSize = size.sp)
|
||||
}
|
||||
|
||||
fun updateLineSpacing(spacing: Float) {
|
||||
prefs.edit().putFloat("line_spacing", spacing).apply()
|
||||
_settings.value = _settings.value.copy(lineSpacing = spacing)
|
||||
}
|
||||
|
||||
fun updateTheme(theme: ReaderTheme) {
|
||||
prefs.edit().putString("theme", theme.name).apply()
|
||||
_settings.value = _settings.value.copy(theme = theme)
|
||||
}
|
||||
|
||||
fun updateTextAlign(align: TextAlign) {
|
||||
val key = if (align == TextAlign.Justify) "JUSTIFY" else "START"
|
||||
prefs.edit().putString("text_align", key).apply()
|
||||
_settings.value = _settings.value.copy(textAlign = align)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
138
app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt
Normal file
138
app/src/main/java/com/shaarit/data/worker/ReminderScheduler.kt
Normal file
@ -0,0 +1,138 @@
|
||||
package com.shaarit.data.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.shaarit.data.local.entity.ReadingReminderEntity
|
||||
import com.shaarit.data.local.entity.RepeatInterval
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Raccourcis rapides pour programmer un rappel
|
||||
*/
|
||||
enum class QuickReminder(val displayName: String) {
|
||||
IN_1_HOUR("Dans 1 heure"),
|
||||
TONIGHT("Ce soir (20h)"),
|
||||
TOMORROW("Demain matin (9h)"),
|
||||
THIS_WEEKEND("Ce week-end"),
|
||||
NEXT_WEEK("La semaine prochaine"),
|
||||
CUSTOM("Date personnalisée…");
|
||||
|
||||
fun computeTimestamp(): Long {
|
||||
val cal = Calendar.getInstance()
|
||||
return when (this) {
|
||||
IN_1_HOUR -> System.currentTimeMillis() + 3_600_000
|
||||
TONIGHT -> {
|
||||
cal.set(Calendar.HOUR_OF_DAY, 20)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
if (cal.timeInMillis <= System.currentTimeMillis()) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
cal.timeInMillis
|
||||
}
|
||||
TOMORROW -> {
|
||||
cal.add(Calendar.DAY_OF_YEAR, 1)
|
||||
cal.set(Calendar.HOUR_OF_DAY, 9)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.timeInMillis
|
||||
}
|
||||
THIS_WEEKEND -> {
|
||||
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
|
||||
val daysUntilSaturday = if (dayOfWeek == Calendar.SATURDAY) 0
|
||||
else if (dayOfWeek == Calendar.SUNDAY) 0
|
||||
else (Calendar.SATURDAY - dayOfWeek)
|
||||
cal.add(Calendar.DAY_OF_YEAR, if (daysUntilSaturday == 0) 0 else daysUntilSaturday)
|
||||
cal.set(Calendar.HOUR_OF_DAY, 10)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
if (cal.timeInMillis <= System.currentTimeMillis()) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, 7)
|
||||
}
|
||||
cal.timeInMillis
|
||||
}
|
||||
NEXT_WEEK -> {
|
||||
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
|
||||
val daysUntilMonday = if (dayOfWeek == Calendar.MONDAY) 7
|
||||
else (Calendar.SATURDAY - dayOfWeek + 2) % 7
|
||||
cal.add(Calendar.DAY_OF_YEAR, if (daysUntilMonday == 0) 7 else daysUntilMonday)
|
||||
cal.set(Calendar.HOUR_OF_DAY, 9)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.timeInMillis
|
||||
}
|
||||
CUSTOM -> System.currentTimeMillis() // Placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Planifie et annule les rappels de lecture via WorkManager
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
fun schedule(reminder: ReadingReminderEntity) {
|
||||
val delay = reminder.remindAt - System.currentTimeMillis()
|
||||
if (delay <= 0) return
|
||||
|
||||
val inputData = Data.Builder()
|
||||
.putLong("reminder_id", reminder.id)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<ReminderNotificationWorker>()
|
||||
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("reminder_${reminder.id}")
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
"reminder_${reminder.id}",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(reminderId: Long) {
|
||||
workManager.cancelUniqueWork("reminder_$reminderId")
|
||||
}
|
||||
|
||||
fun scheduleSnooze(reminderId: Long, delayMs: Long = 3_600_000) {
|
||||
val inputData = Data.Builder()
|
||||
.putLong("reminder_id", reminderId)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<ReminderNotificationWorker>()
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("reminder_${reminderId}")
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
"reminder_$reminderId",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun computeNextRepeatTime(reminder: ReadingReminderEntity): Long {
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = reminder.remindAt }
|
||||
when (reminder.repeatInterval) {
|
||||
RepeatInterval.DAILY -> cal.add(Calendar.DAY_OF_YEAR, 1)
|
||||
RepeatInterval.WEEKLY -> cal.add(Calendar.WEEK_OF_YEAR, 1)
|
||||
RepeatInterval.MONTHLY -> cal.add(Calendar.MONTH, 1)
|
||||
RepeatInterval.NONE -> return 0
|
||||
}
|
||||
return cal.timeInMillis
|
||||
}
|
||||
}
|
||||
@ -279,6 +279,8 @@ fun FeedScreen(
|
||||
onNavigateToHelp: () -> Unit = {},
|
||||
onNavigateToDeadLinks: () -> Unit = {},
|
||||
onNavigateToPinned: () -> Unit = {},
|
||||
onNavigateToReader: (Int) -> Unit = {},
|
||||
onNavigateToReminders: () -> Unit = {},
|
||||
initialTagFilter: String? = null,
|
||||
initialCollectionId: Long? = null,
|
||||
viewModel: FeedViewModel = hiltViewModel()
|
||||
@ -305,6 +307,13 @@ fun FeedScreen(
|
||||
var selectedIds by remember { mutableStateOf(setOf<Int>()) }
|
||||
var showAddToCollectionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Reminder bottom sheet state
|
||||
var showReminderSheet by remember { mutableStateOf(false) }
|
||||
var reminderTargetLinkId by remember { mutableIntStateOf(-1) }
|
||||
var reminderTargetLinkTitle by remember { mutableStateOf("") }
|
||||
val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel()
|
||||
val linkIdsWithReminders by reminderViewModel.linkIdsWithReminders.collectAsState()
|
||||
|
||||
// États des accordéons du drawer
|
||||
var mainMenuExpanded by remember { mutableStateOf(true) }
|
||||
var collectionsExpanded by remember { mutableStateOf(true) }
|
||||
@ -460,6 +469,15 @@ fun FeedScreen(
|
||||
onNavigateToDeadLinks()
|
||||
}
|
||||
)
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.Alarm,
|
||||
label = "Rappels de lecture",
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToReminders()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1451,7 +1469,8 @@ fun FeedScreen(
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
onTogglePin = { id -> viewModel.togglePin(id) },
|
||||
hasReminder = linkIdsWithReminders.contains(link.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1512,7 +1531,8 @@ fun FeedScreen(
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
onTogglePin = { id -> viewModel.togglePin(id) },
|
||||
hasReminder = linkIdsWithReminders.contains(link.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1574,7 +1594,8 @@ fun FeedScreen(
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||
onTogglePin = { id -> viewModel.togglePin(id) }
|
||||
onTogglePin = { id -> viewModel.togglePin(id) },
|
||||
hasReminder = linkIdsWithReminders.contains(link.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1623,6 +1644,14 @@ fun FeedScreen(
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onReadClick = { linkId ->
|
||||
onNavigateToReader(linkId)
|
||||
},
|
||||
onReminderClick = { linkId ->
|
||||
reminderTargetLinkId = linkId
|
||||
reminderTargetLinkTitle = link.title
|
||||
showReminderSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -1666,6 +1695,20 @@ fun FeedScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Reminder Bottom Sheet
|
||||
if (showReminderSheet && reminderTargetLinkId > 0) {
|
||||
com.shaarit.presentation.reminders.ReminderBottomSheet(
|
||||
linkTitle = reminderTargetLinkTitle,
|
||||
onQuickReminderSelected = { quickReminder ->
|
||||
reminderViewModel.scheduleReminder(reminderTargetLinkId, quickReminder)
|
||||
},
|
||||
onCustomTimeSelected = { timestamp ->
|
||||
reminderViewModel.scheduleReminderAt(reminderTargetLinkId, timestamp)
|
||||
},
|
||||
onDismiss = { showReminderSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,496 @@
|
||||
package com.shaarit.presentation.reader
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.FormatSize
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.data.reader.ReaderFont
|
||||
import com.shaarit.data.reader.ReaderSettings
|
||||
import com.shaarit.data.reader.ReaderTheme
|
||||
import com.shaarit.data.reader.ReadableArticle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderModeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ReaderModeViewModel = hiltViewModel()
|
||||
) {
|
||||
val readerState by viewModel.readerState.collectAsState()
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showSettingsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
when (val state = readerState) {
|
||||
is ReaderState.Success -> {
|
||||
Column {
|
||||
Text(
|
||||
text = state.article.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val subtitle = buildString {
|
||||
state.article.siteName?.let { append(it) }
|
||||
append(" · ${state.article.readingTimeMinutes} min")
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Text("Mode Lecture")
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Retour")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showSettingsSheet = true }) {
|
||||
Icon(Icons.Default.FormatSize, contentDescription = "Paramètres")
|
||||
}
|
||||
if (readerState is ReaderState.Success) {
|
||||
val link = (readerState as ReaderState.Success).link
|
||||
IconButton(onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
context.startActivity(intent)
|
||||
}) {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = "Ouvrir")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, link.url)
|
||||
putExtra(Intent.EXTRA_SUBJECT, link.title)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(shareIntent, "Partager"))
|
||||
}) {
|
||||
Icon(Icons.Default.Share, contentDescription = "Partager")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (val state = readerState) {
|
||||
is ReaderState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Extraction de l'article...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = state.message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(onClick = { viewModel.loadArticle() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Réessayer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderState.Success -> {
|
||||
ReaderContent(
|
||||
article = state.article,
|
||||
settings = settings,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettingsSheet) {
|
||||
ReaderSettingsSheet(
|
||||
settings = settings,
|
||||
onDismiss = { showSettingsSheet = false },
|
||||
onFontChange = viewModel::updateFont,
|
||||
onFontSizeChange = viewModel::updateFontSize,
|
||||
onLineSpacingChange = viewModel::updateLineSpacing,
|
||||
onThemeChange = viewModel::updateTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderContent(
|
||||
article: ReadableArticle,
|
||||
settings: ReaderSettings,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val bgColor = when (settings.theme) {
|
||||
ReaderTheme.DARK -> Color(0xFF1A1A1A)
|
||||
ReaderTheme.SEPIA -> Color(0xFFF4ECD8)
|
||||
ReaderTheme.LIGHT -> Color(0xFFFAFAFA)
|
||||
ReaderTheme.AUTO -> if (isDark) Color(0xFF1A1A1A) else Color(0xFFFAFAFA)
|
||||
}
|
||||
val textColor = when (settings.theme) {
|
||||
ReaderTheme.DARK -> Color(0xFFE0E0E0)
|
||||
ReaderTheme.SEPIA -> Color(0xFF5B4636)
|
||||
ReaderTheme.LIGHT -> Color(0xFF1A1A1A)
|
||||
ReaderTheme.AUTO -> if (isDark) Color(0xFFE0E0E0) else Color(0xFF1A1A1A)
|
||||
}
|
||||
val linkColor = when (settings.theme) {
|
||||
ReaderTheme.DARK -> Color(0xFF64B5F6)
|
||||
ReaderTheme.SEPIA -> Color(0xFF8B6914)
|
||||
ReaderTheme.LIGHT -> Color(0xFF1565C0)
|
||||
ReaderTheme.AUTO -> if (isDark) Color(0xFF64B5F6) else Color(0xFF1565C0)
|
||||
}
|
||||
|
||||
val fontFamily = when (settings.fontFamily) {
|
||||
ReaderFont.SANS_SERIF -> "sans-serif"
|
||||
ReaderFont.SERIF -> "serif"
|
||||
ReaderFont.MONOSPACE -> "monospace"
|
||||
}
|
||||
|
||||
val htmlContent = buildReaderHtml(
|
||||
article = article,
|
||||
bgColor = bgColor,
|
||||
textColor = textColor,
|
||||
linkColor = linkColor,
|
||||
fontFamily = fontFamily,
|
||||
fontSize = settings.fontSize.value,
|
||||
lineSpacing = settings.lineSpacing
|
||||
)
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
webViewClient = WebViewClient()
|
||||
getSettings().javaScriptEnabled = false
|
||||
getSettings().loadWithOverviewMode = true
|
||||
getSettings().useWideViewPort = true
|
||||
setBackgroundColor(bgColor.toArgb())
|
||||
loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webView.setBackgroundColor(bgColor.toArgb())
|
||||
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
|
||||
},
|
||||
modifier = modifier.background(bgColor)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildReaderHtml(
|
||||
article: ReadableArticle,
|
||||
bgColor: Color,
|
||||
textColor: Color,
|
||||
linkColor: Color,
|
||||
fontFamily: String,
|
||||
fontSize: Float,
|
||||
lineSpacing: Float
|
||||
): String {
|
||||
val bgHex = colorToHex(bgColor)
|
||||
val textHex = colorToHex(textColor)
|
||||
val linkHex = colorToHex(linkColor)
|
||||
val mutedHex = colorToHex(textColor.copy(alpha = 0.6f))
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background-color: $bgHex;
|
||||
color: $textHex;
|
||||
font-family: $fontFamily;
|
||||
font-size: ${fontSize}px;
|
||||
line-height: $lineSpacing;
|
||||
padding: 16px 20px 40px 20px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.meta {
|
||||
color: $mutedHex;
|
||||
font-size: 0.85em;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid ${mutedHex}33;
|
||||
}
|
||||
h2 { font-size: 1.35em; margin-top: 28px; margin-bottom: 12px; }
|
||||
h3 { font-size: 1.15em; margin-top: 24px; margin-bottom: 10px; }
|
||||
h4, h5, h6 { font-size: 1.05em; margin-top: 20px; margin-bottom: 8px; }
|
||||
p { margin-bottom: 16px; }
|
||||
a { color: $linkHex; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
display: block;
|
||||
}
|
||||
pre {
|
||||
background: ${textHex}11;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
margin: 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
code {
|
||||
font-family: monospace;
|
||||
background: ${textHex}11;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote {
|
||||
border-left: 3px solid $linkHex;
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
color: $mutedHex;
|
||||
font-style: italic;
|
||||
}
|
||||
ul, ol { padding-left: 24px; margin-bottom: 16px; }
|
||||
li { margin-bottom: 6px; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid ${textHex}22;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th { font-weight: 600; background: ${textHex}08; }
|
||||
figure { margin: 16px 0; }
|
||||
figcaption { font-size: 0.85em; color: $mutedHex; text-align: center; margin-top: 8px; }
|
||||
hr { border: none; border-top: 1px solid ${textHex}22; margin: 24px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(article.title)}</h1>
|
||||
<div class="meta">
|
||||
${article.author?.let { "Par ${escapeHtml(it)} · " } ?: ""}${article.siteName ?: ""}
|
||||
· ${article.readingTimeMinutes} min de lecture
|
||||
· ${article.wordCount} mots
|
||||
</div>
|
||||
${article.content}
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun colorToHex(color: Color): String {
|
||||
val r = (color.red * 255).toInt()
|
||||
val g = (color.green * 255).toInt()
|
||||
val b = (color.blue * 255).toInt()
|
||||
return String.format("#%02X%02X%02X", r, g, b)
|
||||
}
|
||||
|
||||
private fun escapeHtml(text: String): String {
|
||||
return text
|
||||
.replace("&", "&")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package com.shaarit.presentation.reader
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.reader.ArticleExtractor
|
||||
import com.shaarit.data.reader.ReadableArticle
|
||||
import com.shaarit.data.reader.ReaderPreferences
|
||||
import com.shaarit.data.reader.ReaderSettings
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class ReaderState {
|
||||
object Loading : ReaderState()
|
||||
data class Success(val article: ReadableArticle, val link: LinkEntity) : ReaderState()
|
||||
data class Error(val message: String) : ReaderState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ReaderModeViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val linkDao: LinkDao,
|
||||
private val articleExtractor: ArticleExtractor,
|
||||
private val readerPreferences: ReaderPreferences
|
||||
) : ViewModel() {
|
||||
|
||||
private val linkId: Int = savedStateHandle["linkId"] ?: -1
|
||||
|
||||
private val _readerState = MutableStateFlow<ReaderState>(ReaderState.Loading)
|
||||
val readerState: StateFlow<ReaderState> = _readerState.asStateFlow()
|
||||
|
||||
val settings: StateFlow<ReaderSettings> = readerPreferences.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), readerPreferences.settings.value)
|
||||
|
||||
companion object {
|
||||
private const val CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
|
||||
}
|
||||
|
||||
init {
|
||||
loadArticle()
|
||||
}
|
||||
|
||||
fun loadArticle() {
|
||||
viewModelScope.launch {
|
||||
_readerState.value = ReaderState.Loading
|
||||
try {
|
||||
val link = linkDao.getLinkById(linkId)
|
||||
if (link == null) {
|
||||
_readerState.value = ReaderState.Error("Lien introuvable")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check cached content
|
||||
val cachedContent = link.readerContent
|
||||
val cacheAge = System.currentTimeMillis() - link.readerContentFetchedAt
|
||||
if (!cachedContent.isNullOrBlank() && cacheAge < CACHE_TTL_MS) {
|
||||
_readerState.value = ReaderState.Success(
|
||||
article = ReadableArticle(
|
||||
title = link.title,
|
||||
author = null,
|
||||
siteName = link.siteName,
|
||||
content = cachedContent,
|
||||
leadImage = link.thumbnailUrl,
|
||||
readingTimeMinutes = link.readingTimeMinutes ?: 1,
|
||||
wordCount = cachedContent.split(Regex("\\s+")).size
|
||||
),
|
||||
link = link
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if it's a note (no URL to extract from)
|
||||
if (link.url.startsWith("note://")) {
|
||||
// Use the description as content for notes
|
||||
_readerState.value = ReaderState.Success(
|
||||
article = ReadableArticle(
|
||||
title = link.title,
|
||||
author = null,
|
||||
siteName = "Note",
|
||||
content = "<p>${link.description}</p>",
|
||||
leadImage = null,
|
||||
readingTimeMinutes = link.readingTimeMinutes ?: 1,
|
||||
wordCount = link.description.split(Regex("\\s+")).size
|
||||
),
|
||||
link = link
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Extract article from URL
|
||||
val article = articleExtractor.extract(link.url)
|
||||
if (article != null) {
|
||||
// Cache the content
|
||||
linkDao.updateLink(
|
||||
link.copy(
|
||||
readerContent = article.content,
|
||||
readerContentFetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
_readerState.value = ReaderState.Success(
|
||||
article = article,
|
||||
link = link
|
||||
)
|
||||
} else {
|
||||
_readerState.value = ReaderState.Error("Impossible d'extraire l'article")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_readerState.value = ReaderState.Error(e.message ?: "Erreur inconnue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFont(font: com.shaarit.data.reader.ReaderFont) = readerPreferences.updateFont(font)
|
||||
fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size)
|
||||
fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing)
|
||||
fun updateTheme(theme: com.shaarit.data.reader.ReaderTheme) = readerPreferences.updateTheme(theme)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.shaarit.presentation.reminders
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.ReminderDao
|
||||
import com.shaarit.data.local.entity.ReadingReminderEntity
|
||||
import com.shaarit.data.local.entity.RepeatInterval
|
||||
import com.shaarit.data.worker.QuickReminder
|
||||
import com.shaarit.data.worker.ReminderScheduler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ReminderWithLinkInfo(
|
||||
val reminder: ReadingReminderEntity,
|
||||
val linkTitle: String,
|
||||
val linkUrl: String,
|
||||
val siteName: String?,
|
||||
val readingTime: Int?
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ReminderViewModel @Inject constructor(
|
||||
private val reminderDao: ReminderDao,
|
||||
private val linkDao: LinkDao,
|
||||
private val reminderScheduler: ReminderScheduler
|
||||
) : ViewModel() {
|
||||
|
||||
val activeReminderCount: StateFlow<Int> = try {
|
||||
reminderDao.getActiveReminderCount()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
} catch (e: Exception) {
|
||||
MutableStateFlow(0)
|
||||
}
|
||||
|
||||
val linkIdsWithReminders: StateFlow<List<Int>> = try {
|
||||
reminderDao.getLinkIdsWithActiveReminders()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
} catch (e: Exception) {
|
||||
MutableStateFlow(emptyList())
|
||||
}
|
||||
|
||||
private val _remindersWithLinks = MutableStateFlow<List<ReminderWithLinkInfo>>(emptyList())
|
||||
val remindersWithLinks: StateFlow<List<ReminderWithLinkInfo>> = _remindersWithLinks.asStateFlow()
|
||||
|
||||
init {
|
||||
loadRemindersWithLinks()
|
||||
}
|
||||
|
||||
private fun loadRemindersWithLinks() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
reminderDao.getAllReminders().collect { reminders ->
|
||||
val result = reminders.mapNotNull { reminder ->
|
||||
try {
|
||||
val link = linkDao.getLinkById(reminder.linkId)
|
||||
if (link != null) {
|
||||
ReminderWithLinkInfo(
|
||||
reminder = reminder,
|
||||
linkTitle = link.title,
|
||||
linkUrl = link.url,
|
||||
siteName = link.siteName,
|
||||
readingTime = link.readingTimeMinutes
|
||||
)
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
_remindersWithLinks.value = result
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_remindersWithLinks.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminder(linkId: Int, quickReminder: QuickReminder) {
|
||||
viewModelScope.launch {
|
||||
val remindAt = quickReminder.computeTimestamp()
|
||||
val reminder = ReadingReminderEntity(
|
||||
linkId = linkId,
|
||||
remindAt = remindAt,
|
||||
repeatInterval = RepeatInterval.NONE
|
||||
)
|
||||
val id = reminderDao.insert(reminder)
|
||||
reminderScheduler.schedule(reminder.copy(id = id))
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminderAt(linkId: Int, timestamp: Long, repeatInterval: RepeatInterval = RepeatInterval.NONE) {
|
||||
viewModelScope.launch {
|
||||
val reminder = ReadingReminderEntity(
|
||||
linkId = linkId,
|
||||
remindAt = timestamp,
|
||||
repeatInterval = repeatInterval
|
||||
)
|
||||
val id = reminderDao.insert(reminder)
|
||||
reminderScheduler.schedule(reminder.copy(id = id))
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissReminder(reminderId: Long) {
|
||||
viewModelScope.launch {
|
||||
reminderDao.markDismissed(reminderId)
|
||||
reminderScheduler.cancel(reminderId)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteReminder(reminderId: Long) {
|
||||
viewModelScope.launch {
|
||||
reminderScheduler.cancel(reminderId)
|
||||
reminderDao.delete(reminderId)
|
||||
}
|
||||
}
|
||||
|
||||
fun snoozeReminder(reminderId: Long) {
|
||||
viewModelScope.launch {
|
||||
val newTime = System.currentTimeMillis() + 3_600_000 // +1h
|
||||
reminderDao.updateRemindAt(reminderId, newTime)
|
||||
reminderScheduler.scheduleSnooze(reminderId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.widget.RemoteViews
|
||||
import android.widget.RemoteViewsService
|
||||
import com.shaarit.R
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.database.ShaarliDatabase
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@ -26,9 +25,6 @@ class ShaarliWidgetItemFactory(
|
||||
) : RemoteViewsService.RemoteViewsFactory {
|
||||
|
||||
private var links: List<WidgetLinkItem> = emptyList()
|
||||
private val linkDao: LinkDao by lazy {
|
||||
ShaarliDatabase.getInstance(context).linkDao()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
// Initialisation
|
||||
@ -38,18 +34,31 @@ class ShaarliWidgetItemFactory(
|
||||
// Charger les liens depuis la base de données
|
||||
links = runBlocking {
|
||||
try {
|
||||
linkDao.getAllLinks()
|
||||
.firstOrNull()
|
||||
?.take(10) // Limiter à 10 liens
|
||||
?.map { link ->
|
||||
WidgetLinkItem(
|
||||
id = link.id,
|
||||
title = link.title,
|
||||
url = link.url,
|
||||
tags = link.tags.take(3).joinToString(", ") // Max 3 tags
|
||||
)
|
||||
} ?: emptyList()
|
||||
val db = ShaarliDatabase.getInstance(context)
|
||||
val linkDao = db.linkDao()
|
||||
val count = WidgetPreferences.getWidgetLinkCount(context)
|
||||
val query = WidgetPreferences.getSearchQuery(context).trim().lowercase()
|
||||
val allLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
|
||||
val filtered = if (query.isNotBlank()) {
|
||||
allLinks.filter { link ->
|
||||
link.title.lowercase().contains(query) ||
|
||||
link.url.lowercase().contains(query) ||
|
||||
link.description.lowercase().contains(query) ||
|
||||
link.tags.any { it.lowercase().contains(query) }
|
||||
}
|
||||
} else {
|
||||
allLinks
|
||||
}
|
||||
filtered.take(count).map { link ->
|
||||
WidgetLinkItem(
|
||||
id = link.id,
|
||||
title = link.title,
|
||||
url = link.url,
|
||||
tags = link.tags.take(3).joinToString(", ") // Max 3 tags
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ShaarliWidget", "Error loading links", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@ -62,6 +71,9 @@ class ShaarliWidgetItemFactory(
|
||||
override fun getCount(): Int = links.size
|
||||
|
||||
override fun getViewAt(position: Int): RemoteViews {
|
||||
if (position < 0 || position >= links.size) {
|
||||
return getLoadingView()
|
||||
}
|
||||
val link = links[position]
|
||||
|
||||
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
|
||||
@ -84,7 +96,13 @@ class ShaarliWidgetItemFactory(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLoadingView(): RemoteViews? = null
|
||||
override fun getLoadingView(): RemoteViews {
|
||||
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
|
||||
setTextViewText(R.id.item_title, "Chargement…")
|
||||
setTextViewText(R.id.item_url, "")
|
||||
setViewVisibility(R.id.item_tags, android.view.View.GONE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getViewTypeCount(): Int = 1
|
||||
|
||||
|
||||
38
app/src/main/java/com/shaarit/widget/WidgetPreferences.kt
Normal file
38
app/src/main/java/com/shaarit/widget/WidgetPreferences.kt
Normal file
@ -0,0 +1,38 @@
|
||||
package com.shaarit.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Préférences pour les widgets (nombre de liens à afficher, etc.)
|
||||
*/
|
||||
object WidgetPreferences {
|
||||
private const val PREFS_NAME = "shaarit_widget_prefs"
|
||||
private const val KEY_WIDGET_LINK_COUNT = "widget_link_count"
|
||||
private const val KEY_SEARCH_QUERY = "widget_search_query"
|
||||
private const val DEFAULT_LINK_COUNT = 5
|
||||
|
||||
private fun getPrefs(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun getWidgetLinkCount(context: Context): Int {
|
||||
return getPrefs(context).getInt(KEY_WIDGET_LINK_COUNT, DEFAULT_LINK_COUNT)
|
||||
}
|
||||
|
||||
fun setWidgetLinkCount(context: Context, count: Int) {
|
||||
getPrefs(context).edit().putInt(KEY_WIDGET_LINK_COUNT, count.coerceIn(3, 20)).apply()
|
||||
}
|
||||
|
||||
fun getSearchQuery(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_SEARCH_QUERY, "") ?: ""
|
||||
}
|
||||
|
||||
fun setSearchQuery(context: Context, query: String) {
|
||||
getPrefs(context).edit().putString(KEY_SEARCH_QUERY, query).apply()
|
||||
}
|
||||
|
||||
fun clearSearchQuery(context: Context) {
|
||||
getPrefs(context).edit().remove(KEY_SEARCH_QUERY).apply()
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt
Normal file
75
app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt
Normal file
@ -0,0 +1,75 @@
|
||||
package com.shaarit.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.shaarit.R
|
||||
|
||||
/**
|
||||
* Activité transparente qui affiche un dialogue de recherche pour le widget ShaarIt.
|
||||
* Quand l'utilisateur tape une requête, elle est sauvegardée dans WidgetPreferences
|
||||
* et le widget est rafraîchi avec les résultats filtrés.
|
||||
*/
|
||||
class WidgetSearchActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val currentQuery = WidgetPreferences.getSearchQuery(this)
|
||||
|
||||
val editText = EditText(this).apply {
|
||||
hint = "Rechercher dans les bookmarks…"
|
||||
setText(currentQuery)
|
||||
setSingleLine(true)
|
||||
requestFocus()
|
||||
}
|
||||
|
||||
val container = LinearLayout(this).apply {
|
||||
setPadding(48, 32, 48, 0)
|
||||
addView(editText, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Rechercher")
|
||||
.setView(container)
|
||||
.setPositiveButton("Rechercher") { _, _ ->
|
||||
val query = editText.text.toString().trim()
|
||||
WidgetPreferences.setSearchQuery(this, query)
|
||||
refreshWidget()
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton("Annuler") { _, _ ->
|
||||
finish()
|
||||
}
|
||||
.setNeutralButton("Effacer") { _, _ ->
|
||||
WidgetPreferences.clearSearchQuery(this)
|
||||
refreshWidget()
|
||||
finish()
|
||||
}
|
||||
.setOnCancelListener {
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun refreshWidget() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(this)
|
||||
val componentName = ComponentName(this, ShaarliWidgetProvider::class.java)
|
||||
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
|
||||
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list)
|
||||
|
||||
val updateIntent = Intent(this, ShaarliWidgetProvider::class.java).apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
|
||||
}
|
||||
sendBroadcast(updateIntent)
|
||||
}
|
||||
}
|
||||
129
app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt
Normal file
129
app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt
Normal file
@ -0,0 +1,129 @@
|
||||
package com.shaarit.widget.glance
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.shaarit.MainActivity
|
||||
|
||||
/**
|
||||
* Widget Glance affichant les statistiques rapides (2×1)
|
||||
*/
|
||||
class QuickStatsWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val stats = WidgetDataProvider.getStats(context)
|
||||
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
QuickStatsContent(stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickStatsContent(stats: WidgetDataProvider.WidgetStats) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.padding(12.dp)
|
||||
.cornerRadius(16.dp)
|
||||
.clickable(actionStartActivity<MainActivity>())
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDCCA ShaarIt",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.height(6.dp))
|
||||
|
||||
// Total links
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatCount(stats.totalLinks),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = " liens",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// This week
|
||||
Text(
|
||||
text = "${stats.linksThisWeek} cette semaine",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
|
||||
// Reading time
|
||||
if (stats.totalReadingTimeMinutes > 0) {
|
||||
val hours = stats.totalReadingTimeMinutes / 60
|
||||
val readingText = if (hours > 0) {
|
||||
"\uD83D\uDCDA ${hours}h de lecture"
|
||||
} else {
|
||||
"\uD83D\uDCDA ${stats.totalReadingTimeMinutes}min de lecture"
|
||||
}
|
||||
Text(
|
||||
text = readingText,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCount(count: Int): String {
|
||||
return when {
|
||||
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
|
||||
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver pour le widget Quick Stats
|
||||
*/
|
||||
class QuickStatsWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = QuickStatsWidget()
|
||||
}
|
||||
180
app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt
Normal file
180
app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt
Normal file
@ -0,0 +1,180 @@
|
||||
package com.shaarit.widget.glance
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.shaarit.MainActivity
|
||||
|
||||
/**
|
||||
* Widget Glance affichant les liens récents (4×2)
|
||||
*/
|
||||
class RecentLinksWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val links = WidgetDataProvider.getRecentLinks(context)
|
||||
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
RecentLinksContent(links)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentLinksContent(links: List<WidgetDataProvider.WidgetLink>) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.padding(12.dp)
|
||||
.cornerRadius(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD16 ShaarIt — Récents",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight()
|
||||
)
|
||||
// Add button
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(28.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "+",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||
// Random button
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(28.dp)
|
||||
.clickable(actionStartActivity<MainActivity>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD00",
|
||||
style = TextStyle(fontSize = 16.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
if (links.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Aucun lien",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(links, itemId = { it.id.toLong() }) { link ->
|
||||
LinkItem(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkItem(link: WidgetDataProvider.WidgetLink) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
.clickable(actionStartActivity<MainActivity>())
|
||||
) {
|
||||
// Content type emoji + title
|
||||
val emoji = getContentEmoji(link.url)
|
||||
Text(
|
||||
text = "$emoji ${link.title}",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
// Site name + relative time
|
||||
val domain = link.siteName ?: WidgetDataProvider.extractDomain(link.url)
|
||||
val relativeTime = WidgetDataProvider.formatRelativeTime(link.createdAt)
|
||||
Text(
|
||||
text = "$domain · $relativeTime",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContentEmoji(url: String): String {
|
||||
val lower = url.lowercase()
|
||||
return when {
|
||||
lower.contains("youtube.com") || lower.contains("youtu.be") || lower.contains("vimeo") -> "\uD83D\uDCF9"
|
||||
lower.contains("github.com") || lower.contains("gitlab.com") -> "\uD83D\uDEE0\uFE0F"
|
||||
lower.contains("spotify") || lower.contains("deezer") -> "\uD83C\uDFB5"
|
||||
lower.contains("twitter.com") || lower.contains("x.com") || lower.contains("mastodon") -> "\uD83D\uDCAC"
|
||||
lower.endsWith(".pdf") -> "\uD83D\uDCC4"
|
||||
else -> "\uD83D\uDCC4"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver pour le widget Liens Récents
|
||||
*/
|
||||
class RecentLinksWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = RecentLinksWidget()
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package com.shaarit.widget.glance
|
||||
|
||||
import android.content.Context
|
||||
import com.shaarit.data.local.database.ShaarliDatabase
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
/**
|
||||
* Fournit les données depuis Room pour les widgets Glance
|
||||
*/
|
||||
object WidgetDataProvider {
|
||||
|
||||
data class WidgetLink(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val siteName: String?,
|
||||
val tags: List<String>,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class WidgetStats(
|
||||
val totalLinks: Int,
|
||||
val linksThisWeek: Int,
|
||||
val totalReadingTimeMinutes: Int
|
||||
)
|
||||
|
||||
suspend fun getRecentLinks(context: Context, limit: Int? = null): List<WidgetLink> {
|
||||
return try {
|
||||
val count = limit ?: com.shaarit.widget.WidgetPreferences.getWidgetLinkCount(context)
|
||||
val db = ShaarliDatabase.getInstance(context)
|
||||
val links = db.linkDao().getAllLinks().firstOrNull() ?: emptyList()
|
||||
links.take(count).map { it.toWidgetLink() }
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStats(context: Context): WidgetStats {
|
||||
return try {
|
||||
val db = ShaarliDatabase.getInstance(context)
|
||||
val linkDao = db.linkDao()
|
||||
val totalLinks = linkDao.getAllLinks().firstOrNull()?.size ?: 0
|
||||
val oneWeekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
|
||||
val linksThisWeek = linkDao.getCountSince(oneWeekAgo)
|
||||
val allLinks = linkDao.getAllLinksForStats()
|
||||
val totalReadingTime = allLinks.sumOf { it.readingTimeMinutes ?: 0 }
|
||||
WidgetStats(
|
||||
totalLinks = totalLinks,
|
||||
linksThisWeek = linksThisWeek,
|
||||
totalReadingTimeMinutes = totalReadingTime
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
WidgetStats(0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LinkEntity.toWidgetLink(): WidgetLink {
|
||||
return WidgetLink(
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
siteName = siteName,
|
||||
tags = tags,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun formatRelativeTime(timestamp: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - timestamp
|
||||
val minutes = diff / 60_000
|
||||
val hours = diff / 3_600_000
|
||||
val days = diff / 86_400_000
|
||||
|
||||
return when {
|
||||
minutes < 1 -> "à l'instant"
|
||||
minutes < 60 -> "il y a ${minutes}min"
|
||||
hours < 24 -> "il y a ${hours}h"
|
||||
days < 7 -> "il y a ${days}j"
|
||||
else -> "il y a ${days / 7}sem"
|
||||
}
|
||||
}
|
||||
|
||||
fun extractDomain(url: String): String {
|
||||
return try {
|
||||
java.net.URL(url).host.removePrefix("www.")
|
||||
} catch (e: Exception) {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.shaarit.widget.glance
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Worker pour mettre à jour périodiquement les widgets Glance
|
||||
*/
|
||||
@HiltWorker
|
||||
class WidgetUpdateWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "widget_update_work"
|
||||
private const val UPDATE_INTERVAL_MINUTES = 30L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<WidgetUpdateWorker>(
|
||||
UPDATE_INTERVAL_MINUTES, TimeUnit.MINUTES
|
||||
).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
RecentLinksWidget().updateAll(applicationContext)
|
||||
QuickStatsWidget().updateAll(applicationContext)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/drawable/widget_search_background.xml
Normal file
9
app/src/main/res/drawable/widget_search_background.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0F1923" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#2A3A4A" />
|
||||
</shape>
|
||||
@ -27,7 +27,7 @@
|
||||
android:id="@+id/widget_btn_add"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/add_link"
|
||||
android:src="@android:drawable/ic_input_add"
|
||||
android:tint="@android:color/white" />
|
||||
@ -36,7 +36,7 @@
|
||||
android:id="@+id/widget_btn_refresh"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/refresh"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:tint="@android:color/white" />
|
||||
@ -45,12 +45,54 @@
|
||||
android:id="@+id/widget_btn_random"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="@string/random"
|
||||
android:src="@android:drawable/ic_menu_sort_by_size"
|
||||
android:tint="@android:color/white" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Search bar (clickable, opens search dialog) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_search_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/widget_search_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:contentDescription="@string/search"
|
||||
android:src="@android:drawable/ic_menu_search"
|
||||
android:tint="#94A3B8" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_search_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="Rechercher…"
|
||||
android:textColor="#94A3B8"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/widget_btn_clear_search"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="Effacer la recherche"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:tint="#94A3B8"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- List of links -->
|
||||
<ListView
|
||||
android:id="@+id/widget_list"
|
||||
|
||||
@ -82,4 +82,39 @@
|
||||
<string name="export_success">Exportation réussie</string>
|
||||
<string name="export_error">Erreur d\'exportation</string>
|
||||
<string name="select_file">Sélectionner un fichier</string>
|
||||
|
||||
<!-- Glance Widgets -->
|
||||
<string name="widget_recent_links_name">ShaarIt — Récents</string>
|
||||
<string name="widget_recent_links_desc">Affiche les derniers liens ajoutés</string>
|
||||
<string name="widget_quick_stats_name">ShaarIt — Stats</string>
|
||||
<string name="widget_quick_stats_desc">Statistiques rapides de vos liens</string>
|
||||
|
||||
<!-- Reader Mode -->
|
||||
<string name="reader_mode">Mode Lecture</string>
|
||||
<string name="reader_loading">Extraction de l\'article…</string>
|
||||
<string name="reader_error">Impossible d\'extraire l\'article</string>
|
||||
<string name="reader_retry">Réessayer</string>
|
||||
<string name="reader_font_sans">Sans-serif</string>
|
||||
<string name="reader_font_serif">Serif</string>
|
||||
<string name="reader_font_mono">Monospace</string>
|
||||
<string name="reader_theme_dark">Sombre</string>
|
||||
<string name="reader_theme_sepia">Sépia</string>
|
||||
<string name="reader_theme_light">Clair</string>
|
||||
<string name="reader_theme_auto">Auto</string>
|
||||
|
||||
<!-- Reading Reminders -->
|
||||
<string name="reminder_title">Rappel de lecture</string>
|
||||
<string name="reminder_channel_name">Rappels de lecture</string>
|
||||
<string name="reminder_channel_desc">Notifications pour les rappels de lecture planifiés</string>
|
||||
<string name="reminder_in_1_hour">Dans 1 heure</string>
|
||||
<string name="reminder_tonight">Ce soir (20h)</string>
|
||||
<string name="reminder_tomorrow">Demain matin (9h)</string>
|
||||
<string name="reminder_this_weekend">Ce week-end</string>
|
||||
<string name="reminder_next_week">La semaine prochaine</string>
|
||||
<string name="reminder_custom">Date personnalisée…</string>
|
||||
<string name="reminder_set">Rappeler de lire</string>
|
||||
<string name="reminder_dismiss">Lu</string>
|
||||
<string name="reminder_snooze">Rappeler dans 1h</string>
|
||||
<string name="my_reminders">Mes rappels</string>
|
||||
<string name="no_reminders">Aucun rappel planifié</string>
|
||||
</resources>
|
||||
16
app/src/main/res/xml/widget_quick_stats_info.xml
Normal file
16
app/src/main/res/xml/widget_quick_stats_info.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_shaarli"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="80dp"
|
||||
android:maxResizeWidth="300dp"
|
||||
android:maxResizeHeight="200dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="1"
|
||||
android:previewImage="@drawable/ic_launcher_foreground"
|
||||
android:description="@string/widget_quick_stats_desc"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
16
app/src/main/res/xml/widget_recent_links_info.xml
Normal file
16
app/src/main/res/xml/widget_recent_links_info.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_shaarli"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="400dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:previewImage="@drawable/ic_launcher_foreground"
|
||||
android:description="@string/widget_recent_links_desc"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
Loading…
x
Reference in New Issue
Block a user