Merge branch 'main' into patch-1

This commit is contained in:
Ashinch 2022-06-01 14:37:24 +08:00 committed by GitHub
commit dbf2f7f194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 4029 additions and 3779 deletions

View File

@ -116,7 +116,7 @@ dependencies {
implementation "org.conscrypt:conscrypt-android:2.5.2"
// https://square.github.io/okhttp/changelogs/changelog/
implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.6"
implementation "com.squareup.okhttp3:okhttp:$okhttp"
implementation "com.squareup.retrofit2:retrofit:$retrofit2"
implementation "com.squareup.retrofit2:converter-gson:$retrofit2"
@ -166,9 +166,9 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/compose-material
implementation "androidx.compose.material:material:$compose"
implementation "androidx.compose.material:material-icons-extended:$compose"
debugImplementation "androidx.compose.ui:ui-tooling:$compose"
implementation "androidx.compose.ui:ui-tooling-preview:$compose"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose"
debugImplementation "androidx.compose.ui:ui-tooling:$compose"
// hilt
implementation "androidx.hilt:hilt-work:1.0.0"

View File

@ -0,0 +1,321 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "98462c2e9c32394054102313366e7262",
"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)",
"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
}
],
"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 DEFAULT false, `isFullContent` INTEGER NOT NULL DEFAULT false, 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,
"defaultValue": "false"
},
{
"fieldPath": "isFullContent",
"columnName": "isFullContent",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"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 DEFAULT true, `isStarred` INTEGER NOT NULL DEFAULT false, `isReadLater` INTEGER NOT NULL DEFAULT false, 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,
"defaultValue": "true"
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isReadLater",
"columnName": "isReadLater",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "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, '98462c2e9c32394054102313366e7262')"
]
}
}

View File

@ -5,11 +5,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<!-- Disable automatic updates in F-Droid -->
<!-- <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />-->
<application
android:name=".App"
android:name=".RYApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/read_you"

View File

