Init
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package me.ash.reader
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.repository.AccountRepository
|
||||
import me.ash.reader.data.repository.ArticleRepository
|
||||
import me.ash.reader.data.repository.OpmlRepository
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.data.source.OpmlLocalDataSource
|
||||
import me.ash.reader.data.source.ReaderDatabase
|
||||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import javax.inject.Inject
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
@HiltAndroidApp
|
||||
class App : Application() {
|
||||
@Inject
|
||||
lateinit var readerDatabase: ReaderDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var opmlLocalDataSource: OpmlLocalDataSource
|
||||
|
||||
@Inject
|
||||
lateinit var rssNetworkDataSource: RssNetworkDataSource
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Inject
|
||||
lateinit var articleRepository: ArticleRepository
|
||||
|
||||
@Inject
|
||||
lateinit var opmlRepository: OpmlRepository
|
||||
|
||||
@Inject
|
||||
lateinit var rssRepository: RssRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
GlobalScope.launch {
|
||||
if (accountRepository.isNoAccount()) {
|
||||
val accountId = accountRepository.addDefaultAccount()
|
||||
applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, accountId)
|
||||
}
|
||||
rssRepository.sync(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package me.ash.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.IOException
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
suspend fun <T> DataStore<Preferences>.put(dataStoreKeys: DataStoreKeys<T>, value: T) {
|
||||
this.edit {
|
||||
it[dataStoreKeys.key] = value
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> DataStore<Preferences>.get(dataStoreKeys: DataStoreKeys<T>): T? {
|
||||
return runBlocking {
|
||||
this@get.data.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e("RLog", "Get data store error $exception")
|
||||
exception.printStackTrace()
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}.map {
|
||||
it[dataStoreKeys.key]
|
||||
}.first() as T
|
||||
}
|
||||
}
|
||||
|
||||
sealed class DataStoreKeys<T> {
|
||||
abstract val key: Preferences.Key<T>
|
||||
|
||||
object CurrentAccountId : DataStoreKeys<Int>() {
|
||||
override val key: Preferences.Key<Int>
|
||||
get() = intPreferencesKey("currentAccountId")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package me.ash.reader
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object DateTimeExt {
|
||||
|
||||
const val HH_MM_SS = "HH:mm:ss"
|
||||
const val HH_MM = "HH:mm"
|
||||
const val MM_SS = "mm:ss"
|
||||
const val YYYY_MM_DD_HH_MM_SS = "yyyy年MM月dd日 HH:mm:ss"
|
||||
const val YYYY_MM_DD_HH_MM = "yyyy年MM月dd日 HH:mm"
|
||||
const val YYYY_MM_DD = "yyyy年MM月dd日"
|
||||
const val YYYY_MM = "yyyy年MM月"
|
||||
const val YYYY = "yyyy年"
|
||||
const val MM = "MM月"
|
||||
const val DD = "dd日"
|
||||
|
||||
/**
|
||||
* Returns a date-time [String] format from a [Date] object.
|
||||
*/
|
||||
fun Date.toString(pattern: String, simpleDate: Boolean? = false): String {
|
||||
return if (simpleDate == true) {
|
||||
val format = if (pattern == YYYY_MM_DD) {
|
||||
""
|
||||
} else {
|
||||
SimpleDateFormat(
|
||||
pattern.replace(YYYY_MM_DD, "")
|
||||
).format(this)
|
||||
}
|
||||
when (this.toString(YYYY_MM_DD)) {
|
||||
Date().toString(YYYY_MM_DD) -> {
|
||||
"今天${format}"
|
||||
}
|
||||
Calendar.getInstance().apply {
|
||||
time = Date()
|
||||
add(Calendar.DAY_OF_MONTH, -1)
|
||||
}.time.toString(YYYY_MM_DD) -> {
|
||||
"昨天${format}"
|
||||
}
|
||||
else -> SimpleDateFormat(pattern).format(this)
|
||||
}
|
||||
} else {
|
||||
SimpleDateFormat(pattern).format(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Date] object parsed from a date-time [String].
|
||||
*/
|
||||
fun String.toDate(pattern: String? = null): Date =
|
||||
SimpleDateFormat((pattern ?: YYYY_MM_DD_HH_MM_SS)).parse(this)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package me.ash.reader
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import me.ash.reader.ui.page.common.HomeEntry
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
HomeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package me.ash.reader
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
|
||||
fun Int.positive() = if (this < 0) 0 else this
|
||||
fun Int.finitelyLarge(value: Int) = if (this > value) value else this
|
||||
fun Int.finitelySmall(value: Int) = if (this < value) value else this
|
||||
|
||||
fun Float.positive() = if (this < 0) 0f else this
|
||||
fun Float.finitelyLarge(value: Float) = if (this > value) value else this
|
||||
fun Float.finitelySmall(value: Float) = if (this < value) value else this
|
||||
|
||||
@Composable
|
||||
fun <T : Any> rememberMutableStateListOf(vararg elements: T): SnapshotStateList<T> {
|
||||
return rememberSaveable(
|
||||
saver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toMutableStateList() }
|
||||
)
|
||||
) {
|
||||
elements.toMutableList().toMutableStateList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package me.ash.reader.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.*
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun toDate(dateLong: Long?): Date? {
|
||||
return dateLong?.let { Date(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(date: Date?): Long? {
|
||||
return date?.time
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package me.ash.reader.data.account
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "account")
|
||||
data class Account(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int? = null,
|
||||
@ColumnInfo
|
||||
var name: String,
|
||||
@ColumnInfo
|
||||
var type: Int,
|
||||
@ColumnInfo
|
||||
var updateAt: Date? = null,
|
||||
) {
|
||||
object Type {
|
||||
const val LOCAL = 1
|
||||
const val FRESH_RSS = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package me.ash.reader.data.account
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface AccountDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM account
|
||||
"""
|
||||
)
|
||||
suspend fun queryAll(): List<Account>
|
||||
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM account
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
suspend fun queryById(id: Int): Account
|
||||
|
||||
@Insert
|
||||
suspend fun insert(account: Account): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertList(accounts: List<Account>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg account: Account)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg account: Account)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package me.ash.reader.data.article
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import java.util.*
|
||||
|
||||
@Entity(
|
||||
tableName = "article",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = Feed::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["feedId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
onUpdate = ForeignKey.CASCADE
|
||||
)]
|
||||
)
|
||||
data class Article(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int? = null,
|
||||
@ColumnInfo
|
||||
val date: Date,
|
||||
@ColumnInfo
|
||||
val title: String,
|
||||
@ColumnInfo
|
||||
val author: String? = null,
|
||||
@ColumnInfo
|
||||
var rawDescription: String,
|
||||
@ColumnInfo
|
||||
var shortDescription: String,
|
||||
@ColumnInfo
|
||||
var fullContent: String? = null,
|
||||
@ColumnInfo
|
||||
val link: String,
|
||||
@ColumnInfo(index = true)
|
||||
val feedId: Int,
|
||||
@ColumnInfo(index = true)
|
||||
val accountId: Int,
|
||||
@ColumnInfo(defaultValue = "true")
|
||||
var isUnread: Boolean = true,
|
||||
@ColumnInfo(defaultValue = "false")
|
||||
var isStarred: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
package me.ash.reader.data.article
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ArticleDao {
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE accountId = :accountId
|
||||
AND (
|
||||
title LIKE :keyword
|
||||
OR rawDescription LIKE :keyword
|
||||
OR fullContent LIKE :keyword
|
||||
)
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun searchArticleWithFeedWhenIsAll(
|
||||
accountId: Int,
|
||||
keyword: String,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE isUnread = :isUnread
|
||||
AND accountId = :accountId
|
||||
AND (
|
||||
title LIKE :keyword
|
||||
OR rawDescription LIKE :keyword
|
||||
OR fullContent LIKE :keyword
|
||||
)
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun searchArticleWithFeedWhenIsUnread(
|
||||
accountId: Int,
|
||||
isUnread: Boolean,
|
||||
keyword: String,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE isStarred = :isStarred
|
||||
AND accountId = :accountId
|
||||
AND (
|
||||
title LIKE :keyword
|
||||
OR rawDescription LIKE :keyword
|
||||
OR fullContent LIKE :keyword
|
||||
)
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun searchArticleWithFeedWhenIsStarred(
|
||||
accountId: Int,
|
||||
isStarred: Boolean,
|
||||
keyword: String,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) AS important, a.feedId, b.groupId
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b
|
||||
ON a.feedId = b.id
|
||||
WHERE a.isUnread = :isUnread
|
||||
AND a.accountId = :accountId
|
||||
GROUP BY a.feedId
|
||||
"""
|
||||
)
|
||||
fun queryImportantCountWhenIsUnread(
|
||||
accountId: Int,
|
||||
isUnread: Boolean
|
||||
): Flow<List<ImportantCount>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) AS important, a.feedId, b.groupId
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b
|
||||
ON a.feedId = b.id
|
||||
WHERE a.isStarred = :isStarred
|
||||
AND a.accountId = :accountId
|
||||
GROUP BY a.feedId
|
||||
"""
|
||||
)
|
||||
fun queryImportantCountWhenIsStarred(
|
||||
accountId: Int,
|
||||
isStarred: Boolean
|
||||
): Flow<List<ImportantCount>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) AS important, a.feedId, b.groupId
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b
|
||||
ON a.feedId = b.id
|
||||
WHERE a.accountId = :accountId
|
||||
GROUP BY a.feedId
|
||||
"""
|
||||
)
|
||||
fun queryImportantCountWhenIsAll(accountId: Int): Flow<List<ImportantCount>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE accountId = :accountId
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedWhenIsAll(accountId: Int): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE isStarred = :isStarred
|
||||
AND accountId = :accountId
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedWhenIsStarred(
|
||||
accountId: Int,
|
||||
isStarred: Boolean
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE isUnread = :isUnread
|
||||
AND accountId = :accountId
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedWhenIsUnread(
|
||||
accountId: Int,
|
||||
isUnread: Boolean
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT a.id, a.date, a.title, a.author, a.rawDescription,
|
||||
a.shortDescription, a.fullContent, a.link, a.feedId,
|
||||
a.accountId, a.isUnread, a.isStarred
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b ON b.id = a.feedId
|
||||
LEFT JOIN `group` AS c ON c.id = b.groupId
|
||||
WHERE c.id = :groupId
|
||||
AND a.accountId = :accountId
|
||||
ORDER BY a.date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByGroupIdWhenIsAll(
|
||||
accountId: Int,
|
||||
groupId: Int
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT a.id, a.date, a.title, a.author, a.rawDescription,
|
||||
a.shortDescription, a.fullContent, a.link, a.feedId,
|
||||
a.accountId, a.isUnread, a.isStarred
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b ON b.id = a.feedId
|
||||
LEFT JOIN `group` AS c ON c.id = b.groupId
|
||||
WHERE c.id = :groupId
|
||||
AND a.isStarred = :isStarred
|
||||
AND a.accountId = :accountId
|
||||
ORDER BY a.date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByGroupIdWhenIsStarred(
|
||||
accountId: Int,
|
||||
groupId: Int,
|
||||
isStarred: Boolean,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT a.id, a.date, a.title, a.author, a.rawDescription,
|
||||
a.shortDescription, a.fullContent, a.link, a.feedId,
|
||||
a.accountId, a.isUnread, a.isStarred
|
||||
FROM article AS a
|
||||
LEFT JOIN feed AS b ON b.id = a.feedId
|
||||
LEFT JOIN `group` AS c ON c.id = b.groupId
|
||||
WHERE c.id = :groupId
|
||||
AND a.isUnread = :isUnread
|
||||
AND a.accountId = :accountId
|
||||
ORDER BY a.date DESC
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByGroupIdWhenIsUnread(
|
||||
accountId: Int,
|
||||
groupId: Int,
|
||||
isUnread: Boolean,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE feedId = :feedId
|
||||
AND accountId = :accountId
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByFeedIdWhenIsAll(
|
||||
accountId: Int,
|
||||
feedId: Int
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * from article
|
||||
WHERE feedId = :feedId
|
||||
AND isStarred = :isStarred
|
||||
AND accountId = :accountId
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByFeedIdWhenIsStarred(
|
||||
accountId: Int,
|
||||
feedId: Int,
|
||||
isStarred: Boolean,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM article
|
||||
WHERE feedId = :feedId
|
||||
AND isUnread = :isUnread
|
||||
AND accountId = :accountId
|
||||
"""
|
||||
)
|
||||
fun queryArticleWithFeedByFeedIdWhenIsUnread(
|
||||
accountId: Int,
|
||||
feedId: Int,
|
||||
isUnread: Boolean,
|
||||
): PagingSource<Int, ArticleWithFeed>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT a.id, a.date, a.title, a.author, a.rawDescription,
|
||||
a.shortDescription, a.fullContent, a.link, a.feedId,
|
||||
a.accountId, a.isUnread, a.isStarred
|
||||
FROM article AS a, feed AS b
|
||||
WHERE a.feedId = :feedId
|
||||
AND a.accountId = :accountId
|
||||
ORDER BY date DESC LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun queryLatestByFeedId(accountId: Int, feedId: Int): Article?
|
||||
|
||||
@Insert
|
||||
suspend fun insert(article: Article): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertList(articles: List<Article>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg article: Article)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg article: Article)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.ash.reader.data.article
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import me.ash.reader.data.feed.Feed
|
||||
|
||||
data class ArticleWithFeed(
|
||||
@Embedded
|
||||
val article: Article,
|
||||
@Relation(parentColumn = "feedId", entityColumn = "id")
|
||||
val feed: Feed,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.ash.reader.data.article
|
||||
|
||||
data class ImportantCount(
|
||||
val important: Int,
|
||||
val feedId: Int,
|
||||
val groupId: Int,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package me.ash.reader.data.feed
|
||||
|
||||
import androidx.room.*
|
||||
import me.ash.reader.data.group.Group
|
||||
|
||||
@Entity(
|
||||
tableName = "feed",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = Group::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["groupId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
onUpdate = ForeignKey.CASCADE,
|
||||
)],
|
||||
)
|
||||
data class Feed(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int? = null,
|
||||
@ColumnInfo
|
||||
val name: String,
|
||||
@ColumnInfo
|
||||
var icon: String? = null,
|
||||
@ColumnInfo
|
||||
val url: String,
|
||||
@ColumnInfo(index = true)
|
||||
var groupId: Int,
|
||||
@ColumnInfo(index = true)
|
||||
val accountId: Int,
|
||||
@ColumnInfo(defaultValue = "false")
|
||||
var isFullContent: Boolean = false,
|
||||
) {
|
||||
@Ignore
|
||||
var important: Int? = 0
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package me.ash.reader.data.feed
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface FeedDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM feed
|
||||
WHERE accountId = :accountId
|
||||
"""
|
||||
)
|
||||
suspend fun queryAll(accountId: Int): List<Feed>
|
||||
|
||||
@Insert
|
||||
suspend fun insertList(feed: List<Feed>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg feed: Feed)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg feed: Feed)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.ash.reader.data.feed
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import me.ash.reader.data.article.Article
|
||||
|
||||
data class FeedWithArticle(
|
||||
@Embedded
|
||||
val feed: Feed,
|
||||
@Relation(parentColumn = "id", entityColumn = "feedId")
|
||||
val articles: List<Article>
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.ash.reader.data.feed
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import me.ash.reader.data.group.Group
|
||||
|
||||
data class FeedWithGroup(
|
||||
@Embedded
|
||||
val feed: Feed,
|
||||
@Relation(parentColumn = "groupId", entityColumn = "id")
|
||||
val group: Group
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package me.ash.reader.data.group
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "group")
|
||||
data class Group(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int? = null,
|
||||
@ColumnInfo
|
||||
val name: String,
|
||||
@ColumnInfo(index = true)
|
||||
val accountId: Int,
|
||||
) {
|
||||
@Ignore
|
||||
var important: Int? = 0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package me.ash.reader.data.group
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GroupDao {
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM `group`
|
||||
WHERE accountId = :accountId
|
||||
"""
|
||||
)
|
||||
fun queryAllGroupWithFeed(accountId: Int): Flow<MutableList<GroupWithFeed>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(group: Group): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(vararg group: Group)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(vararg group: Group)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.ash.reader.data.group
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import me.ash.reader.data.feed.Feed
|
||||
|
||||
data class GroupWithFeed(
|
||||
@Embedded
|
||||
val group: Group,
|
||||
@Relation(parentColumn = "id", entityColumn = "groupId")
|
||||
val feeds: MutableList<Feed>
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
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.data.account.AccountDao
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.source.ReaderDatabase
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class DatabaseModule {
|
||||
@Provides
|
||||
fun provideArticleDao(readerDatabase: ReaderDatabase): ArticleDao =
|
||||
readerDatabase.articleDao()
|
||||
|
||||
@Provides
|
||||
fun provideFeedDao(readerDatabase: ReaderDatabase): FeedDao =
|
||||
readerDatabase.feedDao()
|
||||
|
||||
@Provides
|
||||
fun provideGroupDao(readerDatabase: ReaderDatabase): GroupDao =
|
||||
readerDatabase.groupDao()
|
||||
|
||||
@Provides
|
||||
fun provideAccountDao(readerDatabase: ReaderDatabase): AccountDao =
|
||||
readerDatabase.accountDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideReaderDatabase(@ApplicationContext context: Context): ReaderDatabase =
|
||||
ReaderDatabase.getInstance(context)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package me.ash.reader.data.module
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class RssNetworkModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideRssNetworkDataSource(): RssNetworkDataSource =
|
||||
RssNetworkDataSource.getInstance()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package me.ash.reader.data.module
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class WorkerModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
|
||||
WorkManager.getInstance(context)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package me.ash.reader.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val accountDao: AccountDao,
|
||||
) {
|
||||
|
||||
suspend fun getCurrentAccount(): Account? {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
return accountDao.queryById(accountId)
|
||||
}
|
||||
|
||||
suspend fun isNoAccount(): Boolean {
|
||||
return accountDao.queryAll().isEmpty()
|
||||
}
|
||||
|
||||
suspend fun addDefaultAccount(): Int {
|
||||
return accountDao.insert(
|
||||
Account(
|
||||
name = "Feeds",
|
||||
type = Account.Type.LOCAL,
|
||||
)
|
||||
).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package me.ash.reader.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.paging.PagingSource
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.article.ImportantCount
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import javax.inject.Inject
|
||||
|
||||
class ArticleRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val articleDao: ArticleDao,
|
||||
private val groupDao: GroupDao,
|
||||
) {
|
||||
|
||||
fun pullFeeds(): Flow<MutableList<GroupWithFeed>> {
|
||||
return groupDao.queryAllGroupWithFeed(
|
||||
context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
fun pullArticles(
|
||||
groupId: Int? = null,
|
||||
feedId: Int? = null,
|
||||
isStarred: Boolean = false,
|
||||
isUnread: Boolean = false,
|
||||
): PagingSource<Int, ArticleWithFeed> {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
Log.i(
|
||||
"RLog",
|
||||
"pullArticles: accountId: ${accountId}, groupId: ${groupId}, feedId: ${feedId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
|
||||
)
|
||||
return when {
|
||||
groupId != null -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedByGroupIdWhenIsStarred(accountId, groupId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedByGroupIdWhenIsUnread(accountId, groupId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedByGroupIdWhenIsAll(accountId, groupId)
|
||||
}
|
||||
feedId != null -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedByFeedIdWhenIsStarred(accountId, feedId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedByFeedIdWhenIsUnread(accountId, feedId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedByFeedIdWhenIsAll(accountId, feedId)
|
||||
}
|
||||
else -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedWhenIsStarred(accountId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedWhenIsUnread(accountId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedWhenIsAll(accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pullImportant(
|
||||
isStarred: Boolean = false,
|
||||
isUnread: Boolean = false,
|
||||
): Flow<List<ImportantCount>> {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
Log.i(
|
||||
"RLog",
|
||||
"pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
|
||||
)
|
||||
return when {
|
||||
isStarred -> articleDao
|
||||
.queryImportantCountWhenIsStarred(accountId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryImportantCountWhenIsUnread(accountId, isUnread)
|
||||
else -> articleDao.queryImportantCountWhenIsAll(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateArticleInfo(article: Article) {
|
||||
articleDao.update(article)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package me.ash.reader.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.source.OpmlLocalDataSource
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpmlRepository @Inject constructor(
|
||||
private val groupDao: GroupDao,
|
||||
private val feedDao: FeedDao,
|
||||
private val opmlLocalDataSource: OpmlLocalDataSource
|
||||
) {
|
||||
suspend fun saveToDatabase(inputStream: InputStream) {
|
||||
try {
|
||||
val groupWithFeedList = opmlLocalDataSource.parseFileInputStream(inputStream)
|
||||
groupWithFeedList.forEach { groupWithFeed ->
|
||||
val id = groupDao.insert(groupWithFeed.group).toInt()
|
||||
groupWithFeed.feeds.forEach { it.groupId = id }
|
||||
feedDao.insertList(groupWithFeed.feeds)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("saveToDatabase", "${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package me.ash.reader.data.repository
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.work.*
|
||||
import com.github.muhrifqii.parserss.ParseRSS
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.source.ReaderDatabase
|
||||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import net.dankito.readability4j.Readability4J
|
||||
import net.dankito.readability4j.extended.Readability4JExtended
|
||||
import okhttp3.*
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
class RssRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val accountDao: AccountDao,
|
||||
private val articleDao: ArticleDao,
|
||||
private val feedDao: FeedDao,
|
||||
private val rssNetworkDataSource: RssNetworkDataSource,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
fun parseDescriptionContent(link: String, content: String): String {
|
||||
val readability4J: Readability4J = Readability4JExtended(link, content)
|
||||
val article = readability4J.parse()
|
||||
val element = article.articleContent
|
||||
return element.toString()
|
||||
}
|
||||
|
||||
fun parseFullContent(link: String, title: String, callback: (String) -> Unit) {
|
||||
OkHttpClient()
|
||||
.newCall(Request.Builder().url(link).build())
|
||||
.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
callback(e.message.toString())
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val content = response.body?.string()
|
||||
val readability4J: Readability4J =
|
||||
Readability4JExtended(link, content ?: "")
|
||||
val articleContent = readability4J.parse().articleContent
|
||||
if (articleContent == null) {
|
||||
callback("")
|
||||
} else {
|
||||
val h1Element = articleContent.selectFirst("h1")
|
||||
if (h1Element != null && h1Element.hasText() && h1Element.text() == title) {
|
||||
h1Element.remove()
|
||||
}
|
||||
callback(articleContent.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun peekWork(): String {
|
||||
return workManager.getWorkInfosByTag("sync").get().size.toString()
|
||||
}
|
||||
|
||||
suspend fun sync(isWork: Boolean? = false) {
|
||||
if (isWork == true) {
|
||||
workManager.cancelAllWork()
|
||||
val syncWorkerRequest: WorkRequest =
|
||||
PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
15, TimeUnit.MINUTES
|
||||
).setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresCharging(true)
|
||||
.setRequiresDeviceIdle(true)
|
||||
.build()
|
||||
).addTag("sync").build()
|
||||
workManager.enqueue(syncWorkerRequest)
|
||||
} else {
|
||||
normalSync(context, accountDao, articleDao, feedDao, rssNetworkDataSource)
|
||||
}
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
companion object {
|
||||
data class SyncState(
|
||||
val feedCount: Int = 0,
|
||||
val syncedCount: Int = 0,
|
||||
val currentFeedName: String = "",
|
||||
) {
|
||||
val isSyncing: Boolean = feedCount != 0 || syncedCount != 0 || currentFeedName != ""
|
||||
val isNotSyncing: Boolean = !isSyncing
|
||||
}
|
||||
|
||||
val syncState = MutableStateFlow(SyncState())
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun normalSync(
|
||||
context: Context,
|
||||
accountDao: AccountDao,
|
||||
articleDao: ArticleDao,
|
||||
feedDao: FeedDao,
|
||||
rssNetworkDataSource: RssNetworkDataSource
|
||||
) {
|
||||
doSync(context, accountDao, articleDao, feedDao, rssNetworkDataSource)
|
||||
}
|
||||
|
||||
suspend fun workerSync(context: Context) {
|
||||
val db = ReaderDatabase.getInstance(context)
|
||||
doSync(
|
||||
context,
|
||||
db.accountDao(),
|
||||
db.articleDao(),
|
||||
db.feedDao(),
|
||||
RssNetworkDataSource.getInstance()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun doSync(
|
||||
context: Context,
|
||||
accountDao: AccountDao,
|
||||
articleDao: ArticleDao,
|
||||
feedDao: FeedDao,
|
||||
rssNetworkDataSource: RssNetworkDataSource
|
||||
) {
|
||||
mutex.withLock {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)
|
||||
?: return
|
||||
val feeds = feedDao.queryAll(accountId)
|
||||
val preTime = System.currentTimeMillis()
|
||||
val chunked = feeds.chunked(6)
|
||||
chunked.forEachIndexed { index, item ->
|
||||
item.forEach {
|
||||
Log.i("RlOG", "chunked $index: ${it.name}")
|
||||
}
|
||||
}
|
||||
val flows = mutableListOf<Flow<List<Article>>>()
|
||||
repeat(chunked.size) {
|
||||
flows.add(flow {
|
||||
val articles = mutableListOf<Article>()
|
||||
chunked[it].forEach { feed ->
|
||||
val latest = articleDao.queryLatestByFeedId(accountId, feed.id ?: 0)
|
||||
// if (feed.icon == null) {
|
||||
// queryRssIcon(feedDao, feed, latest?.link)
|
||||
// }
|
||||
articles.addAll(
|
||||
queryRssXml(
|
||||
rssNetworkDataSource,
|
||||
accountId,
|
||||
feed,
|
||||
latest?.title,
|
||||
)
|
||||
)
|
||||
|
||||
syncState.update {
|
||||
it.copy(
|
||||
feedCount = feeds.size,
|
||||
syncedCount = syncState.value.syncedCount + 1,
|
||||
currentFeedName = feed.name
|
||||
)
|
||||
}
|
||||
}
|
||||
emit(articles)
|
||||
})
|
||||
}
|
||||
combine(
|
||||
flows
|
||||
) {
|
||||
val notificationManager: NotificationManager =
|
||||
getSystemService(
|
||||
context,
|
||||
NotificationManager::class.java
|
||||
) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
"ARTICLE_UPDATE",
|
||||
"文章更新",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
it.reversed().forEachIndexed { index, articleList ->
|
||||
articleList.forEach { article ->
|
||||
Log.i("RlOG", "combine $index ${article.feedId}: ${article.title}")
|
||||
val builder = NotificationCompat.Builder(context, "ARTICLE_UPDATE")
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setGroup("ARTICLE_UPDATE")
|
||||
.setContentTitle(article.title)
|
||||
.setContentText(article.shortDescription)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
notificationManager.notify(Random.nextInt(), builder.build().apply {
|
||||
flags = Notification.FLAG_AUTO_CANCEL
|
||||
})
|
||||
}
|
||||
articleDao.insertList(articleList)
|
||||
}
|
||||
}.buffer().onCompletion {
|
||||
val afterTime = System.currentTimeMillis()
|
||||
Log.i("RlOG", "onCompletion: ${afterTime - preTime}")
|
||||
accountDao.queryById(accountId)?.let { account ->
|
||||
accountDao.update(
|
||||
account.apply {
|
||||
updateAt = Date()
|
||||
}
|
||||
)
|
||||
}
|
||||
syncState.update {
|
||||
it.copy(
|
||||
feedCount = 0,
|
||||
syncedCount = 0,
|
||||
currentFeedName = ""
|
||||
)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryRssXml(
|
||||
rssNetworkDataSource: RssNetworkDataSource,
|
||||
accountId: Int,
|
||||
feed: Feed,
|
||||
latestTitle: String? = null,
|
||||
): List<Article> {
|
||||
ParseRSS.init(XmlPullParserFactory.newInstance())
|
||||
val a = mutableListOf<Article>()
|
||||
try {
|
||||
val parseRss = rssNetworkDataSource.parseRss(feed.url)
|
||||
parseRss.items.forEach {
|
||||
if (latestTitle != null && latestTitle == it.title) return a
|
||||
Log.i("RLog", "request rss ${feed.name}: ${it.title}")
|
||||
a.add(
|
||||
Article(
|
||||
accountId = accountId,
|
||||
feedId = feed.id ?: 0,
|
||||
date = Date(it.publishDate.toString()),
|
||||
title = it.title.toString(),
|
||||
author = it.author,
|
||||
rawDescription = it.description.toString(),
|
||||
shortDescription = (Readability4JExtended("", it.description.toString())
|
||||
.parse().textContent ?: "").trim().run {
|
||||
if (this.length > 100) this.substring(0, 100)
|
||||
else this
|
||||
},
|
||||
link = it.link ?: "",
|
||||
)
|
||||
)
|
||||
}
|
||||
return a
|
||||
} catch (e: Exception) {
|
||||
Log.e("RLog", "error ${feed.name}: ${e.message}")
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryRssIcon(
|
||||
feedDao: FeedDao,
|
||||
feed: Feed,
|
||||
articleLink: String?,
|
||||
) {
|
||||
if (articleLink == null) return
|
||||
val exe = OkHttpClient()
|
||||
.newCall(Request.Builder().url(articleLink).build()).execute()
|
||||
val content = exe.body?.string()
|
||||
Log.i("rlog", "queryRssIcon: $content")
|
||||
val regex =
|
||||
Regex("""<link(.+?)rel="shortcut icon"(.+?)type="image/x-icon"(.+?)href="(.+?)"""")
|
||||
if (content != null) {
|
||||
var iconLink = regex
|
||||
.find(content)
|
||||
?.groups?.get(4)
|
||||
?.value
|
||||
if (iconLink != null) {
|
||||
if (iconLink.startsWith("//")) {
|
||||
iconLink = "http:$iconLink"
|
||||
}
|
||||
saveRssIcon(feedDao, feed, iconLink)
|
||||
} else {
|
||||
saveRssIcon(feedDao, feed, "")
|
||||
}
|
||||
} else {
|
||||
saveRssIcon(feedDao, feed, "")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
|
||||
feedDao.update(
|
||||
feed.apply {
|
||||
icon = iconLink
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class SyncWorker(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i("RLog", "doWork: ")
|
||||
RssRepository.workerSync(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package me.ash.reader.data.source
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpmlLocalDataSource @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
) {
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
fun parseFileInputStream(inputStream: InputStream): List<GroupWithFeed> {
|
||||
val groupWithFeedList = mutableListOf<GroupWithFeed>()
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
inputStream.use {
|
||||
val parser: XmlPullParser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(it, null)
|
||||
parser.nextTag()
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType != XmlPullParser.START_TAG) {
|
||||
continue
|
||||
}
|
||||
if (parser.name != "outline") {
|
||||
continue
|
||||
}
|
||||
if ("rss" == parser.getAttributeValue(null, "type")) {
|
||||
val title = parser.getAttributeValue(null, "title")
|
||||
val xmlUrl = parser.getAttributeValue(null, "xmlUrl")
|
||||
Log.i("RLog", "rss: ${title} , ${xmlUrl}")
|
||||
groupWithFeedList.last().feeds.add(
|
||||
Feed(
|
||||
name = title,
|
||||
url = xmlUrl,
|
||||
groupId = 0,
|
||||
accountId = accountId,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val title = parser.getAttributeValue(null, "title")
|
||||
Log.i("RLog", "title: ${title}")
|
||||
groupWithFeedList.add(
|
||||
GroupWithFeed(
|
||||
group = Group(
|
||||
name = title,
|
||||
accountId = accountId,
|
||||
),
|
||||
feeds = mutableListOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return groupWithFeedList
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package me.ash.reader.data.source
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import me.ash.reader.data.Converters
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
|
||||
@Database(
|
||||
entities = [Account::class, Feed::class, Article::class, Group::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class ReaderDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
abstract fun feedDao(): FeedDao
|
||||
abstract fun articleDao(): ArticleDao
|
||||
abstract fun groupDao(): GroupDao
|
||||
|
||||
companion object {
|
||||
private var instance: ReaderDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): ReaderDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
ReaderDatabase::class.java,
|
||||
"Reader"
|
||||
).build().also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package me.ash.reader.data.source
|
||||
|
||||
import com.github.muhrifqii.parserss.RSSFeedObject
|
||||
import com.github.muhrifqii.parserss.retrofit.ParseRSSConverterFactory
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface RssNetworkDataSource {
|
||||
@GET
|
||||
suspend fun parseRss(@Url url: String): RSSFeedObject
|
||||
|
||||
companion object {
|
||||
private var instance: RssNetworkDataSource? = null
|
||||
|
||||
fun getInstance(): RssNetworkDataSource {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Retrofit.Builder()
|
||||
.baseUrl("https://api.feeddd.org/feeds/")
|
||||
.addConverterFactory(ParseRSSConverterFactory.create<RSSFeedObject>())
|
||||
.build().create(RssNetworkDataSource::class.java).also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package me.ash.reader.ui.data
|
||||
|
||||
class Filter(
|
||||
var index: Int,
|
||||
var title: String,
|
||||
var description: String,
|
||||
var important: Int,
|
||||
) {
|
||||
companion object {
|
||||
val Starred = Filter(
|
||||
index = 0,
|
||||
title = "Starred",
|
||||
description = " Starred Items",
|
||||
important = 13
|
||||
)
|
||||
val Unread = Filter(
|
||||
index = 1,
|
||||
title = "Unread",
|
||||
description = " Unread Items",
|
||||
important = 666
|
||||
)
|
||||
val All = Filter(
|
||||
index = 2,
|
||||
title = "All",
|
||||
description = " Unread Items",
|
||||
important = 666
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package me.ash.reader.ui.data
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
class NavigationBarItem(
|
||||
var title: String,
|
||||
var icon: ImageVector,
|
||||
) {
|
||||
companion object {
|
||||
val Starred = NavigationBarItem("STARRED", Icons.Rounded.Star)
|
||||
val Unread = NavigationBarItem("UNREAD", Icons.Rounded.FiberManualRecord)
|
||||
val All = NavigationBarItem("ALL", Icons.Rounded.Subject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package me.ash.reader.ui.page.common
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsHeight
|
||||
import com.google.accompanist.insets.statusBarsPadding
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import me.ash.reader.ui.page.home.HomePage
|
||||
import me.ash.reader.ui.theme.AppTheme
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun HomeEntry() {
|
||||
AppTheme {
|
||||
ProvideWindowInsets {
|
||||
rememberSystemUiController().run {
|
||||
setStatusBarColor(MaterialTheme.colorScheme.surface, !isSystemInDarkTheme())
|
||||
setSystemBarsColor(MaterialTheme.colorScheme.surface, !isSystemInDarkTheme())
|
||||
setNavigationBarColor(MaterialTheme.colorScheme.surface, !isSystemInDarkTheme())
|
||||
}
|
||||
Column (modifier = Modifier.background(MaterialTheme.colorScheme.surface)){
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
HomePage()
|
||||
}
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.navigationBarsHeight()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.ash.reader.ui.page.common
|
||||
|
||||
object RouteName {
|
||||
const val FEED = "feed"
|
||||
const val ARTICLE = "article"
|
||||
const val READ = "read"
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package me.ash.reader.ui.page.home
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.ui.page.home.article.ArticlePage
|
||||
import me.ash.reader.ui.page.home.feed.FeedPage
|
||||
import me.ash.reader.ui.page.home.read.ReadPage
|
||||
import me.ash.reader.ui.page.home.read.ReadViewAction
|
||||
import me.ash.reader.ui.page.home.read.ReadViewModel
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
import me.ash.reader.ui.util.pagerAnimate
|
||||
import me.ash.reader.ui.widget.AppNavigationBar
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun HomePage(
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
readViewModel: ReadViewModel = hiltViewModel(),
|
||||
) {
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = viewModel.filterState.collectAsStateValue()
|
||||
val readState = readViewModel.viewState.collectAsStateValue()
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(true) {
|
||||
val currentPage = viewState.pagerState.currentPage
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = when (currentPage) {
|
||||
2 -> 1
|
||||
else -> 0
|
||||
},
|
||||
callback = {
|
||||
if (currentPage == 2) {
|
||||
readViewModel.dispatch(ReadViewAction.ClearArticle)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.viewState) {
|
||||
viewModel.viewState.collect {
|
||||
Log.i(
|
||||
"RLog",
|
||||
"HomePage: ${it.pagerState.currentPage}, ${it.pagerState.targetPage}, ${it.pagerState.currentPageOffset}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// val items = listOf(
|
||||
// Color.Red,
|
||||
// Color.Blue,
|
||||
// Color.Green,
|
||||
// )
|
||||
|
||||
Column {
|
||||
// CustomPager(
|
||||
// items = items,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(256.dp),
|
||||
// itemFraction = .75f,
|
||||
// overshootFraction = .75f,
|
||||
// initialIndex = 3,
|
||||
// itemSpacing = 16.dp,
|
||||
// ) {
|
||||
// items.forEachIndexed { index, item ->
|
||||
// if (index % 2 == 0) {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxSize()
|
||||
// .background(item),
|
||||
// contentAlignment = Alignment.Center
|
||||
// ) {
|
||||
// Text(
|
||||
// text = item.toString(),
|
||||
// modifier = Modifier.padding(all = 16.dp),
|
||||
//// style = MaterialTheme.typography.h6,
|
||||
// )
|
||||
// }
|
||||
// } else {
|
||||
// Image(
|
||||
// modifier = Modifier.fillMaxSize(),
|
||||
// painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
// contentDescription = null,
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
HorizontalPager(
|
||||
count = 3,
|
||||
state = viewState.pagerState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> FeedPage(
|
||||
modifier = Modifier.pagerAnimate(this, page),
|
||||
filter = filterState.filter,
|
||||
groupAndFeedOnClick = { currentGroup, currentFeed ->
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ChangeFilter(
|
||||
filterState.copy(
|
||||
group = currentGroup,
|
||||
feed = currentFeed,
|
||||
)
|
||||
)
|
||||
)
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 1,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
1 -> ArticlePage(
|
||||
modifier = Modifier.pagerAnimate(this, page),
|
||||
BackOnClick = {
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 0,
|
||||
)
|
||||
)
|
||||
},
|
||||
articleOnClick = {
|
||||
readViewModel.dispatch(ReadViewAction.ScrollToItem(0))
|
||||
readViewModel.dispatch(ReadViewAction.InitData(it))
|
||||
if (it.feed.isFullContent) readViewModel.dispatch(ReadViewAction.RenderFullContent)
|
||||
else readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 2,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
2 -> ReadPage(
|
||||
modifier = Modifier.pagerAnimate(this, page),
|
||||
btnBackOnClickListener = {
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 1,
|
||||
callback = {
|
||||
readViewModel.dispatch(ReadViewAction.ClearArticle)
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
AppNavigationBar(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth(),
|
||||
pagerState = viewState.pagerState,
|
||||
disabled = readState.articleWithFeed == null,
|
||||
isUnread = readState.articleWithFeed?.article?.isUnread ?: false,
|
||||
isStarred = readState.articleWithFeed?.article?.isStarred ?: false,
|
||||
isFullContent = readState.articleWithFeed?.feed?.isFullContent ?: false,
|
||||
unreadOnClick = {
|
||||
readViewModel.dispatch(ReadViewAction.MarkUnread(it))
|
||||
},
|
||||
starredOnClick = {
|
||||
readViewModel.dispatch(ReadViewAction.MarkStarred(it))
|
||||
},
|
||||
fullContentOnClick = { afterIsFullContent ->
|
||||
readState.articleWithFeed?.let {
|
||||
if (afterIsFullContent) readViewModel.dispatch(ReadViewAction.RenderFullContent)
|
||||
else readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
}
|
||||
},
|
||||
filter = filterState.filter,
|
||||
filterOnClick = {
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ChangeFilter(
|
||||
filterState.copy(
|
||||
filter = it
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package me.ash.reader.ui.page.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalPagerApi
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _viewState = MutableStateFlow(HomeViewState())
|
||||
val viewState: StateFlow<HomeViewState> = _viewState.asStateFlow()
|
||||
|
||||
private val _filterState = MutableStateFlow(FilterState())
|
||||
val filterState = _filterState.asStateFlow()
|
||||
|
||||
fun dispatch(action: HomeViewAction) {
|
||||
when (action) {
|
||||
is HomeViewAction.Sync -> sync(action.callback)
|
||||
is HomeViewAction.ChangeFilter -> changeFilter(action.filterState)
|
||||
is HomeViewAction.ScrollToPage -> scrollToPage(
|
||||
action.scope,
|
||||
action.targetPage,
|
||||
action.callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sync(callback: () -> Unit = {}) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
rssRepository.sync()
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeFilter(filterState: FilterState) {
|
||||
_filterState.update {
|
||||
it.copy(
|
||||
group = filterState.group,
|
||||
feed = filterState.feed,
|
||||
filter = filterState.filter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToPage(scope: CoroutineScope, targetPage: Int, callback: () -> Unit = {}) {
|
||||
scope.launch {
|
||||
_viewState.value.pagerState.animateScrollToPage(targetPage)
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FilterState(
|
||||
val group: Group? = null,
|
||||
val feed: Feed? = null,
|
||||
val filter: Filter = Filter.All,
|
||||
)
|
||||
|
||||
@ExperimentalPagerApi
|
||||
data class HomeViewState(
|
||||
val pagerState: PagerState = PagerState(1),
|
||||
)
|
||||
|
||||
sealed class HomeViewAction {
|
||||
data class Sync(
|
||||
val callback: () -> Unit = {},
|
||||
) : HomeViewAction()
|
||||
|
||||
data class ChangeFilter(
|
||||
val filterState: FilterState
|
||||
) : HomeViewAction()
|
||||
|
||||
data class ScrollToPage(
|
||||
val scope: CoroutineScope,
|
||||
val targetPage: Int,
|
||||
val callback: () -> Unit = {},
|
||||
) : HomeViewAction()
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.DoneAll
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
import me.ash.reader.ui.util.paddingFixedHorizontal
|
||||
import me.ash.reader.ui.util.roundClick
|
||||
import me.ash.reader.ui.widget.AnimateLazyColumn
|
||||
import me.ash.reader.ui.widget.TopTitleBox
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun ArticlePage(
|
||||
modifier: Modifier,
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
viewModel: ArticleViewModel = hiltViewModel(),
|
||||
BackOnClick: () -> Unit,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = homeViewModel.filterState.collectAsStateValue()
|
||||
val pagingItems = viewState.pagingData?.collectAsLazyPagingItems()
|
||||
val refreshState = rememberSwipeRefreshState(isRefreshing = viewState.isRefreshing)
|
||||
val syncState = RssRepository.syncState.collectAsStateValue()
|
||||
|
||||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
Log.i("RLog", "LaunchedEffect filterState: ")
|
||||
viewModel.dispatch(
|
||||
ArticleViewAction.FetchData(
|
||||
groupId = state.group?.id,
|
||||
feedId = state.feed?.id,
|
||||
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
|
||||
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = refreshState,
|
||||
refreshTriggerDistance = 100.dp,
|
||||
onRefresh = {
|
||||
if (syncState.isSyncing) return@SwipeRefresh
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}
|
||||
) {
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
TopTitleBox(
|
||||
title = when {
|
||||
filterState.group != null -> filterState.group.name
|
||||
filterState.feed != null -> filterState.feed.name
|
||||
else -> filterState.filter.title
|
||||
},
|
||||
description = if (syncState.isSyncing) {
|
||||
"Syncing (${syncState.syncedCount}/${syncState.feedCount}) : ${syncState.currentFeedName}"
|
||||
} else {
|
||||
"${viewState.filterImportant}${filterState.filter.description}"
|
||||
},
|
||||
listState = viewState.listState,
|
||||
startOffset = Offset(20f, 72f),
|
||||
startHeight = 50f,
|
||||
startTitleFontSize = 24f,
|
||||
startDescriptionFontSize = 14f,
|
||||
) {
|
||||
viewModel.dispatch(ArticleViewAction.ScrollToItem(0))
|
||||
}
|
||||
Column {
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(BackOnClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowBackIosNew,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
viewModel.dispatch(ArticleViewAction.PeekSyncWork)
|
||||
Toast.makeText(context, viewState.syncWorkInfo, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DoneAll,
|
||||
contentDescription = "Done All",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (syncState.isSyncing) return@IconButton
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
AnimateLazyColumn(
|
||||
state = viewState.listState,
|
||||
reference = filterState.filter,
|
||||
) {
|
||||
if (pagingItems == null) return@AnimateLazyColumn
|
||||
var lastItemDay: String? = null
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(74.dp))
|
||||
}
|
||||
for (itemIndex in 0 until pagingItems.itemCount) {
|
||||
val currentItem = pagingItems.peek(itemIndex)
|
||||
val currentItemDay =
|
||||
currentItem?.article?.date?.toString(DateTimeExt.YYYY_MM_DD, true)
|
||||
?: "null"
|
||||
if (lastItemDay != currentItemDay) {
|
||||
if (itemIndex != 0) {
|
||||
item { Spacer(modifier = Modifier.height(40.dp)) }
|
||||
}
|
||||
stickyHeader {
|
||||
ArticleDateHeader(currentItemDay)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArticleItem(
|
||||
modifier = modifier,
|
||||
articleWithFeed = pagingItems[itemIndex],
|
||||
isStarredFilter = filterState.filter == Filter.Starred,
|
||||
index = itemIndex,
|
||||
articleOnClick = articleOnClick,
|
||||
)
|
||||
}
|
||||
lastItemDay = currentItemDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArticleItem(
|
||||
modifier: Modifier = Modifier,
|
||||
articleWithFeed: ArticleWithFeed?,
|
||||
isStarredFilter: Boolean,
|
||||
index: Int,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
if (articleWithFeed == null) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.paddingFixedHorizontal(
|
||||
top = if (index == 0) 8.dp else 0.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
.roundClick {
|
||||
articleOnClick(articleWithFeed)
|
||||
}
|
||||
.alpha(
|
||||
if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
1f
|
||||
} else {
|
||||
0.75f
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = articleWithFeed.feed.name,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = articleWithFeed.article.date.toString(
|
||||
DateTimeExt.HH_MM
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Text(
|
||||
text = articleWithFeed.article.title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Text(
|
||||
text = articleWithFeed.article.shortDescription,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArticleDateHeader(date: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.ArticleRepository
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ArticleViewModel @Inject constructor(
|
||||
private val articleRepository: ArticleRepository,
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
private val _viewState = MutableStateFlow(ArticleViewState())
|
||||
val viewState: StateFlow<ArticleViewState> = _viewState.asStateFlow()
|
||||
|
||||
fun dispatch(action: ArticleViewAction) {
|
||||
when (action) {
|
||||
is ArticleViewAction.FetchData -> fetchData(
|
||||
groupId = action.groupId,
|
||||
feedId = action.feedId,
|
||||
isStarred = action.isStarred,
|
||||
isUnread = action.isUnread,
|
||||
)
|
||||
is ArticleViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
|
||||
is ArticleViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
is ArticleViewAction.PeekSyncWork -> peekSyncWork()
|
||||
}
|
||||
}
|
||||
|
||||
private fun peekSyncWork() {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
syncWorkInfo = rssRepository.peekWork()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData(
|
||||
groupId: Int? = null,
|
||||
feedId: Int? = null,
|
||||
isStarred: Boolean,
|
||||
isUnread: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
articleRepository.pullImportant(isStarred, true)
|
||||
.collect { importantList ->
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
filterImportant = importantList.sumOf { it.important },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
pagingData = Pager(PagingConfig(pageSize = 10)) {
|
||||
articleRepository.pullArticles(
|
||||
groupId = groupId,
|
||||
feedId = feedId,
|
||||
isStarred = isStarred,
|
||||
isUnread = isUnread,
|
||||
)
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToItem(index: Int) {
|
||||
viewModelScope.launch {
|
||||
_viewState.value.listState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeRefreshing(isRefreshing: Boolean) {
|
||||
_viewState.update {
|
||||
it.copy(isRefreshing = isRefreshing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ArticleViewState(
|
||||
val filterImportant: Int = 0,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val pagingData: Flow<PagingData<ArticleWithFeed>>? = null,
|
||||
val syncWorkInfo: String = "",
|
||||
)
|
||||
|
||||
sealed class ArticleViewAction {
|
||||
data class FetchData(
|
||||
val groupId: Int? = null,
|
||||
val feedId: Int? = null,
|
||||
val isStarred: Boolean,
|
||||
val isUnread: Boolean,
|
||||
) : ArticleViewAction()
|
||||
|
||||
data class ChangeRefreshing(
|
||||
val isRefreshing: Boolean
|
||||
) : ArticleViewAction()
|
||||
|
||||
data class ScrollToItem(
|
||||
val index: Int
|
||||
) : ArticleViewAction()
|
||||
|
||||
object PeekSyncWork : ArticleViewAction()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package me.ash.reader.ui.page.home.feed
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
import me.ash.reader.ui.widget.*
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun FeedPage(
|
||||
modifier: Modifier,
|
||||
viewModel: FeedViewModel = hiltViewModel(),
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
filter: Filter,
|
||||
groupAndFeedOnClick: (currentGroup: Group?, currentFeed: Feed?) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val syncState = RssRepository.syncState.collectAsStateValue()
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
||||
Log.i("RLog", "launcher: ${it}")
|
||||
it?.let { uri ->
|
||||
context.contentResolver.openInputStream(uri)?.let { inputStream ->
|
||||
viewModel.dispatch(FeedViewAction.AddFromFile(inputStream))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
viewModel.dispatch(
|
||||
FeedViewAction.FetchData(
|
||||
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
|
||||
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.dispatch(
|
||||
FeedViewAction.FetchAccount()
|
||||
)
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
TopTitleBox(
|
||||
title = viewState.account?.name ?: "未知账户",
|
||||
description = if (syncState.isSyncing) {
|
||||
"Syncing (${syncState.syncedCount}/${syncState.feedCount}) : ${syncState.currentFeedName}"
|
||||
} else {
|
||||
viewState.account?.updateAt?.toString(DateTimeExt.YYYY_MM_DD_HH_MM, true)
|
||||
?: "从未同步"
|
||||
},
|
||||
listState = viewState.listState,
|
||||
startOffset = Offset(20f, 80f),
|
||||
startHeight = 72f,
|
||||
startTitleFontSize = 38f,
|
||||
startDescriptionFontSize = 16f,
|
||||
) {
|
||||
viewModel.dispatch(FeedViewAction.ScrollToItem(0))
|
||||
}
|
||||
Column {
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier.size(22.dp),
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
if (syncState.isSyncing) return@IconButton
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier.size(26.dp),
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = "Sync",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
launcher.launch("*/*")
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier.size(26.dp),
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = "Subscribe",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
LazyColumn(
|
||||
state = viewState.listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(114.dp))
|
||||
BarButton(
|
||||
barButtonType = ButtonType(
|
||||
content = filter.title,
|
||||
important = viewState.filterImportant
|
||||
)
|
||||
) {
|
||||
groupAndFeedOnClick(null, null)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
BarButton(
|
||||
barButtonType = FirstExpandType(
|
||||
content = "Feeds",
|
||||
icon = Icons.Rounded.ExpandMore
|
||||
)
|
||||
) {
|
||||
viewModel.dispatch(FeedViewAction.ChangeGroupVisible)
|
||||
}
|
||||
}
|
||||
itemsIndexed(viewState.groupWithFeedList) { index, groupWithFeed ->
|
||||
GroupList(
|
||||
modifier = modifier,
|
||||
groupVisible = viewState.groupsVisible,
|
||||
feedVisible = viewState.feedsVisible[index],
|
||||
groupWithFeed = groupWithFeed,
|
||||
groupAndFeedOnClick = groupAndFeedOnClick,
|
||||
expandOnClick = {
|
||||
viewModel.dispatch(FeedViewAction.ChangeFeedVisible(index))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
private fun ColumnScope.GroupList(
|
||||
modifier: Modifier = Modifier,
|
||||
groupVisible: Boolean,
|
||||
feedVisible: Boolean,
|
||||
groupWithFeed: GroupWithFeed,
|
||||
groupAndFeedOnClick: (currentGroup: Group?, currentFeed: Feed?) -> Unit = { _, _ -> },
|
||||
expandOnClick: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = groupVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
BarButton(
|
||||
barButtonType = SecondExpandType(
|
||||
content = groupWithFeed.group.name,
|
||||
icon = Icons.Rounded.ExpandMore,
|
||||
important = groupWithFeed.group.important ?: 0,
|
||||
),
|
||||
iconOnClickListener = expandOnClick
|
||||
) {
|
||||
groupAndFeedOnClick(groupWithFeed.group, null)
|
||||
}
|
||||
FeedList(
|
||||
visible = feedVisible,
|
||||
feeds = groupWithFeed.feeds,
|
||||
onClick = { currentFeed ->
|
||||
groupAndFeedOnClick(null, currentFeed)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
private fun ColumnScope.FeedList(
|
||||
visible: Boolean,
|
||||
feeds: List<Feed>,
|
||||
onClick: (currentFeed: Feed?) -> Unit = {},
|
||||
) {
|
||||
// LaunchedEffect(feeds) {
|
||||
//
|
||||
// }
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
feeds.forEach { feed ->
|
||||
BarButton(
|
||||
barButtonType = ItemType(
|
||||
// icon = feed.icon ?: "",
|
||||
icon = painterResource(id = R.drawable.default_folder),
|
||||
content = feed.name,
|
||||
important = feed.important ?: 0
|
||||
)
|
||||
) {
|
||||
onClick(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package me.ash.reader.ui.page.home.feed
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.data.repository.AccountRepository
|
||||
import me.ash.reader.data.repository.ArticleRepository
|
||||
import me.ash.reader.data.repository.OpmlRepository
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FeedViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val articleRepository: ArticleRepository,
|
||||
private val opmlRepository: OpmlRepository,
|
||||
) : ViewModel() {
|
||||
private val _viewState = MutableStateFlow(FeedViewState())
|
||||
val viewState: StateFlow<FeedViewState> = _viewState.asStateFlow()
|
||||
|
||||
fun dispatch(action: FeedViewAction) {
|
||||
when (action) {
|
||||
is FeedViewAction.FetchAccount -> fetchAccount(action.callback)
|
||||
is FeedViewAction.FetchData -> fetchData(action.isStarred, action.isUnread)
|
||||
is FeedViewAction.AddFromFile -> addFromFile(action.inputStream)
|
||||
is FeedViewAction.ChangeFeedVisible -> changeFeedVisible(action.index)
|
||||
is FeedViewAction.ChangeGroupVisible -> changeGroupVisible()
|
||||
is FeedViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAccount(callback: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
account = accountRepository.getCurrentAccount()
|
||||
)
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addFromFile(inputStream: InputStream) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
opmlRepository.saveToDatabase(inputStream)
|
||||
pullFeeds(isStarred = false, isUnread = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData(isStarred: Boolean, isUnread: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
pullFeeds(isStarred, isUnread)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pullFeeds(isStarred: Boolean, isUnread: Boolean) {
|
||||
combine(
|
||||
articleRepository.pullFeeds(),
|
||||
articleRepository.pullImportant(isStarred, isUnread),
|
||||
) { groupWithFeedList, importantList ->
|
||||
val groupImportantMap = mutableMapOf<Int, Int>()
|
||||
val feedImportantMap = mutableMapOf<Int, 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
|
||||
}.onStart {
|
||||
|
||||
}.onEach { groupWithFeedList ->
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
filterImportant = groupWithFeedList.sumOf { it.group.important ?: 0 },
|
||||
groupWithFeedList = groupWithFeedList,
|
||||
feedsVisible = List(groupWithFeedList.size, init = { true })
|
||||
)
|
||||
}
|
||||
}.catch {
|
||||
Log.e("RLog", "catch in articleRepository.pullFeeds(): $this")
|
||||
}.collect()
|
||||
}
|
||||
|
||||
private fun changeFeedVisible(index: Int) {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
feedsVisible = _viewState.value.feedsVisible.toMutableList().apply {
|
||||
this[index] = !this[index]
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeGroupVisible() {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
groupsVisible = !_viewState.value.groupsVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToItem(index: Int) {
|
||||
viewModelScope.launch {
|
||||
_viewState.value.listState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FeedViewState(
|
||||
val account: Account? = null,
|
||||
val filterImportant: Int = 0,
|
||||
val groupWithFeedList: List<GroupWithFeed> = emptyList(),
|
||||
val feedsVisible: List<Boolean> = emptyList(),
|
||||
val listState: LazyListState = LazyListState(),
|
||||
val groupsVisible: Boolean = true,
|
||||
)
|
||||
|
||||
sealed class FeedViewAction {
|
||||
data class FetchData(
|
||||
val isStarred: Boolean,
|
||||
val isUnread: Boolean,
|
||||
) : FeedViewAction()
|
||||
|
||||
data class FetchAccount(
|
||||
val callback: () -> Unit = {},
|
||||
) : FeedViewAction()
|
||||
|
||||
data class AddFromFile(
|
||||
val inputStream: InputStream
|
||||
) : FeedViewAction()
|
||||
|
||||
data class ChangeFeedVisible(
|
||||
val index: Int
|
||||
) : FeedViewAction()
|
||||
|
||||
object ChangeGroupVisible : FeedViewAction()
|
||||
data class ScrollToItem(
|
||||
val index: Int
|
||||
) : FeedViewAction()
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package me.ash.reader.ui.page.home.read
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
import me.ash.reader.ui.util.paddingFixedHorizontal
|
||||
import me.ash.reader.ui.util.roundClick
|
||||
import me.ash.reader.ui.widget.WebView
|
||||
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun ReadPage(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ReadViewModel = hiltViewModel(),
|
||||
btnBackOnClickListener: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
|
||||
LaunchedEffect(viewModel.viewState) {
|
||||
viewModel.viewState.collect {
|
||||
if (it.articleWithFeed != null) {
|
||||
// if (it.articleWithFeed.article.isUnread) {
|
||||
// viewModel.dispatch(ReadViewAction.MarkUnread(false))
|
||||
// }
|
||||
if (it.articleWithFeed.feed.isFullContent) {
|
||||
viewModel.dispatch(ReadViewAction.RenderFullContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { btnBackOnClickListener() }) {
|
||||
Icon(
|
||||
modifier = Modifier.size(28.dp),
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /*TODO*/ }) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = Icons.Rounded.Share,
|
||||
contentDescription = "Add",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /*TODO*/ }) {
|
||||
Icon(
|
||||
modifier = Modifier.size(28.dp),
|
||||
imageVector = Icons.Rounded.MoreHoriz,
|
||||
contentDescription = "Add",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val composition by rememberLottieComposition(
|
||||
LottieCompositionSpec.Url(
|
||||
"https://assets5.lottiefiles.com/packages/lf20_9tvcldy3.json"
|
||||
)
|
||||
)
|
||||
|
||||
if (viewState.articleWithFeed == null) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
modifier = Modifier
|
||||
.padding(50.dp)
|
||||
.alpha(0.6f),
|
||||
isPlaying = true,
|
||||
restartOnPlay = true,
|
||||
iterations = Int.MAX_VALUE
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
visible = viewState.articleWithFeed != null,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
if (viewState.articleWithFeed == null) return@AnimatedVisibility
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
state = viewState.listState,
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
) {
|
||||
val article = viewState.articleWithFeed.article
|
||||
val feed = viewState.articleWithFeed.feed
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.paddingFixedHorizontal()
|
||||
) {
|
||||
Header(context, article, feed)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
WebView(
|
||||
content = viewState.content ?: "",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(
|
||||
context: Context,
|
||||
article: Article,
|
||||
feed: Feed
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.roundClick {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(article.link))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = article.date.toString(DateTimeExt.YYYY_MM_DD_HH_MM, true),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = article.title,
|
||||
fontSize = 27.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 34.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
article.author?.let {
|
||||
Text(
|
||||
text = article.author,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = feed.name,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package me.ash.reader.ui.page.home.read
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.ArticleRepository
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ReadViewModel @Inject constructor(
|
||||
private val articleRepository: ArticleRepository,
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
private val _viewState = MutableStateFlow(ReadViewState())
|
||||
val viewState: StateFlow<ReadViewState> = _viewState.asStateFlow()
|
||||
|
||||
fun dispatch(action: ReadViewAction) {
|
||||
when (action) {
|
||||
is ReadViewAction.InitData -> bindArticleWithFeed(action.articleWithFeed)
|
||||
is ReadViewAction.RenderDescriptionContent -> renderDescriptionContent()
|
||||
is ReadViewAction.RenderFullContent -> renderFullContent()
|
||||
is ReadViewAction.MarkUnread -> markUnread(action.isUnread)
|
||||
is ReadViewAction.MarkStarred -> markStarred(action.isStarred)
|
||||
is ReadViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
is ReadViewAction.ClearArticle -> clearArticle()
|
||||
is ReadViewAction.ChangeLoading -> changeLoading(action.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindArticleWithFeed(articleWithFeed: ArticleWithFeed) {
|
||||
_viewState.update {
|
||||
it.copy(articleWithFeed = articleWithFeed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderDescriptionContent() {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
content = rssRepository.parseDescriptionContent(
|
||||
link = it.articleWithFeed?.article?.link ?: "",
|
||||
content = it.articleWithFeed?.article?.rawDescription ?: "",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFullContent() {
|
||||
changeLoading(true)
|
||||
rssRepository.parseFullContent(
|
||||
_viewState.value.articleWithFeed?.article?.link ?: "",
|
||||
_viewState.value.articleWithFeed?.article?.title ?: ""
|
||||
) { content ->
|
||||
_viewState.update {
|
||||
it.copy(content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markUnread(isUnread: Boolean) {
|
||||
_viewState.value.articleWithFeed?.let {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
articleWithFeed = it.articleWithFeed?.copy(
|
||||
article = it.articleWithFeed.article.copy(
|
||||
isUnread = isUnread
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
articleRepository.updateArticleInfo(
|
||||
it.article.copy(
|
||||
isUnread = isUnread
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markStarred(isStarred: Boolean) {
|
||||
_viewState.value.articleWithFeed?.let {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
articleWithFeed = it.articleWithFeed?.copy(
|
||||
article = it.articleWithFeed.article.copy(
|
||||
isStarred = isStarred
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
articleRepository.updateArticleInfo(
|
||||
it.article.copy(
|
||||
isStarred = isStarred
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToItem(index: Int) {
|
||||
viewModelScope.launch {
|
||||
_viewState.value.listState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearArticle() {
|
||||
_viewState.update {
|
||||
it.copy(articleWithFeed = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeLoading(isLoading: Boolean) {
|
||||
_viewState.update {
|
||||
it.copy(isLoading = isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadViewState(
|
||||
val articleWithFeed: ArticleWithFeed? = null,
|
||||
val content: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
)
|
||||
|
||||
sealed class ReadViewAction {
|
||||
data class InitData(
|
||||
val articleWithFeed: ArticleWithFeed,
|
||||
) : ReadViewAction()
|
||||
|
||||
object RenderDescriptionContent : ReadViewAction()
|
||||
|
||||
object RenderFullContent : ReadViewAction()
|
||||
|
||||
data class MarkUnread(
|
||||
val isUnread: Boolean,
|
||||
) : ReadViewAction()
|
||||
|
||||
data class MarkStarred(
|
||||
val isStarred: Boolean,
|
||||
) : ReadViewAction()
|
||||
|
||||
data class ScrollToItem(
|
||||
val index: Int
|
||||
) : ReadViewAction()
|
||||
|
||||
object ClearArticle : ReadViewAction()
|
||||
|
||||
data class ChangeLoading(
|
||||
val isLoading: Boolean
|
||||
) : ReadViewAction()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package me.ash.reader.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
|
||||
//val md_theme_light_primary = Color(0xFF4D4D4D)
|
||||
val md_theme_light_primary = Color(0xFF6750A4)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFEADDFF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
|
||||
//val md_theme_light_secondary = Color(0xFF868686)
|
||||
val md_theme_light_secondary = Color(0xFF625B71)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
//val md_theme_light_secondaryContainer = Color(0xFFEAEAEA)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
|
||||
//val md_theme_light_tertiary = Color(0xFFC1C1C1)
|
||||
val md_theme_light_tertiary = Color(0xFF7D5260)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF31111D)
|
||||
val md_theme_light_error = Color(0xFFB3261E)
|
||||
val md_theme_light_errorContainer = Color(0xFFF9DEDC)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410E0B)
|
||||
//val md_theme_light_background = Color(0xFFF7F5F4)
|
||||
val md_theme_light_background = Color(0xFFFFFBFE)
|
||||
val md_theme_light_onBackground = Color(0xFF1C1B1F)
|
||||
//val md_theme_light_surface = Color(0xFFF7F5F4)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFE)
|
||||
val md_theme_light_onSurface = Color(0xFF1C1B1F)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFE7E0EC)
|
||||
//val md_theme_light_onSurfaceVariant = md_theme_light_secondary
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF49454F)
|
||||
val md_theme_light_outline = Color(0xFF79747E)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4)
|
||||
val md_theme_light_inverseSurface = Color(0xFF313033)
|
||||
val md_theme_light_inversePrimary = Color(0xFFD0BCFF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFFD0BCFF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF381E72)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF4F378B)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
|
||||
val md_theme_dark_secondary = Color(0xFFCCC2DC)
|
||||
val md_theme_dark_onSecondary = Color(0xFF332D41)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
|
||||
val md_theme_dark_tertiary = Color(0xFFEFB8C8)
|
||||
val md_theme_dark_onTertiary = Color(0xFF492532)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF633B48)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4)
|
||||
val md_theme_dark_error = Color(0xFFF2B8B5)
|
||||
val md_theme_dark_errorContainer = Color(0xFF8C1D18)
|
||||
val md_theme_dark_onError = Color(0xFF601410)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFF9DEDC)
|
||||
val md_theme_dark_background = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_onBackground = Color(0xFFE6E1E5)
|
||||
val md_theme_dark_surface = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_onSurface = Color(0xFFE6E1E5)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF49454F)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0)
|
||||
val md_theme_dark_outline = Color(0xFF938F99)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE6E1E5)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF6750A4)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
|
||||
val seed = Color(0xFF6750A4)
|
||||
val error = Color(0xFFB3261E)
|
||||
@@ -0,0 +1,89 @@
|
||||
package me.ash.reader.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import me.ash.reader.ui.theme.*
|
||||
|
||||
private val LightThemeColors = lightColorScheme(
|
||||
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
// shadow = md_theme_light_shadow,
|
||||
)
|
||||
private val DarkThemeColors = darkColorScheme(
|
||||
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
// shadow = md_theme_dark_shadow,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
// Dynamic color is available on Android 12+
|
||||
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val colorScheme = when {
|
||||
dynamicColor && useDarkTheme -> dynamicDarkColorScheme(LocalContext.current)
|
||||
dynamicColor && !useDarkTheme -> dynamicLightColorScheme(LocalContext.current)
|
||||
useDarkTheme -> DarkThemeColors
|
||||
else -> LightThemeColors
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package me.ash.reader.ui.theme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
//Replace with your font locations
|
||||
val Roboto = FontFamily.Default
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = -0.25.sp,
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.W400,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = Roboto,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package me.ash.reader.ui.util
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerScope
|
||||
import com.google.accompanist.pager.calculateCurrentOffsetForPage
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun <T> StateFlow<T>.collectAsStateValue(
|
||||
context: CoroutineContext = EmptyCoroutineContext
|
||||
): T = collectAsState(context).value
|
||||
|
||||
fun LazyListState.calculateTopBarAnimateValue(start: Float, end: Float): Float =
|
||||
if (firstVisibleItemIndex != 0) end
|
||||
else {
|
||||
val variable = firstVisibleItemScrollOffset.coerceAtLeast(0).toFloat()
|
||||
val duration = 256f
|
||||
val increase = abs(start - end) * (variable / duration)
|
||||
if (start < end) (start + increase).coerceIn(start, end)
|
||||
else (start - increase).coerceIn(end, start)
|
||||
}
|
||||
|
||||
@ExperimentalPagerApi
|
||||
fun Modifier.pagerAnimate(pagerScope: PagerScope, page: Int): Modifier {
|
||||
return graphicsLayer {
|
||||
// Calculate the absolute offset for the current page from the
|
||||
// scroll position. We use the absolute value which allows us to mirror
|
||||
// any effects for both directions
|
||||
val pageOffset = pagerScope.calculateCurrentOffsetForPage(page).absoluteValue
|
||||
|
||||
// We animate the scaleX + scaleY, between 85% and 100%
|
||||
// lerp(
|
||||
// start = 0.85f.dp,
|
||||
// stop = 1f.dp,
|
||||
// fraction = 1f - pageOffset.coerceIn(0f, 1f)
|
||||
// ).also { scale ->
|
||||
// scaleX = scale.value
|
||||
// scaleY = scale.value
|
||||
// }
|
||||
|
||||
// We animate the alpha, between 50% and 100%
|
||||
alpha = lerp(
|
||||
start = 0.2f.dp,
|
||||
stop = 1f.dp,
|
||||
fraction = 1f - pageOffset.coerceIn(0f, 1f) * 1.5f
|
||||
).value
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.roundClick(onClick: () -> Unit = {}) = this
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
fun Modifier.paddingFixedHorizontal(top: Dp = 0.dp, bottom: Dp = 0.dp) = this
|
||||
.padding(horizontal = 10.dp)
|
||||
.padding(top = top, bottom = bottom)
|
||||
@@ -0,0 +1,41 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun AnimateLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
reference: Any?,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(reference) {
|
||||
Log.i("RLog", "reference change")
|
||||
visible = false
|
||||
// delay(50)
|
||||
visible = true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
visible = visible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Suppress("UpdateTransitionLabel", "TransitionPropertiesLabel")
|
||||
@SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter")
|
||||
/**
|
||||
* @param state Use [updateAnimatedItemsState].
|
||||
*/
|
||||
inline fun <T> LazyListScope.animatedItemsIndexed(
|
||||
state: List<AnimatedItem<T>>,
|
||||
enterTransition: EnterTransition = expandVertically(),
|
||||
exitTransition: ExitTransition = shrinkVertically(),
|
||||
noinline key: ((item: T) -> Any)? = null,
|
||||
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
|
||||
) {
|
||||
items(
|
||||
state.size,
|
||||
if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null
|
||||
) { index ->
|
||||
|
||||
val item = state[index]
|
||||
val visibility = item.visibility
|
||||
|
||||
key(key?.invoke(item.item)) {
|
||||
AnimatedVisibility(
|
||||
visibleState = visibility,
|
||||
enter = enterTransition,
|
||||
exit = exitTransition
|
||||
) {
|
||||
itemContent(index, item.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> updateAnimatedItemsState(
|
||||
newList: List<T>
|
||||
): State<List<AnimatedItem<T>>> {
|
||||
|
||||
val state = remember { mutableStateOf(emptyList<AnimatedItem<T>>()) }
|
||||
LaunchedEffect(newList) {
|
||||
if (state.value == newList) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val oldList = state.value.toList()
|
||||
|
||||
val diffCb = object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item == newList[newItemPosition]
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item == newList[newItemPosition]
|
||||
|
||||
}
|
||||
val diffResult = calculateDiff(false, diffCb)
|
||||
val compositeList = oldList.toMutableList()
|
||||
|
||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
val newItem = AnimatedItem(
|
||||
visibility = MutableTransitionState(false),
|
||||
newList[position + i]
|
||||
)
|
||||
newItem.visibility.targetState = true
|
||||
compositeList.add(position + i, newItem)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].visibility.targetState = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
// not detecting moves.
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
// irrelevant with compose.
|
||||
}
|
||||
})
|
||||
if (state.value != compositeList) {
|
||||
state.value = compositeList
|
||||
}
|
||||
val initialAnimation = Animatable(1.0f)
|
||||
initialAnimation.animateTo(0f)
|
||||
state.value = state.value.filter { it.visibility.targetState }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
data class AnimatedItem<T>(
|
||||
val visibility: MutableTransitionState<Boolean>,
|
||||
val item: T,
|
||||
) {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return item?.hashCode() ?: 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AnimatedItem<*>
|
||||
|
||||
if (item != other.item) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun calculateDiff(
|
||||
detectMoves: Boolean = true,
|
||||
diffCb: DiffUtil.Callback
|
||||
): DiffUtil.DiffResult {
|
||||
return withContext(Dispatchers.Unconfined) {
|
||||
DiffUtil.calculateDiff(diffCb, detectMoves)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun AnimatedText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
textDecoration: TextDecoration? = null,
|
||||
textAlign: TextAlign? = null,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = text,
|
||||
transitionSpec = {
|
||||
slideInVertically(
|
||||
tween(
|
||||
200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
) { height -> height } with slideOutVertically { height -> -height } + fadeOut(
|
||||
tween(
|
||||
200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
) { target ->
|
||||
Text(
|
||||
text = target,
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Article
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.Sell
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import me.ash.reader.ui.data.NavigationBarItem
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
fun AppNavigationBar(
|
||||
modifier: Modifier = Modifier,
|
||||
pagerState: PagerState,
|
||||
filter: Filter,
|
||||
filterOnClick: (Filter) -> Unit = {},
|
||||
disabled: Boolean,
|
||||
isUnread: Boolean,
|
||||
isStarred: Boolean,
|
||||
isFullContent: Boolean,
|
||||
unreadOnClick: (afterIsUnread: Boolean) -> Unit = {},
|
||||
starredOnClick: (afterIsStarred: Boolean) -> Unit = {},
|
||||
fullContentOnClick: (afterIsFullContent: Boolean) -> Unit = {},
|
||||
) {
|
||||
val transition = updateTransition(targetState = pagerState, label = "")
|
||||
val readerBarAlpha by transition.animateFloat(
|
||||
label = "",
|
||||
transitionSpec = {
|
||||
tween(
|
||||
easing = FastOutLinearInEasing,
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (it.currentPage < 2) {
|
||||
if (it.currentPage == it.targetPage) {
|
||||
0f
|
||||
} else {
|
||||
if (it.targetPage == 2) {
|
||||
it.currentPageOffset.absoluteValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (it.currentPage == it.targetPage) {
|
||||
1f
|
||||
} else {
|
||||
if (it.targetPage == 1) {
|
||||
1f - it.currentPageOffset.absoluteValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(modifier = Modifier.alpha(0.3f))
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = readerBarAlpha < 1f,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
.alpha(1 - readerBarAlpha),
|
||||
) {
|
||||
// Log.i("RLog", "AppNavigationBar: ${readerBarAlpha}, ${1f - readerBarAlpha}")
|
||||
FilterBar(
|
||||
modifier = modifier,
|
||||
filter = filter,
|
||||
onSelected = filterOnClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = readerBarAlpha > 0f,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
.alpha(readerBarAlpha),
|
||||
) {
|
||||
ReaderBar(
|
||||
modifier = modifier,
|
||||
disabled = disabled,
|
||||
isUnread = isUnread,
|
||||
isStarred = isStarred,
|
||||
isFullContent = isFullContent,
|
||||
unreadOnClick = unreadOnClick,
|
||||
starredOnClick = starredOnClick,
|
||||
fullContentOnClick = fullContentOnClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterBar(
|
||||
modifier: Modifier = Modifier,
|
||||
filter: Filter,
|
||||
onSelected: (Filter) -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
listOf(
|
||||
NavigationBarItem.Starred,
|
||||
NavigationBarItem.Unread,
|
||||
NavigationBarItem.All
|
||||
).forEachIndexed { index, item ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.height(30.dp)
|
||||
.defaultMinSize(
|
||||
minWidth = 82.dp
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = {
|
||||
onSelected(
|
||||
when (index) {
|
||||
0 -> Filter.Starred
|
||||
1 -> Filter.Unread
|
||||
else -> Filter.All
|
||||
}
|
||||
)
|
||||
})
|
||||
.background(
|
||||
if (filter.index == index) {
|
||||
MaterialTheme.colorScheme.inverseOnSurface
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
)
|
||||
) {
|
||||
if (filter.index == index) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Icon(
|
||||
modifier = Modifier.size(
|
||||
if (Filter.Unread.index == index) {
|
||||
15
|
||||
} else {
|
||||
19
|
||||
}.dp
|
||||
),
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.title,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = item.title,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
} else {
|
||||
Icon(
|
||||
modifier = Modifier.size(
|
||||
if (Filter.Unread.index == index) {
|
||||
15
|
||||
} else {
|
||||
19
|
||||
}.dp
|
||||
),
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.title,
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderBar(
|
||||
modifier: Modifier = Modifier,
|
||||
disabled: Boolean,
|
||||
isUnread: Boolean,
|
||||
isStarred: Boolean,
|
||||
isFullContent: Boolean,
|
||||
unreadOnClick: (afterIsUnread: Boolean) -> Unit = {},
|
||||
starredOnClick: (afterIsStarred: Boolean) -> Unit = {},
|
||||
fullContentOnClick: (afterIsFullContent: Boolean) -> Unit = {},
|
||||
) {
|
||||
var fullContent by remember { mutableStateOf(isFullContent) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
CanBeDisabledIconButton(
|
||||
disabled = disabled,
|
||||
imageVector = if (isUnread) {
|
||||
Icons.Rounded.Circle
|
||||
} else {
|
||||
Icons.Outlined.Circle
|
||||
},
|
||||
contentDescription = "Mark Unread",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
unreadOnClick(!isUnread)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = disabled,
|
||||
modifier = Modifier.size(28.dp),
|
||||
imageVector = if (isStarred) {
|
||||
Icons.Rounded.Star
|
||||
} else {
|
||||
Icons.Rounded.StarBorder
|
||||
},
|
||||
contentDescription = "Starred",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
starredOnClick(!isStarred)
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = disabled,
|
||||
modifier = Modifier.size(30.dp),
|
||||
imageVector = Icons.Rounded.ExpandMore,
|
||||
contentDescription = "Next Article",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = disabled,
|
||||
imageVector = Icons.Outlined.Sell,
|
||||
contentDescription = "Add Tag",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
|
||||
}
|
||||
CanBeDisabledIconButton(
|
||||
disabled = disabled,
|
||||
modifier = Modifier.size(26.dp),
|
||||
imageVector = if (fullContent) {
|
||||
Icons.Rounded.Article
|
||||
} else {
|
||||
Icons.Outlined.Article
|
||||
},
|
||||
contentDescription = "Full Content Parsing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
val afterIsFullContent = !fullContent
|
||||
fullContent = afterIsFullContent
|
||||
fullContentOnClick(afterIsFullContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.ash.reader.ui.util.paddingFixedHorizontal
|
||||
import me.ash.reader.ui.util.roundClick
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun BarButton(
|
||||
barButtonType: BarButtonType,
|
||||
iconOnClickListener: () -> Unit = {},
|
||||
onClickListener: () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.paddingFixedHorizontal()
|
||||
.roundClick(onClick = onClickListener),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.padding(
|
||||
start = 10.dp,
|
||||
end = if (barButtonType is FirstExpandType) 2.dp else 10.dp
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (barButtonType) {
|
||||
is SecondExpandType -> {
|
||||
Icon(
|
||||
imageVector = barButtonType.img as ImageVector,
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = iconOnClickListener),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
is ItemType -> {
|
||||
val modifier = Modifier
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(start = 28.dp, end = 4.dp)
|
||||
.size(24.dp)
|
||||
// .background(if (barButtonType.img.isBlank()) MaterialTheme.colorScheme.inversePrimary else Color.Unspecified),
|
||||
.background(MaterialTheme.colorScheme.inversePrimary),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
// painter = rememberImagePainter(barButtonType.img),
|
||||
painter = barButtonType.img,
|
||||
contentDescription = "icon",
|
||||
modifier = modifier.fillMaxSize(),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (barButtonType) {
|
||||
is ButtonType -> {
|
||||
AnimatedText(
|
||||
text = barButtonType.text,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = barButtonType.text,
|
||||
fontSize = if (barButtonType is FirstExpandType) 22.sp else 18.sp,
|
||||
fontWeight = if (barButtonType is FirstExpandType) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = if (barButtonType is FirstExpandType)
|
||||
MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
when (barButtonType) {
|
||||
is ButtonType, is ItemType, is SecondExpandType -> {
|
||||
AnimatedText(
|
||||
text = barButtonType.additional.toString(),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
is FirstExpandType -> {
|
||||
Icon(
|
||||
imageVector = barButtonType.additional as ImageVector,
|
||||
contentDescription = "Expand More",
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BarButtonType {
|
||||
val img: Any?
|
||||
val text: String
|
||||
val additional: Any?
|
||||
}
|
||||
|
||||
class ButtonType(
|
||||
private val content: String,
|
||||
private val important: Int,
|
||||
) : BarButtonType {
|
||||
override val img: ImageVector?
|
||||
get() = null
|
||||
override val text: String
|
||||
get() = content
|
||||
override val additional: Any
|
||||
get() = important
|
||||
}
|
||||
|
||||
class FirstExpandType(
|
||||
private val content: String,
|
||||
private val icon: ImageVector,
|
||||
) : BarButtonType {
|
||||
override val img: ImageVector?
|
||||
get() = null
|
||||
override val text: String
|
||||
get() = content
|
||||
override val additional: Any
|
||||
get() = icon
|
||||
}
|
||||
|
||||
class SecondExpandType(
|
||||
private val icon: ImageVector,
|
||||
private val content: String,
|
||||
private val important: Int,
|
||||
) : BarButtonType {
|
||||
override val img: ImageVector
|
||||
get() = icon
|
||||
override val text: String
|
||||
get() = content
|
||||
override val additional: Any
|
||||
get() = important
|
||||
}
|
||||
|
||||
class ItemType(
|
||||
// private val icon: String,
|
||||
private val icon: Painter,
|
||||
private val content: String,
|
||||
private val important: Int,
|
||||
) : BarButtonType {
|
||||
// override val img: String
|
||||
override val img: Painter
|
||||
get() = icon
|
||||
override val text: String
|
||||
get() = content
|
||||
override val additional: Any
|
||||
get() = important
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun CanBeDisabledIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
disabled: Boolean,
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String?,
|
||||
tint: Color = LocalContentColor.current,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.alpha(
|
||||
if (disabled) {
|
||||
0.75f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
),
|
||||
enabled = !disabled,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
modifier = modifier,
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
tint = if (disabled) MaterialTheme.colorScheme.outline else tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.calculateTargetValue
|
||||
import androidx.compose.animation.splineBasedDecay
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
//val items = listOf(
|
||||
// Color.Red,
|
||||
// Color.Blue,
|
||||
// Color.Green,
|
||||
// Color.Yellow,
|
||||
// Color.Cyan,
|
||||
// Color.Magenta,
|
||||
//)
|
||||
|
||||
@Composable
|
||||
fun <T : Any> CustomPager(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
orientation: Orientation = Orientation.Horizontal,
|
||||
initialIndex: Int = 0,
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
itemFraction: Float = 1f,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
overshootFraction: Float = .5f,
|
||||
onItemSelect: (T) -> Unit = {},
|
||||
contentFactory: @Composable (T) -> Unit,
|
||||
) {
|
||||
Pager(
|
||||
items,
|
||||
modifier,
|
||||
orientation,
|
||||
initialIndex,
|
||||
itemFraction,
|
||||
itemSpacing,
|
||||
overshootFraction,
|
||||
onItemSelect = { index -> onItemSelect(items[index]) },
|
||||
) {
|
||||
items.forEach{ item ->
|
||||
Box(
|
||||
modifier = when (orientation) {
|
||||
Orientation.Horizontal -> Modifier.fillMaxWidth()
|
||||
Orientation.Vertical -> Modifier.fillMaxHeight()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentFactory(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> Pager(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
orientation: Orientation = Orientation.Horizontal,
|
||||
initialIndex: Int = 0,
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
itemFraction: Float = 1f,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
overshootFraction: Float = .5f,
|
||||
onItemSelect: (Int) -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
require(initialIndex in 0..items.lastIndex) { "Initial index out of bounds" }
|
||||
require(itemFraction > 0f && itemFraction <= 1f) { "Item fraction must be in the (0f, 1f] range" }
|
||||
require(overshootFraction > 0f && itemFraction <= 1f) { "Overshoot fraction must be in the (0f, 1f] range" }
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = rememberPagerState()
|
||||
state.currentIndex = initialIndex
|
||||
state.numberOfItems = items.size
|
||||
state.itemFraction = itemFraction
|
||||
state.overshootFraction = overshootFraction
|
||||
state.itemSpacing = with(LocalDensity.current) { itemSpacing.toPx() }
|
||||
state.orientation = orientation
|
||||
state.listener = onItemSelect
|
||||
state.scope = scope
|
||||
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.then(state.inputModifier),
|
||||
) { measurables, constraints ->
|
||||
val dimension = constraints.dimension(orientation)
|
||||
val looseConstraints = constraints.toLooseConstraints(orientation, state.itemFraction)
|
||||
val placeables = measurables.map { measurable -> measurable.measure(looseConstraints) }
|
||||
val size = placeables.getSize(orientation, dimension)
|
||||
val itemDimension = (dimension * state.itemFraction).roundToInt()
|
||||
state.itemDimension = itemDimension
|
||||
val halfItemDimension = itemDimension / 2
|
||||
layout(size.width, size.height) {
|
||||
val centerOffset = dimension / 2 - halfItemDimension
|
||||
val dragOffset = state.dragOffset.value
|
||||
val roundedDragOffset = dragOffset.roundToInt()
|
||||
val spacing = state.itemSpacing.roundToInt()
|
||||
val itemDimensionWithSpace = itemDimension + state.itemSpacing
|
||||
val first = ceil(
|
||||
(dragOffset -itemDimension - centerOffset) / itemDimensionWithSpace
|
||||
).toInt().coerceAtLeast(0)
|
||||
val last = ((dimension + dragOffset - centerOffset) / itemDimensionWithSpace).toInt()
|
||||
.coerceAtMost(items.lastIndex)
|
||||
for (i in first..last) {
|
||||
val offset = i * (itemDimension + spacing) - roundedDragOffset + centerOffset
|
||||
placeables[i].place(
|
||||
x = when (orientation) {
|
||||
Orientation.Horizontal -> offset
|
||||
Orientation.Vertical -> 0
|
||||
},
|
||||
y = when (orientation) {
|
||||
Orientation.Horizontal -> 0
|
||||
Orientation.Vertical -> offset
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = items, key2 = initialIndex) {
|
||||
state.snapTo(initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPagerState(): PagerState = remember { PagerState() }
|
||||
|
||||
private fun Constraints.dimension(orientation: Orientation) = when (orientation) {
|
||||
Orientation.Horizontal -> maxWidth
|
||||
Orientation.Vertical -> maxHeight
|
||||
}
|
||||
|
||||
private fun Constraints.toLooseConstraints(
|
||||
orientation: Orientation,
|
||||
itemFraction: Float,
|
||||
): Constraints {
|
||||
val dimension = dimension(orientation)
|
||||
return when (orientation) {
|
||||
Orientation.Horizontal -> copy(
|
||||
minWidth = (dimension * itemFraction).roundToInt(),
|
||||
maxWidth = (dimension * itemFraction).roundToInt(),
|
||||
minHeight = 0,
|
||||
)
|
||||
Orientation.Vertical -> copy(
|
||||
minWidth = 0,
|
||||
minHeight = (dimension * itemFraction).roundToInt(),
|
||||
maxHeight = (dimension * itemFraction).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Placeable>.getSize(
|
||||
orientation: Orientation,
|
||||
dimension: Int,
|
||||
): IntSize {
|
||||
return when (orientation) {
|
||||
Orientation.Horizontal -> IntSize(
|
||||
dimension,
|
||||
maxByOrNull { it.height }?.height ?: 0
|
||||
)
|
||||
Orientation.Vertical -> IntSize(
|
||||
maxByOrNull { it.width }?.width ?: 0,
|
||||
dimension
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class PagerState {
|
||||
var currentIndex by mutableStateOf(0)
|
||||
var numberOfItems by mutableStateOf(0)
|
||||
var itemFraction by mutableStateOf(0f)
|
||||
var overshootFraction by mutableStateOf(0f)
|
||||
var itemSpacing by mutableStateOf(0f)
|
||||
var itemDimension by mutableStateOf(0)
|
||||
var orientation by mutableStateOf(Orientation.Horizontal)
|
||||
var scope: CoroutineScope? by mutableStateOf(null)
|
||||
var listener: (Int) -> Unit by mutableStateOf({})
|
||||
val dragOffset = Animatable(0f)
|
||||
|
||||
private val animationSpec = SpringSpec<Float>(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
)
|
||||
|
||||
suspend fun snapTo(index: Int) {
|
||||
dragOffset.snapTo(index.toFloat() * (itemDimension + itemSpacing))
|
||||
}
|
||||
|
||||
val inputModifier = Modifier.pointerInput(numberOfItems) {
|
||||
fun itemIndex(offset: Int): Int = (offset / (itemDimension + itemSpacing)).roundToInt()
|
||||
.coerceIn(0, numberOfItems - 1)
|
||||
|
||||
fun updateIndex(offset: Float) {
|
||||
val index = itemIndex(offset.roundToInt())
|
||||
if (index != currentIndex) {
|
||||
currentIndex = index
|
||||
listener(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateOffsetLimit(): OffsetLimit {
|
||||
val dimension = when (orientation) {
|
||||
Orientation.Horizontal -> size.width
|
||||
Orientation.Vertical -> size.height
|
||||
}
|
||||
val itemSideMargin = (dimension - itemDimension) / 2f
|
||||
return OffsetLimit(
|
||||
min = -dimension * overshootFraction + itemSideMargin,
|
||||
max = numberOfItems * (itemDimension + itemSpacing) - (1f - overshootFraction) * dimension + itemSideMargin,
|
||||
)
|
||||
}
|
||||
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val tracker = VelocityTracker()
|
||||
val decay = splineBasedDecay<Float>(this)
|
||||
val down = awaitFirstDown()
|
||||
val offsetLimit = calculateOffsetLimit()
|
||||
val dragHandler = { change: PointerInputChange ->
|
||||
scope?.launch {
|
||||
val dragChange = change.calculateDragChange(orientation)
|
||||
dragOffset.snapTo(
|
||||
(dragOffset.value - dragChange).coerceIn(
|
||||
offsetLimit.min,
|
||||
offsetLimit.max
|
||||
)
|
||||
)
|
||||
updateIndex(dragOffset.value)
|
||||
}
|
||||
tracker.addPosition(change.uptimeMillis, change.position)
|
||||
}
|
||||
when (orientation) {
|
||||
Orientation.Horizontal -> horizontalDrag(down.id, dragHandler)
|
||||
Orientation.Vertical -> verticalDrag(down.id, dragHandler)
|
||||
}
|
||||
val velocity = tracker.calculateVelocity(orientation)
|
||||
scope?.launch {
|
||||
var targetOffset = decay.calculateTargetValue(dragOffset.value, -velocity)
|
||||
val remainder = targetOffset.toInt().absoluteValue % itemDimension
|
||||
val extra = if (remainder > itemDimension / 2f) 1 else 0
|
||||
val lastVisibleIndex =
|
||||
(targetOffset.absoluteValue / itemDimension.toFloat()).toInt() + extra
|
||||
targetOffset = (lastVisibleIndex * (itemDimension + itemSpacing) * targetOffset.sign)
|
||||
.coerceIn(0f, (numberOfItems - 1).toFloat() * (itemDimension + itemSpacing))
|
||||
dragOffset.animateTo(
|
||||
animationSpec = animationSpec,
|
||||
targetValue = targetOffset,
|
||||
initialVelocity = -velocity
|
||||
) {
|
||||
updateIndex(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OffsetLimit(
|
||||
val min: Float,
|
||||
val max: Float,
|
||||
)
|
||||
}
|
||||
|
||||
private fun VelocityTracker.calculateVelocity(orientation: Orientation) = when (orientation) {
|
||||
Orientation.Horizontal -> calculateVelocity().x
|
||||
Orientation.Vertical -> calculateVelocity().y
|
||||
}
|
||||
|
||||
private fun PointerInputChange.calculateDragChange(orientation: Orientation) =
|
||||
when (orientation) {
|
||||
Orientation.Horizontal -> positionChange().x
|
||||
Orientation.Vertical -> positionChange().y
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
fun BoxScope.MaskBox(
|
||||
modifier: Modifier = Modifier,
|
||||
pagerState: PagerState,
|
||||
currentPage: Int = 0,
|
||||
) {
|
||||
val transition = updateTransition(targetState = pagerState, label = "")
|
||||
val maskAlpha by transition.animateFloat(
|
||||
label = "",
|
||||
transitionSpec = {
|
||||
spring()
|
||||
}
|
||||
) {
|
||||
when {
|
||||
it.targetPage == currentPage -> {
|
||||
if (it.currentPage > currentPage) {
|
||||
1f - it.currentPageOffset.absoluteValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
it.targetPage > currentPage -> {
|
||||
it.currentPageOffset.absoluteValue
|
||||
}
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.alpha(maskAlpha)
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.ash.reader.ui.util.calculateTopBarAnimateValue
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun BoxScope.TopTitleBox(
|
||||
title: String,
|
||||
description: String,
|
||||
listState: LazyListState,
|
||||
SpacerHeight: Float = Float.NaN,
|
||||
startOffset: Offset,
|
||||
startHeight: Float,
|
||||
startTitleFontSize: Float,
|
||||
startDescriptionFontSize: Float,
|
||||
clickable: () -> Unit = {},
|
||||
) {
|
||||
val transition = updateTransition(targetState = listState, label = "")
|
||||
val offset by transition.animateOffset(
|
||||
label = "",
|
||||
transitionSpec = { spring() }
|
||||
) {
|
||||
Offset(
|
||||
x = it.calculateTopBarAnimateValue(startOffset.x, 56f),
|
||||
y = it.calculateTopBarAnimateValue(startOffset.y, 0f)
|
||||
)
|
||||
}
|
||||
|
||||
val height by transition.animateFloat(
|
||||
label = "",
|
||||
transitionSpec = { spring() }
|
||||
) {
|
||||
it.calculateTopBarAnimateValue(startHeight, 64f)
|
||||
}
|
||||
|
||||
val titleFontSize by transition.animateFloat(
|
||||
label = "",
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessHigh) }
|
||||
) {
|
||||
it.calculateTopBarAnimateValue(startTitleFontSize, 16f)
|
||||
}
|
||||
|
||||
val descriptionFontSize by transition.animateFloat(
|
||||
label = "",
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessHigh) }
|
||||
) {
|
||||
it.calculateTopBarAnimateValue(startDescriptionFontSize, 12f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.height(height.dp)
|
||||
.offset(offset.x.dp, offset.y.dp)
|
||||
.clickable(
|
||||
interactionSource = MutableInteractionSource(),
|
||||
indication = null,
|
||||
onClickLabel = "回到顶部",
|
||||
onClick = clickable ?: {}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column {
|
||||
AnimatedText(
|
||||
text = title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = titleFontSize.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SpacerHeight.dp))
|
||||
AnimatedText(
|
||||
text = description,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = descriptionFontSize.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package me.ash.reader.ui.widget
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.util.Log
|
||||
import android.webkit.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import me.ash.reader.ui.page.home.read.ReadViewAction
|
||||
import me.ash.reader.ui.page.home.read.ReadViewModel
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
|
||||
@Composable
|
||||
fun WebView(
|
||||
modifier: Modifier = Modifier,
|
||||
content: String,
|
||||
viewModel: ReadViewModel = hiltViewModel(),
|
||||
onProgressChange: (progress: Int) -> Unit = {},
|
||||
onReceivedError: (error: WebResourceError?) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val color = MaterialTheme.colorScheme.secondary.toArgb()
|
||||
val backgroundColor = MaterialTheme.colorScheme.surface.toArgb()
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val webViewClient = object : WebViewClient() {
|
||||
|
||||
override fun onPageStarted(
|
||||
view: WebView?,
|
||||
url: String?,
|
||||
favicon: Bitmap?
|
||||
) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
// _isLoading = true
|
||||
onProgressChange(-1)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
val jsCode = "javascript:(function(){" +
|
||||
"var imgs=document.getElementsByTagName(\"img\");" +
|
||||
"for(var i=0;i<imgs.length;i++){" +
|
||||
"imgs[i].pos = i;" +
|
||||
"imgs[i].onclick=function(){" +
|
||||
// "window.jsCallJavaObj.openImage(this.src,this.pos);" +
|
||||
"alert('asf');" +
|
||||
"}}})()"
|
||||
view!!.loadUrl(jsCode)
|
||||
viewModel.dispatch(ReadViewAction.ChangeLoading(false))
|
||||
onProgressChange(100)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
if (null == request?.url) return false
|
||||
val url = request.url.toString()
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
onReceivedError(error)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Column(
|
||||
// modifier = modifier
|
||||
// .height(if (viewState.isLoading) 100.dp else 0.dp),
|
||||
// ) {
|
||||
// Icon(
|
||||
// modifier = modifier
|
||||
// .size(50.dp),
|
||||
// imageVector = Icons.Rounded.HourglassBottom,
|
||||
// contentDescription = "Loading",
|
||||
// tint = MaterialTheme.colorScheme.primary,
|
||||
// )
|
||||
// Spacer(modifier = modifier.height(50.dp))
|
||||
// }
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(it).apply {
|
||||
this.webViewClient = webViewClient
|
||||
setBackgroundColor(backgroundColor)
|
||||
isHorizontalScrollBarEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.apply {
|
||||
Log.i("RLog", "CustomWebView: ${content}")
|
||||
loadDataWithBaseURL(
|
||||
null,
|
||||
getStyle(color) + content,
|
||||
"text/HTML",
|
||||
"UTF-8", null
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun argbToCssColor(argb: Int): String = String.format("#%06X", 0xFFFFFF and argb)
|
||||
|
||||
fun getStyle(argb: Int): String = """
|
||||
<head><style>
|
||||
*{
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: ${argbToCssColor(argb)}
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0 -20px 20px;
|
||||
width: calc(100% + 40px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
p,span,a,ol,ul,blockquote,article,section {
|
||||
text-align: justify;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ol,ul {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
section ul {
|
||||
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 1px solid ${argbToCssColor(argb)}33;
|
||||
color: ${argbToCssColor(argb)}cc;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-width: 100%;
|
||||
background: ${argbToCssColor(argb)}11;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
border: none;
|
||||
background: ${argbToCssColor(argb)}33;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1,h2,h3,h4,h5,h6,figure,br {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.element::-webkit-scrollbar { width: 0 !important }
|
||||
</style></head>
|
||||
"""
|
||||
Reference in New Issue
Block a user