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
This commit is contained in:
Ashinch 2022-10-07 21:38:24 +08:00 committed by GitHub
parent 386b716e4d
commit 75ac40ed96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 2003 additions and 96 deletions

View File

@ -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')"
]
}
}

View File

@ -20,11 +20,9 @@ class CrashHandler(private val context: Context) : UncaughtExceptionHandler {
* Catch all uncaught exception and log it. * Catch all uncaught exception and log it.
*/ */
override fun uncaughtException(p0: Thread, p1: Throwable) { 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}") Log.e("RLog", "uncaughtException: ${p1.message}")
context.showToastLong(p1.message)
p1.printStackTrace()
android.os.Process.killProcess(android.os.Process.myPid()); android.os.Process.killProcess(android.os.Process.myPid());
exitProcess(1) exitProcess(1)
} }

View File

@ -10,6 +10,8 @@ import androidx.profileinstaller.ProfileInstallerInitializer
import coil.ImageLoader import coil.ImageLoader
import coil.compose.LocalImageLoader import coil.compose.LocalImageLoader
import dagger.hilt.android.AndroidEntryPoint 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.LanguagesPreference
import me.ash.reader.data.model.preference.SettingsProvider import me.ash.reader.data.model.preference.SettingsProvider
import me.ash.reader.ui.ext.languages import me.ash.reader.ui.ext.languages
@ -25,6 +27,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var imageLoader: ImageLoader lateinit var imageLoader: ImageLoader
@Inject
lateinit var accountDao: AccountDao
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@ -40,8 +45,10 @@ class MainActivity : ComponentActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalImageLoader provides imageLoader, LocalImageLoader provides imageLoader,
) { ) {
SettingsProvider { AccountSettingsProvider(accountDao) {
HomeEntry() SettingsProvider {
HomeEntry()
}
} }
} }
} }

View File

