From 75ac40ed961f866fd31c3fd8331f6855cc3db821 Mon Sep 17 00:00:00 2001 From: Ashinch Date: Fri, 7 Oct 2022 21:38:24 +0800 Subject: [PATCH] WIP: Add the Accounts settings feature (#185) * Add the Accounts settings page (WIP) * Fix sync function and Add change account name feature * Adaptation Account List * Support account settings * Fix auto delete archived articles --- .../3.json | 364 +++++++++++++++++ .../main/java/me/ash/reader/CrashHandler.kt | 6 +- .../main/java/me/ash/reader/MainActivity.kt | 11 +- app/src/main/java/me/ash/reader/RYApp.kt | 7 +- .../java/me/ash/reader/data/dao/AccountDao.kt | 16 + .../java/me/ash/reader/data/dao/ArticleDao.kt | 30 +- .../java/me/ash/reader/data/dao/FeedDao.kt | 8 + .../java/me/ash/reader/data/dao/GroupDao.kt | 8 + .../ash/reader/data/model/account/Account.kt | 13 + .../reader/data/model/account/AccountType.kt | 36 +- .../model/account/KeepArchivedConverters.kt | 21 + .../model/account/SyncBlockListConverters.kt | 20 + .../model/account/SyncIntervalConverters.kt | 21 + .../model/account/SyncOnStartConverters.kt | 21 + .../model/account/SyncOnlyOnWiFiConverters.kt | 21 + .../account/SyncOnlyWhenChargingConverters.kt | 22 ++ .../ash/reader/data/model/article/Article.kt | 8 +- .../me/ash/reader/data/model/feed/Feed.kt | 4 +- .../data/model/preference/AccountSettings.kt | 40 ++ .../preference/KeepArchivedPreference.kt | 47 +++ .../preference/SyncBlockListPreference.kt | 23 ++ .../preference/SyncIntervalPreference.kt | 60 +++ .../model/preference/SyncOnStartPreference.kt | 35 ++ .../preference/SyncOnlyOnWiFiPreference.kt | 35 ++ .../SyncOnlyWhenChargingPreference.kt | 35 ++ .../data/repository/AbstractRssRepository.kt | 48 ++- .../data/repository/AccountRepository.kt | 32 +- .../data/repository/LocalRssRepository.kt | 2 +- .../ash/reader/data/repository/RssHelper.kt | 1 + .../reader/data/repository/RssRepository.kt | 4 +- .../ash/reader/data/repository/SyncWorker.kt | 51 ++- .../me/ash/reader/data/source/RYDatabase.kt | 61 ++- .../ui/component/base/ClipboardTextField.kt | 2 + .../reader/ui/component/base/RYTextField.kt | 7 +- .../ui/component/base/TextFieldDialog.kt | 6 +- .../java/me/ash/reader/ui/ext/BooleanExt.kt | 3 + .../java/me/ash/reader/ui/ext/ContextExt.kt | 5 +- .../java/me/ash/reader/ui/ext/NumberExt.kt | 4 +- .../me/ash/reader/ui/page/common/HomeEntry.kt | 16 + .../me/ash/reader/ui/page/common/RouteName.kt | 5 + .../ash/reader/ui/page/home/HomeViewModel.kt | 12 +- .../reader/ui/page/home/feeds/FeedsPage.kt | 25 +- .../ui/page/home/feeds/FeedsViewModel.kt | 13 - .../ash/reader/ui/page/home/flow/FlowPage.kt | 2 +- .../reader/ui/page/home/flow/FlowViewModel.kt | 8 +- .../ui/page/home/reading/ReadingPage.kt | 1 + .../reader/ui/page/settings/SettingItem.kt | 19 +- .../reader/ui/page/settings/SettingsPage.kt | 7 +- .../settings/accounts/AccountDetailsPage.kt | 368 ++++++++++++++++++ .../settings/accounts/AccountViewModel.kt | 98 +++++ .../ui/page/settings/accounts/AccountsPage.kt | 108 +++++ .../page/settings/accounts/AddAccountsPage.kt | 137 +++++++ app/src/main/res/drawable/ic_feedly.xml | 9 + app/src/main/res/drawable/ic_fever.xml | 10 + app/src/main/res/drawable/ic_freshrss.xml | 16 + app/src/main/res/drawable/ic_inoreader.xml | 12 + app/src/main/res/values-zh-rCN/strings.xml | 44 ++- app/src/main/res/values/strings.xml | 51 +++ 58 files changed, 2003 insertions(+), 96 deletions(-) create mode 100644 app/schemas/me.ash.reader.data.source.RYDatabase/3.json create mode 100644 app/src/main/java/me/ash/reader/data/model/account/KeepArchivedConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/SyncBlockListConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/SyncIntervalConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/SyncOnStartConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/SyncOnlyOnWiFiConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/SyncOnlyWhenChargingConverters.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/AccountSettings.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/KeepArchivedPreference.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/SyncBlockListPreference.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/SyncIntervalPreference.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/SyncOnStartPreference.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyOnWiFiPreference.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyWhenChargingPreference.kt create mode 100644 app/src/main/java/me/ash/reader/ui/ext/BooleanExt.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountsPage.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt create mode 100644 app/src/main/res/drawable/ic_feedly.xml create mode 100644 app/src/main/res/drawable/ic_fever.xml create mode 100644 app/src/main/res/drawable/ic_freshrss.xml create mode 100644 app/src/main/res/drawable/ic_inoreader.xml diff --git a/app/schemas/me.ash.reader.data.source.RYDatabase/3.json b/app/schemas/me.ash.reader.data.source.RYDatabase/3.json new file mode 100644 index 0000000..bfd5d88 --- /dev/null +++ b/app/schemas/me.ash.reader.data.source.RYDatabase/3.json @@ -0,0 +1,364 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "b13c17e4d1ff644caeecce9fc365db2e", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [] + } + ], + "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, 'b13c17e4d1ff644caeecce9fc365db2e')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/CrashHandler.kt b/app/src/main/java/me/ash/reader/CrashHandler.kt index 0a289fe..ae38e53 100644 --- a/app/src/main/java/me/ash/reader/CrashHandler.kt +++ b/app/src/main/java/me/ash/reader/CrashHandler.kt @@ -20,11 +20,9 @@ class CrashHandler(private val context: Context) : UncaughtExceptionHandler { * Catch all uncaught exception and log it. */ override fun uncaughtException(p0: Thread, p1: Throwable) { - Looper.myLooper() ?: Looper.prepare() - context.showToastLong(p1.message) - Looper.loop() - p1.printStackTrace() Log.e("RLog", "uncaughtException: ${p1.message}") + context.showToastLong(p1.message) + p1.printStackTrace() android.os.Process.killProcess(android.os.Process.myPid()); exitProcess(1) } diff --git a/app/src/main/java/me/ash/reader/MainActivity.kt b/app/src/main/java/me/ash/reader/MainActivity.kt index b42a312..b5db364 100644 --- a/app/src/main/java/me/ash/reader/MainActivity.kt +++ b/app/src/main/java/me/ash/reader/MainActivity.kt @@ -10,6 +10,8 @@ import androidx.profileinstaller.ProfileInstallerInitializer import coil.ImageLoader import coil.compose.LocalImageLoader import dagger.hilt.android.AndroidEntryPoint +import me.ash.reader.data.dao.AccountDao +import me.ash.reader.data.model.preference.AccountSettingsProvider import me.ash.reader.data.model.preference.LanguagesPreference import me.ash.reader.data.model.preference.SettingsProvider import me.ash.reader.ui.ext.languages @@ -25,6 +27,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var imageLoader: ImageLoader + @Inject + lateinit var accountDao: AccountDao + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -40,8 +45,10 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider( LocalImageLoader provides imageLoader, ) { - SettingsProvider { - HomeEntry() + AccountSettingsProvider(accountDao) { + SettingsProvider { + HomeEntry() + } } } } diff --git a/app/src/main/java/me/ash/reader/RYApp.kt b/app/src/main/java/me/ash/reader/RYApp.kt index a2601b5..4c2acb2 100644 --- a/app/src/main/java/me/ash/reader/RYApp.kt +++ b/app/src/main/java/me/ash/reader/RYApp.kt @@ -103,7 +103,7 @@ class RYApp : Application(), Configuration.Provider { applicationScope.launch { accountInit() workerInit() - if (notFdroid) checkUpdate() + checkUpdate() } } @@ -126,11 +126,12 @@ class RYApp : Application(), Configuration.Provider { } } - private fun workerInit() { - rssRepository.get().doSync() + private suspend fun workerInit() { + rssRepository.get().doSync(isOnStart = true) } private suspend fun checkUpdate() { + if (isFdroid) return withContext(ioDispatcher) { applicationContext.getLatestApk().let { if (it.exists()) it.del() diff --git a/app/src/main/java/me/ash/reader/data/dao/AccountDao.kt b/app/src/main/java/me/ash/reader/data/dao/AccountDao.kt index e16b10b..a4fb1d5 100644 --- a/app/src/main/java/me/ash/reader/data/dao/AccountDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/AccountDao.kt @@ -1,11 +1,20 @@ package me.ash.reader.data.dao import androidx.room.* +import kotlinx.coroutines.flow.Flow import me.ash.reader.data.model.account.Account @Dao interface AccountDao { + @Query( + """ + SELECT * FROM account + WHERE id = :id + """ + ) + fun queryAccount(id: Int): Flow + @Query( """ SELECT * FROM account @@ -13,6 +22,13 @@ interface AccountDao { ) suspend fun queryAll(): List + @Query( + """ + SELECT * FROM account + """ + ) + fun queryAllAsFlow(): Flow> + @Query( """ SELECT * FROM account diff --git a/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt b/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt index bdc08b4..8e03199 100644 --- a/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt @@ -203,6 +203,20 @@ interface ArticleDao { text: String, ): PagingSource + @Query( + """ + DELETE FROM article + WHERE accountId = :accountId + AND updateAt < :before + AND isUnread = 0 + AND isStarred = 0 + """ + ) + suspend fun deleteAllArchivedBeforeThan( + accountId: Int, + before: Date, + ) + @Query( """ UPDATE article SET isUnread = :isUnread @@ -285,6 +299,14 @@ interface ArticleDao { ) suspend fun deleteByGroupId(accountId: Int, groupId: String) + @Query( + """ + DELETE FROM article + WHERE accountId = :accountId + """ + ) + suspend fun deleteByAccountId(accountId: Int) + @Transaction @Query( """ @@ -376,7 +398,7 @@ interface ArticleDao { """ SELECT a.id, a.date, a.title, a.author, a.rawDescription, a.shortDescription, a.fullContent, a.img, a.link, a.feedId, - a.accountId, a.isUnread, a.isStarred, a.isReadLater + a.accountId, a.isUnread, a.isStarred, a.isReadLater, a.updateAt FROM article AS a LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN `group` AS c ON c.id = b.groupId @@ -396,7 +418,7 @@ interface ArticleDao { """ SELECT a.id, a.date, a.title, a.author, a.rawDescription, a.shortDescription, a.fullContent, a.img, a.link, a.feedId, - a.accountId, a.isUnread, a.isStarred, a.isReadLater + a.accountId, a.isUnread, a.isStarred, a.isReadLater, a.updateAt FROM article AS a LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN `group` AS c ON c.id = b.groupId @@ -418,7 +440,7 @@ interface ArticleDao { """ SELECT a.id, a.date, a.title, a.author, a.rawDescription, a.shortDescription, a.fullContent, a.img, a.link, a.feedId, - a.accountId, a.isUnread, a.isStarred, a.isReadLater + a.accountId, a.isUnread, a.isStarred, a.isReadLater, a.updateAt FROM article AS a LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN `group` AS c ON c.id = b.groupId @@ -485,7 +507,7 @@ interface ArticleDao { """ SELECT a.id, a.date, a.title, a.author, a.rawDescription, a.shortDescription, a.fullContent, a.img, a.link, a.feedId, - a.accountId, a.isUnread, a.isStarred, a.isReadLater + a.accountId, a.isUnread, a.isStarred, a.isReadLater, a.updateAt FROM article AS a LEFT JOIN feed AS b ON a.feedId = b.id WHERE a.feedId = :feedId diff --git a/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt b/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt index 4f5e607..757093c 100644 --- a/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt @@ -54,6 +54,14 @@ interface FeedDao { ) suspend fun deleteByGroupId(accountId: Int, groupId: String) + @Query( + """ + DELETE FROM feed + WHERE accountId = :accountId + """ + ) + suspend fun deleteByAccountId(accountId: Int) + @Query( """ SELECT * FROM feed diff --git a/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt b/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt index 625e447..3328a05 100644 --- a/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt @@ -42,6 +42,14 @@ interface GroupDao { ) fun queryAllGroup(accountId: Int): Flow> + @Query( + """ + DELETE FROM `group` + WHERE accountId = :accountId + """ + ) + suspend fun deleteByAccountId(accountId: Int) + @Query( """ SELECT * FROM `group` diff --git a/app/src/main/java/me/ash/reader/data/model/account/Account.kt b/app/src/main/java/me/ash/reader/data/model/account/Account.kt index f862faa..e2aa508 100644 --- a/app/src/main/java/me/ash/reader/data/model/account/Account.kt +++ b/app/src/main/java/me/ash/reader/data/model/account/Account.kt @@ -3,6 +3,7 @@ package me.ash.reader.data.model.account import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import me.ash.reader.data.model.preference.* import java.util.* /** @@ -19,4 +20,16 @@ data class Account( var type: AccountType, @ColumnInfo var updateAt: Date? = null, + @ColumnInfo(defaultValue = "30") + var syncInterval: SyncIntervalPreference = SyncIntervalPreference.default, + @ColumnInfo(defaultValue = "0") + var syncOnStart: SyncOnStartPreference = SyncOnStartPreference.default, + @ColumnInfo(defaultValue = "0") + var syncOnlyOnWiFi: SyncOnlyOnWiFiPreference = SyncOnlyOnWiFiPreference.default, + @ColumnInfo(defaultValue = "0") + var syncOnlyWhenCharging: SyncOnlyWhenChargingPreference = SyncOnlyWhenChargingPreference.default, + @ColumnInfo(defaultValue = "2592000000") + var keepArchived: KeepArchivedPreference = KeepArchivedPreference.default, + @ColumnInfo(defaultValue = "") + var syncBlockList: SyncBlockList = SyncBlockListPreference.default, ) diff --git a/app/src/main/java/me/ash/reader/data/model/account/AccountType.kt b/app/src/main/java/me/ash/reader/data/model/account/AccountType.kt index a7ebcec..c051c1a 100644 --- a/app/src/main/java/me/ash/reader/data/model/account/AccountType.kt +++ b/app/src/main/java/me/ash/reader/data/model/account/AccountType.kt @@ -1,7 +1,14 @@ package me.ash.reader.data.model.account +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.RssFeed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.res.painterResource import androidx.room.RoomDatabase import androidx.room.TypeConverter +import me.ash.reader.R /** * Each account will specify its local or third-party API type. @@ -12,11 +19,35 @@ class AccountType(val id: Int) { * Make sure the constructed object is valid. */ init { - if (id < 1 || id > 3) { + if (id < 1 || id > 6) { throw IllegalArgumentException("Account type id is not valid.") } } + fun toDesc(context: Context): String = + when (this.id) { + 1 -> context.getString(R.string.local) + 2 -> context.getString(R.string.fever) + 3 -> context.getString(R.string.google_reader) + 4 -> context.getString(R.string.fresh_rss) + 5 -> context.getString(R.string.feedlly) + 6 -> context.getString(R.string.inoreader) + else -> context.getString(R.string.unknown) + } + + @Stable + @Composable + fun toIcon(): Any = + when (this.id) { + 1 -> Icons.Rounded.RssFeed + 2 -> painterResource(id = R.drawable.ic_fever) + 3 -> Icons.Rounded.RssFeed + 4 -> painterResource(id = R.drawable.ic_freshrss) + 5 -> painterResource(id = R.drawable.ic_feedly) + 6 -> painterResource(id = R.drawable.ic_inoreader) + else -> Icons.Rounded.RssFeed + } + /** * Type of account currently supported. */ @@ -25,6 +56,9 @@ class AccountType(val id: Int) { val Local = AccountType(1) val Fever = AccountType(2) val GoogleReader = AccountType(3) + val FreshRSS = AccountType(4) + val Feedlly = AccountType(5) + val Inoreader = AccountType(6) } } diff --git a/app/src/main/java/me/ash/reader/data/model/account/KeepArchivedConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/KeepArchivedConverters.kt new file mode 100644 index 0000000..1c96a67 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/KeepArchivedConverters.kt @@ -0,0 +1,21 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.KeepArchivedPreference + +/** + * Provide [TypeConverter] of [KeepArchivedPreference] for [RoomDatabase]. + */ +class KeepArchivedConverters { + + @TypeConverter + fun toKeepArchived(keepArchived: Long): KeepArchivedPreference { + return KeepArchivedPreference.values.find { it.value == keepArchived } ?: KeepArchivedPreference.default + } + + @TypeConverter + fun fromKeepArchived(keepArchived: KeepArchivedPreference): Long { + return keepArchived.value + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/SyncBlockListConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/SyncBlockListConverters.kt new file mode 100644 index 0000000..cb9c400 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/SyncBlockListConverters.kt @@ -0,0 +1,20 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.SyncBlockList +import me.ash.reader.data.model.preference.SyncBlockListPreference + +/** + * Provide [TypeConverter] of [SyncBlockListPreference] for [RoomDatabase]. + */ +class SyncBlockListConverters { + + @TypeConverter + fun toBlockList(syncBlockList: String): SyncBlockList = + SyncBlockListPreference.of(syncBlockList) + + @TypeConverter + fun fromBlockList(syncBlockList: SyncBlockList?): String = + SyncBlockListPreference.toString(syncBlockList ?: emptyList()) +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/SyncIntervalConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/SyncIntervalConverters.kt new file mode 100644 index 0000000..9d41398 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/SyncIntervalConverters.kt @@ -0,0 +1,21 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.SyncIntervalPreference + +/** + * Provide [TypeConverter] of [SyncIntervalPreference] for [RoomDatabase]. + */ +class SyncIntervalConverters { + + @TypeConverter + fun toSyncInterval(syncInterval: Long): SyncIntervalPreference { + return SyncIntervalPreference.values.find { it.value == syncInterval } ?: SyncIntervalPreference.default + } + + @TypeConverter + fun fromSyncInterval(syncInterval: SyncIntervalPreference): Long { + return syncInterval.value + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/SyncOnStartConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/SyncOnStartConverters.kt new file mode 100644 index 0000000..ac81c1e --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/SyncOnStartConverters.kt @@ -0,0 +1,21 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.SyncOnStartPreference + +/** + * Provide [TypeConverter] of [SyncOnStartPreference] for [RoomDatabase]. + */ +class SyncOnStartConverters { + + @TypeConverter + fun toSyncOnStart(syncOnStart: Boolean): SyncOnStartPreference { + return SyncOnStartPreference.values.find { it.value == syncOnStart } ?: SyncOnStartPreference.default + } + + @TypeConverter + fun fromSyncOnStart(syncOnStart: SyncOnStartPreference): Boolean { + return syncOnStart.value + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyOnWiFiConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyOnWiFiConverters.kt new file mode 100644 index 0000000..0ea1b3d --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyOnWiFiConverters.kt @@ -0,0 +1,21 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.SyncOnlyOnWiFiPreference + +/** + * Provide [TypeConverter] of [SyncOnlyOnWiFiPreference] for [RoomDatabase]. + */ +class SyncOnlyOnWiFiConverters { + + @TypeConverter + fun toSyncOnlyOnWiFi(syncOnlyOnWiFi: Boolean): SyncOnlyOnWiFiPreference { + return SyncOnlyOnWiFiPreference.values.find { it.value == syncOnlyOnWiFi } ?: SyncOnlyOnWiFiPreference.default + } + + @TypeConverter + fun fromSyncOnlyOnWiFi(syncOnlyOnWiFi: SyncOnlyOnWiFiPreference): Boolean { + return syncOnlyOnWiFi.value + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyWhenChargingConverters.kt b/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyWhenChargingConverters.kt new file mode 100644 index 0000000..2ea4f54 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/SyncOnlyWhenChargingConverters.kt @@ -0,0 +1,22 @@ +package me.ash.reader.data.model.account + +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import me.ash.reader.data.model.preference.SyncOnlyWhenChargingPreference + +/** + * Provide [TypeConverter] of [SyncOnlyWhenChargingPreference] for [RoomDatabase]. + */ +class SyncOnlyWhenChargingConverters { + + @TypeConverter + fun toSyncOnlyWhenCharging(syncOnlyWhenCharging: Boolean): SyncOnlyWhenChargingPreference { + return SyncOnlyWhenChargingPreference.values.find { it.value == syncOnlyWhenCharging } + ?: SyncOnlyWhenChargingPreference.default + } + + @TypeConverter + fun fromSyncOnlyWhenCharging(syncOnlyWhenCharging: SyncOnlyWhenChargingPreference): Boolean { + return syncOnlyWhenCharging.value + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/article/Article.kt b/app/src/main/java/me/ash/reader/data/model/article/Article.kt index 723b2a9..37cfd95 100644 --- a/app/src/main/java/me/ash/reader/data/model/article/Article.kt +++ b/app/src/main/java/me/ash/reader/data/model/article/Article.kt @@ -40,12 +40,14 @@ data class Article( var feedId: String, @ColumnInfo(index = true) var accountId: Int, - @ColumnInfo(defaultValue = "true") + @ColumnInfo var isUnread: Boolean = true, - @ColumnInfo(defaultValue = "false") + @ColumnInfo var isStarred: Boolean = false, - @ColumnInfo(defaultValue = "false") + @ColumnInfo var isReadLater: Boolean = false, + @ColumnInfo + var updateAt: Date? = null, ) { @Ignore diff --git a/app/src/main/java/me/ash/reader/data/model/feed/Feed.kt b/app/src/main/java/me/ash/reader/data/model/feed/Feed.kt index d8adc18..98a8a75 100644 --- a/app/src/main/java/me/ash/reader/data/model/feed/Feed.kt +++ b/app/src/main/java/me/ash/reader/data/model/feed/Feed.kt @@ -29,9 +29,9 @@ data class Feed( var groupId: String, @ColumnInfo(index = true) var accountId: Int, - @ColumnInfo(defaultValue = "false") + @ColumnInfo var isNotification: Boolean = false, - @ColumnInfo(defaultValue = "false") + @ColumnInfo var isFullContent: Boolean = false, ) { diff --git a/app/src/main/java/me/ash/reader/data/model/preference/AccountSettings.kt b/app/src/main/java/me/ash/reader/data/model/preference/AccountSettings.kt new file mode 100644 index 0000000..92e41d4 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/AccountSettings.kt @@ -0,0 +1,40 @@ +package me.ash.reader.data.model.preference + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalContext +import me.ash.reader.data.dao.AccountDao +import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.ext.currentAccountId + +// Accounts +val LocalSyncInterval = compositionLocalOf { SyncIntervalPreference.default } +val LocalSyncOnStart = compositionLocalOf { SyncOnStartPreference.default } +val LocalSyncOnlyOnWiFi = compositionLocalOf { SyncOnlyOnWiFiPreference.default } +val LocalSyncOnlyWhenCharging = + compositionLocalOf { SyncOnlyWhenChargingPreference.default } +val LocalKeepArchived = compositionLocalOf { KeepArchivedPreference.default } +val LocalSyncBlockList = compositionLocalOf { SyncBlockListPreference.default } + +@Composable +fun AccountSettingsProvider( + accountDao: AccountDao, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val accountSettings = accountDao.queryAccount(context.currentAccountId).collectAsStateValue(initial = null) + + CompositionLocalProvider( + // Accounts + LocalSyncInterval provides (accountSettings?.syncInterval ?: SyncIntervalPreference.default), + LocalSyncOnStart provides (accountSettings?.syncOnStart ?: SyncOnStartPreference.default), + LocalSyncOnlyOnWiFi provides (accountSettings?.syncOnlyOnWiFi ?: SyncOnlyOnWiFiPreference.default), + LocalSyncOnlyWhenCharging provides (accountSettings?.syncOnlyWhenCharging ?: SyncOnlyWhenChargingPreference.default), + LocalKeepArchived provides (accountSettings?.keepArchived ?: KeepArchivedPreference.default), + LocalSyncBlockList provides (accountSettings?.syncBlockList ?: SyncBlockListPreference.default), + ) { + content() + } +} + diff --git a/app/src/main/java/me/ash/reader/data/model/preference/KeepArchivedPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/KeepArchivedPreference.kt new file mode 100644 index 0000000..db6f2a3 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/KeepArchivedPreference.kt @@ -0,0 +1,47 @@ +package me.ash.reader.data.model.preference + +import android.content.Context +import me.ash.reader.R +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +sealed class KeepArchivedPreference( + val value: Long, +) { + + object Always : KeepArchivedPreference(0L) + object For1Day : KeepArchivedPreference(86400000L) + object For2Days : KeepArchivedPreference(172800000L) + object For3Days : KeepArchivedPreference(259200000L) + object For1Week : KeepArchivedPreference(604800000L) + object For2Weeks : KeepArchivedPreference(1209600000L) + object For1Month : KeepArchivedPreference(2592000000L) + + fun put(accountId: Int, viewModel: AccountViewModel) { + viewModel.update(accountId) { keepArchived = this@KeepArchivedPreference } + } + + fun toDesc(context: Context): String = + when (this) { + Always -> context.getString(R.string.always) + For1Day -> context.getString(R.string.for_1_day) + For2Days -> context.getString(R.string.for_2_days) + For3Days -> context.getString(R.string.for_3_days) + For1Week -> context.getString(R.string.for_1_week) + For2Weeks -> context.getString(R.string.for_2_weeks) + For1Month -> context.getString(R.string.for_1_month) + } + + companion object { + + val default = For1Month + val values = listOf( + Always, + For1Day, + For2Days, + For3Days, + For1Week, + For2Weeks, + For1Month, + ) + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/preference/SyncBlockListPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/SyncBlockListPreference.kt new file mode 100644 index 0000000..9ee98ca --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/SyncBlockListPreference.kt @@ -0,0 +1,23 @@ +package me.ash.reader.data.model.preference + +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +typealias SyncBlockList = List + +object SyncBlockListPreference { + + val default: SyncBlockList = emptyList() + + fun put(accountId: Int, viewModel: AccountViewModel, syncBlockList: SyncBlockList) { + viewModel.update(accountId) { this.syncBlockList = syncBlockList } + } + + fun of(syncBlockList: String): SyncBlockList { + return syncBlockList.split("\n") + } + + fun toString(syncBlockList: SyncBlockList): String = syncBlockList + .filter { it.isNotBlank() } + .map { it.trim() } + .joinToString { "$it\n" } +} diff --git a/app/src/main/java/me/ash/reader/data/model/preference/SyncIntervalPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/SyncIntervalPreference.kt new file mode 100644 index 0000000..89fe30f --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/SyncIntervalPreference.kt @@ -0,0 +1,60 @@ +package me.ash.reader.data.model.preference + +import android.content.Context +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import me.ash.reader.R +import me.ash.reader.data.repository.SyncWorker +import me.ash.reader.ui.page.settings.accounts.AccountViewModel +import java.util.concurrent.TimeUnit + +sealed class SyncIntervalPreference( + val value: Long, +) { + + object Manually : SyncIntervalPreference(0L) + object Every15Minutes : SyncIntervalPreference(15L) + object Every30Minutes : SyncIntervalPreference(30L) + object Every1Hour : SyncIntervalPreference(60L) + object Every2Hours : SyncIntervalPreference(120L) + object Every3Hours : SyncIntervalPreference(180L) + object Every6Hours : SyncIntervalPreference(360L) + object Every12Hours : SyncIntervalPreference(720L) + object Every1Day : SyncIntervalPreference(1440L) + + fun put(accountId: Int, viewModel: AccountViewModel) { + viewModel.update(accountId) { syncInterval = this@SyncIntervalPreference } + } + + fun toDesc(context: Context): String = + when (this) { + Manually -> context.getString(R.string.manually) + Every15Minutes -> context.getString(R.string.every_15_minutes) + Every30Minutes -> context.getString(R.string.every_30_minutes) + Every1Hour -> context.getString(R.string.every_1_hour) + Every2Hours -> context.getString(R.string.every_2_hours) + Every3Hours -> context.getString(R.string.every_3_hours) + Every6Hours -> context.getString(R.string.every_6_hours) + Every12Hours -> context.getString(R.string.every_12_hours) + Every1Day -> context.getString(R.string.every_1_day) + } + + fun toPeriodicWorkRequestBuilder(): PeriodicWorkRequest.Builder = + PeriodicWorkRequestBuilder(value, TimeUnit.MINUTES) + + companion object { + + val default = Every30Minutes + val values = listOf( + Manually, + Every15Minutes, + Every30Minutes, + Every1Hour, + Every2Hours, + Every3Hours, + Every6Hours, + Every12Hours, + Every1Day, + ) + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/preference/SyncOnStartPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnStartPreference.kt new file mode 100644 index 0000000..2eb14e0 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnStartPreference.kt @@ -0,0 +1,35 @@ +package me.ash.reader.data.model.preference + +import android.content.Context +import me.ash.reader.R +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +sealed class SyncOnStartPreference( + val value: Boolean, +) { + + object On : SyncOnStartPreference(true) + object Off : SyncOnStartPreference(false) + + fun put(accountId: Int, viewModel: AccountViewModel) { + viewModel.update(accountId) { syncOnStart = this@SyncOnStartPreference } + } + + fun toDesc(context: Context): String = + when (this) { + On -> context.getString(R.string.on) + Off -> context.getString(R.string.off) + } + + companion object { + + val default = Off + val values = listOf(On, Off) + } +} + +operator fun SyncOnStartPreference.not(): SyncOnStartPreference = + when (value) { + true -> SyncOnStartPreference.Off + false -> SyncOnStartPreference.On + } diff --git a/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyOnWiFiPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyOnWiFiPreference.kt new file mode 100644 index 0000000..ace32e1 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyOnWiFiPreference.kt @@ -0,0 +1,35 @@ +package me.ash.reader.data.model.preference + +import android.content.Context +import me.ash.reader.R +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +sealed class SyncOnlyOnWiFiPreference( + val value: Boolean, +) { + + object On : SyncOnlyOnWiFiPreference(true) + object Off : SyncOnlyOnWiFiPreference(false) + + fun put(accountId: Int, viewModel: AccountViewModel) { + viewModel.update(accountId) { syncOnlyOnWiFi = this@SyncOnlyOnWiFiPreference } + } + + fun toDesc(context: Context): String = + when (this) { + On -> context.getString(R.string.on) + Off -> context.getString(R.string.off) + } + + companion object { + + val default = Off + val values = listOf(On, Off) + } +} + +operator fun SyncOnlyOnWiFiPreference.not(): SyncOnlyOnWiFiPreference = + when (value) { + true -> SyncOnlyOnWiFiPreference.Off + false -> SyncOnlyOnWiFiPreference.On + } diff --git a/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyWhenChargingPreference.kt b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyWhenChargingPreference.kt new file mode 100644 index 0000000..c205f78 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/preference/SyncOnlyWhenChargingPreference.kt @@ -0,0 +1,35 @@ +package me.ash.reader.data.model.preference + +import android.content.Context +import me.ash.reader.R +import me.ash.reader.ui.page.settings.accounts.AccountViewModel + +sealed class SyncOnlyWhenChargingPreference( + val value: Boolean, +) { + + object On : SyncOnlyWhenChargingPreference(true) + object Off : SyncOnlyWhenChargingPreference(false) + + fun put(accountId: Int, viewModel: AccountViewModel) { + viewModel.update(accountId) { syncOnlyWhenCharging = this@SyncOnlyWhenChargingPreference } + } + + fun toDesc(context: Context): String = + when (this) { + On -> context.getString(R.string.on) + Off -> context.getString(R.string.off) + } + + companion object { + + val default = Off + val values = listOf(On, Off) + } +} + +operator fun SyncOnlyWhenChargingPreference.not(): SyncOnlyWhenChargingPreference = + when (value) { + true -> SyncOnlyWhenChargingPreference.Off + false -> SyncOnlyWhenChargingPreference.On + } diff --git a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt index 47c6bf6..106b36a 100644 --- a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt @@ -4,7 +4,6 @@ import android.content.Context import android.util.Log import androidx.paging.PagingSource import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ListenableWorker import androidx.work.WorkManager import kotlinx.coroutines.CoroutineDispatcher @@ -20,6 +19,8 @@ import me.ash.reader.data.model.article.ArticleWithFeed import me.ash.reader.data.model.feed.Feed import me.ash.reader.data.model.group.Group import me.ash.reader.data.model.group.GroupWithFeed +import me.ash.reader.data.model.preference.KeepArchivedPreference +import me.ash.reader.data.model.preference.SyncIntervalPreference import me.ash.reader.ui.ext.currentAccountId import java.util.* @@ -50,12 +51,41 @@ abstract class AbstractRssRepository constructor( isUnread: Boolean, ) - fun doSync() { - workManager.enqueueUniquePeriodicWork( - SyncWorker.WORK_NAME, - ExistingPeriodicWorkPolicy.REPLACE, - SyncWorker.repeatingRequest - ) + suspend fun keepArchivedArticles() { + accountDao.queryById(context.currentAccountId)!! + .takeIf { it.keepArchived != KeepArchivedPreference.Always } + ?.let { + articleDao.deleteAllArchivedBeforeThan(it.id!!, Date(System.currentTimeMillis() - it.keepArchived.value)) + } + } + + suspend fun doSync(isOnStart: Boolean = false) { + workManager.cancelAllWork() + accountDao.queryById(context.currentAccountId)?.let { + if (isOnStart) { + if (it.syncOnStart.value) { + SyncWorker.enqueueOneTimeWork(workManager) + } + if (it.syncInterval != SyncIntervalPreference.Manually) { + SyncWorker.enqueuePeriodicWork( + workManager = workManager, + syncInterval = it.syncInterval, + syncOnlyWhenCharging = it.syncOnlyWhenCharging, + syncOnlyOnWiFi = it.syncOnlyOnWiFi, + ) + } else { + + } + } else { + SyncWorker.enqueueOneTimeWork(workManager) + SyncWorker.enqueuePeriodicWork( + workManager = workManager, + syncInterval = it.syncInterval, + syncOnlyWhenCharging = it.syncOnlyWhenCharging, + syncOnlyOnWiFi = it.syncOnlyOnWiFi, + ) + } + } } fun pullGroups(): Flow> = @@ -155,6 +185,10 @@ abstract class AbstractRssRepository constructor( } } + suspend fun deleteAccountArticles(accountId: Int) { + articleDao.deleteByAccountId(accountId) + } + suspend fun groupParseFullContent(group: Group, isFullContent: Boolean) { feedDao.updateIsFullContentByGroupId(context.currentAccountId, group.id, isFullContent) } diff --git a/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt b/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt index e25b354..a080e87 100644 --- a/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt @@ -2,14 +2,19 @@ package me.ash.reader.data.repository import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow import me.ash.reader.R import me.ash.reader.data.dao.AccountDao +import me.ash.reader.data.dao.ArticleDao +import me.ash.reader.data.dao.FeedDao import me.ash.reader.data.dao.GroupDao import me.ash.reader.data.model.account.Account import me.ash.reader.data.model.account.AccountType import me.ash.reader.data.model.group.Group import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.getDefaultGroupId +import me.ash.reader.ui.ext.showToast +import me.ash.reader.ui.ext.showToastLong import javax.inject.Inject class AccountRepository @Inject constructor( @@ -17,9 +22,14 @@ class AccountRepository @Inject constructor( private val context: Context, private val accountDao: AccountDao, private val groupDao: GroupDao, + private val feedDao: FeedDao, + private val articleDao: ArticleDao, ) { + fun getAccounts(): Flow> = accountDao.queryAllAsFlow() - suspend fun getCurrentAccount(): Account? = accountDao.queryById(context.currentAccountId) + fun getAccountById(accountId: Int): Flow = accountDao.queryAccount(accountId) + + suspend fun getCurrentAccount(): Account = accountDao.queryById(context.currentAccountId)!! suspend fun isNoAccount(): Boolean = accountDao.queryAll().isEmpty() @@ -43,4 +53,24 @@ class AccountRepository @Inject constructor( } } } + + suspend fun update(accountId: Int, block: Account.() -> Unit) { + accountDao.queryById(accountId)?.let { + accountDao.update(it.apply(block)) + } + } + + suspend fun delete(accountId: Int) { + if (accountDao.queryAll().size == 1) { + context.showToast(context.getString(R.string.must_have_an_account)) + return + } + accountDao.queryById(accountId)?.let { + articleDao.deleteByAccountId(accountId) + feedDao.deleteByAccountId(accountId) + groupDao.deleteByAccountId(accountId) + accountDao.delete(it) + context.showToastLong(context.getString(R.string.delete_account_toast)) + } + } } diff --git a/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt index 93a2d69..48c10dd 100644 --- a/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt @@ -72,10 +72,10 @@ class LocalRssRepository @Inject constructor( override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = supervisorScope { + coroutineWorker.setProgress(setIsSyncing(true)) val preTime = System.currentTimeMillis() val accountId = context.currentAccountId feedDao.queryAll(accountId) - .also { coroutineWorker.setProgress(setIsSyncing(true)) } .chunked(16) .forEach { it.map { feed -> async { syncFeed(feed) } } diff --git a/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt b/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt index b95cf47..a8a084d 100644 --- a/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt @@ -126,6 +126,7 @@ class RssHelper @Inject constructor( fullContent = content, img = findImg((content ?: desc) ?: ""), link = syndEntry.link ?: "", + updateAt = Date(), ) } diff --git a/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt index 8547dd6..1e6e13a 100644 --- a/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt @@ -14,7 +14,9 @@ class RssRepository @Inject constructor( // private val googleReaderRssRepository: GoogleReaderRssRepository, ) { - fun get() = when (context.currentAccountType) { + fun get() = get(context.currentAccountType) + + fun get(accountId: Int) = when (accountId) { AccountType.Local.id -> localRssRepository // Account.Type.LOCAL -> feverRssRepository // Account.Type.FEVER -> feverRssRepository diff --git a/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt b/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt index 594e208..f592f73 100644 --- a/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt +++ b/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt @@ -8,6 +8,9 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import me.ash.reader.data.model.preference.SyncIntervalPreference +import me.ash.reader.data.model.preference.SyncOnlyOnWiFiPreference +import me.ash.reader.data.model.preference.SyncOnlyWhenChargingPreference import java.util.* import java.util.concurrent.TimeUnit @@ -15,31 +18,55 @@ import java.util.concurrent.TimeUnit class SyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, + private val accountRepository: AccountRepository, private val rssRepository: RssRepository, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result = withContext(Dispatchers.Default) { Log.i("RLog", "doWork: ") - rssRepository.get().sync(this@SyncWorker) + rssRepository.get().sync(this@SyncWorker).also { + rssRepository.get().keepArchivedArticles() + } } companion object { - const val WORK_NAME = "article.sync" + private const val IS_SYNCING = "isSyncing" + const val WORK_NAME = "ReadYou" + lateinit var uuid: UUID - val uuid: UUID - - val repeatingRequest = PeriodicWorkRequestBuilder( - 15, TimeUnit.MINUTES - ).setConstraints( - Constraints.Builder() + fun enqueueOneTimeWork( + workManager: WorkManager, + ) { + workManager.enqueue(OneTimeWorkRequestBuilder() + .addTag(WORK_NAME) .build() - ).addTag(WORK_NAME).build().also { - uuid = it.id + ) } - fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean) - fun Data.getIsSyncing(): Boolean = getBoolean("isSyncing", false) + fun enqueuePeriodicWork( + workManager: WorkManager, + syncInterval: SyncIntervalPreference, + syncOnlyWhenCharging: SyncOnlyWhenChargingPreference, + syncOnlyOnWiFi: SyncOnlyOnWiFiPreference, + ) { + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + PeriodicWorkRequestBuilder(syncInterval.value, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder() + .setRequiresCharging(syncOnlyWhenCharging.value) + .setRequiredNetworkType(if (syncOnlyOnWiFi.value) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + ) + .addTag(WORK_NAME) + .setInitialDelay(syncInterval.value, TimeUnit.MINUTES) + .build() + ) + } + + fun setIsSyncing(boolean: Boolean) = workDataOf(IS_SYNCING to boolean) + fun Data.getIsSyncing(): Boolean = getBoolean(IS_SYNCING, false) } } diff --git a/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt b/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt index 19b5463..030b655 100644 --- a/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt +++ b/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt @@ -8,18 +8,28 @@ import me.ash.reader.data.dao.AccountDao import me.ash.reader.data.dao.ArticleDao import me.ash.reader.data.dao.FeedDao import me.ash.reader.data.dao.GroupDao -import me.ash.reader.data.model.account.Account -import me.ash.reader.data.model.account.AccountTypeConverters +import me.ash.reader.data.model.account.* import me.ash.reader.data.model.article.Article import me.ash.reader.data.model.feed.Feed import me.ash.reader.data.model.group.Group +import me.ash.reader.data.model.preference.* +import me.ash.reader.ui.ext.toInt import java.util.* @Database( entities = [Account::class, Feed::class, Article::class, Group::class], - version = 2 + version = 3 +) +@TypeConverters( + RYDatabase.DateConverters::class, + AccountTypeConverters::class, + SyncIntervalConverters::class, + SyncOnStartConverters::class, + SyncOnlyOnWiFiConverters::class, + SyncOnlyWhenChargingConverters::class, + KeepArchivedConverters::class, + SyncBlockListConverters::class, ) -@TypeConverters(RYDatabase.DateConverters::class, AccountTypeConverters::class) abstract class RYDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao @@ -60,6 +70,7 @@ abstract class RYDatabase : RoomDatabase() { val allMigrations = arrayOf( MIGRATION_1_2, + MIGRATION_2_3, ) @Suppress("ClassName") @@ -73,3 +84,45 @@ object MIGRATION_1_2 : Migration(1, 2) { ) } } + +@Suppress("ClassName") +object MIGRATION_2_3 : Migration(2, 3) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + ALTER TABLE article ADD COLUMN updateAt INTEGER DEFAULT ${System.currentTimeMillis()} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN syncInterval INTEGER NOT NULL DEFAULT ${SyncIntervalPreference.default.value} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN syncOnStart INTEGER NOT NULL DEFAULT ${SyncOnStartPreference.default.value.toInt()} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN syncOnlyOnWiFi INTEGER NOT NULL DEFAULT ${SyncOnlyOnWiFiPreference.default.value.toInt()} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN syncOnlyWhenCharging INTEGER NOT NULL DEFAULT ${SyncOnlyWhenChargingPreference.default.value.toInt()} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN keepArchived INTEGER NOT NULL DEFAULT ${KeepArchivedPreference.default.value} + """.trimIndent() + ) + database.execSQL( + """ + ALTER TABLE account ADD COLUMN syncBlockList TEXT NOT NULL DEFAULT '' + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt b/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt index eae0a4f..8c0ce60 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt @@ -23,6 +23,7 @@ fun ClipboardTextField( modifier: Modifier = Modifier, readOnly: Boolean = false, value: String = "", + singleLine: Boolean = true, onValueChange: (String) -> Unit = {}, placeholder: String = "", errorText: String = "", @@ -35,6 +36,7 @@ fun ClipboardTextField( RYTextField( readOnly = readOnly, value = value, + singleLine = singleLine, onValueChange = onValueChange, placeholder = placeholder, errorMessage = errorText, diff --git a/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt b/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt index e881e88..770bf81 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt @@ -22,6 +22,7 @@ import me.ash.reader.R fun RYTextField( readOnly: Boolean, value: String, + singleLine: Boolean = true, onValueChange: (String) -> Unit, placeholder: String, errorMessage: String, @@ -41,7 +42,7 @@ fun RYTextField( colors = TextFieldDefaults.textFieldColors( containerColor = Color.Transparent, ), - maxLines = 1, + maxLines = if (singleLine) 1 else Int.MAX_VALUE, enabled = !readOnly, value = value, onValueChange = { @@ -55,7 +56,7 @@ fun RYTextField( ) }, isError = errorMessage.isNotEmpty(), - singleLine = true, + singleLine = singleLine, trailingIcon = { if (value.isNotEmpty()) { IconButton(onClick = { @@ -82,4 +83,4 @@ fun RYTextField( keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt b/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt index 4646188..206b062 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt @@ -21,6 +21,7 @@ fun TextFieldDialog( properties: DialogProperties = DialogProperties(), visible: Boolean = false, readOnly: Boolean = false, + singleLine: Boolean = true, title: String = "", icon: ImageVector? = null, value: String = "", @@ -31,7 +32,7 @@ fun TextFieldDialog( onValueChange: (String) -> Unit = {}, onDismissRequest: () -> Unit = {}, onConfirm: (String) -> Unit = {}, - imeAction: ImeAction = ImeAction.Done, + imeAction: ImeAction = if (singleLine) ImeAction.Done else ImeAction.Default, ) { val focusManager = LocalFocusManager.current @@ -55,6 +56,7 @@ fun TextFieldDialog( modifier = modifier, readOnly = readOnly, value = value, + singleLine = singleLine, onValueChange = onValueChange, placeholder = placeholder, errorText = errorText, @@ -87,4 +89,4 @@ fun TextFieldDialog( } }, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/BooleanExt.kt b/app/src/main/java/me/ash/reader/ui/ext/BooleanExt.kt new file mode 100644 index 0000000..5b27081 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/ext/BooleanExt.kt @@ -0,0 +1,3 @@ +package me.ash.reader.ui.ext + +fun Boolean.toInt(): Int = if (this) 1 else 0 diff --git a/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt b/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt index b685970..1f66ac4 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/ContextExt.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.net.Uri +import android.os.Looper import android.util.Log import android.widget.Toast import androidx.core.content.FileProvider @@ -48,9 +49,11 @@ fun Context.installLatestApk() { private var toast: Toast? = null fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) { + Looper.myLooper() ?: Looper.prepare() toast?.cancel() toast = Toast.makeText(this, message, duration) toast?.show() + Looper.loop() } fun Context.showToastLong(message: String?) { @@ -70,4 +73,4 @@ fun Context.share(content: String) { fun Context.openURL(url: String?) { url?.takeIf { it.trim().isNotEmpty() } ?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt b/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt index 91e0694..b40ead2 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt @@ -2,4 +2,6 @@ package me.ash.reader.ui.ext fun Int.spacerDollar(str: Any): String = "$this$$str" -fun Int.getDefaultGroupId() = this.spacerDollar("read_you_app_default_group") \ No newline at end of file +fun Int.getDefaultGroupId() = this.spacerDollar("read_you_app_default_group") + +fun Int.toBoolean(): Boolean = this != 0 diff --git a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt index e7a7baf..1ff2518 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt @@ -24,6 +24,9 @@ import me.ash.reader.ui.page.home.feeds.FeedsPage import me.ash.reader.ui.page.home.flow.FlowPage import me.ash.reader.ui.page.home.reading.ReadingPage import me.ash.reader.ui.page.settings.SettingsPage +import me.ash.reader.ui.page.settings.accounts.AccountDetailsPage +import me.ash.reader.ui.page.settings.accounts.AccountsPage +import me.ash.reader.ui.page.settings.accounts.AddAccountsPage import me.ash.reader.ui.page.settings.color.ColorAndStylePage import me.ash.reader.ui.page.settings.color.DarkThemePage import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage @@ -140,6 +143,19 @@ fun HomeEntry( SettingsPage(navController) } + // Accounts + animatedComposable(route = RouteName.ACCOUNTS) { + AccountsPage(navController) + } + + animatedComposable(route = "${RouteName.ACCOUNT_DETAILS}/{accountId}") { + AccountDetailsPage(navController) + } + + animatedComposable(route = RouteName.ADD_ACCOUNTS) { + AddAccountsPage(navController) + } + // Color & Style animatedComposable(route = RouteName.COLOR_AND_STYLE) { ColorAndStylePage(navController) diff --git a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt index d735c23..f728842 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt @@ -13,6 +13,11 @@ object RouteName { // Settings const val SETTINGS = "settings" + // Accounts + const val ACCOUNTS = "accounts" + const val ACCOUNT_DETAILS = "account_details" + const val ADD_ACCOUNTS = "add_accounts" + // Color & Style const val COLOR_AND_STYLE = "color_and_style" const val DARK_THEME = "dark_theme" diff --git a/app/src/main/java/me/ash/reader/ui/page/home/HomeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/HomeViewModel.kt index 342ce46..910bf93 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/HomeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/HomeViewModel.kt @@ -1,20 +1,24 @@ package me.ash.reader.ui.page.home import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.work.WorkManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import me.ash.reader.data.model.article.ArticleFlowItem import me.ash.reader.data.model.article.mapPagingFlowItem import me.ash.reader.data.model.feed.Feed import me.ash.reader.data.model.general.Filter import me.ash.reader.data.model.group.Group import me.ash.reader.data.module.ApplicationScope +import me.ash.reader.data.module.IODispatcher import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.StringsRepository import me.ash.reader.data.repository.SyncWorker @@ -27,6 +31,8 @@ class HomeViewModel @Inject constructor( @ApplicationScope private val applicationScope: CoroutineScope, private val workManager: WorkManager, + @IODispatcher + private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val _homeUiState = MutableStateFlow(HomeUiState()) @@ -35,10 +41,12 @@ class HomeViewModel @Inject constructor( private val _filterUiState = MutableStateFlow(FilterState()) val filterUiState = _filterUiState.asStateFlow() - val syncWorkLiveData = workManager.getWorkInfoByIdLiveData(SyncWorker.uuid) + val syncWorkLiveData = workManager.getWorkInfosByTagLiveData(SyncWorker.WORK_NAME) fun sync() { - rssRepository.get().doSync() + viewModelScope.launch(ioDispatcher) { + rssRepository.get().doSync() + } } fun changeFilter(filterState: FilterState) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 8ea6f09..46940dd 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -1,8 +1,6 @@ package me.ash.reader.ui.page.home.feeds import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.* import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* @@ -30,10 +28,7 @@ import me.ash.reader.data.model.preference.* import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing import me.ash.reader.ui.component.FilterBar import me.ash.reader.ui.component.base.* -import me.ash.reader.ui.ext.alphaLN -import me.ash.reader.ui.ext.collectAsStateValue -import me.ash.reader.ui.ext.findActivity -import me.ash.reader.ui.ext.getCurrentVersion +import me.ash.reader.ui.ext.* import me.ash.reader.ui.page.common.RouteName import me.ash.reader.ui.page.home.FilterState import me.ash.reader.ui.page.home.HomeViewModel @@ -76,7 +71,7 @@ fun FeedsPage( val owner = LocalLifecycleOwner.current var isSyncing by remember { mutableStateOf(false) } homeViewModel.syncWorkLiveData.observe(owner) { - it?.let { isSyncing = it.progress.getIsSyncing() } + it?.let { isSyncing = it.any { it.progress.getIsSyncing() } } } val infiniteTransition = rememberInfiniteTransition() @@ -88,18 +83,6 @@ fun FeedsPage( ) ) - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument() - ) { result -> - feedsViewModel.exportAsOpml { string -> - result?.let { uri -> - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(string.toByteArray()) - } - } - } - } - val feedBadgeAlpha by remember { derivedStateOf { (ln(groupListTonalElevation.value + 1.4f) + 2f) / 100f } } val groupAlpha by remember { derivedStateOf { groupListTonalElevation.value.dp.alphaLN(weight = 1.2f) } } val groupIndicatorAlpha by remember { @@ -172,11 +155,11 @@ fun FeedsPage( modifier = Modifier.pointerInput(Unit) { detectTapGestures( onLongPress = { - launcher.launch("ReadYou.opml") + } ) }, - text = feedsUiState.account?.name ?: stringResource(R.string.read_you), + text = feedsUiState.account?.name ?: "", desc = if (isSyncing) stringResource(R.string.syncing) else "", ) } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt index cd560d9..71acb11 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt @@ -1,6 +1,5 @@ package me.ash.reader.ui.page.home.feeds -import android.util.Log import androidx.compose.foundation.lazy.LazyListState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -13,7 +12,6 @@ import me.ash.reader.data.model.account.Account import me.ash.reader.data.module.DefaultDispatcher import me.ash.reader.data.module.IODispatcher import me.ash.reader.data.repository.AccountRepository -import me.ash.reader.data.repository.OpmlRepository import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.StringsRepository import me.ash.reader.ui.page.home.FilterState @@ -23,7 +21,6 @@ import javax.inject.Inject class FeedsViewModel @Inject constructor( private val accountRepository: AccountRepository, private val rssRepository: RssRepository, - private val opmlRepository: OpmlRepository, private val stringsRepository: StringsRepository, @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -40,16 +37,6 @@ class FeedsViewModel @Inject constructor( } } - fun exportAsOpml(callback: (String) -> Unit = {}) { - viewModelScope.launch(defaultDispatcher) { - try { - callback(opmlRepository.saveToString()) - } catch (e: Exception) { - Log.e("FeedsViewModel", "exportAsOpml: ", e) - } - } - } - fun pullFeeds(filterState: FilterState) { val isStarred = filterState.filter.isStarred() val isUnread = filterState.filter.isUnread() diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt index 850d086..d29f986 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt @@ -69,7 +69,7 @@ fun FlowPage( val owner = LocalLifecycleOwner.current var isSyncing by remember { mutableStateOf(false) } homeViewModel.syncWorkLiveData.observe(owner) { - it?.let { isSyncing = it.progress.getIsSyncing() } + it?.let { isSyncing = it.any { it.progress.getIsSyncing() } } } LaunchedEffect(onSearch) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt index 12e54d2..1a7ab22 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowViewModel.kt @@ -4,24 +4,30 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import me.ash.reader.data.model.general.MarkAsReadConditions +import me.ash.reader.data.module.IODispatcher import me.ash.reader.data.repository.RssRepository import javax.inject.Inject @HiltViewModel class FlowViewModel @Inject constructor( private val rssRepository: RssRepository, + @IODispatcher + private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val _flowUiState = MutableStateFlow(FlowUiState()) val flowUiState: StateFlow = _flowUiState.asStateFlow() fun sync() { - rssRepository.get().doSync() + viewModelScope.launch(ioDispatcher) { + rssRepository.get().doSync() + } } fun scrollToItem(index: Int) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt index ac67bb0..adc6f88 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingPage.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt index 03d5209..986db49 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +30,7 @@ fun SettingItem( title: String, desc: String? = null, icon: ImageVector? = null, + iconPainter: Painter? = null, separatedActions: Boolean = false, onClick: () -> Unit, action: (@Composable () -> Unit)? = null, @@ -47,13 +49,24 @@ fun SettingItem( .padding(24.dp, 16.dp, 16.dp, 16.dp), verticalAlignment = Alignment.CenterVertically ) { - icon?.let { + if (icon != null) { Icon( - imageVector = it, - contentDescription = null, modifier = Modifier.padding(end = 24.dp), + imageVector = icon, + contentDescription = title, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else { + iconPainter?.let { + Icon( + modifier = Modifier + .padding(end = 24.dp) + .size(24.dp), + painter = it, + contentDescription = title, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt index 461d83f..e85ea07 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt @@ -92,8 +92,11 @@ fun SettingsPage( title = stringResource(R.string.accounts), desc = stringResource(R.string.accounts_desc), icon = Icons.Outlined.AccountCircle, - enable = false, - ) {} + ) { + navController.navigate(RouteName.ACCOUNTS) { + launchSingleTop = true + } + } } item { SelectableSettingGroupItem( diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt new file mode 100644 index 0000000..371fd49 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt @@ -0,0 +1,368 @@ +package me.ash.reader.ui.page.settings.accounts + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.PersonOff +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import me.ash.reader.R +import me.ash.reader.data.model.preference.* +import me.ash.reader.ui.component.base.* +import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.ext.showToast +import me.ash.reader.ui.ext.showToastLong +import me.ash.reader.ui.page.settings.SettingItem +import me.ash.reader.ui.theme.palette.onLight + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AccountDetailsPage( + navController: NavHostController = rememberAnimatedNavController(), + viewModel: AccountViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val uiState = viewModel.accountUiState.collectAsStateValue() + val context = LocalContext.current + val syncInterval = LocalSyncInterval.current + val syncOnStart = LocalSyncOnStart.current + val syncOnlyOnWiFi = LocalSyncOnlyOnWiFi.current + val syncOnlyWhenCharging = LocalSyncOnlyWhenCharging.current + val keepArchived = LocalKeepArchived.current + val syncBlockList = LocalSyncBlockList.current + + val selectedAccount = uiState.selectedAccount.collectAsStateValue(initial = null) + + var nameValue by remember { mutableStateOf(selectedAccount?.name) } + var nameDialogVisible by remember { mutableStateOf(false) } + var blockListValue by remember { mutableStateOf(SyncBlockListPreference.toString(syncBlockList)) } + var blockListDialogVisible by remember { mutableStateOf(false) } + var syncIntervalDialogVisible by remember { mutableStateOf(false) } + var keepArchivedDialogVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + navController.currentBackStackEntryFlow.collect { + it.arguments?.getString("accountId")?.let { + viewModel.initData(it.toInt()) + } + } + } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument() + ) { result -> + viewModel.exportAsOPML { string -> + result?.let { uri -> + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(string.toByteArray()) + } + } + } + } + + RYScaffold( + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.popBackStack() + } + }, + content = { + LazyColumn { + item { + DisplayText(text = selectedAccount?.type?.toDesc(context) ?: "", desc = "") + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.display), + ) + SettingItem( + title = stringResource(R.string.name), + desc = selectedAccount?.name ?: "", + onClick = { nameDialogVisible = true }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.synchronous), + ) + SettingItem( + title = stringResource(R.string.sync_interval), + desc = syncInterval.toDesc(context), + onClick = { syncIntervalDialogVisible = true }, + ) {} + SettingItem( + title = stringResource(R.string.sync_once_on_start), + onClick = { + selectedAccount?.id?.let { + (!syncOnStart).put(it, viewModel) + } + }, + ) { + RYSwitch(activated = syncOnStart.value) { + selectedAccount?.id?.let { + (!syncOnStart).put(it, viewModel) + } + } + } + SettingItem( + title = stringResource(R.string.only_on_wifi), + onClick = { + selectedAccount?.id?.let { + (!syncOnlyOnWiFi).put(it, viewModel) + } + }, + ) { + RYSwitch(activated = syncOnlyOnWiFi.value) { + selectedAccount?.id?.let { + (!syncOnlyOnWiFi).put(it, viewModel) + } + } + } + SettingItem( + title = stringResource(R.string.only_when_charging), + onClick = { + selectedAccount?.id?.let { + (!syncOnlyWhenCharging).put(it, viewModel) + } + }, + ) { + RYSwitch(activated = syncOnlyWhenCharging.value) { + selectedAccount?.id?.let { + (!syncOnlyWhenCharging).put(it, viewModel) + } + } + } + SettingItem( + title = stringResource(R.string.keep_archived_articles), + desc = keepArchived.toDesc(context), + onClick = { keepArchivedDialogVisible = true }, + ) {} + // SettingItem( + // title = stringResource(R.string.block_list), + // onClick = { blockListDialogVisible = true }, + // ) {} + Tips(text = stringResource(R.string.synchronous_tips)) + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.advanced), + ) + SettingItem( + title = stringResource(R.string.export_as_opml), + onClick = { + launcher.launch("ReadYou.opml") + }, + ) {} + SettingItem( + title = stringResource(R.string.clear_all_articles), + onClick = { viewModel.showClearDialog() }, + ) {} + SettingItem( + title = stringResource(R.string.delete_account), + onClick = { viewModel.showDeleteDialog() }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } + } + } + ) + + TextFieldDialog( + visible = nameDialogVisible, + title = stringResource(R.string.name), + value = nameValue ?: "", + placeholder = stringResource(R.string.value), + onValueChange = { + nameValue = it + }, + onDismissRequest = { + nameDialogVisible = false + }, + onConfirm = { + if (nameValue?.isNotBlank() == true) { + selectedAccount?.id?.let { + viewModel.update(it) { + name = nameValue ?: "" + } + } + nameDialogVisible = false + } + } + ) + + RadioDialog( + visible = syncIntervalDialogVisible, + title = stringResource(R.string.sync_interval), + options = SyncIntervalPreference.values.map { + RadioDialogOption( + text = it.toDesc(context), + selected = it == syncInterval, + ) { + selectedAccount?.id?.let { accountId -> + it.put(accountId, viewModel) + } + } + } + ) { + syncIntervalDialogVisible = false + } + + RadioDialog( + visible = keepArchivedDialogVisible, + title = stringResource(R.string.keep_archived_articles), + options = KeepArchivedPreference.values.map { + RadioDialogOption( + text = it.toDesc(context), + selected = it == keepArchived, + ) { + selectedAccount?.id?.let { accountId -> + it.put(accountId, viewModel) + } + } + } + ) { + keepArchivedDialogVisible = false + } + + TextFieldDialog( + visible = blockListDialogVisible, + title = stringResource(R.string.block_list), + value = blockListValue, + singleLine = false, + placeholder = stringResource(R.string.value), + onValueChange = { + blockListValue = it + }, + onDismissRequest = { + blockListDialogVisible = false + }, + onConfirm = { + selectedAccount?.id?.let { + SyncBlockListPreference.put(it, viewModel, syncBlockList) + blockListDialogVisible = false + context.showToast(selectedAccount.syncBlockList.toString()) + } + } + ) + + RYDialog( + visible = uiState.clearDialogVisible, + onDismissRequest = { + viewModel.hideClearDialog() + }, + icon = { + Icon( + imageVector = Icons.Outlined.DeleteForever, + contentDescription = stringResource(R.string.clear_all_articles), + ) + }, + title = { + Text(text = stringResource(R.string.clear_all_articles)) + }, + text = { + Text(text = stringResource(R.string.clear_all_articles_tips)) + }, + confirmButton = { + TextButton( + onClick = { + selectedAccount?.id?.let { + viewModel.clear(it) { + viewModel.hideClearDialog() + context.showToastLong(context.getString(R.string.clear_all_articles_toast)) + } + } + } + ) { + Text( + text = stringResource(R.string.clear), + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.hideClearDialog() + } + ) { + Text( + text = stringResource(R.string.cancel), + ) + } + }, + ) + + RYDialog( + visible = uiState.deleteDialogVisible, + onDismissRequest = { + viewModel.hideDeleteDialog() + }, + icon = { + Icon( + imageVector = Icons.Outlined.PersonOff, + contentDescription = stringResource(R.string.delete_account), + ) + }, + title = { + Text(text = stringResource(R.string.delete_account)) + }, + text = { + Text(text = stringResource(R.string.delete_account_tips)) + }, + confirmButton = { + TextButton( + onClick = { + selectedAccount?.id?.let { + viewModel.delete(it) { + viewModel.hideDeleteDialog() + } + } + } + ) { + Text( + text = stringResource(R.string.delete), + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.hideDeleteDialog() + } + ) { + Text( + text = stringResource(R.string.cancel), + ) + } + }, + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt new file mode 100644 index 0000000..78f5b52 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt @@ -0,0 +1,98 @@ +package me.ash.reader.ui.page.settings.accounts + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.ash.reader.data.model.account.Account +import me.ash.reader.data.module.DefaultDispatcher +import me.ash.reader.data.module.IODispatcher +import me.ash.reader.data.module.MainDispatcher +import me.ash.reader.data.repository.AccountRepository +import me.ash.reader.data.repository.OpmlRepository +import me.ash.reader.data.repository.RssRepository +import javax.inject.Inject + +@HiltViewModel +class AccountViewModel @Inject constructor( + private val accountRepository: AccountRepository, + private val rssRepository: RssRepository, + private val opmlRepository: OpmlRepository, + @IODispatcher + private val ioDispatcher: CoroutineDispatcher, + @DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, + @MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _accountUiState = MutableStateFlow(AccountUiState()) + val accountUiState: StateFlow = _accountUiState.asStateFlow() + val accounts = accountRepository.getAccounts() + + fun initData(accountId: Int) { + viewModelScope.launch(ioDispatcher) { + _accountUiState.update { it.copy(selectedAccount = accountRepository.getAccountById(accountId)) } + } + } + + fun update(accountId: Int, block: Account.() -> Unit) { + viewModelScope.launch(ioDispatcher) { + accountRepository.update(accountId, block) + } + } + + fun exportAsOPML(callback: (String) -> Unit = {}) { + viewModelScope.launch(defaultDispatcher) { + try { + callback(opmlRepository.saveToString()) + } catch (e: Exception) { + Log.e("FeedsViewModel", "exportAsOpml: ", e) + } + } + } + + fun hideDeleteDialog() { + _accountUiState.update { it.copy(deleteDialogVisible = false) } + } + + fun showDeleteDialog() { + _accountUiState.update { it.copy(deleteDialogVisible = true) } + } + + fun showClearDialog() { + _accountUiState.update { it.copy(clearDialogVisible = true) } + } + + fun hideClearDialog() { + _accountUiState.update { it.copy(clearDialogVisible = false) } + } + + fun delete(accountId: Int, callback: () -> Unit = {}) { + viewModelScope.launch(ioDispatcher) { + accountRepository.delete(accountId) + withContext(mainDispatcher) { + callback() + } + } + } + + fun clear(accountId: Int, callback: () -> Unit = {}) { + viewModelScope.launch(ioDispatcher) { + rssRepository.get(accountId).deleteAccountArticles(accountId) + withContext(mainDispatcher) { + callback() + } + } + } +} + +data class AccountUiState( + val selectedAccount: Flow = emptyFlow(), + val deleteDialogVisible: Boolean = false, + val clearDialogVisible: Boolean = false, +) diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountsPage.kt new file mode 100644 index 0000000..51eb337 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountsPage.kt @@ -0,0 +1,108 @@ +package me.ash.reader.ui.page.settings.accounts + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import me.ash.reader.R +import me.ash.reader.ui.component.base.DisplayText +import me.ash.reader.ui.component.base.FeedbackIconButton +import me.ash.reader.ui.component.base.RYScaffold +import me.ash.reader.ui.component.base.Subtitle +import me.ash.reader.ui.ext.collectAsStateValue +import me.ash.reader.ui.page.common.RouteName +import me.ash.reader.ui.page.settings.SettingItem +import me.ash.reader.ui.theme.palette.onLight + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AccountsPage( + navController: NavHostController = rememberAnimatedNavController(), + viewModel: AccountViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState = viewModel.accountUiState.collectAsStateValue() + val accounts = viewModel.accounts.collectAsStateValue(initial = emptyList()) + + RYScaffold( + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.popBackStack() + } + }, + content = { + LazyColumn { + item { + DisplayText(text = stringResource(R.string.accounts), desc = "") + Spacer(modifier = Modifier.height(16.dp)) + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.list), + ) + } + accounts.forEach { + item { + SettingItem( + title = it.name, + desc = it.type.toDesc(context), + icon = it.type.toIcon().takeIf { it is ImageVector }?.let { it as ImageVector }, + iconPainter = it.type.toIcon().takeIf { it is Painter }?.let { it as Painter }, + onClick = { + navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") { + launchSingleTop = true + } + }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.more), + ) + SettingItem( + title = stringResource(R.string.add_accounts), + desc = stringResource(R.string.add_accounts_desc), + icon = Icons.Outlined.PersonAdd, + onClick = { + navController.navigate(RouteName.ADD_ACCOUNTS) { + launchSingleTop = true + } + }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } + } + } + ) +} + +@Preview +@Composable +fun AccountsPreview() { + AccountsPage() +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt new file mode 100644 index 0000000..03c368a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt @@ -0,0 +1,137 @@ +package me.ash.reader.ui.page.settings.accounts + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.RssFeed +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import me.ash.reader.R +import me.ash.reader.ui.component.base.DisplayText +import me.ash.reader.ui.component.base.FeedbackIconButton +import me.ash.reader.ui.component.base.RYScaffold +import me.ash.reader.ui.component.base.Subtitle +import me.ash.reader.ui.page.settings.SettingItem +import me.ash.reader.ui.theme.palette.onLight + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AddAccountsPage( + navController: NavHostController = rememberAnimatedNavController(), +) { + val context = LocalContext.current + + RYScaffold( + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.popBackStack() + } + }, + content = { + LazyColumn { + item { + DisplayText(text = stringResource(R.string.add_accounts), desc = "") + Spacer(modifier = Modifier.height(16.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.local), + ) + SettingItem( + enable = false, + title = stringResource(R.string.local), + desc = stringResource(R.string.local_desc), + icon = Icons.Rounded.RssFeed, + onClick = { + // navController.navigate(RouteName.ACCOUNT_DETAILS) { + // launchSingleTop = true + // } + }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.services), + ) + SettingItem( + enable = false, + title = stringResource(R.string.feedlly), + desc = stringResource(R.string.feedlly_desc), + iconPainter = painterResource(id = R.drawable.ic_feedly), + onClick = {}, + ) {} + SettingItem( + enable = false, + title = stringResource(R.string.inoreader), + desc = stringResource(R.string.inoreader_desc), + iconPainter = painterResource(id = R.drawable.ic_inoreader), + onClick = {}, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.self_hosted), + ) + SettingItem( + enable = false, + title = stringResource(R.string.fresh_rss), + desc = stringResource(R.string.fresh_rss_desc), + iconPainter = painterResource(id = R.drawable.ic_freshrss), + onClick = { + + }, + ) {} + SettingItem( + enable = false, + title = stringResource(R.string.google_reader), + desc = stringResource(R.string.google_reader_desc), + icon = Icons.Rounded.RssFeed, + onClick = { + + }, + ) {} + SettingItem( + enable = false, + title = stringResource(R.string.fever), + desc = stringResource(R.string.fever_desc), + iconPainter = painterResource(id = R.drawable.ic_fever), + onClick = { + + }, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } + } + } + ) +} + +@Preview +@Composable +fun AddAccountsPreview() { + AddAccountsPage() +} diff --git a/app/src/main/res/drawable/ic_feedly.xml b/app/src/main/res/drawable/ic_feedly.xml new file mode 100644 index 0000000..a712dd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_feedly.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fever.xml b/app/src/main/res/drawable/ic_fever.xml new file mode 100644 index 0000000..6294525 --- /dev/null +++ b/app/src/main/res/drawable/ic_fever.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_freshrss.xml b/app/src/main/res/drawable/ic_freshrss.xml new file mode 100644 index 0000000..b888bf9 --- /dev/null +++ b/app/src/main/res/drawable/ic_freshrss.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_inoreader.xml b/app/src/main/res/drawable/ic_inoreader.xml new file mode 100644 index 0000000..5f80037 --- /dev/null +++ b/app/src/main/res/drawable/ic_inoreader.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55fc935..b87537d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -301,4 +301,46 @@ 圆角、水平边距 圆角、水平边距 最大化 - \ No newline at end of file + 每 15 分钟 + 每 30 分钟 + 每 1 小时 + 每 2 小时 + 每 3 小时 + 每 6 小时 + 每 12 小时 + 每 1 天 + 手动 + 总是 + 1 天 + 2 天 + 3 天 + 1 周 + 2 周 + 1 个月 + 本地 + 在这台设备上 + 服务 + 自托管 + 已过时,不推荐。 + 更多 + 添加账户 + 列表 + 本地、第三方服务、自托管 + 显示 + 同步时 + 同步频率 + 启动时同步一次 + 仅限连接 Wi-Fi 时 + 仅限连接充电器时 + 保留已归档的文章 + 屏蔽列表 + 导出为 OPML + 清空所有文章 + 删除账户 + 确定要清空该账户中所有的文章吗? + 确定要删除该账户吗? + 必须保留一个账户 + 已清空该账户的所有文章 + 该账户已被删除 + 需要重新启动才能生效。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 756f8bc..921978e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,4 +330,55 @@ Rounded corners, horizontal padding Rounded corners, horizontal padding Maximize + Every 15 minutes + Every 30 minutes + Every hour + Every 2 hours + Every 3 hours + Every 6 hours + Every 12 hours + Every day + Manually + Always + 1 day + 2 days + 3 days + 1 week + 2 weeks + 1 month + Local + Fever + Google Reader + FreshRSS + Feedlly + Inoreader + On this device + Services + feedlly.com + inoreader.com + Self-Hosted + freshrss.org + Google Reader API + Deprecated. Not recommended. + More + Add accounts + List + Local, Services, Self-Hosted + Display + Syncing + Sync + Sync once on start + Only on Wi-Fi + Only when charging + Keep archived articles + Block list + Export as OPML + Clear all articles + Delete account + Are you sure you want to clear all articles from this account? + Are you sure you want to delete this account? + Must have an account + All articles from this account have been cleared + This account has been deleted + Restart is require for changes to take effect.