@ -12,20 +12,17 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.DispatcherDefault
import me.ash.reader.data.repository.*
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RYDatabase
import me.ash.reader.data.source.RYNetworkDataSource
import me.ash.reader.ui.ext.*
import okhttp3.Cache
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import java.io.File
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidApp
class App : Application(), Configuration.Provider {
class RYApp : Application(), Configuration.Provider {
init {
// From: https://gitlab.com/spacecowboy/Feeder
// Install Conscrypt to handle TLSv1.3 pre Android10
@ -33,7 +30,7 @@ class App : Application(), Configuration.Provider {
}
@Inject
lateinit var readerDatabase: ReaderDatabase
lateinit var RYDatabase: RYDatabase
@Inject
lateinit var workerFactory: HiltWorkerFactory
@ -42,7 +39,7 @@ class App : Application(), Configuration.Provider {
lateinit var workManager: WorkManager
@Inject
lateinit var appNetworkDataSource: AppNetworkDataSource
lateinit var RYNetworkDataSource: RYNetworkDataSource
@Inject
lateinit var opmlLocalDataSource: OpmlLocalDataSource
@ -51,7 +48,10 @@ class App : Application(), Configuration.Provider {
lateinit var rssHelper: RssHelper
@Inject
lateinit var appRepository: AppRepository
lateinit var notificationHelper: NotificationHelper
@Inject
lateinit var ryRepository: RYRepository
@Inject
lateinit var stringsRepository: StringsRepository
@ -62,9 +62,6 @@ class App : Application(), Configuration.Provider {
@Inject
lateinit var localRssRepository: LocalRssRepository
// @Inject
// lateinit var feverRssRepository: FeverRssRepository
@Inject
lateinit var opmlRepository: OpmlRepository
@ -79,6 +76,9 @@ class App : Application(), Configuration.Provider {
@DispatcherDefault
lateinit var dispatcherDefault: CoroutineDispatcher
@Inject
lateinit var okHttpClient: OkHttpClient
@Inject
lateinit var imageLoader: ImageLoader
@ -89,7 +89,7 @@ class App : Application(), Configuration.Provider {
applicationScope.launch(dispatcherDefault) {
accountInit()
workerInit()
if (BuildConfig.FLAVOR != "fdroid") {
if (notFdroid) {
checkUpdate()
}
}
@ -116,7 +116,7 @@ class App : Application(), Configuration.Provider {
it.del()
}
}
appRepository.checkUpdate(showToast = false)
ryRepository.checkUpdate(showToast = false)
}
override fun getWorkManagerConfiguration(): Configuration =
@ -125,28 +125,3 @@ class App : Application(), Configuration.Provider {
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
}
fun cachingHttpClient(
cacheDirectory: File? = null,
cacheSize: Long = 10L * 1024L * 1024L,
trustAllCerts: Boolean = true,
connectTimeoutSecs: Long = 30L,
readTimeoutSecs: Long = 30L
): OkHttpClient {
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
if (cacheDirectory != null) {
builder.cache(Cache(cacheDirectory, cacheSize))
}
builder
.connectTimeout(connectTimeoutSecs, TimeUnit.SECONDS)
.readTimeout(readTimeoutSecs, TimeUnit.SECONDS)
.followRedirects(true)
// if (trustAllCerts) {
// builder.trustAllCerts()
// }
return builder.build()
}

View File

@ -0,0 +1,10 @@
package me.ash.reader.data.constant
object ElevationTokens {
const val Level0 = 0
const val Level1 = 1
const val Level2 = 3
const val Level3 = 6
const val Level4 = 8
const val Level5 = 12
}

View File

@ -18,7 +18,7 @@ interface AccountDao {
WHERE id = :id
"""
)
suspend fun queryById(id: Int): Account
suspend fun queryById(id: Int): Account?
@Insert
suspend fun insert(account: Account): Long

View File

@ -5,7 +5,7 @@ import androidx.room.*
import kotlinx.coroutines.flow.Flow
import me.ash.reader.data.entity.Article
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.entity.ImportantCount
import me.ash.reader.data.model.ImportantCount
import java.util.*
@Dao

View File

@ -1,9 +1,6 @@
package me.ash.reader.data.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import androidx.room.*
import java.util.*
@Entity(
@ -18,13 +15,13 @@ import java.util.*
)
data class Article(
@PrimaryKey
val id: String,
var id: String,
@ColumnInfo
val date: Date,
var date: Date,
@ColumnInfo
val title: String,
var title: String,
@ColumnInfo
val author: String? = null,
var author: String? = null,
@ColumnInfo
var rawDescription: String,
@ColumnInfo
@ -32,17 +29,20 @@ data class Article(
@ColumnInfo
var fullContent: String? = null,
@ColumnInfo
val img: String? = null,
var img: String? = null,
@ColumnInfo
val link: String,
var link: String,
@ColumnInfo(index = true)
val feedId: String,
var feedId: String,
@ColumnInfo(index = true)
val accountId: Int,
var accountId: Int,
@ColumnInfo(defaultValue = "true")
var isUnread: Boolean = true,
@ColumnInfo(defaultValue = "false")
var isStarred: Boolean = false,
@ColumnInfo(defaultValue = "false")
var isReadLater: Boolean = false,
)
) {
@Ignore
var dateString: String? = null
}

View File

@ -5,7 +5,7 @@ import androidx.room.Relation
data class ArticleWithFeed(
@Embedded
val article: Article,
var article: Article,
@Relation(parentColumn = "feedId", entityColumn = "id")
val feed: Feed,
var feed: Feed,
)

View File

@ -14,17 +14,17 @@ import androidx.room.*
)
data class Feed(
@PrimaryKey
val id: String,
var id: String,
@ColumnInfo
val name: String,
var name: String,
@ColumnInfo
var icon: String? = null,
@ColumnInfo
val url: String,
var url: String,
@ColumnInfo(index = true)
var groupId: String,
@ColumnInfo(index = true)
val accountId: Int,
var accountId: Int,
@ColumnInfo(defaultValue = "false")
var isNotification: Boolean = false,
@ColumnInfo(defaultValue = "false")

View File

@ -5,7 +5,7 @@ import androidx.room.Relation
data class FeedWithArticle(
@Embedded
val feed: Feed,
var feed: Feed,
@Relation(parentColumn = "id", entityColumn = "feedId")
val articles: List<Article>
var articles: List<Article>
)

View File

@ -5,7 +5,7 @@ import androidx.room.Relation
data class FeedWithGroup(
@Embedded
val feed: Feed,
var feed: Feed,
@Relation(parentColumn = "groupId", entityColumn = "id")
val group: Group
var group: Group
)

View File

@ -8,11 +8,11 @@ import androidx.room.PrimaryKey
@Entity(tableName = "group")
data class Group(
@PrimaryKey
val id: String,
var id: String,
@ColumnInfo
val name: String,
var name: String,
@ColumnInfo(index = true)
val accountId: Int,
var accountId: Int,
) {
@Ignore
var important: Int? = 0

View File

@ -5,7 +5,7 @@ import androidx.room.Relation
data class GroupWithFeed(
@Embedded
val group: Group,
var group: Group,
@Relation(parentColumn = "id", entityColumn = "groupId")
val feeds: MutableList<Feed>
var feeds: MutableList<Feed>
)

View File

@ -1,23 +0,0 @@
package me.ash.reader.data.entity
data class LatestRelease(
val html_url: String? = null,
val tag_name: String? = null,
val name: String? = null,
val draft: Boolean? = null,
val prerelease: Boolean? = null,
val created_at: String? = null,
val published_at: String? = null,
val assets: List<AssetsItem>? = null,
val body: String? = null,
)
data class AssetsItem(
val name: String? = null,
val content_type: String? = null,
val size: Int? = null,
val download_count: Int? = null,
val created_at: String? = null,
val updated_at: String? = null,
val browser_download_url: String? = null,
)

View File

@ -1,4 +1,4 @@
package me.ash.reader.data.entity
package me.ash.reader.data.model
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FiberManualRecord
@ -6,7 +6,10 @@ import androidx.compose.material.icons.rounded.FiberManualRecord
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Subject
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import me.ash.reader.R
class Filter(
val index: Int,
@ -33,5 +36,13 @@ class Filter(
iconOutline = Icons.Rounded.Subject,
iconFilled = Icons.Rounded.Subject,
)
val values = listOf(Starred, Unread, All)
}
}
@Composable
fun Filter.getName(): String = when (this) {
Filter.Unread -> stringResource(R.string.unread)
Filter.Starred -> stringResource(R.string.starred)
else -> stringResource(R.string.all)
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.data.entity
package me.ash.reader.data.model
data class ImportantCount(
val important: Int,

View File

@ -0,0 +1,2 @@
package me.ash.reader.data.model

View File

@ -1,4 +1,4 @@
package me.ash.reader.data.entity
package me.ash.reader.data.model
class Version(identifiers: List<String>) {
private var major: Int = 0

View File

@ -10,7 +10,7 @@ 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.source.ReaderDatabase
import me.ash.reader.data.source.RYDatabase
import javax.inject.Singleton
@Module
@ -19,26 +19,26 @@ object DatabaseModule {
@Provides
@Singleton
fun provideArticleDao(readerDatabase: ReaderDatabase): ArticleDao =
readerDatabase.articleDao()
fun provideArticleDao(RYDatabase: RYDatabase): ArticleDao =
RYDatabase.articleDao()
@Provides
@Singleton
fun provideFeedDao(readerDatabase: ReaderDatabase): FeedDao =
readerDatabase.feedDao()
fun provideFeedDao(RYDatabase: RYDatabase): FeedDao =
RYDatabase.feedDao()
@Provides
@Singleton
fun provideGroupDao(readerDatabase: ReaderDatabase): GroupDao =
readerDatabase.groupDao()
fun provideGroupDao(RYDatabase: RYDatabase): GroupDao =
RYDatabase.groupDao()
@Provides
@Singleton
fun provideAccountDao(readerDatabase: ReaderDatabase): AccountDao =
readerDatabase.accountDao()
fun provideAccountDao(RYDatabase: RYDatabase): AccountDao =
RYDatabase.accountDao()
@Provides
@Singleton
fun provideReaderDatabase(@ApplicationContext context: Context): ReaderDatabase =
ReaderDatabase.getInstance(context)
fun provideReaderDatabase(@ApplicationContext context: Context): RYDatabase =
RYDatabase.getInstance(context)
}

View File

@ -15,7 +15,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import me.ash.reader.cachingHttpClient
import okhttp3.OkHttpClient
import javax.inject.Singleton
@Module
@ -25,16 +25,11 @@ object ImageLoaderModule {
@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context
@ApplicationContext context: Context,
okHttpClient: OkHttpClient,
): ImageLoader {
return ImageLoader.Builder(context)
.okHttpClient(
okHttpClient = cachingHttpClient(
cacheDirectory = context.cacheDir.resolve("http")
).newBuilder()
//.addNetworkInterceptor(UserAgentInterceptor)
.build()
)
.okHttpClient(okHttpClient)
.dispatcher(Dispatchers.Default) // This slightly improves scrolling performance
.components{
add(SvgDecoder.Factory())
@ -59,4 +54,4 @@ object ImageLoaderModule {
)
.build()
}
}
}

View File

@ -0,0 +1,121 @@
/*
* Feeder: Android RSS reader app
* https://gitlab.com/spacecowboy/Feeder
*
* Copyright (C) 2022 Jonas Kalderstam
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package me.ash.reader.data.module
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.ash.reader.BuildConfig
import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.File
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
@Module
@InstallIn(SingletonComponent::class)
object OkHttpClientModule {
@Provides
@Singleton
fun provideOkHttpClient(
@ApplicationContext context: Context
): OkHttpClient = cachingHttpClient(
cacheDirectory = context.cacheDir.resolve("http")
).newBuilder()
.addNetworkInterceptor(UserAgentInterceptor)
.build()
}
fun cachingHttpClient(
cacheDirectory: File? = null,
cacheSize: Long = 10L * 1024L * 1024L,
trustAllCerts: Boolean = true,
connectTimeoutSecs: Long = 30L,
readTimeoutSecs: Long = 30L
): OkHttpClient {
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
if (cacheDirectory != null) {
builder.cache(Cache(cacheDirectory, cacheSize))
}
builder
.connectTimeout(connectTimeoutSecs, TimeUnit.SECONDS)
.readTimeout(readTimeoutSecs, TimeUnit.SECONDS)
.followRedirects(true)
if (trustAllCerts) {
builder.trustAllCerts()
}
return builder.build()
}
fun OkHttpClient.Builder.trustAllCerts() {
try {
val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(trustManager), null)
val sslSocketFactory = sslContext.socketFactory
sslSocketFactory(sslSocketFactory, trustManager)
.hostnameVerifier(HostnameVerifier { _, _ -> true })
} catch (e: NoSuchAlgorithmException) {
// ignore
} catch (e: KeyManagementException) {
// ignore
}
}
object UserAgentInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(
chain.request()
.newBuilder()
.header("User-Agent", USER_AGENT_STRING)
.build()
)
}
}
const val USER_AGENT_STRING = "ReadYou / ${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"

View File

@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.source.RYNetworkDataSource
import me.ash.reader.data.source.FeverApiDataSource
import me.ash.reader.data.source.GoogleReaderApiDataSource
import javax.inject.Singleton
@ -15,8 +15,8 @@ object RetrofitModule {
@Provides
@Singleton
fun provideAppNetworkDataSource(): AppNetworkDataSource =
AppNetworkDataSource.getInstance()
fun provideAppNetworkDataSource(): RYNetworkDataSource =
RYNetworkDataSource.getInstance()
@Provides
@Singleton

View File

@ -3,6 +3,7 @@ package me.ash.reader.data.preference
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -33,6 +34,7 @@ sealed class DarkThemePreference(val value: Int) : Preference() {
}
@Composable
@ReadOnlyComposable
fun isDarkTheme(): Boolean = when (this) {
UseDeviceTheme -> isSystemInDarkTheme()
ON -> true

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FeedsFilterBarTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FeedsFilterBarTonalElevationPreference(0)
object Level1 : FeedsFilterBarTonalElevationPreference(1)
object Level2 : FeedsFilterBarTonalElevationPreference(3)
object Level3 : FeedsFilterBarTonalElevationPreference(6)
object Level4 : FeedsFilterBarTonalElevationPreference(8)
object Level5 : FeedsFilterBarTonalElevationPreference(12)
object Level0 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FeedsFilterBarTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FeedsFilterBarTonalElevationPreference(val value: Int) : Preference
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,13 +42,14 @@ sealed class FeedsFilterBarTonalElevationPreference(val value: Int) : Preference
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FeedsFilterBarTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}
}
}

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FeedsGroupListTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FeedsGroupListTonalElevationPreference(0)
object Level1 : FeedsGroupListTonalElevationPreference(1)
object Level2 : FeedsGroupListTonalElevationPreference(3)
object Level3 : FeedsGroupListTonalElevationPreference(6)
object Level4 : FeedsGroupListTonalElevationPreference(8)
object Level5 : FeedsGroupListTonalElevationPreference(12)
object Level0 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FeedsGroupListTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FeedsGroupListTonalElevationPreference(val value: Int) : Preference
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,12 +42,12 @@ sealed class FeedsGroupListTonalElevationPreference(val value: Int) : Preference
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FeedsGroupListTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FeedsTopBarTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FeedsTopBarTonalElevationPreference(0)
object Level1 : FeedsTopBarTonalElevationPreference(1)
object Level2 : FeedsTopBarTonalElevationPreference(3)
object Level3 : FeedsTopBarTonalElevationPreference(6)
object Level4 : FeedsTopBarTonalElevationPreference(8)
object Level5 : FeedsTopBarTonalElevationPreference(12)
object Level0 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FeedsTopBarTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FeedsTopBarTonalElevationPreference(val value: Int) : Preference()
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,12 +42,12 @@ sealed class FeedsTopBarTonalElevationPreference(val value: Int) : Preference()
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FeedsTopBarTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FlowArticleListTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FlowArticleListTonalElevationPreference(0)
object Level1 : FlowArticleListTonalElevationPreference(1)
object Level2 : FlowArticleListTonalElevationPreference(3)
object Level3 : FlowArticleListTonalElevationPreference(6)
object Level4 : FlowArticleListTonalElevationPreference(8)
object Level5 : FlowArticleListTonalElevationPreference(12)
object Level0 : FlowArticleListTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FlowArticleListTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FlowArticleListTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FlowArticleListTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FlowArticleListTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FlowArticleListTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FlowArticleListTonalElevationPreference(val value: Int) : Preferenc
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,12 +42,12 @@ sealed class FlowArticleListTonalElevationPreference(val value: Int) : Preferenc
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FlowArticleListTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FlowFilterBarTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FlowFilterBarTonalElevationPreference(0)
object Level1 : FlowFilterBarTonalElevationPreference(1)
object Level2 : FlowFilterBarTonalElevationPreference(3)
object Level3 : FlowFilterBarTonalElevationPreference(6)
object Level4 : FlowFilterBarTonalElevationPreference(8)
object Level5 : FlowFilterBarTonalElevationPreference(12)
object Level0 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FlowFilterBarTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FlowFilterBarTonalElevationPreference(val value: Int) : Preference(
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,12 +42,12 @@ sealed class FlowFilterBarTonalElevationPreference(val value: Int) : Preference(
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FlowFilterBarTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}

View File

@ -4,17 +4,18 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.data.constant.ElevationTokens
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class FlowTopBarTonalElevationPreference(val value: Int) : Preference() {
object Level0 : FlowTopBarTonalElevationPreference(0)
object Level1 : FlowTopBarTonalElevationPreference(1)
object Level2 : FlowTopBarTonalElevationPreference(3)
object Level3 : FlowTopBarTonalElevationPreference(6)
object Level4 : FlowTopBarTonalElevationPreference(8)
object Level5 : FlowTopBarTonalElevationPreference(12)
object Level0 : FlowTopBarTonalElevationPreference(ElevationTokens.Level0)
object Level1 : FlowTopBarTonalElevationPreference(ElevationTokens.Level1)
object Level2 : FlowTopBarTonalElevationPreference(ElevationTokens.Level2)
object Level3 : FlowTopBarTonalElevationPreference(ElevationTokens.Level3)
object Level4 : FlowTopBarTonalElevationPreference(ElevationTokens.Level4)
object Level5 : FlowTopBarTonalElevationPreference(ElevationTokens.Level5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -27,12 +28,12 @@ sealed class FlowTopBarTonalElevationPreference(val value: Int) : Preference() {
fun getDesc(context: Context): String =
when (this) {
Level0 -> "Level 0 (0dp)"
Level1 -> "Level 1 (1dp)"
Level2 -> "Level 2 (3dp)"
Level3 -> "Level 3 (6dp)"
Level4 -> "Level 4 (8dp)"
Level5 -> "Level 5 (12dp)"
Level0 -> "Level 0 (${ElevationTokens.Level0}dp)"
Level1 -> "Level 1 (${ElevationTokens.Level1}dp)"
Level2 -> "Level 2 (${ElevationTokens.Level2}dp)"
Level3 -> "Level 3 (${ElevationTokens.Level3}dp)"
Level4 -> "Level 4 (${ElevationTokens.Level4}dp)"
Level5 -> "Level 5 (${ElevationTokens.Level5}dp)"
}
companion object {
@ -41,12 +42,12 @@ sealed class FlowTopBarTonalElevationPreference(val value: Int) : Preference() {
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.FlowTopBarTonalElevation.key]) {
0 -> Level0
1 -> Level1
3 -> Level2
6 -> Level3
8 -> Level4
12 -> Level5
ElevationTokens.Level0 -> Level0
ElevationTokens.Level1 -> Level1
ElevationTokens.Level2 -> Level2
ElevationTokens.Level3 -> Level3
ElevationTokens.Level4 -> Level4
ElevationTokens.Level5 -> Level5
else -> default
}
}

View File

@ -0,0 +1,45 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class InitialFilterPreference(val value: Int) : Preference() {
object Starred : InitialFilterPreference(0)
object Unread : InitialFilterPreference(1)
object All : InitialFilterPreference(2)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
context.dataStore.put(
DataStoreKeys.InitialFilter,
value
)
}
}
fun getDesc(context: Context): String =
when (this) {
Starred -> context.getString(R.string.starred)
Unread -> context.getString(R.string.unread)
All -> context.getString(R.string.all)
}
companion object {
val default = All
val values = listOf(Starred, Unread, All)
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.InitialFilter.key]) {
0 -> Starred
1 -> Unread
2 -> All
else -> default
}
}
}

View File

@ -0,0 +1,42 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
sealed class InitialPagePreference(val value: Int) : Preference() {
object FeedsPage : InitialPagePreference(0)
object FlowPage : InitialPagePreference(1)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
context.dataStore.put(
DataStoreKeys.InitialPage,
value
)
}
}
fun getDesc(context: Context): String =
when (this) {
FeedsPage -> context.getString(R.string.feeds_page)
FlowPage -> context.getString(R.string.flow_page)
}
companion object {
val default = FeedsPage
val values = listOf(FeedsPage, FlowPage)
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKeys.InitialPage.key]) {
0 -> FeedsPage
1 -> FlowPage
else -> default
}
}
}

View File

@ -17,6 +17,8 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
object English : LanguagesPreference(1)
object ChineseSimplified : LanguagesPreference(2)
object German : LanguagesPreference(3)
object French : LanguagesPreference(4)
object Czech : LanguagesPreference(5)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
@ -34,6 +36,8 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
English -> context.getString(R.string.english)
ChineseSimplified -> context.getString(R.string.chinese_simplified)
German -> context.getString(R.string.german)
French -> context.getString(R.string.french)
Czech -> context.getString(R.string.czech)
}
fun getLocale(): Locale =
@ -42,6 +46,8 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
English -> Locale("en", "US")
ChineseSimplified -> Locale("zh", "CN")
German -> Locale("de", "DE")
French -> Locale("fr", "FR")
Czech -> Locale("cs", "CZ")
}
fun setLocale(context: Context) {
@ -68,7 +74,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
companion object {
val default = UseDeviceLanguages
val values = listOf(UseDeviceLanguages, English, ChineseSimplified, German)
val values = listOf(UseDeviceLanguages, English, ChineseSimplified, German, French, Czech)
fun fromPreferences(preferences: Preferences): LanguagesPreference =
when (preferences[DataStoreKeys.Languages.key]) {
@ -76,6 +82,8 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
1 -> English
2 -> ChineseSimplified
3 -> German
4 -> French
5 -> Czech
else -> default
}
@ -85,6 +93,8 @@ sealed class LanguagesPreference(val value: Int) : Preference() {
1 -> English
2 -> ChineseSimplified
3 -> German
4 -> French
5 -> Czech
else -> default
}
}

View File

@ -0,0 +1,23 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object NewVersionDownloadUrlPreference {
const val default = ""
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.NewVersionDownloadUrl, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.NewVersionDownloadUrl.key] ?: default
}

View File

@ -0,0 +1,23 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object NewVersionLogPreference {
const val default = ""
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.NewVersionLog, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.NewVersionLog.key] ?: default
}

View File

@ -0,0 +1,25 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.data.model.Version
import me.ash.reader.data.model.toVersion
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object NewVersionNumberPreference {
val default = Version()
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.NewVersionNumber, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.NewVersionNumber.key].toVersion()
}

View File

@ -0,0 +1,23 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object NewVersionPublishDatePreference {
const val default = ""
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.NewVersionPublishDate, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.NewVersionPublishDate.key] ?: default
}

View File

@ -0,0 +1,28 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object NewVersionSizePreference {
const val default = ""
fun Int.formatSize(): String =
(this / 1024f / 1024f)
.takeIf { it > 0f }
?.run { " ${String.format("%.2f", this)} MB" } ?: ""
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.NewVersionSize, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.NewVersionSize.key] ?: default
}

View File

@ -1,8 +1,53 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
sealed class Preference {
abstract fun put(context: Context, scope: CoroutineScope)
}
fun Preferences.toSettings(): Settings {
return Settings(
newVersionNumber = NewVersionNumberPreference.fromPreferences(this),
skipVersionNumber = SkipVersionNumberPreference.fromPreferences(this),
newVersionPublishDate = NewVersionPublishDatePreference.fromPreferences(this),
newVersionLog = NewVersionLogPreference.fromPreferences(this),
newVersionSize = NewVersionSizePreference.fromPreferences(this),
newVersionDownloadUrl = NewVersionDownloadUrlPreference.fromPreferences(this),
themeIndex = ThemeIndexPreference.fromPreferences(this),
customPrimaryColor = CustomPrimaryColorPreference.fromPreferences(this),
darkTheme = DarkThemePreference.fromPreferences(this),
amoledDarkTheme = AmoledDarkThemePreference.fromPreferences(this),
feedsFilterBarStyle = FeedsFilterBarStylePreference.fromPreferences(this),
feedsFilterBarFilled = FeedsFilterBarFilledPreference.fromPreferences(this),
feedsFilterBarPadding = FeedsFilterBarPaddingPreference.fromPreferences(this),
feedsFilterBarTonalElevation = FeedsFilterBarTonalElevationPreference.fromPreferences(this),
feedsTopBarTonalElevation = FeedsTopBarTonalElevationPreference.fromPreferences(this),
feedsGroupListExpand = FeedsGroupListExpandPreference.fromPreferences(this),
feedsGroupListTonalElevation = FeedsGroupListTonalElevationPreference.fromPreferences(this),
flowFilterBarStyle = FlowFilterBarStylePreference.fromPreferences(this),
flowFilterBarFilled = FlowFilterBarFilledPreference.fromPreferences(this),
flowFilterBarPadding = FlowFilterBarPaddingPreference.fromPreferences(this),
flowFilterBarTonalElevation = FlowFilterBarTonalElevationPreference.fromPreferences(this),
flowTopBarTonalElevation = FlowTopBarTonalElevationPreference.fromPreferences(this),
flowArticleListFeedIcon = FlowArticleListFeedIconPreference.fromPreferences(this),
flowArticleListFeedName = FlowArticleListFeedNamePreference.fromPreferences(this),
flowArticleListImage = FlowArticleListImagePreference.fromPreferences(this),
flowArticleListDesc = FlowArticleListDescPreference.fromPreferences(this),
flowArticleListTime = FlowArticleListTimePreference.fromPreferences(this),
flowArticleListDateStickyHeader = FlowArticleListDateStickyHeaderPreference.fromPreferences(
this
),
flowArticleListTonalElevation = FlowArticleListTonalElevationPreference.fromPreferences(this),
initialPage = InitialPagePreference.fromPreferences(this),
initialFilter = InitialFilterPreference.fromPreferences(this),
languages = LanguagesPreference.fromPreferences(this),
)
}

View File

@ -6,12 +6,19 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.flow.map
import me.ash.reader.data.model.Version
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.dataStore
data class Settings(
val newVersionNumber: Version = NewVersionNumberPreference.default,
val skipVersionNumber: Version = SkipVersionNumberPreference.default,
val newVersionPublishDate: String = NewVersionPublishDatePreference.default,
val newVersionLog: String = NewVersionLogPreference.default,
val newVersionSize: String = NewVersionSizePreference.default,
val newVersionDownloadUrl: String = NewVersionDownloadUrlPreference.default,
val themeIndex: Int = ThemeIndexPreference.default,
val customPrimaryColor: String = CustomPrimaryColorPreference.default,
val darkTheme: DarkThemePreference = DarkThemePreference.default,
@ -38,43 +45,12 @@ data class Settings(
val flowArticleListDateStickyHeader: FlowArticleListDateStickyHeaderPreference = FlowArticleListDateStickyHeaderPreference.default,
val flowArticleListTonalElevation: FlowArticleListTonalElevationPreference = FlowArticleListTonalElevationPreference.default,
val initialPage: InitialPagePreference = InitialPagePreference.default,
val initialFilter: InitialFilterPreference = InitialFilterPreference.default,
val languages: LanguagesPreference = LanguagesPreference.default,
)
fun Preferences.toSettings(): Settings {
return Settings(
themeIndex = ThemeIndexPreference.fromPreferences(this),
customPrimaryColor = CustomPrimaryColorPreference.fromPreferences(this),
darkTheme = DarkThemePreference.fromPreferences(this),
amoledDarkTheme = AmoledDarkThemePreference.fromPreferences(this),
feedsFilterBarStyle = FeedsFilterBarStylePreference.fromPreferences(this),
feedsFilterBarFilled = FeedsFilterBarFilledPreference.fromPreferences(this),
feedsFilterBarPadding = FeedsFilterBarPaddingPreference.fromPreferences(this),
feedsFilterBarTonalElevation = FeedsFilterBarTonalElevationPreference.fromPreferences(this),
feedsTopBarTonalElevation = FeedsTopBarTonalElevationPreference.fromPreferences(this),
feedsGroupListExpand = FeedsGroupListExpandPreference.fromPreferences(this),
feedsGroupListTonalElevation = FeedsGroupListTonalElevationPreference.fromPreferences(this),
flowFilterBarStyle = FlowFilterBarStylePreference.fromPreferences(this),
flowFilterBarFilled = FlowFilterBarFilledPreference.fromPreferences(this),
flowFilterBarPadding = FlowFilterBarPaddingPreference.fromPreferences(this),
flowFilterBarTonalElevation = FlowFilterBarTonalElevationPreference.fromPreferences(this),
flowTopBarTonalElevation = FlowTopBarTonalElevationPreference.fromPreferences(this),
flowArticleListFeedIcon = FlowArticleListFeedIconPreference.fromPreferences(this),
flowArticleListFeedName = FlowArticleListFeedNamePreference.fromPreferences(this),
flowArticleListImage = FlowArticleListImagePreference.fromPreferences(this),
flowArticleListDesc = FlowArticleListDescPreference.fromPreferences(this),
flowArticleListTime = FlowArticleListTimePreference.fromPreferences(this),
flowArticleListDateStickyHeader = FlowArticleListDateStickyHeaderPreference.fromPreferences(
this
),
flowArticleListTonalElevation = FlowArticleListTonalElevationPreference.fromPreferences(this),
languages = LanguagesPreference.fromPreferences(this),
)
}
@Composable
fun SettingsProvider(
content: @Composable () -> Unit,
@ -88,6 +64,13 @@ fun SettingsProvider(
}.collectAsStateValue(initial = Settings())
CompositionLocalProvider(
LocalNewVersionNumber provides settings.newVersionNumber,
LocalSkipVersionNumber provides settings.skipVersionNumber,
LocalNewVersionPublishDate provides settings.newVersionPublishDate,
LocalNewVersionLog provides settings.newVersionLog,
LocalNewVersionSize provides settings.newVersionSize,
LocalNewVersionDownloadUrl provides settings.newVersionDownloadUrl,
LocalThemeIndex provides settings.themeIndex,
LocalCustomPrimaryColor provides settings.customPrimaryColor,
LocalDarkTheme provides settings.darkTheme,
@ -114,12 +97,22 @@ fun SettingsProvider(
LocalFlowFilterBarPadding provides settings.flowFilterBarPadding,
LocalFlowFilterBarTonalElevation provides settings.flowFilterBarTonalElevation,
LocalInitialPage provides settings.initialPage,
LocalInitialFilter provides settings.initialFilter,
LocalLanguages provides settings.languages,
) {
content()
}
}
val LocalNewVersionNumber = compositionLocalOf { NewVersionNumberPreference.default }
val LocalSkipVersionNumber = compositionLocalOf { SkipVersionNumberPreference.default }
val LocalNewVersionPublishDate = compositionLocalOf { NewVersionPublishDatePreference.default }
val LocalNewVersionLog = compositionLocalOf { NewVersionLogPreference.default }
val LocalNewVersionSize = compositionLocalOf { NewVersionSizePreference.default }
val LocalNewVersionDownloadUrl = compositionLocalOf { NewVersionDownloadUrlPreference.default }
val LocalThemeIndex =
compositionLocalOf { ThemeIndexPreference.default }
val LocalCustomPrimaryColor =
@ -169,5 +162,9 @@ val LocalFlowArticleListDateStickyHeader =
val LocalFlowArticleListTonalElevation =
compositionLocalOf<FlowArticleListTonalElevationPreference> { FlowArticleListTonalElevationPreference.default }
val LocalInitialPage = compositionLocalOf<InitialPagePreference> { InitialPagePreference.default }
val LocalInitialFilter =
compositionLocalOf<InitialFilterPreference> { InitialFilterPreference.default }
val LocalLanguages =
compositionLocalOf<LanguagesPreference> { LanguagesPreference.default }

View File

@ -0,0 +1,25 @@
package me.ash.reader.data.preference
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.ash.reader.data.model.Version
import me.ash.reader.data.model.toVersion
import me.ash.reader.ui.ext.DataStoreKeys
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
object SkipVersionNumberPreference {
val default = Version()
fun put(context: Context, scope: CoroutineScope, value: String) {
scope.launch(Dispatchers.IO) {
context.dataStore.put(DataStoreKeys.SkipVersionNumber, value)
}
}
fun fromPreferences(preferences: Preferences) =
preferences[DataStoreKeys.SkipVersionNumber.key].toVersion()
}

View File

@ -2,14 +2,15 @@ package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.paging.PagingSource
import androidx.work.*
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
@ -17,7 +18,6 @@ import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.entity.*
import me.ash.reader.ui.ext.currentAccountId
import java.util.*
import java.util.concurrent.TimeUnit
abstract class AbstractRssRepository constructor(
private val context: Context,
@ -99,7 +99,7 @@ abstract class AbstractRssRepository constructor(
fun pullImportant(
isStarred: Boolean = false,
isUnread: Boolean = false,
): Flow<List<ImportantCount>> {
): Flow<Map<String, Int>> {
val accountId = context.currentAccountId
Log.i(
"RLog",
@ -111,6 +111,12 @@ abstract class AbstractRssRepository constructor(
isUnread -> articleDao
.queryImportantCountWhenIsUnread(accountId, isUnread)
else -> articleDao.queryImportantCountWhenIsAll(accountId)
}.mapLatest {
mapOf(
*(it.map {
it.feedId to it.important
}.toTypedArray())
)
}.flowOn(dispatcherIO)
}
@ -130,10 +136,6 @@ abstract class AbstractRssRepository constructor(
return feedDao.queryByLink(context.currentAccountId, url).isNotEmpty()
}
fun peekWork(): String {
return workManager.getWorkInfosByTag("sync").get().size.toString()
}
suspend fun updateGroup(group: Group) {
groupDao.update(group)
}
@ -207,34 +209,3 @@ abstract class AbstractRssRepository constructor(
}
}
}
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val rssRepository: RssRepository,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Log.i("RLog", "doWork: ")
return rssRepository.get().sync(this)
}
companion object {
const val WORK_NAME = "article.sync"
val UUID: UUID
val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.build()
).addTag(WORK_NAME).build().also {
UUID = it.id
}
fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean)
fun Data.getIsSyncing(): Boolean = getBoolean("isSyncing", false)
}
}

View File

@ -1,163 +0,0 @@
//package me.ash.reader.data.repository
//
//import android.content.Context
//import android.util.Log
//import androidx.work.WorkManager
//import dagger.hilt.android.qualifiers.ApplicationContext
//import kotlinx.coroutines.CoroutineDispatcher
//import kotlinx.coroutines.CoroutineScope
//import kotlinx.coroutines.launch
//import kotlinx.coroutines.sync.withLock
//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.entity.Article
//import me.ash.reader.data.entity.Feed
//import me.ash.reader.data.entity.Group
//import me.ash.reader.data.module.ApplicationScope
//import me.ash.reader.data.module.DispatcherDefault
//import me.ash.reader.data.module.DispatcherIO
//import me.ash.reader.data.source.FeverApiDataSource
//import me.ash.reader.data.source.RssNetworkDataSource
//import me.ash.reader.ui.ext.currentAccountId
//import me.ash.reader.ui.ext.spacerDollar
//import net.dankito.readability4j.extended.Readability4JExtended
//import java.util.*
//import javax.inject.Inject
//import kotlin.collections.set
//
//class FeverRssRepository @Inject constructor(
// @ApplicationContext
// private val context: Context,
// private val articleDao: ArticleDao,
// private val feedDao: FeedDao,
// private val groupDao: GroupDao,
// private val rssHelper: RssHelper,
// private val feverApiDataSource: FeverApiDataSource,
// private val accountDao: AccountDao,
// rssNetworkDataSource: RssNetworkDataSource,
// @ApplicationScope
// private val applicationScope: CoroutineScope,
// @DispatcherDefault
// private val dispatcherDefault: CoroutineDispatcher,
// @DispatcherIO
// private val dispatcherIO: CoroutineDispatcher,
// workManager: WorkManager,
//) : AbstractRssRepository(
// context, accountDao, articleDao, groupDao,
// feedDao, rssNetworkDataSource, workManager,
// dispatcherIO
//) {
// override suspend fun updateArticleInfo(article: Article) {
// articleDao.update(article)
// }
//
// override suspend fun subscribe(feed: Feed, articles: List<Article>) {
// feedDao.insert(feed)
// articleDao.insertList(articles.map {
// it.copy(feedId = feed.id)
// })
// }
//
// override suspend fun addGroup(name: String): String {
// return UUID.randomUUID().toString().also {
// groupDao.insert(
// Group(
// id = it,
// name = name,
// accountId = context.currentAccountId
// )
// )
// }
// }
//
// override suspend fun sync() {
// applicationScope.launch(dispatcherDefault) {
// mutex.withLock {
// val accountId = context.currentAccountId
//
// updateSyncState {
// it.copy(
// feedCount = 1,
// syncedCount = 1,
// currentFeedName = "Fever"
// )
// }
//
// if (feedDao.queryAll(accountId).isNullOrEmpty()) {
// // Temporary add feeds
// val feverFeeds = feverApiDataSource.feeds().execute().body()!!.feeds
// val feverGroupsBody = feverApiDataSource.groups().execute().body()!!
// Log.i("RLog", "Fever groups: $feverGroupsBody")
// feverGroupsBody.groups.forEach {
// groupDao.insert(
// Group(
// id = accountId.spacerDollar(it.id),
// name = it.title,
// accountId = accountId,
// )
// )
// }
// val feverFeedsGroupsMap = mutableMapOf<Int, Int>()
// feverGroupsBody.feeds_groups.forEach { item ->
// item.feed_ids
// .split(",")
// .map { it.toInt() }
// .forEach { id ->
// feverFeedsGroupsMap[id] = item.group_id
// }
// }
// val feeds = feverFeeds.map {
// Feed(
// id = accountId.spacerDollar(it.id),
// name = it.title,
// url = it.url,
// groupId = feverFeedsGroupsMap[it.id].toString(),
// accountId = accountId
// )
// }
// feedDao.insertList(feeds)
// }
//
// // Add articles
// val articles = mutableListOf<Article>()
// feverApiDataSource.itemsBySince(since = 1647444325925621L)
// .execute().body()!!.items
// .forEach {
// articles.add(
// Article(
// id = accountId.spacerDollar(it.id),
// date = Date(it.created_on_time * 1000),
// title = it.title,
// author = it.author,
// rawDescription = it.html,
// shortDescription = (
// Readability4JExtended("", it.html)
// .parse().textContent ?: ""
// ).take(100).trim(),
// link = it.url,
// accountId = accountId,
// feedId = it.feed_id.toString(),
// isUnread = it.is_read == 0,
// isStarred = it.is_saved == 1,
// )
// )
// }
// articleDao.insertList(articles)
//
// // Complete sync
// accountDao.update(accountDao.queryById(accountId)!!.apply {
// updateAt = Date()
// })
// updateSyncState {
// it.copy(
// feedCount = 0,
// syncedCount = 0,
// currentFeedName = ""
// )
// }
// }
// }
// }
//}

View File

@ -1,12 +1,7 @@
package me.ash.reader.data.repository
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
@ -15,8 +10,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import me.ash.reader.MainActivity
import me.ash.reader.R
import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
@ -30,8 +23,6 @@ import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.repository.SyncWorker.Companion.setIsSyncing
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.spacerDollar
import me.ash.reader.ui.page.common.ExtraName
import me.ash.reader.ui.page.common.NotificationGroupName
import java.util.*
import javax.inject.Inject
@ -41,6 +32,7 @@ class LocalRssRepository @Inject constructor(
private val articleDao: ArticleDao,
private val feedDao: FeedDao,
private val rssHelper: RssHelper,
private val notificationHelper: NotificationHelper,
private val accountDao: AccountDao,
private val groupDao: GroupDao,
@DispatcherDefault
@ -52,16 +44,6 @@ class LocalRssRepository @Inject constructor(
context, accountDao, articleDao, groupDao,
feedDao, workManager, dispatcherIO
) {
private val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(context).apply {
createNotificationChannel(
NotificationChannel(
NotificationGroupName.ARTICLE_UPDATE,
NotificationGroupName.ARTICLE_UPDATE,
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
override suspend fun updateArticleInfo(article: Article) {
articleDao.update(article)
@ -98,7 +80,7 @@ class LocalRssRepository @Inject constructor(
.awaitAll()
.forEach {
if (it.isNotify) {
notify(
notificationHelper.notify(
FeedWithArticle(
it.feedWithArticle.feed,
articleDao.insertIfNotExist(it.feedWithArticle.articles)
@ -183,75 +165,4 @@ class LocalRssRepository @Inject constructor(
isNotify = articles.isNotEmpty() && feed.isNotification
)
}
private fun notify(
feedWithArticle: FeedWithArticle,
) {
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(
feedWithArticle.feed.id,
feedWithArticle.feed.name
)
)
feedWithArticle.articles.forEach { article ->
val builder = NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(
(BitmapFactory.decodeResource(
context.resources,
R.drawable.ic_notification
))
)
.setContentTitle(article.title)
.setContentIntent(
PendingIntent.getActivity(
context,
Random().nextInt() + article.id.hashCode(),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(
ExtraName.ARTICLE_ID,
article.id
)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setGroup(feedWithArticle.feed.id)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(article.shortDescription)
.setSummaryText(feedWithArticle.feed.name)
)
notificationManager.notify(
Random().nextInt() + article.id.hashCode(),
builder.build().apply {
flags = Notification.FLAG_AUTO_CANCEL
}
)
}
if (feedWithArticle.articles.size > 1) {
notificationManager.notify(
Random().nextInt() + feedWithArticle.feed.id.hashCode(),
NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(
(BitmapFactory.decodeResource(
context.resources,
R.drawable.ic_notification
))
)
.setStyle(
NotificationCompat.InboxStyle()
.setSummaryText(feedWithArticle.feed.name)
)
.setGroup(feedWithArticle.feed.id)
.setGroupSummary(true)
.build()
)
}
}
}

View File

@ -0,0 +1,103 @@
package me.ash.reader.data.repository
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import me.ash.reader.MainActivity
import me.ash.reader.R
import me.ash.reader.data.entity.FeedWithArticle
import me.ash.reader.ui.page.common.ExtraName
import me.ash.reader.ui.page.common.NotificationGroupName
import java.util.*
import javax.inject.Inject
class NotificationHelper @Inject constructor(
@ApplicationContext
private val context: Context,
) {
private val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(context).apply {
createNotificationChannel(
NotificationChannel(
NotificationGroupName.ARTICLE_UPDATE,
NotificationGroupName.ARTICLE_UPDATE,
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
fun notify(
feedWithArticle: FeedWithArticle,
) {
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(
feedWithArticle.feed.id,
feedWithArticle.feed.name
)
)
feedWithArticle.articles.forEach { article ->
val builder = NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(
(BitmapFactory.decodeResource(
context.resources,
R.drawable.ic_notification
))
)
.setContentTitle(article.title)
.setContentIntent(
PendingIntent.getActivity(
context,
Random().nextInt() + article.id.hashCode(),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(
ExtraName.ARTICLE_ID,
article.id
)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setGroup(feedWithArticle.feed.id)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(article.shortDescription)
.setSummaryText(feedWithArticle.feed.name)
)
notificationManager.notify(
Random().nextInt() + article.id.hashCode(),
builder.build().apply {
flags = Notification.FLAG_AUTO_CANCEL
}
)
}
if (feedWithArticle.articles.size > 1) {
notificationManager.notify(
Random().nextInt() + feedWithArticle.feed.id.hashCode(),
NotificationCompat.Builder(context, NotificationGroupName.ARTICLE_UPDATE)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(
(BitmapFactory.decodeResource(
context.resources,
R.drawable.ic_notification
))
)
.setStyle(
NotificationCompat.InboxStyle()
.setSummaryText(feedWithArticle.feed.name)
)
.setGroup(feedWithArticle.feed.id)
.setGroupSummary(true)
.build()
)
}
}
}

View File

@ -43,7 +43,7 @@ class OpmlRepository @Inject constructor(
repeatList.add(it)
}
}
feedDao.insertList((groupWithFeed.feeds subtract repeatList).toList())
feedDao.insertList((groupWithFeed.feeds subtract repeatList.toSet()).toList())
}
}
@ -54,7 +54,7 @@ class OpmlRepository @Inject constructor(
Opml(
"2.0",
Head(
accountDao.queryById(context.currentAccountId).name,
accountDao.queryById(context.currentAccountId)?.name,
Date().toString(), null, null, null,
null, null, null, null,
null, null, null, null,

View File

@ -4,27 +4,28 @@ import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.withContext
import me.ash.reader.R
import me.ash.reader.data.entity.toVersion
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.model.toVersion
import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.module.DispatcherMain
import me.ash.reader.data.source.AppNetworkDataSource
import me.ash.reader.data.preference.*
import me.ash.reader.data.preference.NewVersionSizePreference.formatSize
import me.ash.reader.data.source.Download
import me.ash.reader.data.source.RYNetworkDataSource
import me.ash.reader.data.source.downloadToFileWithProgress
import me.ash.reader.ui.ext.*
import me.ash.reader.ui.ext.getCurrentVersion
import me.ash.reader.ui.ext.getLatestApk
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.ext.skipVersionNumber
import javax.inject.Inject
class AppRepository @Inject constructor(
class RYRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val appNetworkDataSource: AppNetworkDataSource,
@ApplicationScope
private val applicationScope: CoroutineScope,
private val RYNetworkDataSource: RYNetworkDataSource,
@DispatcherIO
private val dispatcherIO: CoroutineDispatcher,
@DispatcherMain
@ -33,7 +34,7 @@ class AppRepository @Inject constructor(
suspend fun checkUpdate(showToast: Boolean = true): Boolean? = withContext(dispatcherIO) {
try {
val response =
appNetworkDataSource.getReleaseLatest(context.getString(R.string.update_link))
RYNetworkDataSource.getReleaseLatest(context.getString(R.string.update_link))
when {
response.code() == 403 -> {
withContext(dispatcherMain) {
@ -50,31 +51,22 @@ class AppRepository @Inject constructor(
}
val latest = response.body()!!
val latestVersion = latest.tag_name.toVersion()
// val latestVersion = "0.7.3".toVersion()
// val latestVersion = "1.0.0".toVersion()
val skipVersion = context.skipVersionNumber.toVersion()
val currentVersion = context.getCurrentVersion()
val latestLog = latest.body ?: ""
val latestPublishDate = latest.published_at ?: latest.created_at ?: ""
val latestSize = latest.assets
?.first()
?.size
?: 0
val latestDownloadUrl = latest.assets
?.first()
?.browser_download_url
?: ""
val latestSize = latest.assets?.first()?.size ?: 0
val latestDownloadUrl = latest.assets?.first()?.browser_download_url ?: ""
Log.i("RLog", "current version $currentVersion")
if (latestVersion.whetherNeedUpdate(currentVersion, skipVersion)) {
Log.i("RLog", "new version $latestVersion")
context.dataStore.put(
DataStoreKeys.NewVersionNumber,
latestVersion.toString()
)
context.dataStore.put(DataStoreKeys.NewVersionLog, latestLog)
context.dataStore.put(DataStoreKeys.NewVersionPublishDate, latestPublishDate)
context.dataStore.put(DataStoreKeys.NewVersionSize, latestSize)
context.dataStore.put(DataStoreKeys.NewVersionDownloadUrl, latestDownloadUrl)
NewVersionNumberPreference.put(context, this, latestVersion.toString())
NewVersionLogPreference.put(context, this, latestLog)
NewVersionPublishDatePreference.put(context, this, latestPublishDate)
NewVersionSizePreference.put(context, this, latestSize.formatSize())
NewVersionDownloadUrlPreference.put(context, this, latestDownloadUrl)
true
} else {
false
@ -93,7 +85,7 @@ class AppRepository @Inject constructor(
withContext(dispatcherIO) {
Log.i("RLog", "downloadFile start: $url")
try {
return@withContext appNetworkDataSource.downloadFile(url)
return@withContext RYNetworkDataSource.downloadFile(url)
.downloadToFileWithProgress(context.getLatestApk())
} catch (e: Exception) {
e.printStackTrace()

View File

@ -21,8 +21,6 @@ import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URL
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
@ -31,6 +29,7 @@ class RssHelper @Inject constructor(
private val context: Context,
@DispatcherIO
private val dispatcherIO: CoroutineDispatcher,
private val okHttpClient: OkHttpClient,
) {
@Throws(Exception::class)
suspend fun searchFeed(feedLink: String): FeedWithArticle {
@ -58,7 +57,7 @@ class RssHelper @Inject constructor(
@Throws(Exception::class)
suspend fun parseFullContent(link: String, title: String): String {
return withContext(dispatcherIO) {
val response = OkHttpClient()
val response = okHttpClient
.newCall(Request.Builder().url(link).build())
.execute()
val content = response.body!!.string()
@ -85,7 +84,12 @@ class RssHelper @Inject constructor(
return withContext(dispatcherIO) {
val a = mutableListOf<Article>()
val accountId = context.currentAccountId
val parseRss: SyndFeed = SyndFeedInput().build(XmlReader(URL(feed.url)))
val parseRss: SyndFeed = SyndFeedInput().build(
XmlReader(URL(feed.url).openConnection().apply {
connectTimeout = 5000
readTimeout = 5000
})
)
parseRss.entries.forEach {
if (latestLink != null && latestLink == it.link) return@withContext a
val desc = it.description?.value
@ -110,13 +114,13 @@ class RssHelper @Inject constructor(
date = it.publishedDate ?: it.updatedDate ?: Date(),
title = Html.fromHtml(it.title.toString()).toString(),
author = it.author,
rawDescription = (desc ?: content) ?: "",
rawDescription = (content ?: desc) ?: "",
shortDescription = (Readability4JExtended("", desc ?: content ?: "")
.parse().textContent ?: "")
.take(100)
.trim(),
fullContent = content,
img = findImg((desc ?: content) ?: ""),
img = findImg((content ?: desc) ?: ""),
link = it.link ?: "",
)
)
@ -182,27 +186,4 @@ class RssHelper @Inject constructor(
}
)
}
private fun parseDate(
inputDate: String, patterns: Array<String> = arrayOf(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd",
"yyyy-MM-dd HH:mm:ss",
"yyyyMMdd",
"yyyy/MM/dd",
"yyyy年MM月dd日",
"yyyy MM dd",
)
): Date? {
val df = SimpleDateFormat()
for (pattern in patterns) {
df.applyPattern(pattern)
df.isLenient = false
val date = df.parse(inputDate, ParsePosition(0))
if (date != null) {
return date
}
}
return null
}
}

View File

@ -11,6 +11,13 @@ class StringsRepository @Inject constructor(
private val context: Context,
) {
fun getString(resId: Int, vararg formatArgs: Any) = context.getString(resId, *formatArgs)
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any) = context.resources.getQuantityString(resId, quantity, *formatArgs)
fun formatAsString(date: Date?) = date?.formatAsString(context)
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any) =
context.resources.getQuantityString(resId, quantity, *formatArgs)
fun formatAsString(
date: Date?,
onlyHourMinute: Boolean? = false,
atHourMinute: Boolean? = false
) = date?.formatAsString(context, onlyHourMinute, atHourMinute)
}

View File

@ -0,0 +1,41 @@
package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.*
import java.util.concurrent.TimeUnit
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val rssRepository: RssRepository,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Log.i("RLog", "doWork: ")
return rssRepository.get().sync(this)
}
companion object {
const val WORK_NAME = "article.sync"
val UUID: UUID
val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.build()
).addTag(WORK_NAME).build().also {
UUID = it.id
}
fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean)
fun Data.getIsSyncing(): Boolean = getBoolean("isSyncing", false)
}
}

View File

@ -18,21 +18,21 @@ import java.util.*
entities = [Account::class, Feed::class, Article::class, Group::class],
version = 2,
)
@TypeConverters(ReaderDatabase.Converters::class)
abstract class ReaderDatabase : RoomDatabase() {
@TypeConverters(RYDatabase.Converters::class)
abstract class RYDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun feedDao(): FeedDao
abstract fun articleDao(): ArticleDao
abstract fun groupDao(): GroupDao
companion object {
private var instance: ReaderDatabase? = null
private var instance: RYDatabase? = null
fun getInstance(context: Context): ReaderDatabase {
fun getInstance(context: Context): RYDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
ReaderDatabase::class.java,
RYDatabase::class.java,
"Reader"
).addMigrations(*allMigrations).build().also {
instance = it

View File

@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import me.ash.reader.data.entity.LatestRelease
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.Retrofit
@ -15,13 +14,7 @@ import retrofit2.http.Streaming
import retrofit2.http.Url
import java.io.File
sealed class Download {
object NotYet : Download()
data class Progress(val percent: Int) : Download()
data class Finished(val file: File) : Download()
}
interface AppNetworkDataSource {
interface RYNetworkDataSource {
@GET
suspend fun getReleaseLatest(@Url url: String): Response<LatestRelease>
@ -30,14 +23,14 @@ interface AppNetworkDataSource {
suspend fun downloadFile(@Url url: String): ResponseBody
companion object {
private var instance: AppNetworkDataSource? = null
private var instance: RYNetworkDataSource? = null
fun getInstance(): AppNetworkDataSource {
fun getInstance(): RYNetworkDataSource {
return instance ?: synchronized(this) {
instance ?: Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(AppNetworkDataSource::class.java).also {
.build().create(RYNetworkDataSource::class.java).also {
instance = it
}
}
@ -92,4 +85,32 @@ fun ResponseBody.downloadToFileWithProgress(saveFile: File): Flow<Download> =
saveFile.delete()
}
}
}.flowOn(Dispatchers.IO).distinctUntilChanged()
}.flowOn(Dispatchers.IO).distinctUntilChanged()
data class LatestRelease(
val html_url: String? = null,
val tag_name: String? = null,
val name: String? = null,
val draft: Boolean? = null,
val prerelease: Boolean? = null,
val created_at: String? = null,
val published_at: String? = null,
val assets: List<AssetsItem>? = null,
val body: String? = null,
)
data class AssetsItem(
val name: String? = null,
val content_type: String? = null,
val size: Int? = null,
val download_count: Int? = null,
val created_at: String? = null,
val updated_at: String? = null,
val browser_download_url: String? = null,
)
sealed class Download {
object NotYet : Download()
data class Progress(val percent: Int) : Download()
data class Finished(val file: File) : Download()
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.page.home
package me.ash.reader.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.page.home
package me.ash.reader.ui.component
import android.os.Build
import android.view.SoundEffectConstants
@ -12,16 +12,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import me.ash.reader.data.entity.Filter
import me.ash.reader.data.model.Filter
import me.ash.reader.data.model.getName
import me.ash.reader.data.preference.FlowFilterBarStylePreference
import me.ash.reader.data.preference.LocalThemeIndex
import me.ash.reader.ui.ext.getName
import me.ash.reader.ui.ext.surfaceColorAtElevation
import me.ash.reader.ui.theme.palette.onDark
@Composable
fun FilterBar(
modifier: Modifier = Modifier,
filter: Filter,
filterBarStyle: Int,
filterBarFilled: Boolean,
@ -39,11 +38,7 @@ fun FilterBar(
tonalElevation = filterBarTonalElevation,
) {
Spacer(modifier = Modifier.width(filterBarPadding))
listOf(
Filter.Starred,
Filter.Unread,
Filter.All,
).forEach { item ->
Filter.values.forEach { item ->
NavigationBarItem(
// modifier = Modifier.height(60.dp),
alwaysShowLabel = when (filterBarStyle) {

View File

@ -1,6 +1,6 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.animation.*
import RYExtensibleVisibility
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
@ -38,11 +38,7 @@ fun AnimatedPopup(
}
},
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = visible) {
content()
}
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing

View File

@ -6,7 +6,7 @@
* @modifier Ashinch
*/
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import android.view.SoundEffectConstants
import androidx.compose.animation.Crossfade

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun BlockRadioGroupButton(
fun BlockRadioButton(
modifier: Modifier = Modifier,
selected: Int = 0,
onSelected: (Int) -> Unit,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
@ -32,7 +32,7 @@ fun ClipboardTextField(
) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(10.dp))
TextField(
RYTextField(
readOnly = readOnly,
value = value,
onValueChange = onValueChange,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize

View File

@ -1,6 +1,6 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.animation.*
import RYExtensibleVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -41,11 +41,7 @@ fun DisplayText(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AnimatedVisibility(
visible = desc.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
RYExtensibleVisibility(visible = desc.isNotEmpty()) {
Text(
modifier = Modifier.height(16.dp),
text = desc,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import android.graphics.drawable.PictureDrawable
import androidx.compose.animation.Crossfade
@ -41,7 +41,7 @@ fun DynamicSVGImage(
},
) {
Crossfade(targetState = pic) {
AsyncImage(
RYAsyncImage(
contentDescription = contentDescription,
data = it,
placeholder = null,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import android.view.HapticFeedbackConstants
import android.view.SoundEffectConstants

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu

View File

@ -1,7 +1,7 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.annotation.DrawableRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
@ -10,17 +10,16 @@ import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.LocalImageLoader
import coil.request.ImageRequest
import coil.compose.rememberImagePainter
import coil.size.Precision
import coil.size.Scale
import coil.size.Size
import me.ash.reader.R
val SIZE_1000 = Size(1000, 1000)
@Composable
fun AsyncImage(
fun RYAsyncImage(
modifier: Modifier = Modifier,
data: Any? = null,
size: Size = Size.ORIGINAL,
@ -31,34 +30,51 @@ fun AsyncImage(
@DrawableRes placeholder: Int? = R.drawable.ic_hourglass_empty_black_24dp,
@DrawableRes error: Int? = R.drawable.ic_broken_image_black_24dp,
) {
coil.compose.AsyncImage(
modifier = modifier,
model = ImageRequest
.Builder(LocalContext.current)
.data(data)
.crossfade(true)
.scale(scale)
.precision(precision)
.size(size)
.build(),
Image(
painter = rememberImagePainter(
data = data,
builder = {
if (placeholder != null) placeholder(placeholder)
if (error != null) error(error)
crossfade(true)
scale(scale)
precision(precision)
size(size)
},
),
contentDescription = contentDescription,
contentScale = contentScale,
imageLoader = LocalImageLoader.current,
placeholder = placeholder?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
error = error?.run {
forwardingPainter(
painter = painterResource(this),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
alpha = 0.1f,
)
},
modifier = modifier,
)
// coil.compose.AsyncImage(
// modifier = modifier,
// model = ImageRequest
// .Builder(LocalContext.current)
// .data(data)
// .crossfade(true)
// .scale(scale)
// .precision(precision)
// .size(size)
// .build(),
// contentDescription = contentDescription,
// contentScale = contentScale,
// imageLoader = LocalImageLoader.current,
// placeholder = placeholder?.run {
// forwardingPainter(
// painter = painterResource(this),
// colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
// alpha = 0.1f,
// )
// },
// error = error?.run {
// forwardingPainter(
// painter = painterResource(this),
// colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
// alpha = 0.1f,
// )
// },
// )
}
// From: https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable
@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.window.DialogProperties
@Composable
fun Dialog(
fun RYDialog(
modifier: Modifier = Modifier,
visible: Boolean,
properties: DialogProperties = DialogProperties(),

View File

@ -0,0 +1,15 @@
import androidx.compose.animation.*
import androidx.compose.runtime.Composable
@Composable
fun RYExtensibleVisibility(
visible: Boolean,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
content = content,
)
}

View File

@ -0,0 +1,69 @@
package me.ash.reader.ui.component.base
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.ash.reader.ui.ext.surfaceColorAtElevation
import me.ash.reader.ui.theme.palette.onDark
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RYScaffold(
containerColor: Color = MaterialTheme.colorScheme.surface,
topBarTonalElevation: Dp = 0.dp,
containerTonalElevation: Dp = 0.dp,
navigationIcon: (@Composable () -> Unit)? = null,
actions: (@Composable RowScope.() -> Unit)? = null,
bottomBar: (@Composable () -> Unit)? = null,
floatingActionButton: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit = {},
) {
Scaffold(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
topBarTonalElevation,
color = containerColor
)
)
.statusBarsPadding(),
// .run {
// if (bottomBar != null || floatingActionButton != null) {
// navigationBarsPadding()
// } else {
// this
// }
// },
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
containerTonalElevation,
color = containerColor
) onDark MaterialTheme.colorScheme.surface,
topBar = {
if (navigationIcon != null || actions != null) {
SmallTopAppBar(
title = {},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
topBarTonalElevation, color = containerColor
),
),
navigationIcon = { navigationIcon?.invoke() },
actions = { actions?.invoke(this) },
)
}
},
content = {
Column {
Spacer(modifier = Modifier.height(it.calculateTopPadding()))
content()
}
},
bottomBar = { bottomBar?.invoke() },
floatingActionButton = { floatingActionButton?.invoke() },
)
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -26,7 +26,7 @@ import me.ash.reader.ui.theme.palette.alwaysLight
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SelectionChip(
fun RYSelectionChip(
content: String,
selected: Boolean,
modifier: Modifier = Modifier,

View File

@ -6,7 +6,7 @@
* @modifier Ashinch
*/
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
@ -31,7 +31,7 @@ import me.ash.reader.ui.theme.palette.onDark
// TODO: ripple & swipe
@Composable
fun Switch(
fun RYSwitch(
modifier: Modifier = Modifier,
activated: Boolean,
enable: Boolean = true,
@ -101,7 +101,7 @@ fun SwitchHeadline(
)
}
Box(Modifier.padding(start = 20.dp)) {
Switch(activated = activated)
RYSwitch(activated = activated)
}
}
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@ -19,7 +19,7 @@ import kotlinx.coroutines.delay
import me.ash.reader.R
@Composable
fun TextField(
fun RYTextField(
readOnly: Boolean,
value: String,
onValueChange: (String) -> Unit,
@ -39,7 +39,7 @@ fun TextField(
TextField(
modifier = Modifier.focusRequester(focusRequester),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
containerColor = Color.Transparent,
),
maxLines = 1,
enabled = !readOnly,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
@ -19,9 +19,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
@OptIn(ExperimentalPagerApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RadioDialog(
modifier: Modifier = Modifier,
@ -30,7 +29,7 @@ fun RadioDialog(
options: List<RadioDialogOption> = emptyList(),
onDismissRequest: () -> Unit = {},
) {
Dialog(
RYDialog(
modifier = modifier,
visible = visible,
onDismissRequest = onDismissRequest,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -13,10 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.window.DialogProperties
import com.google.accompanist.pager.ExperimentalPagerApi
import me.ash.reader.R
@OptIn(ExperimentalPagerApi::class)
@Composable
fun TextFieldDialog(
modifier: Modifier = Modifier,
@ -37,7 +35,7 @@ fun TextFieldDialog(
) {
val focusManager = LocalFocusManager.current
Dialog(
RYDialog(
modifier = modifier,
visible = visible,
onDismissRequest = onDismissRequest,

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.fillMaxWidth

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.component
package me.ash.reader.ui.component.base
import android.content.Intent
import android.net.Uri

View File

@ -33,7 +33,6 @@ import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.material.Text
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
@ -48,12 +47,11 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.size.Precision
import coil.size.Size
import coil.size.pxOrElse
import me.ash.reader.R
import me.ash.reader.ui.component.AsyncImage
import me.ash.reader.ui.component.base.RYAsyncImage
import org.jsoup.Jsoup
import org.jsoup.helper.StringUtil
import org.jsoup.nodes.Element
@ -178,7 +176,6 @@ private fun LazyListScope.formatCodeBlock(
composer.terminateCurrentText()
}
@OptIn(ExperimentalComposeApi::class, ExperimentalCoilApi::class)
private fun TextComposer.appendTextChildren(
nodes: List<Node>,
preFormatted: Boolean = false,
@ -241,7 +238,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -250,7 +247,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -259,7 +256,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -268,7 +265,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -277,7 +274,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -286,7 +283,7 @@ private fun TextComposer.appendTextChildren(
withComposableStyle(
style = { h5Style().toSpanStyle() }
) {
append(element.text())
append("\n${element.text()}")
}
}
}
@ -445,6 +442,7 @@ private fun TextComposer.appendTextChildren(
// .padding(horizontal = PADDING_HORIZONTAL.dp)
.width(MAX_CONTENT_WIDTH.dp)
) {
Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
DisableSelection {
BoxWithConstraints(
modifier = Modifier
@ -468,8 +466,12 @@ private fun TextComposer.appendTextChildren(
// }
) {
val imageSize = maxImageSize()
AsyncImage(
modifier = Modifier.fillMaxWidth(),
RYAsyncImage(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = PADDING_HORIZONTAL.dp)
.clip(IMAGE_SHAPE)
.clickable { },
data = imageCandidates.getBestImageForMaxSize(
pixelDensity = pixelDensity(),
maxSize = imageSize,
@ -594,12 +596,14 @@ private fun TextComposer.appendTextChildren(
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
RYAsyncImage(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = PADDING_HORIZONTAL.dp)
.clip(IMAGE_SHAPE)
.clickable {
onLinkClick(video.link)
}
.fillMaxWidth(),
},
data = video.imageUrl,
size = maxImageSize(),
contentDescription = stringResource(R.string.touch_to_play_video),
@ -646,7 +650,6 @@ private fun TextComposer.appendTextChildren(
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun String.asFontFamily(): FontFamily? = when (this.lowercase()) {
"monospace" -> FontFamily.Monospace
"serif" -> FontFamily.Serif

View File

@ -27,7 +27,8 @@ import android.util.Log
import androidx.compose.foundation.lazy.LazyListScope
import me.ash.reader.R
fun LazyListScope.reader(
@Suppress("FunctionName")
fun LazyListScope.Reader(
context: Context,
link: String,
content: String,

View File

@ -20,6 +20,7 @@
package me.ash.reader.ui.component.reader
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@ -27,12 +28,14 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import me.ash.reader.ui.ext.alphaLN
const val PADDING_HORIZONTAL = 24.0
const val MAX_CONTENT_WIDTH = 840.0
val IMAGE_SHAPE = RoundedCornerShape(32.dp)
@Composable
fun bodyForeground(): Color =
@ -71,7 +74,7 @@ fun h4Style(): TextStyle =
@Composable
fun h5Style(): TextStyle =
MaterialTheme.typography.headlineSmall.copy(
color = bodyForeground()
color = bodyForeground(),
)
@Composable
@ -83,7 +86,8 @@ fun h6Style(): TextStyle =
@Composable
fun captionStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(
color = bodyForeground().copy(alpha = 0.6f)
color = bodyForeground().copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
@Composable

View File

@ -79,7 +79,7 @@ class TextComposer(
) -> R
): R {
val url = link ?: findClosestLink()
builder.ensureDoubleNewline()
//builder.ensureDoubleNewline()
terminateCurrentText()
val onClick: (() -> Unit)? = if (url?.isNotBlank() == true) {
{

View File

@ -1,16 +1,19 @@
package me.ash.reader.ui.ext
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.ln
@Composable
fun ColorScheme.surfaceColorAtElevation(
elevation: Dp,
color: Color = surface,
): Color = color.atElevation(surfaceTint, elevation)
): Color = remember(this, elevation, color) { color.atElevation(surfaceTint, elevation) }
fun Color.atElevation(
sourceColor: Color,

View File

@ -4,11 +4,13 @@ import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import me.ash.reader.data.entity.Version
import me.ash.reader.data.entity.toVersion
import me.ash.reader.R
import me.ash.reader.data.model.Version
import me.ash.reader.data.model.toVersion
import java.io.File
fun Context.findActivity(): Activity? = when (this) {
@ -53,4 +55,19 @@ fun Context.showToast(message: String?, duration: Int = Toast.LENGTH_SHORT) {
fun Context.showToastLong(message: String?) {
showToast(message, Toast.LENGTH_LONG)
}
fun Context.share(content: String) {
startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
putExtra(
Intent.EXTRA_TEXT,
content,
)
type = "text/plain"
}, getString(R.string.share)))
}
fun Context.openURL(url: String?) {
url?.takeIf { it.trim().isNotEmpty() }
?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }
}

View File

@ -15,16 +15,6 @@ import java.io.IOException
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val Context.newVersionPublishDate: String
get() = this.dataStore.get(DataStoreKeys.NewVersionPublishDate) ?: ""
val Context.newVersionLog: String
get() = this.dataStore.get(DataStoreKeys.NewVersionLog) ?: ""
val Context.newVersionSize: Int
get() = this.dataStore.get(DataStoreKeys.NewVersionSize) ?: 0
val Context.newVersionDownloadUrl: String
get() = this.dataStore.get(DataStoreKeys.NewVersionDownloadUrl) ?: ""
val Context.newVersionNumber: String
get() = this.dataStore.get(DataStoreKeys.NewVersionNumber) ?: ""
val Context.skipVersionNumber: String
get() = this.dataStore.get(DataStoreKeys.SkipVersionNumber) ?: ""
val Context.isFirstLaunch: Boolean
@ -93,9 +83,9 @@ sealed class DataStoreKeys<T> {
get() = stringPreferencesKey("newVersionLog")
}
object NewVersionSize : DataStoreKeys<Int>() {
override val key: Preferences.Key<Int>
get() = intPreferencesKey("newVersionSize")
object NewVersionSize : DataStoreKeys<String>() {
override val key: Preferences.Key<String>
get() = stringPreferencesKey("newVersionSizeString")
}
object NewVersionDownloadUrl : DataStoreKeys<String>() {

View File

@ -4,6 +4,7 @@ import android.content.Context
import androidx.core.os.ConfigurationCompat
import me.ash.reader.R
import java.text.DateFormat
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.*
@ -40,4 +41,27 @@ fun Date.formatAsString(
}
}
}
}
private fun String.parseToDate(
patterns: Array<String> = arrayOf(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd",
"yyyy-MM-dd HH:mm:ss",
"yyyyMMdd",
"yyyy/MM/dd",
"yyyy年MM月dd日",
"yyyy MM dd",
)
): Date? {
val df = SimpleDateFormat()
for (pattern in patterns) {
df.applyPattern(pattern)
df.isLenient = false
val date = df.parse(this, ParsePosition(0))
if (date != null) {
return date
}
}
return null
}

View File

@ -1,13 +0,0 @@
package me.ash.reader.ui.ext
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.ash.reader.R
import me.ash.reader.data.entity.Filter
@Composable
fun Filter.getName(): String = when (this) {
Filter.Unread -> stringResource(R.string.unread)
Filter.Starred -> stringResource(R.string.starred)
else -> stringResource(R.string.all)
}

View File

@ -0,0 +1,11 @@
@file:Suppress("SpellCheckingInspection")
package me.ash.reader.ui.ext
import me.ash.reader.BuildConfig
const val GITHUB = "github"
const val FDROID = "fdroid"
const val isFdroid = BuildConfig.FLAVOR == FDROID
const val notFdroid = !isFdroid

View File

@ -1,8 +1,7 @@
package me.ash.reader.ui.ext
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.paging.compose.LazyPagingItems
import kotlin.math.abs
@ -27,4 +26,34 @@ fun <T : Any> LazyPagingItems<T>.rememberLazyListState(): LazyListState {
// Return rememberLazyListState (normal case).
else -> androidx.compose.foundation.lazy.rememberLazyListState()
}
}
/**
* TODO: To be improved
*
* Returns whether the LazyListState is currently in the
* downward scrolling state.
*/
@Composable
fun LazyListState.isScrollDown(): Boolean {
var isScrollDown by remember { mutableStateOf(false) }
var preItemIndex by remember { mutableStateOf(0) }
var preScrollStartOffset by remember { mutableStateOf(0) }
LaunchedEffect(this) {
snapshotFlow { isScrollInProgress }.collect {
if (isScrollInProgress) {
isScrollDown = when {
firstVisibleItemIndex > preItemIndex -> true
firstVisibleItemScrollOffset < preItemIndex -> false
else -> firstVisibleItemScrollOffset > preScrollStartOffset
}
} else {
preItemIndex = firstVisibleItemIndex
preScrollStartOffset = firstVisibleItemScrollOffset
}
}
}
return isScrollDown
}

View File

@ -52,6 +52,7 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
@ -173,11 +174,15 @@ private fun CacheDrawScope.onDrawScrollbar(
return {
if (showScrollbar) {
drawRect(
drawRoundRect(
color = color,
topLeft = topLeft,
size = size,
alpha = alpha()
alpha = alpha(),
cornerRadius = CornerRadius(
x = size.width,
y = size.width,
)
)
}
}
@ -217,7 +222,7 @@ private fun Modifier.drawScrollbar(
val alpha = remember { Animatable(0f) }
LaunchedEffect(scrolled, alpha) {
scrolled.collectLatest {
alpha.snapTo(1f)
alpha.snapTo(0.3f)
delay(ViewConfiguration.getScrollDefaultDelay().toLong())
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
}
@ -231,7 +236,7 @@ private fun Modifier.drawScrollbar(
// Calculate thickness here to workaround https://issuetracker.google.com/issues/206972664
val thickness = with(LocalDensity.current) { Thickness.toPx() }
val color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
val color = MaterialTheme.colorScheme.onSurfaceVariant
Modifier
.nestedScroll(nestedScrollConnection)
.drawWithCache {

View File

@ -12,22 +12,21 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import me.ash.reader.data.entity.Filter
import me.ash.reader.data.model.Filter
import me.ash.reader.data.preference.LocalDarkTheme
import me.ash.reader.ui.ext.*
import me.ash.reader.ui.page.home.HomeViewAction
import me.ash.reader.ui.page.home.HomeViewModel
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.read.ReadPage
import me.ash.reader.ui.page.home.reading.ReadingPage
import me.ash.reader.ui.page.settings.SettingsPage
import me.ash.reader.ui.page.settings.color.ColorAndStyle
import me.ash.reader.ui.page.settings.color.DarkTheme
import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStyle
import me.ash.reader.ui.page.settings.color.flow.FlowPageStyle
import me.ash.reader.ui.page.settings.interaction.Interaction
import me.ash.reader.ui.page.settings.languages.Languages
import me.ash.reader.ui.page.settings.tips.TipsAndSupport
import me.ash.reader.ui.page.settings.color.ColorAndStylePage
import me.ash.reader.ui.page.settings.color.DarkThemePage
import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage
import me.ash.reader.ui.page.settings.color.flow.FlowPageStylePage
import me.ash.reader.ui.page.settings.interaction.InteractionPage
import me.ash.reader.ui.page.settings.languages.LanguagesPage
import me.ash.reader.ui.page.settings.tips.TipsAndSupportPage
import me.ash.reader.ui.page.startup.StartupPage
import me.ash.reader.ui.theme.AppTheme
@ -37,7 +36,7 @@ fun HomeEntry(
homeViewModel: HomeViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val filterState = homeViewModel.filterState.collectAsStateValue()
val filterUiState = homeViewModel.filterUiState.collectAsStateValue()
val navController = rememberAnimatedNavController()
val intent by rememberSaveable { mutableStateOf(context.findActivity()?.intent) }
@ -57,16 +56,14 @@ fun HomeEntry(
// Other initial pages
}
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
filterState.copy(
filter = when (context.initialFilter) {
0 -> Filter.Starred
1 -> Filter.Unread
2 -> Filter.All
else -> Filter.All
}
)
homeViewModel.changeFilter(
filterUiState.copy(
filter = when (context.initialFilter) {
0 -> Filter.Starred
1 -> Filter.Unread
2 -> Filter.All
else -> Filter.All
}
)
)
}
@ -114,7 +111,7 @@ fun HomeEntry(
)
}
animatedComposable(route = "${RouteName.READING}/{articleId}") {
ReadPage(navController = navController)
ReadingPage(navController = navController)
}
// Settings
@ -124,31 +121,31 @@ fun HomeEntry(
// Color & Style
animatedComposable(route = RouteName.COLOR_AND_STYLE) {
ColorAndStyle(navController)
ColorAndStylePage(navController)
}
animatedComposable(route = RouteName.DARK_THEME) {
DarkTheme(navController)
DarkThemePage(navController)
}
animatedComposable(route = RouteName.FEEDS_PAGE_STYLE) {
FeedsPageStyle(navController)
FeedsPageStylePage(navController)
}
animatedComposable(route = RouteName.FLOW_PAGE_STYLE) {
FlowPageStyle(navController)
FlowPageStylePage(navController)
}
// Interaction
animatedComposable(route = RouteName.INTERACTION) {
Interaction(navController)
InteractionPage(navController)
}
// Languages
animatedComposable(route = RouteName.LANGUAGES) {
Languages(navController = navController)
LanguagesPage(navController = navController)
}
// Tips & Support
animatedComposable(route = RouteName.TIPS_AND_SUPPORT) {
TipsAndSupport(navController)
TipsAndSupportPage(navController)
}
}
}

View File

@ -7,8 +7,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import me.ash.reader.data.entity.Feed
import me.ash.reader.data.entity.Filter
import me.ash.reader.data.entity.Group
import me.ash.reader.data.model.Filter
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.data.repository.StringsRepository
@ -24,30 +24,20 @@ class HomeViewModel @Inject constructor(
private val applicationScope: CoroutineScope,
private val workManager: WorkManager,
) : ViewModel() {
private val _homeUiState = MutableStateFlow(HomeUiState())
val homeUiState: StateFlow<HomeUiState> = _homeUiState.asStateFlow()
private val _viewState = MutableStateFlow(HomeViewState())
val viewState: StateFlow<HomeViewState> = _viewState.asStateFlow()
private val _filterState = MutableStateFlow(FilterState())
val filterState = _filterState.asStateFlow()
private val _filterUiState = MutableStateFlow(FilterState())
val filterUiState = _filterUiState.asStateFlow()
val syncWorkLiveData = workManager.getWorkInfoByIdLiveData(SyncWorker.UUID)
fun dispatch(action: HomeViewAction) {
when (action) {
is HomeViewAction.Sync -> sync()
is HomeViewAction.ChangeFilter -> changeFilter(action.filterState)
is HomeViewAction.FetchArticles -> fetchArticles()
is HomeViewAction.InputSearchContent -> inputSearchContent(action.content)
}
}
private fun sync() {
fun sync() {
rssRepository.get().doSync()
}
private fun changeFilter(filterState: FilterState) {
_filterState.update {
fun changeFilter(filterState: FilterState) {
_filterUiState.update {
it.copy(
group = filterState.group,
feed = filterState.feed,
@ -57,28 +47,40 @@ class HomeViewModel @Inject constructor(
fetchArticles()
}
private fun fetchArticles() {
_viewState.update {
fun fetchArticles() {
_homeUiState.update {
it.copy(
pagingData = Pager(PagingConfig(pageSize = 50)) {
if (_viewState.value.searchContent.isNotBlank()) {
pagingData = Pager(
config = PagingConfig(
pageSize = 100,
enablePlaceholders = false,
)
) {
if (_homeUiState.value.searchContent.isNotBlank()) {
rssRepository.get().searchArticles(
content = _viewState.value.searchContent.trim(),
groupId = _filterState.value.group?.id,
feedId = _filterState.value.feed?.id,
isStarred = _filterState.value.filter.isStarred(),
isUnread = _filterState.value.filter.isUnread(),
content = _homeUiState.value.searchContent.trim(),
groupId = _filterUiState.value.group?.id,
feedId = _filterUiState.value.feed?.id,
isStarred = _filterUiState.value.filter.isStarred(),
isUnread = _filterUiState.value.filter.isUnread(),
)
} else {
rssRepository.get().pullArticles(
groupId = _filterState.value.group?.id,
feedId = _filterState.value.feed?.id,
isStarred = _filterState.value.filter.isStarred(),
isUnread = _filterState.value.filter.isUnread(),
groupId = _filterUiState.value.group?.id,
feedId = _filterUiState.value.feed?.id,
isStarred = _filterUiState.value.filter.isStarred(),
isUnread = _filterUiState.value.filter.isUnread(),
)
}
}.flow.map {
it.map { FlowItemView.Article(it) }.insertSeparators { before, after ->
it.map {
FlowItemView.Article(it.apply {
article.dateString = stringsRepository.formatAsString(
date = article.date,
onlyHourMinute = true
)
})
}.insertSeparators { before, after ->
val beforeDate =
stringsRepository.formatAsString(before?.articleWithFeed?.article?.date)
val afterDate =
@ -94,8 +96,8 @@ class HomeViewModel @Inject constructor(
}
}
private fun inputSearchContent(content: String) {
_viewState.update {
fun inputSearchContent(content: String) {
_homeUiState.update {
it.copy(
searchContent = content,
)
@ -110,21 +112,7 @@ data class FilterState(
val filter: Filter = Filter.All,
)
data class HomeViewState(
data class HomeUiState(
val pagingData: Flow<PagingData<FlowItemView>> = emptyFlow(),
val searchContent: String = "",
)
sealed class HomeViewAction {
object Sync : HomeViewAction()
data class ChangeFilter(
val filterState: FilterState
) : HomeViewAction()
object FetchArticles : HomeViewAction()
data class InputSearchContent(
val content: String,
) : HomeViewAction()
}
)

View File

@ -1,96 +1,99 @@
package me.ash.reader.ui.page.home.feeds
import RYExtensibleVisibility
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.data.entity.Feed
import me.ash.reader.ui.page.home.FeedIcon
import me.ash.reader.ui.page.home.feeds.option.feed.FeedOptionViewAction
import me.ash.reader.ui.page.home.feeds.option.feed.FeedOptionViewModel
import kotlin.math.ln
import me.ash.reader.ui.component.FeedIcon
import me.ash.reader.ui.page.home.feeds.drawer.feed.FeedOptionViewModel
import me.ash.reader.ui.theme.ShapeBottom32
@OptIn(
androidx.compose.foundation.ExperimentalFoundationApi::class,
androidx.compose.material.ExperimentalMaterialApi::class,
)
@Composable
fun FeedItem(
feed: Feed,
alpha: Float = 1f,
badgeAlpha: Float = 1f,
isEnded: Boolean = false,
isExpanded: () -> Boolean,
feedOptionViewModel: FeedOptionViewModel = hiltViewModel(),
tonalElevation: Dp,
onClick: () -> Unit = {},
) {
val view = LocalView.current
val scope = rememberCoroutineScope()
val tonalElevationAlpha by remember {
derivedStateOf {
(ln(tonalElevation.value + 1.4f) + 2f) / 100f
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.clip(RoundedCornerShape(32.dp))
.combinedClickable(
onClick = {
onClick()
},
onLongClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
feedOptionViewModel.dispatch(FeedOptionViewAction.Show(scope, feed.id))
}
)
.padding(vertical = 14.dp),
) {
RYExtensibleVisibility(visible = isExpanded()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.weight(1f)) {
FeedIcon(feed.name)
Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
text = feed.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
.padding(horizontal = 16.dp)
.background(
color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha),
shape = if (isEnded) ShapeBottom32 else RectangleShape,
)
}
if ((feed.important ?: 0) != 0) {
Badge(
containerColor = MaterialTheme.colorScheme.surfaceTint.copy(
alpha = tonalElevationAlpha
),
contentColor = MaterialTheme.colorScheme.outline,
content = {
Text(
text = feed.important.toString(),
style = MaterialTheme.typography.labelSmall
)
.combinedClickable(
onClick = {
onClick()
},
onLongClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
feedOptionViewModel.showDrawer(scope, feed.id)
}
)
.padding(horizontal = 14.dp)
.padding(top = 14.dp, bottom = if (isEnded) 22.dp else 14.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.weight(1f)) {
FeedIcon(feed.name)
Text(
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
text = feed.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if ((feed.important ?: 0) != 0) {
Badge(
containerColor = MaterialTheme.colorScheme.surfaceTint.copy(
alpha = badgeAlpha
),
contentColor = MaterialTheme.colorScheme.outline,
content = {
Text(
text = feed.important.toString(),
style = MaterialTheme.typography.labelSmall
)
},
)
}
}
}
}

View File

@ -1,11 +1,9 @@
package me.ash.reader.ui.page.home.feeds
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -15,7 +13,8 @@ import androidx.compose.material.icons.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@ -26,31 +25,26 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.map
import me.ash.reader.R
import me.ash.reader.data.entity.toVersion
import me.ash.reader.data.model.getName
import me.ash.reader.data.preference.*
import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.ui.component.Banner
import me.ash.reader.ui.component.DisplayText
import me.ash.reader.ui.component.FeedbackIconButton
import me.ash.reader.ui.component.Subtitle
import me.ash.reader.ui.ext.*
import me.ash.reader.ui.component.FilterBar
import me.ash.reader.ui.component.base.*
import me.ash.reader.ui.ext.alphaLN
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.findActivity
import me.ash.reader.ui.ext.getCurrentVersion
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.home.FilterBar
import me.ash.reader.ui.page.home.FilterState
import me.ash.reader.ui.page.home.HomeViewAction
import me.ash.reader.ui.page.home.HomeViewModel
import me.ash.reader.ui.page.home.feeds.option.feed.FeedOptionDrawer
import me.ash.reader.ui.page.home.feeds.option.group.GroupOptionDrawer
import me.ash.reader.ui.page.home.feeds.drawer.feed.FeedOptionDrawer
import me.ash.reader.ui.page.home.feeds.drawer.group.GroupOptionDrawer
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewAction
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewModel
import me.ash.reader.ui.theme.palette.onDark
import kotlin.math.ln
@SuppressLint("FlowOperatorInvokedInComposition")
@OptIn(
ExperimentalMaterial3Api::class, com.google.accompanist.pager.ExperimentalPagerApi::class,
androidx.compose.foundation.ExperimentalFoundationApi::class
)
@Composable
@ -69,20 +63,12 @@ fun FeedsPage(
val filterBarPadding = LocalFeedsFilterBarPadding.current
val filterBarTonalElevation = LocalFeedsFilterBarTonalElevation.current
val feedsViewState = feedsViewModel.viewState.collectAsStateValue()
val filterState = homeViewModel.filterState.collectAsStateValue()
val feedsUiState = feedsViewModel.feedsUiState.collectAsStateValue()
val filterUiState = homeViewModel.filterUiState.collectAsStateValue()
val skipVersion = context.dataStore.data
.map { it[DataStoreKeys.SkipVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.toVersion()
val latestVersion = context.dataStore.data
.map { it[DataStoreKeys.NewVersionNumber.key] ?: "" }
.collectAsState(initial = "")
.value
.toVersion()
val currentVersion by remember { mutableStateOf(context.getCurrentVersion()) }
val newVersion = LocalNewVersionNumber.current
val skipVersion = LocalSkipVersionNumber.current
val currentVersion = remember { context.getCurrentVersion() }
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
@ -102,22 +88,40 @@ fun FeedsPage(
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument()
) { result ->
feedsViewModel.dispatch(FeedsViewAction.ExportAsString { string ->
feedsViewModel.exportAsOpml { string ->
result?.let { uri ->
context.contentResolver.openOutputStream(uri)?.let { outputStream ->
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(string.toByteArray())
}
}
})
}
}
val feedBadgeAlpha by remember { derivedStateOf { (ln(groupListTonalElevation.value + 1.4f) + 2f) / 100f } }
val groupAlpha by remember { derivedStateOf { groupListTonalElevation.value.dp.alphaLN(weight = 1.2f) } }
val groupIndicatorAlpha by remember {
derivedStateOf {
groupListTonalElevation.value.dp.alphaLN(
weight = 1.4f
)
}
}
val groupsVisible = remember(feedsUiState.groupWithFeedList) {
mutableStateMapOf(
*(feedsUiState.groupWithFeedList.filterIsInstance<GroupFeedsView.Group>().map {
it.group.id to groupListExpand.value
}.toTypedArray())
)
}
LaunchedEffect(Unit) {
feedsViewModel.dispatch(FeedsViewAction.FetchAccount)
feedsViewModel.fetchAccount()
}
LaunchedEffect(filterState) {
snapshotFlow { filterState }.collect {
feedsViewModel.dispatch(FeedsViewAction.FetchData(it))
LaunchedEffect(filterUiState) {
snapshotFlow { filterUiState }.collect {
feedsViewModel.fetchData(it)
}
}
@ -125,52 +129,38 @@ fun FeedsPage(
context.findActivity()?.moveTaskToBack(false)
}
Scaffold(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(topBarTonalElevation.value.dp))
.statusBarsPadding(),
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
groupListTonalElevation.value.dp
) onDark MaterialTheme.colorScheme.surface,
topBar = {
SmallTopAppBar(
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
topBarTonalElevation.value.dp
),
),
title = {},
navigationIcon = {
FeedbackIconButton(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.settings),
tint = MaterialTheme.colorScheme.onSurface,
showBadge = latestVersion.whetherNeedUpdate(currentVersion, skipVersion),
) {
navController.navigate(RouteName.SETTINGS) {
launchSingleTop = true
}
}
},
actions = {
FeedbackIconButton(
modifier = Modifier.rotate(if (isSyncing) angle else 0f),
imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.refresh),
tint = MaterialTheme.colorScheme.onSurface,
) {
if (!isSyncing) homeViewModel.dispatch(HomeViewAction.Sync)
}
FeedbackIconButton(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
tint = MaterialTheme.colorScheme.onSurface,
) {
subscribeViewModel.dispatch(SubscribeViewAction.Show)
}
RYScaffold(
topBarTonalElevation = topBarTonalElevation.value.dp,
containerTonalElevation = groupListTonalElevation.value.dp,
navigationIcon = {
FeedbackIconButton(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.settings),
tint = MaterialTheme.colorScheme.onSurface,
showBadge = newVersion.whetherNeedUpdate(currentVersion, skipVersion),
) {
navController.navigate(RouteName.SETTINGS) {
launchSingleTop = true
}
)
}
},
actions = {
FeedbackIconButton(
modifier = Modifier.rotate(if (isSyncing) angle else 0f),
imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.refresh),
tint = MaterialTheme.colorScheme.onSurface,
) {
if (!isSyncing) homeViewModel.sync()
}
FeedbackIconButton(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
tint = MaterialTheme.colorScheme.onSurface,
) {
subscribeViewModel.showDrawer()
}
},
content = {
LazyColumn {
@ -183,15 +173,15 @@ fun FeedsPage(
}
)
},
text = feedsViewState.account?.name ?: stringResource(R.string.read_you),
text = feedsUiState.account?.name ?: stringResource(R.string.read_you),
desc = if (isSyncing) stringResource(R.string.syncing) else "",
)
}
item {
Banner(
title = filterState.filter.getName(),
desc = feedsViewState.importantCount.ifEmpty { stringResource(R.string.loading) },
icon = filterState.filter.iconOutline,
title = filterUiState.filter.getName(),
desc = feedsUiState.importantSum.ifEmpty { stringResource(R.string.loading) },
icon = filterUiState.filter.iconOutline,
action = {
Icon(
imageVector = Icons.Outlined.KeyboardArrowRight,
@ -202,7 +192,7 @@ fun FeedsPage(
filterChange(
navController = navController,
homeViewModel = homeViewModel,
filterState = filterState.copy(
filterState = filterUiState.copy(
group = null,
feed = null,
)
@ -217,40 +207,51 @@ fun FeedsPage(
)
Spacer(modifier = Modifier.height(8.dp))
}
itemsIndexed(feedsViewState.groupWithFeedList) { index, groupWithFeed ->
// Crossfade(targetState = groupWithFeed) { groupWithFeed ->
Column {
GroupItem(
isExpanded = groupListExpand.value,
tonalElevation = groupListTonalElevation.value.dp,
group = groupWithFeed.group,
feeds = groupWithFeed.feeds,
groupOnClick = {
itemsIndexed(feedsUiState.groupWithFeedList) { index, groupWithFeed ->
when (groupWithFeed) {
is GroupFeedsView.Group -> {
if (index != 0) {
Spacer(modifier = Modifier.height(16.dp))
}
GroupItem(
isExpanded = { groupsVisible[groupWithFeed.group.id] ?: false },
group = groupWithFeed.group,
alpha = groupAlpha,
indicatorAlpha = groupIndicatorAlpha,
onExpanded = {
groupsVisible[groupWithFeed.group.id] =
!(groupsVisible[groupWithFeed.group.id] ?: false)
}
) {
filterChange(
navController = navController,
homeViewModel = homeViewModel,
filterState = filterState.copy(
filterState = filterUiState.copy(
group = groupWithFeed.group,
feed = null,
)
)
},
feedOnClick = { feed ->
}
}
is GroupFeedsView.Feed -> {
FeedItem(
feed = groupWithFeed.feed,
alpha = groupAlpha,
badgeAlpha = feedBadgeAlpha,
isEnded = index != feedsUiState.groupWithFeedList.lastIndex && feedsUiState.groupWithFeedList[index + 1] is GroupFeedsView.Group,
isExpanded = { groupsVisible[groupWithFeed.feed.groupId] ?: false },
) {
filterChange(
navController = navController,
homeViewModel = homeViewModel,
filterState = filterState.copy(
filterState = filterUiState.copy(
group = null,
feed = feed,
feed = groupWithFeed.feed,
)
)
}
)
if (index != feedsViewState.groupWithFeedList.lastIndex) {
Spacer(modifier = Modifier.height(8.dp))
}
}
// }
}
item {
Spacer(modifier = Modifier.height(128.dp))
@ -260,7 +261,7 @@ fun FeedsPage(
},
bottomBar = {
FilterBar(
filter = filterState.filter,
filter = filterUiState.filter,
filterBarStyle = filterBarStyle.value,
filterBarFilled = filterBarFilled.value,
filterBarPadding = filterBarPadding.dp,
@ -269,7 +270,7 @@ fun FeedsPage(
filterChange(
navController = navController,
homeViewModel = homeViewModel,
filterState = filterState.copy(filter = it),
filterState = filterUiState.copy(filter = it),
isNavigate = false,
)
}
@ -287,7 +288,7 @@ private fun filterChange(
filterState: FilterState,
isNavigate: Boolean = true,
) {
homeViewModel.dispatch(HomeViewAction.ChangeFilter(filterState))
homeViewModel.changeFilter(filterState)
if (isNavigate) {
navController.navigate(RouteName.FLOW) {
launchSingleTop = true

View File

@ -2,15 +2,17 @@ package me.ash.reader.ui.page.home.feeds
import android.util.Log
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.data.entity.Account
import me.ash.reader.data.entity.GroupWithFeed
import me.ash.reader.data.module.DispatcherDefault
import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.repository.AccountRepository
import me.ash.reader.data.repository.OpmlRepository
import me.ash.reader.data.repository.RssRepository
@ -24,22 +26,17 @@ class FeedsViewModel @Inject constructor(
private val rssRepository: RssRepository,
private val opmlRepository: OpmlRepository,
private val stringsRepository: StringsRepository,
@DispatcherDefault
private val dispatcherDefault: CoroutineDispatcher,
@DispatcherIO
private val dispatcherIO: CoroutineDispatcher,
) : ViewModel() {
private val _viewState = MutableStateFlow(FeedsViewState())
val viewState: StateFlow<FeedsViewState> = _viewState.asStateFlow()
private val _feedsUiState = MutableStateFlow(FeedsUiState())
val feedsUiState: StateFlow<FeedsUiState> = _feedsUiState.asStateFlow()
fun dispatch(action: FeedsViewAction) {
when (action) {
is FeedsViewAction.FetchAccount -> fetchAccount()
is FeedsViewAction.FetchData -> fetchData(action.filterState)
is FeedsViewAction.ExportAsString -> exportAsOpml(action.callback)
is FeedsViewAction.ScrollToItem -> scrollToItem(action.index)
}
}
private fun fetchAccount() {
viewModelScope.launch(Dispatchers.IO) {
_viewState.update {
fun fetchAccount() {
viewModelScope.launch(dispatcherIO) {
_feedsUiState.update {
it.copy(
account = accountRepository.getCurrentAccount()
)
@ -47,8 +44,8 @@ class FeedsViewModel @Inject constructor(
}
}
private fun exportAsOpml(callback: (String) -> Unit = {}) {
viewModelScope.launch(Dispatchers.Default) {
fun exportAsOpml(callback: (String) -> Unit = {}) {
viewModelScope.launch(dispatcherDefault) {
try {
callback(opmlRepository.saveToString())
} catch (e: Exception) {
@ -57,8 +54,8 @@ class FeedsViewModel @Inject constructor(
}
}
private fun fetchData(filterState: FilterState) {
viewModelScope.launch(Dispatchers.IO) {
fun fetchData(filterState: FilterState) {
viewModelScope.launch(dispatcherIO) {
pullFeeds(
isStarred = filterState.filter.isStarred(),
isUnread = filterState.filter.isUnread(),
@ -70,85 +67,64 @@ class FeedsViewModel @Inject constructor(
combine(
rssRepository.get().pullFeeds(),
rssRepository.get().pullImportant(isStarred, isUnread),
) { groupWithFeedList, importantList ->
val groupImportantMap = mutableMapOf<String, Int>()
val feedImportantMap = mutableMapOf<String, Int>()
importantList.groupBy { it.groupId }.forEach { (i, list) ->
var groupImportantSum = 0
list.forEach {
feedImportantMap[it.feedId] = it.important
groupImportantSum += it.important
}
groupImportantMap[i] = groupImportantSum
}
val groupsIt = groupWithFeedList.iterator()
while (groupsIt.hasNext()) {
val groupWithFeed = groupsIt.next()
val groupImportant = groupImportantMap[groupWithFeed.group.id]
if (groupImportant == null && (isStarred || isUnread)) {
groupsIt.remove()
} else {
groupWithFeed.group.important = groupImportant
val feedsIt = groupWithFeed.feeds.iterator()
while (feedsIt.hasNext()) {
val feed = feedsIt.next()
val feedImportant = feedImportantMap[feed.id]
if (feedImportant == null && (isStarred || isUnread)) {
feedsIt.remove()
} else {
feed.important = feedImportant
}
}
) { groupWithFeedList, importantMap ->
groupWithFeedList.fastForEach {
var groupImportant = 0
it.feeds.fastForEach {
it.important = importantMap[it.id]
groupImportant += it.important ?: 0
}
it.group.important = groupImportant
}
groupWithFeedList
}.onEach { groupWithFeedList ->
_viewState.update {
}.mapLatest { groupWithFeedList ->
_feedsUiState.update {
it.copy(
importantCount = groupWithFeedList.sumOf { it.group.important ?: 0 }.run {
importantSum = groupWithFeedList.sumOf { it.group.important ?: 0 }.run {
when {
isStarred -> stringsRepository.getQuantityString(R.plurals.starred_desc, this, this)
isUnread -> stringsRepository.getQuantityString(R.plurals.unread_desc, this, this)
else -> stringsRepository.getQuantityString(R.plurals.all_desc, this, this)
isStarred -> stringsRepository.getQuantityString(
R.plurals.starred_desc,
this,
this
)
isUnread -> stringsRepository.getQuantityString(
R.plurals.unread_desc,
this,
this
)
else -> stringsRepository.getQuantityString(
R.plurals.all_desc,
this,
this
)
}
},
groupWithFeedList = groupWithFeedList,
feedsVisible = List(groupWithFeedList.size, init = { true })
groupWithFeedList = groupWithFeedList.map {
mutableListOf<GroupFeedsView>(GroupFeedsView.Group(it.group)).apply {
addAll(
it.feeds.map {
GroupFeedsView.Feed(it)
}
)
}
}.flatten(),
)
}
}.catch {
Log.e("RLog", "catch in articleRepository.pullFeeds(): ${it.message}")
}.flowOn(Dispatchers.Default).collect()
}
private fun scrollToItem(index: Int) {
viewModelScope.launch {
_viewState.value.listState.scrollToItem(index)
}
}.flowOn(dispatcherDefault).collect()
}
}
data class FeedsViewState(
data class FeedsUiState(
val account: Account? = null,
val importantCount: String = "",
val groupWithFeedList: List<GroupWithFeed> = emptyList(),
val feedsVisible: List<Boolean> = emptyList(),
val importantSum: String = "",
val groupWithFeedList: List<GroupFeedsView> = emptyList(),
val listState: LazyListState = LazyListState(),
val groupsVisible: Boolean = true,
)
sealed class FeedsViewAction {
data class FetchData(
val filterState: FilterState,
) : FeedsViewAction()
object FetchAccount : FeedsViewAction()
data class ExportAsString(
val callback: (String) -> Unit = {}
) : FeedsViewAction()
data class ScrollToItem(
val index: Int
) : FeedsViewAction()
sealed class GroupFeedsView {
class Group(val group: me.ash.reader.data.entity.Group) : GroupFeedsView()
class Feed(val feed: me.ash.reader.data.entity.Feed) : GroupFeedsView()
}

View File

@ -1,59 +1,56 @@
package me.ash.reader.ui.page.home.feeds
import android.view.HapticFeedbackConstants
import androidx.compose.animation.*
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
import me.ash.reader.data.entity.Feed
import me.ash.reader.data.entity.Group
import me.ash.reader.ui.ext.alphaLN
import me.ash.reader.ui.page.home.feeds.option.group.GroupOptionViewAction
import me.ash.reader.ui.page.home.feeds.option.group.GroupOptionViewModel
import me.ash.reader.ui.page.home.feeds.drawer.group.GroupOptionViewModel
import me.ash.reader.ui.theme.Shape32
import me.ash.reader.ui.theme.ShapeTop32
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun GroupItem(
modifier: Modifier = Modifier,
tonalElevation: Dp,
group: Group,
feeds: List<Feed>,
isExpanded: Boolean = true,
alpha: Float = 1f,
indicatorAlpha: Float = 1f,
isExpanded: () -> Boolean,
groupOptionViewModel: GroupOptionViewModel = hiltViewModel(),
onExpanded: () -> Unit = {},
groupOnClick: () -> Unit = {},
feedOnClick: (feed: Feed) -> Unit = {},
) {
val view = LocalView.current
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(isExpanded) }
Column(
modifier = Modifier
.animateContentSize()
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(32.dp))
.clip(if (isExpanded()) ShapeTop32 else Shape32)
.background(
MaterialTheme.colorScheme.secondary.copy(alpha = tonalElevation.alphaLN(weight = 1.2f))
MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
)
.combinedClickable(
onClick = {
@ -61,13 +58,13 @@ fun GroupItem(
},
onLongClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
groupOptionViewModel.dispatch(GroupOptionViewAction.Show(scope, group.id))
groupOptionViewModel.showDrawer(scope, group.id)
}
)
.padding(top = 22.dp)
) {
Row(
modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -87,42 +84,21 @@ fun GroupItem(
.size(24.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.surfaceTint.copy(
alpha = tonalElevation.alphaLN(weight = 1.4f)
)
MaterialTheme.colorScheme.surfaceTint.copy(alpha = indicatorAlpha)
)
.clickable {
expanded = !expanded
onExpanded()
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = stringResource(if (expanded) R.string.expand_less else R.string.expand_more),
imageVector = if (isExpanded()) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = stringResource(if (isExpanded()) R.string.expand_less else R.string.expand_more),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Spacer(modifier = Modifier.height(22.dp))
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column {
feeds.forEach { feed ->
FeedItem(
feed = feed,
tonalElevation = tonalElevation,
) {
feedOnClick(feed)
}
}
if (feeds.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}

View File

@ -1,4 +1,4 @@
package me.ash.reader.ui.page.home.feeds.option.feed
package me.ash.reader.ui.page.home.feeds.drawer.feed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteForever
@ -7,32 +7,28 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.pager.ExperimentalPagerApi
import me.ash.reader.R
import me.ash.reader.ui.component.Dialog
import me.ash.reader.ui.component.base.RYDialog
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ClearFeedDialog(
modifier: Modifier = Modifier,
feedName: String,
viewModel: FeedOptionViewModel = hiltViewModel(),
feedOptionViewModel: FeedOptionViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val viewState = viewModel.viewState.collectAsStateValue()
val feedOptionUiState = feedOptionViewModel.feedOptionUiState.collectAsStateValue()
val scope = rememberCoroutineScope()
val toastString = stringResource(R.string.clear_articles_in_feed_toast, feedName)
Dialog(
visible = viewState.clearDialogVisible,
RYDialog(
visible = feedOptionUiState.clearDialogVisible,
onDismissRequest = {
viewModel.dispatch(FeedOptionViewAction.HideClearDialog)
feedOptionViewModel.hideClearDialog()
},
icon = {
Icon(
@ -49,11 +45,11 @@ fun ClearFeedDialog(
confirmButton = {
TextButton(
onClick = {
viewModel.dispatch(FeedOptionViewAction.Clear {
viewModel.dispatch(FeedOptionViewAction.HideClearDialog)
viewModel.dispatch(FeedOptionViewAction.Hide(scope))
feedOptionViewModel.clearFeed {
feedOptionViewModel.hideClearDialog()
feedOptionViewModel.hideDrawer(scope)
context.showToast(toastString)
})
}
}
) {
Text(
@ -64,7 +60,7 @@ fun ClearFeedDialog(
dismissButton = {
TextButton(
onClick = {
viewModel.dispatch(FeedOptionViewAction.HideClearDialog)
feedOptionViewModel.hideClearDialog()
}
) {
Text(

Some files were not shown because too many files have changed in this diff Show More