Init
98
.gitignore
vendored
Normal 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
|
@ -0,0 +1 @@
|
|||
/build
|
94
app/build.gradle
Normal 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
|
@ -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
|
20
app/release/output-metadata.json
Normal 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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
28
app/src/main/AndroidManifest.xml
Normal 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>
|
51
app/src/main/java/me/ash/reader/App.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
49
app/src/main/java/me/ash/reader/DataStoreExt.kt
Normal 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")
|
||||
}
|
||||
}
|
53
app/src/main/java/me/ash/reader/DateTimeExt.kt
Normal 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)
|
||||
}
|
28
app/src/main/java/me/ash/reader/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
27
app/src/main/java/me/ash/reader/NumberExt.kt
Normal 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()
|
||||
}
|
||||
}
|
17
app/src/main/java/me/ash/reader/data/Converters.kt
Normal 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
|
||||
}
|
||||
}
|
23
app/src/main/java/me/ash/reader/data/account/Account.kt
Normal 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
|
||||
}
|
||||
}
|
34
app/src/main/java/me/ash/reader/data/account/AccountDao.kt
Normal 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)
|
||||
}
|
45
app/src/main/java/me/ash/reader/data/article/Article.kt
Normal 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,
|
||||
)
|
285
app/src/main/java/me/ash/reader/data/article/ArticleDao.kt
Normal 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)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package me.ash.reader.data.article
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
import me.ash.reader.data.feed.Feed
|
||||
|
||||
data class ArticleWithFeed(
|
||||
@Embedded
|
||||
val article: Article,
|
||||
@Relation(parentColumn = "feedId", entityColumn = "id")
|
||||
val feed: Feed,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
package me.ash.reader.data.article
|
||||
|
||||
data class ImportantCount(
|
||||
val important: Int,
|
||||
val feedId: Int,
|
||||
val groupId: Int,
|
||||
)
|
34
app/src/main/java/me/ash/reader/data/feed/Feed.kt
Normal 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
|
||||
}
|
23
app/src/main/java/me/ash/reader/data/feed/FeedDao.kt
Normal 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)
|
||||
}
|
12
app/src/main/java/me/ash/reader/data/feed/FeedWithArticle.kt
Normal 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>
|
||||
)
|
12
app/src/main/java/me/ash/reader/data/feed/FeedWithGroup.kt
Normal 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
|
||||
)
|
19
app/src/main/java/me/ash/reader/data/group/Group.kt
Normal 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
|
||||
}
|
25
app/src/main/java/me/ash/reader/data/group/GroupDao.kt
Normal 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)
|
||||
}
|
12
app/src/main/java/me/ash/reader/data/group/GroupWithFeed.kt
Normal 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>
|
||||
)
|
|
@ -0,0 +1,39 @@
|
|||
package me.ash.reader.data.module
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.source.ReaderDatabase
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class DatabaseModule {
|
||||
@Provides
|
||||
fun provideArticleDao(readerDatabase: ReaderDatabase): ArticleDao =
|
||||
readerDatabase.articleDao()
|
||||
|
||||
@Provides
|
||||
fun provideFeedDao(readerDatabase: ReaderDatabase): FeedDao =
|
||||
readerDatabase.feedDao()
|
||||
|
||||
@Provides
|
||||
fun provideGroupDao(readerDatabase: ReaderDatabase): GroupDao =
|
||||
readerDatabase.groupDao()
|
||||
|
||||
@Provides
|
||||
fun provideAccountDao(readerDatabase: ReaderDatabase): AccountDao =
|
||||
readerDatabase.accountDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideReaderDatabase(@ApplicationContext context: Context): ReaderDatabase =
|
||||
ReaderDatabase.getInstance(context)
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package me.ash.reader.data.module
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class RssNetworkModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideRssNetworkDataSource(): RssNetworkDataSource =
|
||||
RssNetworkDataSource.getInstance()
|
||||
}
|
19
app/src/main/java/me/ash/reader/data/module/WorkerModule.kt
Normal 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)
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package me.ash.reader.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val accountDao: AccountDao,
|
||||
) {
|
||||
|
||||
suspend fun getCurrentAccount(): Account? {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
return accountDao.queryById(accountId)
|
||||
}
|
||||
|
||||
suspend fun isNoAccount(): Boolean {
|
||||
return accountDao.queryAll().isEmpty()
|
||||
}
|
||||
|
||||
suspend fun addDefaultAccount(): Int {
|
||||
return accountDao.insert(
|
||||
Account(
|
||||
name = "Feeds",
|
||||
type = Account.Type.LOCAL,
|
||||
)
|
||||
).toInt()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package me.ash.reader.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.paging.PagingSource
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.article.ImportantCount
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import javax.inject.Inject
|
||||
|
||||
class ArticleRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val articleDao: ArticleDao,
|
||||
private val groupDao: GroupDao,
|
||||
) {
|
||||
|
||||
fun pullFeeds(): Flow<MutableList<GroupWithFeed>> {
|
||||
return groupDao.queryAllGroupWithFeed(
|
||||
context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
fun pullArticles(
|
||||
groupId: Int? = null,
|
||||
feedId: Int? = null,
|
||||
isStarred: Boolean = false,
|
||||
isUnread: Boolean = false,
|
||||
): PagingSource<Int, ArticleWithFeed> {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
Log.i(
|
||||
"RLog",
|
||||
"pullArticles: accountId: ${accountId}, groupId: ${groupId}, feedId: ${feedId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
|
||||
)
|
||||
return when {
|
||||
groupId != null -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedByGroupIdWhenIsStarred(accountId, groupId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedByGroupIdWhenIsUnread(accountId, groupId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedByGroupIdWhenIsAll(accountId, groupId)
|
||||
}
|
||||
feedId != null -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedByFeedIdWhenIsStarred(accountId, feedId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedByFeedIdWhenIsUnread(accountId, feedId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedByFeedIdWhenIsAll(accountId, feedId)
|
||||
}
|
||||
else -> when {
|
||||
isStarred -> articleDao
|
||||
.queryArticleWithFeedWhenIsStarred(accountId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryArticleWithFeedWhenIsUnread(accountId, isUnread)
|
||||
else -> articleDao.queryArticleWithFeedWhenIsAll(accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pullImportant(
|
||||
isStarred: Boolean = false,
|
||||
isUnread: Boolean = false,
|
||||
): Flow<List<ImportantCount>> {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
Log.i(
|
||||
"RLog",
|
||||
"pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
|
||||
)
|
||||
return when {
|
||||
isStarred -> articleDao
|
||||
.queryImportantCountWhenIsStarred(accountId, isStarred)
|
||||
isUnread -> articleDao
|
||||
.queryImportantCountWhenIsUnread(accountId, isUnread)
|
||||
else -> articleDao.queryImportantCountWhenIsAll(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateArticleInfo(article: Article) {
|
||||
articleDao.update(article)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package me.ash.reader.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.data.source.OpmlLocalDataSource
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpmlRepository @Inject constructor(
|
||||
private val groupDao: GroupDao,
|
||||
private val feedDao: FeedDao,
|
||||
private val opmlLocalDataSource: OpmlLocalDataSource
|
||||
) {
|
||||
suspend fun saveToDatabase(inputStream: InputStream) {
|
||||
try {
|
||||
val groupWithFeedList = opmlLocalDataSource.parseFileInputStream(inputStream)
|
||||
groupWithFeedList.forEach { groupWithFeed ->
|
||||
val id = groupDao.insert(groupWithFeed.group).toInt()
|
||||
groupWithFeed.feeds.forEach { it.groupId = id }
|
||||
feedDao.insertList(groupWithFeed.feeds)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("saveToDatabase", "${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
327
app/src/main/java/me/ash/reader/data/repository/RssRepository.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package me.ash.reader.data.source
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupWithFeed
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpmlLocalDataSource @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
) {
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
fun parseFileInputStream(inputStream: InputStream): List<GroupWithFeed> {
|
||||
val groupWithFeedList = mutableListOf<GroupWithFeed>()
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
inputStream.use {
|
||||
val parser: XmlPullParser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(it, null)
|
||||
parser.nextTag()
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType != XmlPullParser.START_TAG) {
|
||||
continue
|
||||
}
|
||||
if (parser.name != "outline") {
|
||||
continue
|
||||
}
|
||||
if ("rss" == parser.getAttributeValue(null, "type")) {
|
||||
val title = parser.getAttributeValue(null, "title")
|
||||
val xmlUrl = parser.getAttributeValue(null, "xmlUrl")
|
||||
Log.i("RLog", "rss: ${title} , ${xmlUrl}")
|
||||
groupWithFeedList.last().feeds.add(
|
||||
Feed(
|
||||
name = title,
|
||||
url = xmlUrl,
|
||||
groupId = 0,
|
||||
accountId = accountId,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val title = parser.getAttributeValue(null, "title")
|
||||
Log.i("RLog", "title: ${title}")
|
||||
groupWithFeedList.add(
|
||||
GroupWithFeed(
|
||||
group = Group(
|
||||
name = title,
|
||||
accountId = accountId,
|
||||
),
|
||||
feeds = mutableListOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return groupWithFeedList
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package me.ash.reader.data.source
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import me.ash.reader.data.Converters
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.data.article.Article
|
||||
import me.ash.reader.data.article.ArticleDao
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.feed.FeedDao
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
|
||||
@Database(
|
||||
entities = [Account::class, Feed::class, Article::class, Group::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class ReaderDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
abstract fun feedDao(): FeedDao
|
||||
abstract fun articleDao(): ArticleDao
|
||||
abstract fun groupDao(): GroupDao
|
||||
|
||||
companion object {
|
||||
private var instance: ReaderDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): ReaderDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
ReaderDatabase::class.java,
|
||||
"Reader"
|
||||
).build().also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package me.ash.reader.data.source
|
||||
|
||||
import com.github.muhrifqii.parserss.RSSFeedObject
|
||||
import com.github.muhrifqii.parserss.retrofit.ParseRSSConverterFactory
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface RssNetworkDataSource {
|
||||
@GET
|
||||
suspend fun parseRss(@Url url: String): RSSFeedObject
|
||||
|
||||
companion object {
|
||||
private var instance: RssNetworkDataSource? = null
|
||||
|
||||
fun getInstance(): RssNetworkDataSource {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Retrofit.Builder()
|
||||
.baseUrl("https://api.feeddd.org/feeds/")
|
||||
.addConverterFactory(ParseRSSConverterFactory.create<RSSFeedObject>())
|
||||
.build().create(RssNetworkDataSource::class.java).also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
app/src/main/java/me/ash/reader/ui/data/Filter.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
16
app/src/main/java/me/ash/reader/ui/data/NavigationBarItem.kt
Normal 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)
|
||||
}
|
||||
}
|
52
app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
216
app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt
Normal 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
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package me.ash.reader.ui.page.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.feed.Feed
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalPagerApi
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _viewState = MutableStateFlow(HomeViewState())
|
||||
val viewState: StateFlow<HomeViewState> = _viewState.asStateFlow()
|
||||
|
||||
private val _filterState = MutableStateFlow(FilterState())
|
||||
val filterState = _filterState.asStateFlow()
|
||||
|
||||
fun dispatch(action: HomeViewAction) {
|
||||
when (action) {
|
||||
is HomeViewAction.Sync -> sync(action.callback)
|
||||
is HomeViewAction.ChangeFilter -> changeFilter(action.filterState)
|
||||
is HomeViewAction.ScrollToPage -> scrollToPage(
|
||||
action.scope,
|
||||
action.targetPage,
|
||||
action.callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sync(callback: () -> Unit = {}) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
rssRepository.sync()
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeFilter(filterState: FilterState) {
|
||||
_filterState.update {
|
||||
it.copy(
|
||||
group = filterState.group,
|
||||
feed = filterState.feed,
|
||||
filter = filterState.filter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToPage(scope: CoroutineScope, targetPage: Int, callback: () -> Unit = {}) {
|
||||
scope.launch {
|
||||
_viewState.value.pagerState.animateScrollToPage(targetPage)
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FilterState(
|
||||
val group: Group? = null,
|
||||
val feed: Feed? = null,
|
||||
val filter: Filter = Filter.All,
|
||||
)
|
||||
|
||||
@ExperimentalPagerApi
|
||||
data class HomeViewState(
|
||||
val pagerState: PagerState = PagerState(1),
|
||||
)
|
||||
|
||||
sealed class HomeViewAction {
|
||||
data class Sync(
|
||||
val callback: () -> Unit = {},
|
||||
) : HomeViewAction()
|
||||
|
||||
data class ChangeFilter(
|
||||
val filterState: FilterState
|
||||
) : HomeViewAction()
|
||||
|
||||
data class ScrollToPage(
|
||||
val scope: CoroutineScope,
|
||||
val targetPage: Int,
|
||||
val callback: () -> Unit = {},
|
||||
) : HomeViewAction()
|
||||
}
|
|
@ -0,0 +1,276 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.DoneAll
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.data.Filter
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.util.collectAsStateValue
|
||||
import me.ash.reader.ui.util.paddingFixedHorizontal
|
||||
import me.ash.reader.ui.util.roundClick
|
||||
import me.ash.reader.ui.widget.AnimateLazyColumn
|
||||
import me.ash.reader.ui.widget.TopTitleBox
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
@ExperimentalAnimationApi
|
||||
@ExperimentalMaterial3Api
|
||||
@ExperimentalPagerApi
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun ArticlePage(
|
||||
modifier: Modifier,
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
viewModel: ArticleViewModel = hiltViewModel(),
|
||||
BackOnClick: () -> Unit,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = homeViewModel.filterState.collectAsStateValue()
|
||||
val pagingItems = viewState.pagingData?.collectAsLazyPagingItems()
|
||||
val refreshState = rememberSwipeRefreshState(isRefreshing = viewState.isRefreshing)
|
||||
val syncState = RssRepository.syncState.collectAsStateValue()
|
||||
|
||||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
Log.i("RLog", "LaunchedEffect filterState: ")
|
||||
viewModel.dispatch(
|
||||
ArticleViewAction.FetchData(
|
||||
groupId = state.group?.id,
|
||||
feedId = state.feed?.id,
|
||||
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
|
||||
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = refreshState,
|
||||
refreshTriggerDistance = 100.dp,
|
||||
onRefresh = {
|
||||
if (syncState.isSyncing) return@SwipeRefresh
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}
|
||||
) {
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
TopTitleBox(
|
||||
title = when {
|
||||
filterState.group != null -> filterState.group.name
|
||||
filterState.feed != null -> filterState.feed.name
|
||||
else -> filterState.filter.title
|
||||
},
|
||||
description = if (syncState.isSyncing) {
|
||||
"Syncing (${syncState.syncedCount}/${syncState.feedCount}) : ${syncState.currentFeedName}"
|
||||
} else {
|
||||
"${viewState.filterImportant}${filterState.filter.description}"
|
||||
},
|
||||
listState = viewState.listState,
|
||||
startOffset = Offset(20f, 72f),
|
||||
startHeight = 50f,
|
||||
startTitleFontSize = 24f,
|
||||
startDescriptionFontSize = 14f,
|
||||
) {
|
||||
viewModel.dispatch(ArticleViewAction.ScrollToItem(0))
|
||||
}
|
||||
Column {
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(BackOnClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowBackIosNew,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
viewModel.dispatch(ArticleViewAction.PeekSyncWork)
|
||||
Toast.makeText(context, viewState.syncWorkInfo, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DoneAll,
|
||||
contentDescription = "Done All",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (syncState.isSyncing) return@IconButton
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
AnimateLazyColumn(
|
||||
state = viewState.listState,
|
||||
reference = filterState.filter,
|
||||
) {
|
||||
if (pagingItems == null) return@AnimateLazyColumn
|
||||
var lastItemDay: String? = null
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(74.dp))
|
||||
}
|
||||
for (itemIndex in 0 until pagingItems.itemCount) {
|
||||
val currentItem = pagingItems.peek(itemIndex)
|
||||
val currentItemDay =
|
||||
currentItem?.article?.date?.toString(DateTimeExt.YYYY_MM_DD, true)
|
||||
?: "null"
|
||||
if (lastItemDay != currentItemDay) {
|
||||
if (itemIndex != 0) {
|
||||
item { Spacer(modifier = Modifier.height(40.dp)) }
|
||||
}
|
||||
stickyHeader {
|
||||
ArticleDateHeader(currentItemDay)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArticleItem(
|
||||
modifier = modifier,
|
||||
articleWithFeed = pagingItems[itemIndex],
|
||||
isStarredFilter = filterState.filter == Filter.Starred,
|
||||
index = itemIndex,
|
||||
articleOnClick = articleOnClick,
|
||||
)
|
||||
}
|
||||
lastItemDay = currentItemDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArticleItem(
|
||||
modifier: Modifier = Modifier,
|
||||
articleWithFeed: ArticleWithFeed?,
|
||||
isStarredFilter: Boolean,
|
||||
index: Int,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
if (articleWithFeed == null) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.paddingFixedHorizontal(
|
||||
top = if (index == 0) 8.dp else 0.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
.roundClick {
|
||||
articleOnClick(articleWithFeed)
|
||||
}
|
||||
.alpha(
|
||||
if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
1f
|
||||
} else {
|
||||
0.75f
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = articleWithFeed.feed.name,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = articleWithFeed.article.date.toString(
|
||||
DateTimeExt.HH_MM
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Text(
|
||||
text = articleWithFeed.article.title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Text(
|
||||
text = articleWithFeed.article.shortDescription,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArticleDateHeader(date: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.ArticleRepository
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ArticleViewModel @Inject constructor(
|
||||
private val articleRepository: ArticleRepository,
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
private val _viewState = MutableStateFlow(ArticleViewState())
|
||||
val viewState: StateFlow<ArticleViewState> = _viewState.asStateFlow()
|
||||
|
||||
fun dispatch(action: ArticleViewAction) {
|
||||
when (action) {
|
||||
is ArticleViewAction.FetchData -> fetchData(
|
||||
groupId = action.groupId,
|
||||
feedId = action.feedId,
|
||||
isStarred = action.isStarred,
|
||||
isUnread = action.isUnread,
|
||||
)
|
||||
is ArticleViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
|
||||
is ArticleViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
is ArticleViewAction.PeekSyncWork -> peekSyncWork()
|
||||
}
|
||||
}
|
||||
|
||||
private fun peekSyncWork() {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
syncWorkInfo = rssRepository.peekWork()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData(
|
||||
groupId: Int? = null,
|
||||
feedId: Int? = null,
|
||||
isStarred: Boolean,
|
||||
isUnread: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
articleRepository.pullImportant(isStarred, true)
|
||||
.collect { importantList ->
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
filterImportant = importantList.sumOf { it.important },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
pagingData = Pager(PagingConfig(pageSize = 10)) {
|
||||
articleRepository.pullArticles(
|
||||
groupId = groupId,
|
||||
feedId = feedId,
|
||||
isStarred = isStarred,
|
||||
isUnread = isUnread,
|
||||
)
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToItem(index: Int) {
|
||||
viewModelScope.launch {
|
||||
_viewState.value.listState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeRefreshing(isRefreshing: Boolean) {
|
||||
_viewState.update {
|
||||
it.copy(isRefreshing = isRefreshing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ArticleViewState(
|
||||
val filterImportant: Int = 0,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val pagingData: Flow<PagingData<ArticleWithFeed>>? = null,
|
||||
val syncWorkInfo: String = "",
|
||||
)
|
||||
|
||||
sealed class ArticleViewAction {
|
||||
data class FetchData(
|
||||
val groupId: Int? = null,
|
||||
val feedId: Int? = null,
|
||||
val isStarred: Boolean,
|
||||
val isUnread: Boolean,
|
||||
) : ArticleViewAction()
|
||||
|
||||
data class ChangeRefreshing(
|
||||
val isRefreshing: Boolean
|
||||
) : ArticleViewAction()
|
||||
|
||||
data class ScrollToItem(
|
||||
val index: Int
|
||||
) : ArticleViewAction()
|
||||
|
||||
object PeekSyncWork : ArticleViewAction()
|
||||
}
|
255
app/src/main/java/me/ash/reader/ui/page/home/feed/FeedPage.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
209
app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
69
app/src/main/java/me/ash/reader/ui/theme/Color.kt
Normal 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)
|
89
app/src/main/java/me/ash/reader/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
116
app/src/main/java/me/ash/reader/ui/theme/Type.kt
Normal 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,
|
||||
),
|
||||
)
|
72
app/src/main/java/me/ash/reader/ui/util/Extension.kt
Normal 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)
|
|
@ -0,0 +1,41 @@
|
|||
package me.ash.reader.ui.widget
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun AnimateLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
reference: Any?,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(reference) {
|
||||
Log.i("RLog", "reference change")
|
||||
visible = false
|
||||
// delay(50)
|
||||
visible = true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
visible = visible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Suppress("UpdateTransitionLabel", "TransitionPropertiesLabel")
|
||||
@SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter")
|
||||
/**
|
||||
* @param state Use [updateAnimatedItemsState].
|
||||
*/
|
||||
inline fun <T> LazyListScope.animatedItemsIndexed(
|
||||
state: List<AnimatedItem<T>>,
|
||||
enterTransition: EnterTransition = expandVertically(),
|
||||
exitTransition: ExitTransition = shrinkVertically(),
|
||||
noinline key: ((item: T) -> Any)? = null,
|
||||
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
|
||||
) {
|
||||
items(
|
||||
state.size,
|
||||
if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null
|
||||
) { index ->
|
||||
|
||||
val item = state[index]
|
||||
val visibility = item.visibility
|
||||
|
||||
key(key?.invoke(item.item)) {
|
||||
AnimatedVisibility(
|
||||
visibleState = visibility,
|
||||
enter = enterTransition,
|
||||
exit = exitTransition
|
||||
) {
|
||||
itemContent(index, item.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> updateAnimatedItemsState(
|
||||
newList: List<T>
|
||||
): State<List<AnimatedItem<T>>> {
|
||||
|
||||
val state = remember { mutableStateOf(emptyList<AnimatedItem<T>>()) }
|
||||
LaunchedEffect(newList) {
|
||||
if (state.value == newList) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val oldList = state.value.toList()
|
||||
|
||||
val diffCb = object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item == newList[newItemPosition]
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item == newList[newItemPosition]
|
||||
|
||||
}
|
||||
val diffResult = calculateDiff(false, diffCb)
|
||||
val compositeList = oldList.toMutableList()
|
||||
|
||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
val newItem = AnimatedItem(
|
||||
visibility = MutableTransitionState(false),
|
||||
newList[position + i]
|
||||
)
|
||||
newItem.visibility.targetState = true
|
||||
compositeList.add(position + i, newItem)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].visibility.targetState = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
// not detecting moves.
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
// irrelevant with compose.
|
||||
}
|
||||
})
|
||||
if (state.value != compositeList) {
|
||||
state.value = compositeList
|
||||
}
|
||||
val initialAnimation = Animatable(1.0f)
|
||||
initialAnimation.animateTo(0f)
|
||||
state.value = state.value.filter { it.visibility.targetState }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
data class AnimatedItem<T>(
|
||||
val visibility: MutableTransitionState<Boolean>,
|
||||
val item: T,
|
||||
) {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return item?.hashCode() ?: 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AnimatedItem<*>
|
||||
|
||||
if (item != other.item) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun calculateDiff(
|
||||
detectMoves: Boolean = true,
|
||||
diffCb: DiffUtil.Callback
|
||||
): DiffUtil.DiffResult {
|
||||
return withContext(Dispatchers.Unconfined) {
|
||||
DiffUtil.calculateDiff(diffCb, detectMoves)
|
||||
}
|
||||
}
|
77
app/src/main/java/me/ash/reader/ui/widget/AnimatedText.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
306
app/src/main/java/me/ash/reader/ui/widget/AppNavigationBar.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
177
app/src/main/java/me/ash/reader/ui/widget/BarButton.kt
Normal 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
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
301
app/src/main/java/me/ash/reader/ui/widget/CustomPager.kt
Normal 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
|
||||
}
|
58
app/src/main/java/me/ash/reader/ui/widget/MaskBox.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
98
app/src/main/java/me/ash/reader/ui/widget/TopTitleBox.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
194
app/src/main/java/me/ash/reader/ui/widget/WebView.kt
Normal 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>
|
||||
"""
|
30
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal 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>
|
BIN
app/src/main/res/drawable/default_folder.png
Normal file
After Width: | Height: | Size: 392 B |
8
app/src/main/res/drawable/ic_default_feed_icon.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_default_folder.xml
Normal 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" />
|
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
36
app/src/main/res/drawable/ic_undraw_no_data.xml
Normal 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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
After Width: | Height: | Size: 7.6 KiB |
46
app/src/main/res/raw/ash.opml
Normal 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>
|
5
app/src/main/res/raw/style.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
*{
|
||||
fontSize: 18px;
|
||||
fontWeight: SemiBold;
|
||||
lineHeight: 32px;
|
||||
}
|
10
app/src/main/res/values/colors.xml
Normal 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>
|
3
app/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Reader</string>
|
||||
</resources>
|
7
app/src/main/res/values/themes.xml
Normal 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>
|
4
app/src/main/res/xml/network_security_config.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
17
app/src/test/java/me/ash/reader/ExampleUnitTest.kt
Normal 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
|
@ -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
|
@ -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
|
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
@ -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
|
@ -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
|
@ -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'
|