@ -103,7 +103,7 @@ class RYApp : Application(), Configuration.Provider {
applicationScope.launch { applicationScope.launch {
accountInit() accountInit()
workerInit() workerInit()
if (notFdroid) checkUpdate() checkUpdate()
} }
} }
@ -126,11 +126,12 @@ class RYApp : Application(), Configuration.Provider {
} }
} }
private fun workerInit() { private suspend fun workerInit() {
rssRepository.get().doSync() rssRepository.get().doSync(isOnStart = true)
} }
private suspend fun checkUpdate() { private suspend fun checkUpdate() {
if (isFdroid) return
withContext(ioDispatcher) { withContext(ioDispatcher) {
applicationContext.getLatestApk().let { applicationContext.getLatestApk().let {
if (it.exists()) it.del() if (it.exists()) it.del()

View File

@ -1,11 +1,20 @@
package me.ash.reader.data.dao package me.ash.reader.data.dao
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
import me.ash.reader.data.model.account.Account import me.ash.reader.data.model.account.Account
@Dao @Dao
interface AccountDao { interface AccountDao {
@Query(
"""
SELECT * FROM account
WHERE id = :id
"""
)
fun queryAccount(id: Int): Flow<Account?>
@Query( @Query(
""" """
SELECT * FROM account SELECT * FROM account
@ -13,6 +22,13 @@ interface AccountDao {
) )
suspend fun queryAll(): List<Account> suspend fun queryAll(): List<Account>
@Query(
"""
SELECT * FROM account
"""
)
fun queryAllAsFlow(): Flow<List<Account>>
@Query( @Query(
""" """
SELECT * FROM account SELECT * FROM account

View File

@ -203,6 +203,20 @@ interface ArticleDao {
text: String, text: String,
): PagingSource<Int, ArticleWithFeed> ): PagingSource<Int, ArticleWithFeed>
@Query(
"""
DELETE FROM article
WHERE accountId = :accountId
AND updateAt < :before
AND isUnread = 0
AND isStarred = 0
"""
)
suspend fun deleteAllArchivedBeforeThan(
accountId: Int,
before: Date,
)
@Query( @Query(
""" """
UPDATE article SET isUnread = :isUnread UPDATE article SET isUnread = :isUnread
@ -285,6 +299,14 @@ interface ArticleDao {
) )
suspend fun deleteByGroupId(accountId: Int, groupId: String) suspend fun deleteByGroupId(accountId: Int, groupId: String)
@Query(
"""
DELETE FROM article
WHERE accountId = :accountId
"""
)
suspend fun deleteByAccountId(accountId: Int)
@Transaction @Transaction
@Query( @Query(
""" """
@ -376,7 +398,7 @@ interface ArticleDao {
""" """
SELECT a.id, a.date, a.title, a.author, a.rawDescription, SELECT a.id, a.date, a.title, a.author, a.rawDescription,
a.shortDescription, a.fullContent, a.img, a.link, a.feedId, 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 FROM article AS a
LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN feed AS b ON b.id = a.feedId
LEFT JOIN `group` AS c ON c.id = b.groupId 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, SELECT a.id, a.date, a.title, a.author, a.rawDescription,
a.shortDescription, a.fullContent, a.img, a.link, a.feedId, 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 FROM article AS a
LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN feed AS b ON b.id = a.feedId
LEFT JOIN `group` AS c ON c.id = b.groupId 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, SELECT a.id, a.date, a.title, a.author, a.rawDescription,
a.shortDescription, a.fullContent, a.img, a.link, a.feedId, 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 FROM article AS a
LEFT JOIN feed AS b ON b.id = a.feedId LEFT JOIN feed AS b ON b.id = a.feedId
LEFT JOIN `group` AS c ON c.id = b.groupId 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, SELECT a.id, a.date, a.title, a.author, a.rawDescription,
a.shortDescription, a.fullContent, a.img, a.link, a.feedId, 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 FROM article AS a LEFT JOIN feed AS b
ON a.feedId = b.id ON a.feedId = b.id
WHERE a.feedId = :feedId WHERE a.feedId = :feedId

View File

@ -54,6 +54,14 @@ interface FeedDao {
) )
suspend fun deleteByGroupId(accountId: Int, groupId: String) suspend fun deleteByGroupId(accountId: Int, groupId: String)
@Query(
"""
DELETE FROM feed
WHERE accountId = :accountId
"""
)
suspend fun deleteByAccountId(accountId: Int)
@Query( @Query(
""" """
SELECT * FROM feed SELECT * FROM feed

View File

@ -42,6 +42,14 @@ interface GroupDao {
) )
fun queryAllGroup(accountId: Int): Flow<MutableList<Group>> fun queryAllGroup(accountId: Int): Flow<MutableList<Group>>
@Query(
"""
DELETE FROM `group`
WHERE accountId = :accountId
"""
)
suspend fun deleteByAccountId(accountId: Int)
@Query( @Query(
""" """
SELECT * FROM `group` SELECT * FROM `group`

View File

@ -3,6 +3,7 @@ package me.ash.reader.data.model.account
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import me.ash.reader.data.model.preference.*
import java.util.* import java.util.*
/** /**
@ -19,4 +20,16 @@ data class Account(
var type: AccountType, var type: AccountType,
@ColumnInfo @ColumnInfo
var updateAt: Date? = null, 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,
) )

View File

@ -1,7 +1,14 @@
package me.ash.reader.data.model.account 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.RoomDatabase
import androidx.room.TypeConverter import androidx.room.TypeConverter
import me.ash.reader.R
/** /**
* Each account will specify its local or third-party API type. * 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. * Make sure the constructed object is valid.
*/ */
init { init {
if (id < 1 || id > 3) { if (id < 1 || id > 6) {
throw IllegalArgumentException("Account type id is not valid.") 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. * Type of account currently supported.
*/ */
@ -25,6 +56,9 @@ class AccountType(val id: Int) {
val Local = AccountType(1) val Local = AccountType(1)
val Fever = AccountType(2) val Fever = AccountType(2)
val GoogleReader = AccountType(3) val GoogleReader = AccountType(3)
val FreshRSS = AccountType(4)
val Feedlly = AccountType(5)
val Inoreader = AccountType(6)
} }
} }

View File

@ -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
}
}

View File

@ -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())
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -40,12 +40,14 @@ data class Article(
var feedId: String, var feedId: String,
@ColumnInfo(index = true) @ColumnInfo(index = true)
var accountId: Int, var accountId: Int,
@ColumnInfo(defaultValue = "true") @ColumnInfo
var isUnread: Boolean = true, var isUnread: Boolean = true,
@ColumnInfo(defaultValue = "false") @ColumnInfo
var isStarred: Boolean = false, var isStarred: Boolean = false,
@ColumnInfo(defaultValue = "false") @ColumnInfo
var isReadLater: Boolean = false, var isReadLater: Boolean = false,
@ColumnInfo
var updateAt: Date? = null,
) { ) {
@Ignore @Ignore

View File

@ -29,9 +29,9 @@ data class Feed(
var groupId: String, var groupId: String,
@ColumnInfo(index = true) @ColumnInfo(index = true)
var accountId: Int, var accountId: Int,
@ColumnInfo(defaultValue = "false") @ColumnInfo
var isNotification: Boolean = false, var isNotification: Boolean = false,
@ColumnInfo(defaultValue = "false") @ColumnInfo
var isFullContent: Boolean = false, var isFullContent: Boolean = false,
) { ) {

View File

@ -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> { SyncIntervalPreference.default }
val LocalSyncOnStart = compositionLocalOf<SyncOnStartPreference> { SyncOnStartPreference.default }
val LocalSyncOnlyOnWiFi = compositionLocalOf<SyncOnlyOnWiFiPreference> { SyncOnlyOnWiFiPreference.default }
val LocalSyncOnlyWhenCharging =
compositionLocalOf<SyncOnlyWhenChargingPreference> { SyncOnlyWhenChargingPreference.default }
val LocalKeepArchived = compositionLocalOf<KeepArchivedPreference> { 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()
}
}

View File

@ -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,
)
}
}

View File

@ -0,0 +1,23 @@
package me.ash.reader.data.model.preference
import me.ash.reader.ui.page.settings.accounts.AccountViewModel
typealias SyncBlockList = List<String>
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" }
}

View File

@ -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<SyncWorker>(value, TimeUnit.MINUTES)
companion object {
val default = Every30Minutes
val values = listOf(
Manually,
Every15Minutes,
Every30Minutes,
Every1Hour,
Every2Hours,
Every3Hours,
Every6Hours,
Every12Hours,
Every1Day,
)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.util.Log import android.util.Log
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.WorkManager import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineDispatcher 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.feed.Feed
import me.ash.reader.data.model.group.Group import me.ash.reader.data.model.group.Group
import me.ash.reader.data.model.group.GroupWithFeed 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 me.ash.reader.ui.ext.currentAccountId
import java.util.* import java.util.*
@ -50,12 +51,41 @@ abstract class AbstractRssRepository constructor(
isUnread: Boolean, isUnread: Boolean,
) )
fun doSync() { suspend fun keepArchivedArticles() {
workManager.enqueueUniquePeriodicWork( accountDao.queryById(context.currentAccountId)!!
SyncWorker.WORK_NAME, .takeIf { it.keepArchived != KeepArchivedPreference.Always }
ExistingPeriodicWorkPolicy.REPLACE, ?.let {
SyncWorker.repeatingRequest 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<MutableList<Group>> = fun pullGroups(): Flow<MutableList<Group>> =
@ -155,6 +185,10 @@ abstract class AbstractRssRepository constructor(
} }
} }
suspend fun deleteAccountArticles(accountId: Int) {
articleDao.deleteByAccountId(accountId)
}
suspend fun groupParseFullContent(group: Group, isFullContent: Boolean) { suspend fun groupParseFullContent(group: Group, isFullContent: Boolean) {
feedDao.updateIsFullContentByGroupId(context.currentAccountId, group.id, isFullContent) feedDao.updateIsFullContentByGroupId(context.currentAccountId, group.id, isFullContent)
} }

View File

@ -2,14 +2,19 @@ package me.ash.reader.data.repository
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.data.dao.AccountDao 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.dao.GroupDao
import me.ash.reader.data.model.account.Account import me.ash.reader.data.model.account.Account
import me.ash.reader.data.model.account.AccountType import me.ash.reader.data.model.account.AccountType
import me.ash.reader.data.model.group.Group import me.ash.reader.data.model.group.Group
import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.getDefaultGroupId 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 import javax.inject.Inject
class AccountRepository @Inject constructor( class AccountRepository @Inject constructor(
@ -17,9 +22,14 @@ class AccountRepository @Inject constructor(
private val context: Context, private val context: Context,
private val accountDao: AccountDao, private val accountDao: AccountDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val feedDao: FeedDao,
private val articleDao: ArticleDao,
) { ) {
fun getAccounts(): Flow<List<Account>> = accountDao.queryAllAsFlow()
suspend fun getCurrentAccount(): Account? = accountDao.queryById(context.currentAccountId) fun getAccountById(accountId: Int): Flow<Account?> = accountDao.queryAccount(accountId)
suspend fun getCurrentAccount(): Account = accountDao.queryById(context.currentAccountId)!!
suspend fun isNoAccount(): Boolean = accountDao.queryAll().isEmpty() 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))
}
}
} }

View File

@ -72,10 +72,10 @@ class LocalRssRepository @Inject constructor(
override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result =
supervisorScope { supervisorScope {
coroutineWorker.setProgress(setIsSyncing(true))
val preTime = System.currentTimeMillis() val preTime = System.currentTimeMillis()
val accountId = context.currentAccountId val accountId = context.currentAccountId
feedDao.queryAll(accountId) feedDao.queryAll(accountId)
.also { coroutineWorker.setProgress(setIsSyncing(true)) }
.chunked(16) .chunked(16)
.forEach { .forEach {
it.map { feed -> async { syncFeed(feed) } } it.map { feed -> async { syncFeed(feed) } }

View File

@ -126,6 +126,7 @@ class RssHelper @Inject constructor(
fullContent = content, fullContent = content,
img = findImg((content ?: desc) ?: ""), img = findImg((content ?: desc) ?: ""),
link = syndEntry.link ?: "", link = syndEntry.link ?: "",
updateAt = Date(),
) )
} }

View File

@ -14,7 +14,9 @@ class RssRepository @Inject constructor(
// private val googleReaderRssRepository: GoogleReaderRssRepository, // private val googleReaderRssRepository: GoogleReaderRssRepository,
) { ) {
fun get() = when (context.currentAccountType) { fun get() = get(context.currentAccountType)
fun get(accountId: Int) = when (accountId) {
AccountType.Local.id -> localRssRepository AccountType.Local.id -> localRssRepository
// Account.Type.LOCAL -> feverRssRepository // Account.Type.LOCAL -> feverRssRepository
// Account.Type.FEVER -> feverRssRepository // Account.Type.FEVER -> feverRssRepository

View File

@ -8,6 +8,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -15,31 +18,55 @@ import java.util.concurrent.TimeUnit
class SyncWorker @AssistedInject constructor( class SyncWorker @AssistedInject constructor(
@Assisted context: Context, @Assisted context: Context,
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
private val accountRepository: AccountRepository,
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = override suspend fun doWork(): Result =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
Log.i("RLog", "doWork: ") Log.i("RLog", "doWork: ")
rssRepository.get().sync(this@SyncWorker) rssRepository.get().sync(this@SyncWorker).also {
rssRepository.get().keepArchivedArticles()
}
} }
companion object { 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 fun enqueueOneTimeWork(
workManager: WorkManager,
val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>( ) {
15, TimeUnit.MINUTES workManager.enqueue(OneTimeWorkRequestBuilder<SyncWorker>()
).setConstraints( .addTag(WORK_NAME)
Constraints.Builder()
.build() .build()
).addTag(WORK_NAME).build().also { )
uuid = it.id
} }
fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean) fun enqueuePeriodicWork(
fun Data.getIsSyncing(): Boolean = getBoolean("isSyncing", false) workManager: WorkManager,
syncInterval: SyncIntervalPreference,
syncOnlyWhenCharging: SyncOnlyWhenChargingPreference,
syncOnlyOnWiFi: SyncOnlyOnWiFiPreference,
) {
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<SyncWorker>(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)
} }
} }

View File

@ -8,18 +8,28 @@ import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.account.Account import me.ash.reader.data.model.account.*
import me.ash.reader.data.model.account.AccountTypeConverters
import me.ash.reader.data.model.article.Article import me.ash.reader.data.model.article.Article
import me.ash.reader.data.model.feed.Feed import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.group.Group 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.* import java.util.*
@Database( @Database(
entities = [Account::class, Feed::class, Article::class, Group::class], 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 class RYDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
@ -60,6 +70,7 @@ abstract class RYDatabase : RoomDatabase() {
val allMigrations = arrayOf( val allMigrations = arrayOf(
MIGRATION_1_2, MIGRATION_1_2,
MIGRATION_2_3,
) )
@Suppress("ClassName") @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()
)
}
}

View File

@ -23,6 +23,7 @@ fun ClipboardTextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
readOnly: Boolean = false, readOnly: Boolean = false,
value: String = "", value: String = "",
singleLine: Boolean = true,
onValueChange: (String) -> Unit = {}, onValueChange: (String) -> Unit = {},
placeholder: String = "", placeholder: String = "",
errorText: String = "", errorText: String = "",
@ -35,6 +36,7 @@ fun ClipboardTextField(
RYTextField( RYTextField(
readOnly = readOnly, readOnly = readOnly,
value = value, value = value,
singleLine = singleLine,
onValueChange = onValueChange, onValueChange = onValueChange,
placeholder = placeholder, placeholder = placeholder,
errorMessage = errorText, errorMessage = errorText,

View File

@ -22,6 +22,7 @@ import me.ash.reader.R
fun RYTextField( fun RYTextField(
readOnly: Boolean, readOnly: Boolean,
value: String, value: String,
singleLine: Boolean = true,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
placeholder: String, placeholder: String,
errorMessage: String, errorMessage: String,
@ -41,7 +42,7 @@ fun RYTextField(
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
maxLines = 1, maxLines = if (singleLine) 1 else Int.MAX_VALUE,
enabled = !readOnly, enabled = !readOnly,
value = value, value = value,
onValueChange = { onValueChange = {
@ -55,7 +56,7 @@ fun RYTextField(
) )
}, },
isError = errorMessage.isNotEmpty(), isError = errorMessage.isNotEmpty(),
singleLine = true, singleLine = singleLine,
trailingIcon = { trailingIcon = {
if (value.isNotEmpty()) { if (value.isNotEmpty()) {
IconButton(onClick = { IconButton(onClick = {
@ -82,4 +83,4 @@ fun RYTextField(
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
) )
} }

View File

@ -21,6 +21,7 @@ fun TextFieldDialog(
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
visible: Boolean = false, visible: Boolean = false,
readOnly: Boolean = false, readOnly: Boolean = false,
singleLine: Boolean = true,
title: String = "", title: String = "",
icon: ImageVector? = null, icon: ImageVector? = null,
value: String = "", value: String = "",
@ -31,7 +32,7 @@ fun TextFieldDialog(
onValueChange: (String) -> Unit = {}, onValueChange: (String) -> Unit = {},
onDismissRequest: () -> Unit = {}, onDismissRequest: () -> Unit = {},
onConfirm: (String) -> Unit = {}, onConfirm: (String) -> Unit = {},
imeAction: ImeAction = ImeAction.Done, imeAction: ImeAction = if (singleLine) ImeAction.Done else ImeAction.Default,
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -55,6 +56,7 @@ fun TextFieldDialog(
modifier = modifier, modifier = modifier,
readOnly = readOnly, readOnly = readOnly,
value = value, value = value,
singleLine = singleLine,
onValueChange = onValueChange, onValueChange = onValueChange,
placeholder = placeholder, placeholder = placeholder,
errorText = errorText, errorText = errorText,
@ -87,4 +89,4 @@ fun TextFieldDialog(
} }
}, },
) )
} }

View File

@ -0,0 +1,3 @@
package me.ash.reader.ui.ext
fun Boolean.toInt(): Int = if (this) 1 else 0

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -48,9 +49,11 @@ fun Context.installLatestApk() {
private var toast: Toast? = null private var toast: Toast? = null
fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) { fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) {
Looper.myLooper() ?: Looper.prepare()
toast?.cancel() toast?.cancel()
toast = Toast.makeText(this, message, duration) toast = Toast.makeText(this, message, duration)
toast?.show() toast?.show()
Looper.loop()
} }
fun Context.showToastLong(message: String?) { fun Context.showToastLong(message: String?) {
@ -70,4 +73,4 @@ fun Context.share(content: String) {
fun Context.openURL(url: String?) { fun Context.openURL(url: String?) {
url?.takeIf { it.trim().isNotEmpty() } url?.takeIf { it.trim().isNotEmpty() }
?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) } ?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }
} }

View File

@ -2,4 +2,6 @@ package me.ash.reader.ui.ext
fun Int.spacerDollar(str: Any): String = "$this$$str" fun Int.spacerDollar(str: Any): String = "$this$$str"
fun Int.getDefaultGroupId() = this.spacerDollar("read_you_app_default_group") fun Int.getDefaultGroupId() = this.spacerDollar("read_you_app_default_group")
fun Int.toBoolean(): Boolean = this != 0

View File

@ -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.flow.FlowPage
import me.ash.reader.ui.page.home.reading.ReadingPage import me.ash.reader.ui.page.home.reading.ReadingPage
import me.ash.reader.ui.page.settings.SettingsPage 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.ColorAndStylePage
import me.ash.reader.ui.page.settings.color.DarkThemePage import me.ash.reader.ui.page.settings.color.DarkThemePage
import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage
@ -140,6 +143,19 @@ fun HomeEntry(
SettingsPage(navController) 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 // Color & Style
animatedComposable(route = RouteName.COLOR_AND_STYLE) { animatedComposable(route = RouteName.COLOR_AND_STYLE) {
ColorAndStylePage(navController) ColorAndStylePage(navController)

View File

@ -13,6 +13,11 @@ object RouteName {
// Settings // Settings
const val SETTINGS = "settings" const val SETTINGS = "settings"
// Accounts
const val ACCOUNTS = "accounts"
const val ACCOUNT_DETAILS = "account_details"
const val ADD_ACCOUNTS = "add_accounts"
// Color & Style // Color & Style
const val COLOR_AND_STYLE = "color_and_style" const val COLOR_AND_STYLE = "color_and_style"
const val DARK_THEME = "dark_theme" const val DARK_THEME = "dark_theme"

View File

@ -1,20 +1,24 @@
package me.ash.reader.ui.page.home package me.ash.reader.ui.page.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.work.WorkManager import androidx.work.WorkManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import me.ash.reader.data.model.article.ArticleFlowItem import me.ash.reader.data.model.article.ArticleFlowItem
import me.ash.reader.data.model.article.mapPagingFlowItem import me.ash.reader.data.model.article.mapPagingFlowItem
import me.ash.reader.data.model.feed.Feed import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.general.Filter import me.ash.reader.data.model.general.Filter
import me.ash.reader.data.model.group.Group import me.ash.reader.data.model.group.Group
import me.ash.reader.data.module.ApplicationScope 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.RssRepository
import me.ash.reader.data.repository.StringsRepository import me.ash.reader.data.repository.StringsRepository
import me.ash.reader.data.repository.SyncWorker import me.ash.reader.data.repository.SyncWorker
@ -27,6 +31,8 @@ class HomeViewModel @Inject constructor(
@ApplicationScope @ApplicationScope
private val applicationScope: CoroutineScope, private val applicationScope: CoroutineScope,
private val workManager: WorkManager, private val workManager: WorkManager,
@IODispatcher
private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val _homeUiState = MutableStateFlow(HomeUiState()) private val _homeUiState = MutableStateFlow(HomeUiState())
@ -35,10 +41,12 @@ class HomeViewModel @Inject constructor(
private val _filterUiState = MutableStateFlow(FilterState()) private val _filterUiState = MutableStateFlow(FilterState())
val filterUiState = _filterUiState.asStateFlow() val filterUiState = _filterUiState.asStateFlow()
val syncWorkLiveData = workManager.getWorkInfoByIdLiveData(SyncWorker.uuid) val syncWorkLiveData = workManager.getWorkInfosByTagLiveData(SyncWorker.WORK_NAME)
fun sync() { fun sync() {
rssRepository.get().doSync() viewModelScope.launch(ioDispatcher) {
rssRepository.get().doSync()
}
} }
fun changeFilter(filterState: FilterState) { fun changeFilter(filterState: FilterState) {

View File

@ -1,8 +1,6 @@
package me.ash.reader.ui.page.home.feeds package me.ash.reader.ui.page.home.feeds
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* 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.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.ui.component.FilterBar import me.ash.reader.ui.component.FilterBar
import me.ash.reader.ui.component.base.* import me.ash.reader.ui.component.base.*
import me.ash.reader.ui.ext.alphaLN import me.ash.reader.ui.ext.*
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.page.common.RouteName import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.home.FilterState import me.ash.reader.ui.page.home.FilterState
import me.ash.reader.ui.page.home.HomeViewModel import me.ash.reader.ui.page.home.HomeViewModel
@ -76,7 +71,7 @@ fun FeedsPage(
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) } var isSyncing by remember { mutableStateOf(false) }
homeViewModel.syncWorkLiveData.observe(owner) { homeViewModel.syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.progress.getIsSyncing() } it?.let { isSyncing = it.any { it.progress.getIsSyncing() } }
} }
val infiniteTransition = rememberInfiniteTransition() 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 feedBadgeAlpha by remember { derivedStateOf { (ln(groupListTonalElevation.value + 1.4f) + 2f) / 100f } }
val groupAlpha by remember { derivedStateOf { groupListTonalElevation.value.dp.alphaLN(weight = 1.2f) } } val groupAlpha by remember { derivedStateOf { groupListTonalElevation.value.dp.alphaLN(weight = 1.2f) } }
val groupIndicatorAlpha by remember { val groupIndicatorAlpha by remember {
@ -172,11 +155,11 @@ fun FeedsPage(
modifier = Modifier.pointerInput(Unit) { modifier = Modifier.pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onLongPress = { 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 "", desc = if (isSyncing) stringResource(R.string.syncing) else "",
) )
} }

View File

@ -1,6 +1,5 @@
package me.ash.reader.ui.page.home.feeds package me.ash.reader.ui.page.home.feeds
import android.util.Log
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.DefaultDispatcher
import me.ash.reader.data.module.IODispatcher import me.ash.reader.data.module.IODispatcher
import me.ash.reader.data.repository.AccountRepository 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.RssRepository
import me.ash.reader.data.repository.StringsRepository import me.ash.reader.data.repository.StringsRepository
import me.ash.reader.ui.page.home.FilterState import me.ash.reader.ui.page.home.FilterState
@ -23,7 +21,6 @@ import javax.inject.Inject
class FeedsViewModel @Inject constructor( class FeedsViewModel @Inject constructor(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
private val opmlRepository: OpmlRepository,
private val stringsRepository: StringsRepository, private val stringsRepository: StringsRepository,
@DefaultDispatcher @DefaultDispatcher
private val defaultDispatcher: CoroutineDispatcher, 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) { fun pullFeeds(filterState: FilterState) {
val isStarred = filterState.filter.isStarred() val isStarred = filterState.filter.isStarred()
val isUnread = filterState.filter.isUnread() val isUnread = filterState.filter.isUnread()

View File

@ -69,7 +69,7 @@ fun FlowPage(
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) } var isSyncing by remember { mutableStateOf(false) }
homeViewModel.syncWorkLiveData.observe(owner) { homeViewModel.syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.progress.getIsSyncing() } it?.let { isSyncing = it.any { it.progress.getIsSyncing() } }
} }
LaunchedEffect(onSearch) { LaunchedEffect(onSearch) {

View File

@ -4,24 +4,30 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.data.model.general.MarkAsReadConditions import me.ash.reader.data.model.general.MarkAsReadConditions
import me.ash.reader.data.module.IODispatcher
import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.RssRepository
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class FlowViewModel @Inject constructor( class FlowViewModel @Inject constructor(
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
@IODispatcher
private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val _flowUiState = MutableStateFlow(FlowUiState()) private val _flowUiState = MutableStateFlow(FlowUiState())
val flowUiState: StateFlow<FlowUiState> = _flowUiState.asStateFlow() val flowUiState: StateFlow<FlowUiState> = _flowUiState.asStateFlow()
fun sync() { fun sync() {
rssRepository.get().doSync() viewModelScope.launch(ioDispatcher) {
rssRepository.get().doSync()
}
} }
fun scrollToItem(index: Int) { fun scrollToItem(index: Int) {

View File

@ -7,6 +7,7 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -29,6 +30,7 @@ fun SettingItem(
title: String, title: String,
desc: String? = null, desc: String? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
iconPainter: Painter? = null,
separatedActions: Boolean = false, separatedActions: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
action: (@Composable () -> Unit)? = null, action: (@Composable () -> Unit)? = null,
@ -47,13 +49,24 @@ fun SettingItem(
.padding(24.dp, 16.dp, 16.dp, 16.dp), .padding(24.dp, 16.dp, 16.dp, 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
icon?.let { if (icon != null) {
Icon( Icon(
imageVector = it,
contentDescription = null,
modifier = Modifier.padding(end = 24.dp), modifier = Modifier.padding(end = 24.dp),
imageVector = icon,
contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant, 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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View File

@ -92,8 +92,11 @@ fun SettingsPage(
title = stringResource(R.string.accounts), title = stringResource(R.string.accounts),
desc = stringResource(R.string.accounts_desc), desc = stringResource(R.string.accounts_desc),
icon = Icons.Outlined.AccountCircle, icon = Icons.Outlined.AccountCircle,
enable = false, ) {
) {} navController.navigate(RouteName.ACCOUNTS) {
launchSingleTop = true
}
}
} }
item { item {
SelectableSettingGroupItem( SelectableSettingGroupItem(

View File

@ -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),
)
}
},
)
}

View File

@ -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> = _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<Account?> = emptyFlow(),
val deleteDialogVisible: Boolean = false,
val clearDialogVisible: Boolean = false,
)

View File

@ -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()
}

View File

@ -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()
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M20.027,45L29.969,45C31.563,45 33.086,44.367 34.215,43.242L46.215,31.242C48.555,28.898 48.555,25.102 46.215,22.758L29.242,5.785C26.898,3.445 23.102,3.445 20.758,5.785L3.785,22.758C1.445,25.102 1.445,28.898 3.785,31.242L15.785,43.242C16.91,44.367 18.438,45 20.027,45ZM17.465,33.605L16.516,32.656C16.121,32.266 16.121,31.633 16.516,31.242L23.586,24.172C23.977,23.781 24.609,23.781 25,24.172L27.121,26.293C27.512,26.684 27.512,27.316 27.121,27.707L21.219,33.605C21.031,33.793 20.777,33.898 20.512,33.898L18.172,33.898C17.906,33.898 17.652,33.793 17.465,33.605ZM27.828,38.313L26.879,39.266C26.691,39.453 26.438,39.559 26.172,39.559L23.828,39.559C23.563,39.559 23.309,39.453 23.121,39.266L22.172,38.313C21.781,37.922 21.781,37.289 22.172,36.898L24.293,34.777C24.684,34.387 25.316,34.387 25.707,34.777L27.828,36.898C28.219,37.289 28.219,37.922 27.828,38.313ZM10.859,25.586L23.586,12.859C23.977,12.469 24.609,12.469 25,12.859L27.121,14.98C27.512,15.371 27.512,16.004 27.121,16.395L15.566,27.949C15.379,28.137 15.121,28.242 14.859,28.242L12.516,28.242C12.25,28.242 11.996,28.137 11.809,27.949L10.859,27C10.469,26.609 10.469,25.977 10.859,25.586Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88dp"
android:height="88dp"
android:viewportWidth="88"
android:viewportHeight="88">
<path
android:pathData="M28.8,17.8c-17.5,17.5 -19.1,20.4 -19.2,33.2 -0.1,7.7 0.2,9.2 3.2,15.2 3.8,7.7 9.2,13 17.1,16.7 8.1,3.7 20.1,3.7 28.2,-0 7.9,-3.7 13.3,-9 17.1,-16.7 3,-6 3.3,-7.5 3.2,-15.2 -0.1,-12.8 -1.7,-15.7 -19.2,-33.2 -8,-8.2 -14.9,-14.8 -15.2,-14.8 -0.3,-0 -7.2,6.6 -15.2,14.8zM53.8,37.3c4.7,3.1 7.2,8 7.2,14.2 0,7.9 -5.1,14.4 -13,16.5 -7.5,2 -17,-2.8 -20,-10 -1.8,-4.3 -0.8,-13 1.8,-16.7 5.2,-7.4 16.2,-9.2 24,-4z"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,16 @@
<vector android:height="64dp" android:viewportHeight="256"
android:viewportWidth="256" android:width="64dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#0062BE" android:pathData="M128,128m-33,0a33,33 0,1 1,66 0a33,33 0,1 1,-66 0"/>
<path android:fillColor="#00000000"
android:pathData="M12,128A116,116 0,1 1,128 244"
android:strokeAlpha="0.3" android:strokeColor="#0062BE" android:strokeWidth="24"/>
<path android:fillColor="#00000000"
android:pathData="M54,128A74,74 0,1 1,128 202"
android:strokeAlpha="0.3" android:strokeColor="#0062BE" android:strokeWidth="24"/>
<path android:fillColor="#00000000"
android:pathData="M128,12A116,116 0,0 1,244 128"
android:strokeColor="#0062BE" android:strokeWidth="24"/>
<path android:fillColor="#00000000"
android:pathData="M128,54A74,74 0,0 1,202 128"
android:strokeColor="#0062BE" android:strokeWidth="24"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:pathData="M36,0C55.882,0 72,16.118 72,36C72,55.882 55.882,72 36,72C16.118,72 0,55.882 0,36C0,16.118 16.118,0 36,0ZM28.01,30.98C20.825,30.98 15,36.804 15,43.989C15,51.175 20.825,57 28.01,57C35.193,57 41.019,51.175 41.019,43.989C41.019,36.804 35.193,30.98 28.01,30.98ZM31.679,36.545C33.736,36.545 35.404,38.215 35.404,40.27C35.404,42.331 33.736,43.999 31.679,43.999C29.619,43.999 27.95,42.331 27.95,40.27C27.95,38.215 29.619,36.545 31.679,36.545ZM28.01,21.787L28.01,26.474C37.666,26.474 45.526,34.332 45.526,43.989L45.526,43.989L50.214,43.989C50.214,31.747 40.254,21.787 28.01,21.787L28.01,21.787ZM28.01,12L28.01,16.687C35.302,16.687 42.158,19.528 47.316,24.685C52.474,29.842 55.311,36.699 55.311,43.989L55.311,43.989L60,43.989C60,35.445 56.672,27.412 50.629,21.368C44.586,15.327 36.555,12 28.01,12L28.01,12Z"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</vector>

View File

@ -301,4 +301,46 @@
<string name="images_desc">圆角、水平边距</string> <string name="images_desc">圆角、水平边距</string>
<string name="videos_desc">圆角、水平边距</string> <string name="videos_desc">圆角、水平边距</string>
<string name="maximize">最大化</string> <string name="maximize">最大化</string>
</resources> <string name="every_15_minutes">每 15 分钟</string>
<string name="every_30_minutes">每 30 分钟</string>
<string name="every_1_hour">每 1 小时</string>
<string name="every_2_hours">每 2 小时</string>
<string name="every_3_hours">每 3 小时</string>
<string name="every_6_hours">每 6 小时</string>
<string name="every_12_hours">每 12 小时</string>
<string name="every_1_day">每 1 天</string>
<string name="manually">手动</string>
<string name="always">总是</string>
<string name="for_1_day">1 天</string>
<string name="for_2_days">2 天</string>
<string name="for_3_days">3 天</string>
<string name="for_1_week">1 周</string>
<string name="for_2_weeks">2 周</string>
<string name="for_1_month">1 个月</string>
<string name="local">本地</string>
<string name="local_desc">在这台设备上</string>
<string name="services">服务</string>
<string name="self_hosted">自托管</string>
<string name="fever_desc">已过时,不推荐。</string>
<string name="more">更多</string>
<string name="add_accounts">添加账户</string>
<string name="list">列表</string>
<string name="add_accounts_desc">本地、第三方服务、自托管</string>
<string name="display">显示</string>
<string name="synchronous">同步时</string>
<string name="sync_interval">同步频率</string>
<string name="sync_once_on_start">启动时同步一次</string>
<string name="only_on_wifi">仅限连接 Wi-Fi 时</string>
<string name="only_when_charging">仅限连接充电器时</string>
<string name="keep_archived_articles">保留已归档的文章</string>
<string name="block_list">屏蔽列表</string>
<string name="export_as_opml">导出为 OPML</string>
<string name="clear_all_articles">清空所有文章</string>
<string name="delete_account">删除账户</string>
<string name="clear_all_articles_tips">确定要清空该账户中所有的文章吗?</string>
<string name="delete_account_tips">确定要删除该账户吗?</string>
<string name="must_have_an_account">必须保留一个账户</string>
<string name="clear_all_articles_toast">已清空该账户的所有文章</string>
<string name="delete_account_toast">该账户已被删除</string>
<string name="synchronous_tips">需要重新启动才能生效。</string>
</resources>

View File

@ -330,4 +330,55 @@
<string name="images_desc">Rounded corners, horizontal padding</string> <string name="images_desc">Rounded corners, horizontal padding</string>
<string name="videos_desc">Rounded corners, horizontal padding</string> <string name="videos_desc">Rounded corners, horizontal padding</string>
<string name="maximize">Maximize</string> <string name="maximize">Maximize</string>
<string name="every_15_minutes">Every 15 minutes</string>
<string name="every_30_minutes">Every 30 minutes</string>
<string name="every_1_hour">Every hour</string>
<string name="every_2_hours">Every 2 hours</string>
<string name="every_3_hours">Every 3 hours</string>
<string name="every_6_hours">Every 6 hours</string>
<string name="every_12_hours">Every 12 hours</string>
<string name="every_1_day">Every day</string>
<string name="manually">Manually</string>
<string name="always">Always</string>
<string name="for_1_day">1 day</string>
<string name="for_2_days">2 days</string>
<string name="for_3_days">3 days</string>
<string name="for_1_week">1 week</string>
<string name="for_2_weeks">2 weeks</string>
<string name="for_1_month">1 month</string>
<string name="local">Local</string>
<string name="fever" translatable="false">Fever</string>
<string name="google_reader" translatable="false">Google Reader</string>
<string name="fresh_rss" translatable="false">FreshRSS</string>
<string name="feedlly" translatable="false">Feedlly</string>
<string name="inoreader" translatable="false">Inoreader</string>
<string name="local_desc">On this device</string>
<string name="services">Services</string>
<string name="feedlly_desc" translatable="false">feedlly.com</string>
<string name="inoreader_desc" translatable="false">inoreader.com</string>
<string name="self_hosted">Self-Hosted</string>
<string name="fresh_rss_desc" translatable="false">freshrss.org</string>
<string name="google_reader_desc" translatable="false">Google Reader API</string>
<string name="fever_desc">Deprecated. Not recommended.</string>
<string name="more">More</string>
<string name="add_accounts">Add accounts</string>
<string name="list">List</string>
<string name="add_accounts_desc">Local, Services, Self-Hosted</string>
<string name="display">Display</string>
<string name="synchronous">Syncing</string>
<string name="sync_interval">Sync</string>
<string name="sync_once_on_start">Sync once on start</string>
<string name="only_on_wifi">Only on Wi-Fi</string>
<string name="only_when_charging">Only when charging</string>
<string name="keep_archived_articles">Keep archived articles</string>
<string name="block_list">Block list</string>
<string name="export_as_opml">Export as OPML</string>
<string name="clear_all_articles">Clear all articles</string>
<string name="delete_account">Delete account</string>
<string name="clear_all_articles_tips">Are you sure you want to clear all articles from this account?</string>
<string name="delete_account_tips">Are you sure you want to delete this account?</string>
<string name="must_have_an_account">Must have an account</string>
<string name="clear_all_articles_toast">All articles from this account have been cleared</string>
<string name="delete_account_toast">This account has been deleted</string>
<string name="synchronous_tips">Restart is require for changes to take effect.</string>
</resources> </resources>