This commit is contained in:
Ash 2022-03-02 01:40:53 +08:00
commit 8d296b4b01
93 changed files with 5815 additions and 0 deletions

98
.gitignore vendored Normal file
View File

@ -0,0 +1,98 @@
# Built application files
*.apk
*.ap_
# Files for the Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
.DS_Store
*/.DS_Store
*/dist/
*/node_modules/
backend/build/
**/target/
**/build/
HELP.md
.gradle
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

94
app/build.gradle Normal file
View File

@ -0,0 +1,94 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id "androidx.navigation.safeargs.kotlin"
}
android {
compileSdk 32
defaultConfig {
applicationId "me.ash.reader"
minSdk 24
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled true
// shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation("io.coil-kt:coil-compose:1.4.0")
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "com.airbnb.android:lottie-compose:4.2.2"
implementation "androidx.work:work-runtime-ktx:2.8.0-alpha01"
implementation "net.dankito.readability4j:readability4j:1.0.8"
implementation "androidx.navigation:navigation-compose:2.5.0-alpha01"
implementation "com.google.dagger:hilt-android:2.40.5"
kapt "com.google.dagger:hilt-android-compiler:2.40.5"
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.1-alpha'
implementation 'androidx.paging:paging-compose:1.0.0-alpha14'
implementation 'androidx.paging:paging-runtime:3.1.0'
implementation 'androidx.paging:paging-common:3.1.0'
implementation 'androidx.room:room-paging:2.4.1'
implementation 'androidx.room:room-common:2.4.1'
implementation 'androidx.room:room-ktx:2.4.1'
kapt "androidx.room:room-compiler:2.4.1"
implementation "com.github.muhrifqii.ParseRSS:parserss:0.4.0"
implementation "com.github.muhrifqii.ParseRSS:retrofit:0.4.0"
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.4'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation "me.onebone:toolbar-compose:2.3.1"
implementation "com.google.accompanist:accompanist-insets:0.24.1-alpha"
implementation "com.google.accompanist:accompanist-systemuicontroller:0.24.1-alpha"
implementation 'com.google.accompanist:accompanist-pager:0.24.1-alpha'
implementation 'androidx.core:core-ktx:1.7.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.0-alpha01"
implementation "androidx.compose.material3:material3:1.0.0-alpha04"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,20 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "me.ash.reader",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File"
}

View File

@ -0,0 +1,24 @@
package me.ash.reader
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("me.ash.reader", appContext.packageName)
}
}

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.ash.reader">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Reader">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Reader">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package me.ash.reader.data.article
data class ImportantCount(
val important: Int,
val feedId: Int,
val groupId: Int,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:pathData="M0,0H28V28H0V0Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44" />

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="647.6362dp"
android:height="632.1738dp"
android:viewportWidth="647.6362"
android:viewportHeight="632.1738">
<path
android:pathData="M411.146,142.174L236.636,142.174a15.018,15.018 0,0 0,-15 15v387.85l-2,0.61 -42.81,13.11a8.007,8.007 0,0 1,-9.99 -5.31L39.496,137.484a8.003,8.003 0,0 1,5.31 -9.99l65.97,-20.2 191.25,-58.54 65.97,-20.2a7.989,7.989 0,0 1,9.99 5.3l32.55,106.32Z"
android:fillColor="#f2f2f2"/>
<path
android:pathData="M449.226,140.174l-39.23,-128.14a16.994,16.994 0,0 0,-21.23 -11.28l-92.75,28.39L104.776,87.694l-92.75,28.4a17.015,17.015 0,0 0,-11.28 21.23l134.08,437.93a17.027,17.027 0,0 0,16.26 12.03,16.789 16.789,0 0,0 4.97,-0.75l63.58,-19.46 2,-0.62v-2.09l-2,0.61 -64.17,19.65a15.015,15.015 0,0 1,-18.73 -9.95l-134.07,-437.94a14.979,14.979 0,0 1,9.95 -18.73l92.75,-28.4 191.24,-58.54 92.75,-28.4a15.156,15.156 0,0 1,4.41 -0.66,15.015 15.015,0 0,1 14.32,10.61l39.05,127.56 0.62,2h2.08Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M122.681,127.821a9.016,9.016 0,0 1,-8.611 -6.367l-12.88,-42.072a8.999,8.999 0,0 1,5.971 -11.24l175.939,-53.864a9.009,9.009 0,0 1,11.241 5.971l12.88,42.072a9.01,9.01 0,0 1,-5.971 11.241L125.31,127.426A8.976,8.976 0,0 1,122.681 127.821Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M190.154,24.955m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:fillColor="#6c63ff"/>
<path
android:pathData="M190.154,24.955m-12.665,0a12.665,12.665 0,1 1,25.329 0a12.665,12.665 0,1 1,-25.329 0"
android:fillColor="#fff"/>
<path
android:pathData="M602.636,582.174h-338a8.51,8.51 0,0 1,-8.5 -8.5v-405a8.51,8.51 0,0 1,8.5 -8.5h338a8.51,8.51 0,0 1,8.5 8.5v405A8.51,8.51 0,0 1,602.636 582.174Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M447.136,140.174h-210.5a17.024,17.024 0,0 0,-17 17v407.8l2,-0.61v-407.19a15.018,15.018 0,0 1,15 -15L447.756,142.174ZM630.636,140.174h-394a17.024,17.024 0,0 0,-17 17v458a17.024,17.024 0,0 0,17 17h394a17.024,17.024 0,0 0,17 -17v-458A17.024,17.024 0,0 0,630.636 140.174ZM645.636,615.174a15.018,15.018 0,0 1,-15 15h-394a15.018,15.018 0,0 1,-15 -15v-458a15.018,15.018 0,0 1,15 -15h394a15.018,15.018 0,0 1,15 15Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M525.636,184.174h-184a9.01,9.01 0,0 1,-9 -9v-44a9.01,9.01 0,0 1,9 -9h184a9.01,9.01 0,0 1,9 9v44A9.01,9.01 0,0 1,525.636 184.174Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M433.636,105.174m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:fillColor="#6c63ff"/>
<path
android:pathData="M433.636,105.174m-12.182,0a12.182,12.182 0,1 1,24.364 0a12.182,12.182 0,1 1,-24.364 0"
android:fillColor="#fff"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>Ash</title>
</head>
<body>
<outline title="技术" text="技术">
<outline xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzUyNjQxNjYyMg==.xml" htmlUrl="http://MzUyNjQxNjYyMg.favicon.privacyhide.com/favicon.ico" text="五分钟学算法 | wechat-feeds" type="rss" title="五分钟学算法 | wechat-feeds" />
<outline htmlUrl="http://MzU0MDEwMjgwNA.favicon.privacyhide.com/favicon.ico" xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzU0MDEwMjgwNA==.xml" text="SpringForAll社区 | wechat-feeds" type="rss" title="SpringForAll社区 | wechat-feeds" />
<outline htmlUrl="https://tech.meituan.com/feed/" title="美团技术团队" type="rss" text="美团技术团队" xmlUrl="https://tech.meituan.com/feed/" />
<outline title="扔物线 | wechat-feeds" type="rss" text="扔物线 | wechat-feeds" htmlUrl="http://MzIwNTczNTY0NA.favicon.privacyhide.com/favicon.ico" xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzIwNTczNTY0NA==.xml" />
<outline text="伴鱼技术团队" title="伴鱼技术团队" type="rss" htmlUrl="https://tech.ipalfish.com/blog/atom.xml" xmlUrl="http://tech.ipalfish.com/blog/atom.xml" />
<outline text="博客园_编程大观园" type="rss" xmlUrl="http://feed.cnblogs.com/blog/u/201575/rss/" title="博客园_编程大观园" htmlUrl="http://feed.cnblogs.com" />
<outline xmlUrl="https://tech.youzan.com/rss/" title="有赞技术团队" type="rss" text="有赞技术团队" htmlUrl="https://tech.youzan.com" />
<outline text="eBay技术荟 | wechat-feeds" htmlUrl="http://MzA3MDMyNDUzOQ.favicon.privacyhide.com/favicon.ico" type="rss" title="eBay技术荟 | wechat-feeds" xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzA3MDMyNDUzOQ==.xml" />
<outline type="rss" text="小米信息部技术团队" htmlUrl="/atom.xml" title="小米信息部技术团队" xmlUrl="https://xiaomi-info.github.io/atom" />
<outline type="rss" text="IBM Developer" xmlUrl="https://developer.ibm.com/feed/" htmlUrl="https://developer.ibm.com" title="IBM Developer" />
<outline title="滴滴技术 | wechat-feeds" htmlUrl="http://MzU1ODEzNjI2NA.favicon.privacyhide.com/favicon.ico" text="滴滴技术 | wechat-feeds" type="rss" xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzU1ODEzNjI2NA==.xml" />
<outline title="vivo互联网技术 | wechat-feeds" text="vivo互联网技术 | wechat-feeds" xmlUrl="https://github.com/hellodword/wechat-feeds/raw/feeds/MzI4NjY4MTU5Nw==.xml" htmlUrl="http://MzI4NjY4MTU5Nw.favicon.privacyhide.com/favicon.ico" type="rss" />
<outline htmlUrl="http://ashinch.com/" type="rss" title="Ash's Knowledge Base" xmlUrl="https://www.ashinch.com/feed" text="Ash's Knowledge Base" />
<outline type="rss" text="阿里巴巴中间件 | wechat-feeds" title="阿里巴巴中间件 | wechat-feeds" xmlUrl="https://api.feeddd.org/feeds/615ea22c1269c358aa166d7a" htmlUrl="https://mp.weixin.qq.com" />
<outline title="阿里技术" type="rss" xmlUrl="https://api.feeddd.org/feeds/612c4e602b6da10dfaec7645" htmlUrl="https://mp.weixin.qq.com" text="阿里技术" />
<outline xmlUrl="https://api.feeddd.org/feeds/615ea22c1269c358aa166d6a" type="rss" title="艾小仙" text="艾小仙" htmlUrl="https://mp.weixin.qq.com" />
<outline htmlUrl="https://mp.weixin.qq.com" type="rss" xmlUrl="https://api.feeddd.org/feeds/6131b4041269c358aa0deac7" title="爱奇艺技术产品团队" text="爱奇艺技术产品团队" />
<outline xmlUrl="https://api.feeddd.org/feeds/613381fa1269c358aa0eaec1" text="三太子敖丙 | wechat-feeds" htmlUrl="https://api.feeddd.org" title="三太子敖丙 | wechat-feeds" type="rss" />
<outline htmlUrl="https://mp.weixin.qq.com" xmlUrl="https://api.feeddd.org/feeds/61455d381269c358aa11efca" title="字节跳动技术团队" type="rss" text="字节跳动技术团队" />
<outline title="Java3y" text="Java3y" type="rss" htmlUrl="https://mp.weixin.qq.com" xmlUrl="https://api.feeddd.org/feeds/613381f91269c358aa0eabd1" />
<outline type="rss" text="JavaGuide | wechat-feeds" xmlUrl="https://api.feeddd.org/feeds/613381f91269c358aa0eabd9" title="JavaGuide | wechat-feeds" htmlUrl="https://api.feeddd.org" />
<outline htmlUrl="https://api.feeddd.org" type="rss" xmlUrl="https://api.feeddd.org/feeds/6140676c1269c358aa11056c" text="Thoughtworks | wechat-feeds" title="Thoughtworks | wechat-feeds" />
<outline htmlUrl="https://mp.weixin.qq.com" text="有赞coder" type="rss" title="有赞coder" xmlUrl="https://api.feeddd.org/feeds/616e984cb9a7e049c387659c" />
<outline title="计算机视觉life" type="rss" htmlUrl="https://mp.weixin.qq.com" xmlUrl="https://api.feeddd.org/feeds/61e3f060dca58a380c4456c0" text="计算机视觉life" />
<outline title="我爱计算机视觉" text="我爱计算机视觉" type="rss" xmlUrl="https://api.feeddd.org/feeds/6159cf1d1269c358aa1550e9" htmlUrl="https://mp.weixin.qq.com" />
<outline htmlUrl="https://mp.weixin.qq.com" type="rss" xmlUrl="https://api.feeddd.org/feeds/613381fa1269c358aa0eafa9" title="我是程序汪" text="我是程序汪" />
<outline title="低并发编程" text="低并发编程" type="rss" xmlUrl="https://api.feeddd.org/feeds/61e2b1d2dca58a380c43846b" htmlUrl="https://mp.weixin.qq.com" />
<outline htmlUrl="https://mp.weixin.qq.com" xmlUrl="https://api.feeddd.org/feeds/61f55b22dca58a380c510633" type="rss" title="eBay技术荟" text="eBay技术荟" />
<outline htmlUrl="https://mp.weixin.qq.com" title="vivo互联网技术" xmlUrl="https://api.feeddd.org/feeds/61f55b22dca58a380c510624" type="rss" text="vivo互联网技术" />
</outline>
<outline text="新鲜事" title="新鲜事">
<outline title="月光博客" xmlUrl="https://www.williamlong.info/rss.xml" text="月光博客" type="rss" htmlUrl="https://www.williamlong.info/" />
<outline htmlUrl="https://sspai.com" text="少数派" title="少数派" xmlUrl="https://sspai.com/feed" type="rss" />
<outline text="知乎每日精选" title="知乎每日精选" xmlUrl="https://www.zhihu.com/rss" type="rss" htmlUrl="http://www.zhihu.com" />
<outline type="rss" title="澎湃新闻" text="澎湃新闻" htmlUrl="http://thepaper.cn" xmlUrl="https://feedx.net/rss/thepaper.xml" />
<outline htmlUrl="https://www.qbitai.com" title="量子位" text="量子位" type="rss" xmlUrl="https://www.qbitai.com/feed" />
</outline>
</body>
</opml>

View File

@ -0,0 +1,5 @@
*{
fontSize: 18px;
fontWeight: SemiBold;
lineHeight: 32px;
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Reader</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Reader" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View File

@ -0,0 +1,17 @@
package me.ash.reader
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

20
build.gradle Normal file
View File

@ -0,0 +1,20 @@
buildscript {
ext {
compose_version = '1.2.0-alpha02'
}
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.5.0-alpha01"
}
}
plugins {
id 'com.android.application' version '7.1.1' apply false
id 'com.android.library' version '7.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@ -0,0 +1,6 @@
#Sat Feb 05 00:02:40 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

17
settings.gradle Normal file
View File

@ -0,0 +1,17 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "Reader"
include ':app'