a
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,104 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'kotlin-android'
|
||||
id "kotlin-kapt"
|
||||
id "kotlin-parcelize"
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.compose.wanandroid"
|
||||
minSdk 21
|
||||
targetSdk 32
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
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}'
|
||||
}
|
||||
}
|
||||
namespace 'com.compose.wanandroid'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
// implementation "androidx.compose.material3:material3:1.0.0-alpha12"
|
||||
implementation "androidx.compose.material:material:$compose_version"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.compose.material:material-ripple:$compose_version"
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'androidx.navigation:navigation-compose:2.4.2'
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
implementation "androidx.datastore:datastore-core:1.0.0"
|
||||
implementation "androidx.paging:paging-runtime-ktx:3.1.1"
|
||||
implementation "androidx.paging:paging-compose:1.0.0-alpha14"
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-rc01'
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
|
||||
def room_version = "2.5.0-alpha01"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
implementation "androidx.room:room-paging:$room_version"
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
|
||||
implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-navigation-animation:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-navigation-material:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-placeholder:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-placeholder-material:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-webview:$accompanist_version"
|
||||
// implementation "com.google.accompanist:accompanist-insets:$accompanist_version"
|
||||
|
||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||
implementation "io.insert-koin:koin-android:3.2.0"
|
||||
implementation "io.insert-koin:koin-androidx-compose:3.2.0"
|
||||
implementation "com.google.code.gson:gson:2.9.0"
|
||||
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||
implementation "com.squareup.retrofit2:converter-scalars:$retrofit_version"
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.compose.wanandroid
|
||||
|
||||
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("com.compose.wanandroid", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<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.App">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.compose.wanandroid
|
||||
|
||||
import android.app.Application
|
||||
import com.compose.wanandroid.data.local.dbModule
|
||||
import com.compose.wanandroid.data.remote.apiModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
startKoin {
|
||||
androidContext(this@App)
|
||||
modules(listOf(apiModule, dbModule))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.compose.wanandroid
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.compose.wanandroid.logic.darkMode
|
||||
import com.compose.wanandroid.ui.page.main.MainPage
|
||||
import com.compose.wanandroid.ui.theme.AppSurface
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.ThemeState
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splash = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch {
|
||||
splash.setKeepOnScreenCondition { true }
|
||||
delay(500)
|
||||
splash.setKeepOnScreenCondition { false }
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
// 读取主题配置
|
||||
ThemeState.read(darkMode.collectAsState("").value)
|
||||
AppTheme {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
// systemUiController.setSystemBarsColor(color = AppTheme.colors.primary)
|
||||
// systemUiController.systemBarsDarkContentEnabled = !isSystemInDarkTheme()
|
||||
SideEffect {
|
||||
systemUiController.setSystemBarsColor(
|
||||
Color.Transparent,
|
||||
darkIcons = false
|
||||
)
|
||||
}
|
||||
AppSurface(modifier = Modifier.fillMaxSize()) {
|
||||
MainPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.compose.wanandroid.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import com.compose.wanandroid.data.local.dao.BookmarkDao
|
||||
import com.compose.wanandroid.data.local.dao.HistoryDao
|
||||
import com.compose.wanandroid.data.local.model.Bookmark
|
||||
import com.compose.wanandroid.data.local.model.History
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
Bookmark::class,
|
||||
History::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
private const val NAME = "wanandroid.db"
|
||||
|
||||
fun build(context: Context): AppDatabase {
|
||||
return Room.databaseBuilder(context, AppDatabase::class.java, NAME).build()
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun bookmarkDao(): BookmarkDao
|
||||
|
||||
abstract fun historyDao(): HistoryDao
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.compose.wanandroid.data.local
|
||||
|
||||
import com.compose.wanandroid.data.repository.BookmarkRepository
|
||||
import com.compose.wanandroid.data.repository.HistoryRepository
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val dbModule = module {
|
||||
single { AppDatabase.build(androidContext()) }
|
||||
single { HistoryRepository(get<AppDatabase>().historyDao()) }
|
||||
single { BookmarkRepository(get<AppDatabase>().bookmarkDao()) }
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.compose.wanandroid.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.compose.wanandroid.data.local.model.Bookmark
|
||||
|
||||
@Dao
|
||||
interface BookmarkDao {
|
||||
@Transaction
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(vararg mode: Bookmark)
|
||||
|
||||
@Transaction
|
||||
@Query("DELETE FROM bookmark WHERE link = :link")
|
||||
suspend fun delete(link: String)
|
||||
|
||||
@Transaction
|
||||
@Query("DELETE FROM bookmark")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT * FROM bookmark ORDER BY time DESC LIMIT (:offset), (:count)")
|
||||
suspend fun query(offset: Int, count: Int): List<Bookmark>
|
||||
|
||||
@Query("SELECT * FROM bookmark WHERE link = :link")
|
||||
suspend fun query(link: String): List<Bookmark>
|
||||
|
||||
@Query("SELECT * FROM bookmark ORDER BY time DESC LIMIT 0, (:count)")
|
||||
suspend fun queryLately(count: Int): List<Bookmark>
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.compose.wanandroid.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.compose.wanandroid.data.local.model.History
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface HistoryDao {
|
||||
@Transaction
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(vararg mode: History)
|
||||
|
||||
@Transaction
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun update(vararg mode: History)
|
||||
|
||||
@Transaction
|
||||
@Query("DELETE FROM history WHERE link = :link")
|
||||
suspend fun delete(link: String)
|
||||
|
||||
@Transaction
|
||||
@Query("DELETE FROM history")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"DELETE FROM history WHERE link NOT IN (SELECT link FROM history ORDER BY time DESC LIMIT 0, :maxCount)"
|
||||
)
|
||||
suspend fun deleteIfMaxCount(maxCount: Int)
|
||||
|
||||
@Transaction
|
||||
@Query("UPDATE history SET lastTime = :lastTime, percent = :percent WHERE (link = :link AND percent < :percent)")
|
||||
suspend fun updatePercent(link: String, percent: Int, lastTime: Long)
|
||||
|
||||
@Query("SELECT * FROM history ORDER BY time DESC LIMIT (:offset), (:count)")
|
||||
suspend fun query(offset: Int, count: Int): List<History>
|
||||
|
||||
@Query("SELECT * FROM history WHERE link = :link")
|
||||
suspend fun queryByLink(link: String): History?
|
||||
|
||||
@Query("SELECT * FROM history WHERE link in (:links)")
|
||||
suspend fun queryByLinks(links: List<String>): List<History>
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.compose.wanandroid.data.local.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "bookmark")
|
||||
data class Bookmark(
|
||||
@PrimaryKey val link: String,
|
||||
@ColumnInfo val title: String,
|
||||
@ColumnInfo val time: Long
|
||||
)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.compose.wanandroid.data.local.model
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "history")
|
||||
data class History(
|
||||
@PrimaryKey val link: String,
|
||||
@ColumnInfo val title: String,
|
||||
@ColumnInfo val time: Long,
|
||||
@ColumnInfo val lastTime: Long,
|
||||
@ColumnInfo @IntRange(from = 0, to = 10000) val percent: Int,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_PERCENT = 0
|
||||
const val MAX_PERCENT = 10000
|
||||
}
|
||||
|
||||
val percentFloat: Float
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
get() = (percent.toFloat() / MAX_PERCENT.toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.compose.wanandroid.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.core.text.HtmlCompat
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Article(
|
||||
var apkLink: String = "",
|
||||
var audit: Int = -1,
|
||||
var author: String = "",
|
||||
var canEdit: Boolean = false,
|
||||
var chapterId: Int = -1,
|
||||
var chapterName: String = "",
|
||||
var collect: Boolean = false,
|
||||
var courseId: Int = -1,
|
||||
var desc: String = "",
|
||||
var descMd: String = "",
|
||||
var envelopePic: String = "",
|
||||
var fresh: Boolean = false,
|
||||
var host: String = "",
|
||||
var id: Int = -1,
|
||||
var link: String = "",
|
||||
var niceDate: String = "",
|
||||
var niceShareDate: String = "",
|
||||
var origin: String = "",
|
||||
var prefix: String = "",
|
||||
var projectLink: String = "https://www.wanandroid.com",
|
||||
var publishTime: Long = 0L,
|
||||
var realSuperChapterId: Int = -1,
|
||||
var selfVisible: Int = -1,
|
||||
var shareDate: Long = 0L,
|
||||
var shareUser: String = "",
|
||||
var superChapterId: Int = -1,
|
||||
var superChapterName: String = "",
|
||||
var tags: MutableList<ArticleTag>? = null,
|
||||
var title: String = "",
|
||||
var type: Int = -1,
|
||||
var userId: Int = -1,
|
||||
var visible: Int = -1,
|
||||
var zan: Int = -1,
|
||||
var originId: Int = -1
|
||||
) : Parcelable
|
||||
|
||||
val Article.authorName: String
|
||||
get() {
|
||||
return when {
|
||||
author.isNotEmpty() -> author
|
||||
shareUser.isNotEmpty() -> shareUser
|
||||
else -> "匿名"
|
||||
}
|
||||
}
|
||||
|
||||
val Article.fixTitle: String
|
||||
get() = HtmlCompat.fromHtml(title, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
|
||||
|
||||
val Article.chapter: String
|
||||
get() = listOf(superChapterName, chapterName).filter { it.isNotEmpty() }.joinToString("·")
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.compose.wanandroid.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Link(
|
||||
var url: String,
|
||||
var title: String = "",
|
||||
var addHistory: Boolean = true
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ArticleTag(
|
||||
var name: String,
|
||||
var url: String
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Banner(
|
||||
var desc: String = "",
|
||||
var id: Int = -1,
|
||||
var imagePath: String = "",
|
||||
var isVisible: Int = 1,
|
||||
var order: Int = 0,
|
||||
var title: String = "",
|
||||
var type: Int = 0,
|
||||
var url: String = ""
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Struct(
|
||||
var children: MutableList<Struct> = mutableListOf(),
|
||||
var courseId: Int = -1,
|
||||
var id: Int = -1,
|
||||
var name: String = "",
|
||||
var order: Int = -1,
|
||||
var parentChapterId: Int = -1,
|
||||
var userControlSetTop: Boolean = false,
|
||||
var visible: Int = -1,
|
||||
var icon: String = "",
|
||||
var link: String = "",
|
||||
var type: Int = TYPE_STRUCT
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
const val TYPE_STRUCT = 0
|
||||
const val TYPE_ACCOUNT = 1
|
||||
const val TYPE_PROJECT = 2
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Navigate(
|
||||
var articles: MutableList<Article> = mutableListOf(),
|
||||
var cid: Int = -1,
|
||||
var name: String = ""
|
||||
) : Parcelable
|
||||
|
||||
data class CollectLink(
|
||||
var desc: String = "",
|
||||
var icon: String = "",
|
||||
var id: Int = 0,
|
||||
var link: String = "",
|
||||
var name: String = "",
|
||||
var order: Int = 0,
|
||||
var userId: Int = 0,
|
||||
var visible: Int = 0,
|
||||
)
|
||||
|
||||
data class HotKey(
|
||||
val id: Int,
|
||||
val link: String = "",
|
||||
val name: String = "",
|
||||
val order: Int = 1,
|
||||
val visible: Int = 1,
|
||||
)
|
||||
|
||||
data class Coin(
|
||||
val coinCount: Int = 0,
|
||||
val date: Long = 0,
|
||||
val desc: String = "",
|
||||
val id: Int = 0,
|
||||
val type: Int = 0,
|
||||
val userId: Int = 0,
|
||||
val userName: String = ""
|
||||
) {
|
||||
val title: String
|
||||
get() {
|
||||
// 2022-05-16 10:23:02 签到 , 积分:10 + 4
|
||||
val firstSpace = desc.indexOf(" ")
|
||||
val secondSpace = desc.indexOf(" ", firstSpace + 1)
|
||||
return desc.substring(secondSpace + 1)
|
||||
.replace(" , ", "")
|
||||
// .replace(":", "")
|
||||
// .replace(" ", "")
|
||||
}
|
||||
|
||||
val time: String
|
||||
get() {
|
||||
val firstSpace = desc.indexOf(" ")
|
||||
val secondSpace = desc.indexOf(" ", firstSpace + 1)
|
||||
return desc.substring(0, secondSpace)
|
||||
}
|
||||
|
||||
val count: String
|
||||
get() = if (coinCount < 0) "-$coinCount" else "+$coinCount"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.compose.wanandroid.data.model
|
||||
|
||||
data class Response<T>(
|
||||
var data: T?,
|
||||
var errorCode: Int,
|
||||
var errorMsg: String
|
||||
) {
|
||||
val isSuccess: Boolean
|
||||
get() = errorCode == 0
|
||||
}
|
||||
|
||||
data class ListWrapper<T>(
|
||||
var curPage: Int,
|
||||
var offset: Int,
|
||||
var over: Boolean,
|
||||
var pageCount: Int,
|
||||
var size: Int,
|
||||
var total: Int,
|
||||
var datas: MutableList<T>
|
||||
)
|
||||
|
||||
typealias ListResponse<T> = Response<ListWrapper<T>>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.compose.wanandroid.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class UserInfo(
|
||||
var id: Int,
|
||||
var admin: Boolean,
|
||||
var chapterTops: MutableList<Int>,
|
||||
var coinCount: Int,
|
||||
var collectIds: MutableList<Int>,
|
||||
var email: String,
|
||||
var icon: String,
|
||||
var nickname: String,
|
||||
var password: String,
|
||||
var token: String,
|
||||
var type: Int,
|
||||
var username: String,
|
||||
) : Parcelable {
|
||||
val avatar: String // 找个默认的头像
|
||||
get() = icon.ifEmpty { "https://tva1.sinaimg.cn/large/e6c9d24egy1h25egcfp3aj20yq0nvq7i.jpg" }
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class CoinInfo(
|
||||
var coinCount: Int = 0,
|
||||
var level: Int = 0,
|
||||
var rank: Int = 0,
|
||||
var userId: Int = 0,
|
||||
var username: String = "",
|
||||
var nickname: String = ""
|
||||
) : Parcelable
|
||||
|
||||
data class UserResponse(
|
||||
var userInfo: UserInfo? = null,
|
||||
var coinInfo: CoinInfo? = null
|
||||
)
|
||||
|
||||
data class UserShareResponse(
|
||||
var shareArticles: ListWrapper<Article>? = null,
|
||||
var coinInfo: CoinInfo? = null
|
||||
)
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.compose.wanandroid.data.remote
|
||||
|
||||
import com.compose.wanandroid.data.model.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import retrofit2.http.*
|
||||
|
||||
interface Api {
|
||||
companion object {
|
||||
const val HOST = "https://www.wanandroid.com/"
|
||||
}
|
||||
|
||||
@GET("banner/json")
|
||||
fun banners(): Flow<Response<MutableList<Banner>>>
|
||||
|
||||
@GET("article/top/json")
|
||||
fun topArticles(): Flow<Response<MutableList<Article>>>
|
||||
|
||||
@GET("article/list/{page}/json")
|
||||
suspend fun articles(@Path("page") page: Int): ListResponse<Article>
|
||||
|
||||
@GET("user_article/list/{page}/json")
|
||||
suspend fun squareArticles(@Path("page") page: Int): ListResponse<Article>
|
||||
|
||||
@GET("wenda/list/{page}/json")
|
||||
suspend fun wendaList(@Path("page") page: Int): ListResponse<Article>
|
||||
|
||||
@GET("tree/json")
|
||||
fun structList(): Flow<Response<MutableList<Struct>>>
|
||||
|
||||
@GET("navi/json")
|
||||
suspend fun navigationList(): Response<MutableList<Navigate>>
|
||||
|
||||
@GET("project/tree/json")
|
||||
fun projectList(): Flow<Response<MutableList<Struct>>>
|
||||
|
||||
@GET("wxarticle/chapters/json")
|
||||
fun wxAccountList(): Flow<Response<MutableList<Struct>>>
|
||||
|
||||
@GET("article/list/{page}/json")
|
||||
suspend fun structArticles(
|
||||
@Path("page") page: Int,
|
||||
@Query("cid") cid: Int
|
||||
): ListResponse<Article>
|
||||
|
||||
@GET("project/list/{page}/json")
|
||||
suspend fun projectArticles(
|
||||
@Path("page") page: Int,
|
||||
@Query("cid") cid: Int
|
||||
): ListResponse<Article>
|
||||
|
||||
@GET("wxarticle/list/{cid}/{page}/json")
|
||||
suspend fun wxArticles(
|
||||
@Path("page") page: Int,
|
||||
@Query("cid") cid: Int
|
||||
): ListResponse<Article>
|
||||
|
||||
@POST("user/login")
|
||||
@FormUrlEncoded
|
||||
suspend fun login(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String
|
||||
): Response<UserInfo>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("user/register")
|
||||
suspend fun register(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String,
|
||||
@Field("repassword") repassword: String
|
||||
): Response<UserInfo>
|
||||
|
||||
@GET("user/logout/json")
|
||||
suspend fun logout(): Response<String>
|
||||
|
||||
@GET("user/lg/userinfo/json")
|
||||
fun userInfo(): Flow<Response<UserResponse>>
|
||||
|
||||
@GET("lg/collect/list/{page}/json")
|
||||
suspend fun collectArticles(@Path("page") page: Int): ListResponse<Article>
|
||||
|
||||
@GET("lg/collect/usertools/json")
|
||||
fun collectLinks(): Flow<Response<MutableList<CollectLink>>>
|
||||
|
||||
@POST("lg/collect/{id}/json")
|
||||
suspend fun collectArticle(@Path("id") id: Int): Response<Any>
|
||||
|
||||
/** 收藏站外文章 */
|
||||
@FormUrlEncoded
|
||||
@POST("lg/collect/add/json")
|
||||
suspend fun collectArticle(
|
||||
@Field("title") title: String,
|
||||
@Field("author") author: String,
|
||||
@Field("link") link: String
|
||||
): Response<Article>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("lg/collect/addtool/json")
|
||||
suspend fun collectLink(
|
||||
@Field("name") name: String,
|
||||
@Field("link") link: String
|
||||
): Response<CollectLink>
|
||||
|
||||
@POST("lg/uncollect_originId/{id}/json")
|
||||
suspend fun unCollectArticle(@Path("id") id: Int): Response<Any>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("lg/collect/deletetool/json")
|
||||
suspend fun unCollectLink(@Field("id") id: Int): Response<Any>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("lg/uncollect/{id}/json")
|
||||
suspend fun unCollectArticle(
|
||||
@Path("id") id: Int,
|
||||
@Field("originId") originId: Int
|
||||
): Response<Any>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("lg/collect/updatetool/json")
|
||||
suspend fun updateCollectLink(
|
||||
@Field("id") id: Int,
|
||||
@Field("name") name: String?,
|
||||
@Field("link") link: String?
|
||||
): Response<CollectLink>
|
||||
|
||||
@GET("hotkey/json")
|
||||
fun hotKey(): Flow<Response<MutableList<HotKey>>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("article/query/{page}/json")
|
||||
suspend fun search(
|
||||
@Path("page") page: Int,
|
||||
@Field("k") key: String
|
||||
): ListResponse<Article>
|
||||
|
||||
@GET("user/lg/private_articles/{page}/json")
|
||||
suspend fun mineShareArticles(@Path("page") page: Int): Response<UserShareResponse>
|
||||
|
||||
@GET("lg/coin/list/{page}/json")
|
||||
suspend fun coinList(@Path("page") page: Int): ListResponse<Coin>
|
||||
}
|
||||
|
||||
object ApiService : KoinComponent {
|
||||
val api: Api by inject { parametersOf(Api.HOST) }
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.compose.wanandroid.data.remote
|
||||
|
||||
import com.compose.wanandroid.BuildConfig
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.data.remote.flowadapter.FlowCallAdapterFactory
|
||||
import com.compose.wanandroid.data.remote.interceptor.CacheCookieInterceptor
|
||||
import com.compose.wanandroid.data.remote.interceptor.LogInterceptor
|
||||
import com.compose.wanandroid.data.remote.interceptor.SetCookieInterceptor
|
||||
import com.compose.wanandroid.data.remote.ssl.SSLContextFactory
|
||||
import com.compose.wanandroid.data.remote.ssl.SimpleX509TrustManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.*
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.converter.scalars.ScalarsConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val apiModule = module {
|
||||
singleOf(::createOkHttpClient)
|
||||
singleOf(::createRetrofitBuilder)
|
||||
factory { (baseUrl: String) -> createApi(get(), baseUrl) }
|
||||
}
|
||||
|
||||
val gson: Gson = GsonBuilder()
|
||||
// .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||
.disableHtmlEscaping()
|
||||
.create()
|
||||
|
||||
private fun createApi(builder: Retrofit.Builder, baseUrl: String) = builder.baseUrl(baseUrl).build().create(Api::class.java)
|
||||
|
||||
private fun createRetrofitBuilder(okHttpClient: OkHttpClient) = Retrofit.Builder()
|
||||
.client(okHttpClient)
|
||||
.addCallAdapterFactory(FlowCallAdapterFactory.create())
|
||||
.addConverterFactory(ScalarsConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
|
||||
private fun createOkHttpClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder().apply {
|
||||
connectTimeout(10, TimeUnit.SECONDS)
|
||||
readTimeout(20, TimeUnit.SECONDS)
|
||||
writeTimeout(20, TimeUnit.SECONDS)
|
||||
|
||||
addInterceptor { chain ->
|
||||
// 统一添加header,解决:java.io.IOException: unexpected end of stream on Connection
|
||||
val builder = chain.request().newBuilder()
|
||||
builder.addHeader("Connection", "close")
|
||||
chain.proceed(builder.build())
|
||||
}
|
||||
|
||||
addInterceptor(SetCookieInterceptor())
|
||||
addInterceptor(CacheCookieInterceptor())
|
||||
addNetworkInterceptor(LogInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) LogInterceptor.Level.BASIC else LogInterceptor.Level.NONE
|
||||
})
|
||||
|
||||
try {
|
||||
connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT))
|
||||
sslSocketFactory(SSLContextFactory.makeContext().socketFactory, SimpleX509TrustManager())
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.compose.wanandroid.data.remote
|
||||
|
||||
sealed class HttpResult<out T> {
|
||||
data class Success<T>(val result: T) : HttpResult<T>()
|
||||
data class Error(val e: Exception) : HttpResult<Nothing>()
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.compose.wanandroid.data.remote.flowadapter
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import retrofit2.CallAdapter
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() {
|
||||
override fun get(
|
||||
returnType: Type,
|
||||
annotations: Array<Annotation>,
|
||||
retrofit: Retrofit
|
||||
): CallAdapter<*, *>? {
|
||||
if (getRawType(returnType) != Flow::class.java) {
|
||||
return null
|
||||
}
|
||||
check(returnType is ParameterizedType) { "Flow return type must be parameterized as Flow<Foo> or Flow<out Foo>" }
|
||||
val responseType = getParameterUpperBound(0, returnType)
|
||||
val rawFlowType = getRawType(responseType)
|
||||
return if (rawFlowType == Response::class.java) {
|
||||
check(responseType is ParameterizedType) { "Response must be parameterized as Response<Foo> or Response<out Foo>" }
|
||||
ResponseCallAdapter<Any>(getParameterUpperBound(0, responseType))
|
||||
} else {
|
||||
BodyCallAdapter<Any>(responseType)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun create() = FlowCallAdapterFactory()
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.compose.wanandroid.data.remote.flowadapter;
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import retrofit2.Call
|
||||
import retrofit2.CallAdapter
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.lang.reflect.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class ResponseCallAdapter<T>(
|
||||
private val responseType: Type
|
||||
) : CallAdapter<T, Flow<Response<T>>> {
|
||||
override fun adapt(call: Call<T>): Flow<Response<T>> {
|
||||
return flow {
|
||||
emit(
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
call.enqueue(object : Callback<T> {
|
||||
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||
continuation.resumeWithException(t)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||
continuation.resume(response)
|
||||
}
|
||||
})
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun responseType() = responseType
|
||||
}
|
||||
|
||||
|
||||
class BodyCallAdapter<T>(private val responseType: Type) : CallAdapter<T, Flow<T>> {
|
||||
override fun adapt(call: Call<T>): Flow<T> {
|
||||
return flow {
|
||||
emit(
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
call.enqueue(object : Callback<T> {
|
||||
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||
continuation.resumeWithException(t)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||
try {
|
||||
continuation.resume(response.body()!!)
|
||||
} catch (e: Exception) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun responseType() = responseType
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.compose.wanandroid.data.remote.interceptor
|
||||
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.logic.Pref
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class CacheCookieInterceptor : Interceptor {
|
||||
|
||||
private val loginUrl = "user/login"
|
||||
private val registerUrl = "user/register"
|
||||
private val setCookie = "set-cookie"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val requestUrl = request.url.toString()
|
||||
val domain = request.url.host
|
||||
if (aboutUser(requestUrl)) {
|
||||
val cookies = response.headers(setCookie)
|
||||
if (cookies.isNotEmpty()) {
|
||||
// cookie可能有多个,都保存下来
|
||||
runBlocking {
|
||||
Logger.i("Request", "Cache cookie: ${encodeCookie(cookies)}")
|
||||
Pref.put(domain, encodeCookie(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun aboutUser(url: String): Boolean = url.contains(loginUrl) or url.contains(registerUrl)
|
||||
|
||||
private fun encodeCookie(cookies: List<String>): String {
|
||||
val sb = StringBuilder()
|
||||
val set = HashSet<String>()
|
||||
cookies
|
||||
.map { cookie ->
|
||||
cookie.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
}
|
||||
.forEach { it ->
|
||||
it.filterNot { set.contains(it) }.forEach { set.add(it) }
|
||||
}
|
||||
|
||||
val ite = set.iterator()
|
||||
while (ite.hasNext()) {
|
||||
val cookie = ite.next()
|
||||
sb.append(cookie).append(";")
|
||||
}
|
||||
|
||||
val last = sb.lastIndexOf(";")
|
||||
if (sb.length - 1 == last) {
|
||||
sb.deleteCharAt(last)
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package com.compose.wanandroid.data.remote.interceptor
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http.promisesBody
|
||||
import okio.Buffer
|
||||
import okio.EOFException
|
||||
import okio.GzipSource
|
||||
import okio.IOException
|
||||
import java.nio.charset.Charset
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class LogInterceptor : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Request"
|
||||
private val UTF8 = Charset.forName("UTF-8")
|
||||
}
|
||||
|
||||
var level = Level.BASIC
|
||||
|
||||
enum class Level {
|
||||
/** No logs. */
|
||||
NONE,
|
||||
|
||||
/**
|
||||
* Logs request and response lines.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* --> POST /greeting http/1.1 (3-byte body)
|
||||
*
|
||||
* <-- 200 OK (22ms, 6-byte body)
|
||||
* ```
|
||||
*/
|
||||
BASIC,
|
||||
|
||||
/**
|
||||
* Logs request and response lines and their respective headers.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* --> POST /greeting http/1.1
|
||||
* Host: example.com
|
||||
* Content-Type: plain/text
|
||||
* Content-Length: 3
|
||||
* --> END POST
|
||||
*
|
||||
* <-- 200 OK (22ms)
|
||||
* Content-Type: plain/text
|
||||
* Content-Length: 6
|
||||
* <-- END HTTP
|
||||
* ```
|
||||
*/
|
||||
HEADERS,
|
||||
|
||||
/**
|
||||
* Logs request and response lines and their respective headers and bodies (if present).
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* --> POST /greeting http/1.1
|
||||
* Host: example.com
|
||||
* Content-Type: plain/text
|
||||
* Content-Length: 3
|
||||
*
|
||||
* Hi?
|
||||
* --> END POST
|
||||
*
|
||||
* <-- 200 OK (22ms)
|
||||
* Content-Type: plain/text
|
||||
* Content-Length: 6
|
||||
*
|
||||
* Hello!
|
||||
* <-- END HTTP
|
||||
* ```
|
||||
*/
|
||||
BODY
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (level == Level.NONE) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val url = Uri.decode(request.url.toString())
|
||||
val method = request.method.uppercase()
|
||||
val protocol = chain.connection()?.protocol()?.toString() ?: ""
|
||||
var startMessage = "--> [$method] [$protocol] $url"
|
||||
if (request.headers.size > 0) {
|
||||
startMessage += "\n${request.headers}"
|
||||
}
|
||||
Logger.i(TAG, startMessage)
|
||||
|
||||
val logBody = level == Level.BODY
|
||||
val logHeaders = logBody || level == Level.HEADERS
|
||||
|
||||
// 打印请求所有参数
|
||||
if (logHeaders) {
|
||||
val headers = request.headers
|
||||
|
||||
val requestBody = request.body
|
||||
if (requestBody != null) {
|
||||
// Request body headers are only present when installed as a network interceptor. When not
|
||||
// already present, force them to be included (if available) so their values are known.
|
||||
requestBody.contentType()?.let {
|
||||
if (headers["Content-Type"] == null) {
|
||||
Logger.i(TAG, "Content-Type: $it")
|
||||
}
|
||||
}
|
||||
if (requestBody.contentLength() != -1L) {
|
||||
if (headers["Content-Length"] == null) {
|
||||
Logger.i(TAG, "Content-Length: ${requestBody.contentLength()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0 until headers.size) {
|
||||
Logger.i(TAG, headers.name(i) + ": " + headers.value(i))
|
||||
}
|
||||
|
||||
if (!logBody || requestBody == null) {
|
||||
Logger.i(TAG, "--> END ${request.method}")
|
||||
} else if (bodyHasUnknownEncoding(request.headers)) {
|
||||
Logger.i(TAG, "--> END ${request.method} (encoded body omitted)")
|
||||
} else if (requestBody.isDuplex()) {
|
||||
Logger.i(TAG, "--> END ${request.method} (duplex request body omitted)")
|
||||
} else if (requestBody.isOneShot()) {
|
||||
Logger.i(TAG, "--> END ${request.method} (one-shot body omitted)")
|
||||
} else {
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
|
||||
val contentType = requestBody.contentType()
|
||||
val charset: Charset = contentType?.charset(UTF8) ?: UTF8
|
||||
|
||||
Logger.i(TAG, "")
|
||||
if (buffer.isProbablyUtf8()) {
|
||||
Logger.i(TAG, buffer.readString(charset))
|
||||
Logger.i(TAG, "--> END ${request.method} (${requestBody.contentLength()}-byte body)")
|
||||
} else {
|
||||
Logger.i(TAG, "--> END ${request.method} (binary ${requestBody.contentLength()}-byte body omitted)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startNs = System.nanoTime()
|
||||
val response: Response
|
||||
try {
|
||||
response = chain.proceed(request)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "<-- [$method] FAILED: $url \n ${Log.getStackTraceString(e)}")
|
||||
throw e
|
||||
}
|
||||
|
||||
// 打印请求响应参数
|
||||
if (logHeaders) {
|
||||
val headers = response.headers
|
||||
for (i in 0 until headers.size) {
|
||||
Logger.d(TAG, headers.name(i) + ": " + headers.value(i))
|
||||
}
|
||||
|
||||
if (!logBody || !response.promisesBody()) {
|
||||
Logger.d(TAG, "<-- END HTTP")
|
||||
} else if (bodyHasUnknownEncoding(response.headers)) {
|
||||
Logger.d(TAG, "<-- END HTTP (encoded body omitted)")
|
||||
} else {
|
||||
val responseBody = response.body ?: return response
|
||||
val source = responseBody.source()
|
||||
source.request(Long.MAX_VALUE) // Buffer the entire body.
|
||||
var buffer = source.buffer
|
||||
|
||||
var gzippedLength: Long? = null
|
||||
if ("gzip".equals(headers["Content-Encoding"], ignoreCase = true)) {
|
||||
gzippedLength = buffer.size
|
||||
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
||||
buffer = Buffer()
|
||||
buffer.writeAll(gzippedResponseBody)
|
||||
}
|
||||
}
|
||||
|
||||
val contentType = responseBody.contentType()
|
||||
val charset: Charset = contentType?.charset(UTF8) ?: UTF8
|
||||
|
||||
if (!buffer.isProbablyUtf8()) {
|
||||
Logger.d(TAG, "")
|
||||
Logger.d(TAG, "<-- END HTTP (binary ${buffer.size}-byte body omitted)")
|
||||
return response
|
||||
}
|
||||
|
||||
if (responseBody.contentLength() != 0L) {
|
||||
Logger.d(TAG, "")
|
||||
Logger.d(TAG, buffer.clone().readString(charset))
|
||||
}
|
||||
|
||||
if (gzippedLength != null) {
|
||||
Logger.d(TAG, "<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)")
|
||||
} else {
|
||||
Logger.d(TAG, "<-- END HTTP (${buffer.size}-byte body)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
|
||||
|
||||
val msg = "<-- [$method] [$protocol] ${response.code} ${response.message} ${Uri.decode(response.request.url.toString())} (${tookMs}ms)"
|
||||
if (response.isSuccessful) {
|
||||
Logger.w(TAG, msg)
|
||||
} else {
|
||||
Logger.e(TAG, msg)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
|
||||
val contentEncoding = headers["Content-Encoding"] ?: return false
|
||||
return !contentEncoding.equals("identity", ignoreCase = true) &&
|
||||
!contentEncoding.equals("gzip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Buffer.isProbablyUtf8(): Boolean {
|
||||
try {
|
||||
val prefix = Buffer()
|
||||
val byteCount = size.coerceAtMost(64)
|
||||
copyTo(prefix, 0, byteCount)
|
||||
for (i in 0 until 16) {
|
||||
if (prefix.exhausted()) {
|
||||
break
|
||||
}
|
||||
val codePoint = prefix.readUtf8CodePoint()
|
||||
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (_: EOFException) {
|
||||
return false // Truncated UTF-8 sequence.
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.compose.wanandroid.data.remote.interceptor
|
||||
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.logic.Pref
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class SetCookieInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val builder = request.newBuilder()
|
||||
val domain = request.url.host
|
||||
// 获取domain内的cookie
|
||||
if (domain.isNotEmpty()) {
|
||||
val cookie = runBlocking { Pref.get(domain, "") }
|
||||
if (cookie.isNotEmpty()) {
|
||||
Logger.i("Request", "Set cookie: $cookie")
|
||||
builder.addHeader("Cookie", cookie)
|
||||
}
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.compose.wanandroid.data.remote.ssl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
class GenericTrustManager(vararg keyStores: KeyStore) : X509TrustManager {
|
||||
|
||||
private var trustManagers = mutableListOf<X509TrustManager>()
|
||||
|
||||
init {
|
||||
val factories = mutableListOf<TrustManagerFactory>()
|
||||
try {
|
||||
// The default TrustManager with default keystore
|
||||
val original = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
original.init(null as KeyStore?)
|
||||
factories.add(original)
|
||||
|
||||
for (keyStore in keyStores) {
|
||||
val certs = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
certs.init(keyStore)
|
||||
factories.add(certs)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
|
||||
trustManagers.addAll(factories.asSequence().flatMap { it.trustManagers.asSequence() }.filterIsInstance<X509TrustManager>())
|
||||
|
||||
if (trustManagers.isEmpty()) {
|
||||
throw RuntimeException("Couldn't find any X509TrustManagers")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate?> {
|
||||
val list = mutableListOf<X509Certificate>()
|
||||
for (tm in trustManagers) {
|
||||
list.addAll(tm.acceptedIssuers)
|
||||
}
|
||||
return list.toTypedArray()
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
// Delegate to the default trust manager.
|
||||
val defaultX509TrustManager = trustManagers[0]
|
||||
defaultX509TrustManager.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
// Loop over the TrustManagers until we find one that accepts our server
|
||||
for (tm in trustManagers) {
|
||||
try {
|
||||
tm.checkServerTrusted(chain, authType)
|
||||
return
|
||||
} catch (e: CertificateException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw CertificateException()
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.compose.wanandroid.data.remote.ssl
|
||||
|
||||
import android.util.Base64
|
||||
import java.io.*
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
object SSLContextFactory {
|
||||
|
||||
/**
|
||||
* Creates an SSLContext with trust all certificates
|
||||
*
|
||||
* @return An initialized SSLContext
|
||||
*/
|
||||
fun makeContext(): SSLContext {
|
||||
val context: SSLContext = try {
|
||||
SSLContext.getInstance("TLS")
|
||||
} catch (e: Exception) {
|
||||
SSLContext.getInstance("LLS")
|
||||
}
|
||||
context.init(null, arrayOf<X509TrustManager>(SimpleX509TrustManager()), SecureRandom())
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSLContext with the KeyStore certificates
|
||||
*
|
||||
* @param keyStore a keyStore
|
||||
* @return An initialized SSLContext
|
||||
*/
|
||||
fun makeContext(keyStore: KeyStore): SSLContext {
|
||||
val context: SSLContext = try {
|
||||
SSLContext.getInstance("TLS")
|
||||
} catch (e: Exception) {
|
||||
SSLContext.getInstance("LLS")
|
||||
}
|
||||
context.init(null, arrayOf<X509TrustManager>(GenericTrustManager(keyStore)), SecureRandom())
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSLContext with the client and server certificates
|
||||
*
|
||||
* @param clientCertFile A File containing the client certificate
|
||||
* @param clientCertPassword Password for the client certificate
|
||||
* @param caCertString A String containing the server certificate
|
||||
* @return An initialized SSLContext
|
||||
*/
|
||||
fun makeContext(clientCertFile: File, clientCertPassword: String, caCertString: String): SSLContext {
|
||||
val keyStore = loadPKCS12KeyStore(clientCertFile, clientCertPassword)
|
||||
val kmf = KeyManagerFactory.getInstance("X509")
|
||||
kmf.init(keyStore, clientCertPassword.toCharArray())
|
||||
val keyManagers = kmf.keyManagers
|
||||
|
||||
val trustStore = loadPEMTrustStore(caCertString)
|
||||
val trustManagers = arrayOf<TrustManager>(GenericTrustManager(trustStore))
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(keyManagers, trustManagers, null)
|
||||
return sslContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSLContext with the server certificates
|
||||
*
|
||||
* @param caCertString A String containing the server certificate
|
||||
* @return An initialized SSLContext
|
||||
* @throws Exception
|
||||
*/
|
||||
fun makeContext(caCertString: String): SSLContext {
|
||||
val trustStore = loadPEMTrustStore(caCertString)
|
||||
val trustManagers = arrayOf<TrustManager>(GenericTrustManager(trustStore))
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustManagers, null)
|
||||
return sslContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a KeyStore from a PKCS12 (.p12) certificate file, typically the client certificate
|
||||
*
|
||||
* @param certificateFile A file containing the client certificate
|
||||
* @param clientCertPassword Password for the certificate
|
||||
* @return A KeyStore containing the certificate from the certificateFile
|
||||
* @throws Exception
|
||||
*/
|
||||
private fun loadPKCS12KeyStore(certificateFile: File, clientCertPassword: String): KeyStore {
|
||||
val keyStore: KeyStore
|
||||
var fis: FileInputStream? = null
|
||||
try {
|
||||
keyStore = KeyStore.getInstance("PKCS12")
|
||||
fis = FileInputStream(certificateFile)
|
||||
keyStore.load(fis, clientCertPassword.toCharArray())
|
||||
} finally {
|
||||
try {
|
||||
fis?.close()
|
||||
} catch (ex: IOException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return keyStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a KeyStore from a String containing a PEM certificate (typically, the server's CA certificate)
|
||||
*
|
||||
* @param certificateString A String containing the PEM-encoded certificate
|
||||
* @return a KeyStore (to be used as a trust store) that contains the certificate
|
||||
* @throws Exception
|
||||
*/
|
||||
private fun loadPEMTrustStore(certificateString: String): KeyStore {
|
||||
val der = loadPemCertificate(ByteArrayInputStream(certificateString.toByteArray()))
|
||||
val derInputStream = ByteArrayInputStream(der)
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
val cert = certificateFactory.generateCertificate(derInputStream) as X509Certificate
|
||||
val alias = cert.subjectX500Principal.name
|
||||
|
||||
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
trustStore.load(null)
|
||||
trustStore.setCertificateEntry(alias, cert)
|
||||
return trustStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and decodes a base-64 encoded DER certificate (a .pem certificate), typically the server's CA cert.
|
||||
*
|
||||
* @param certificateStream an InputStream from which to read the cert
|
||||
* @return a byte[] containing the decoded certificate
|
||||
* @throws IOException
|
||||
*/
|
||||
private fun loadPemCertificate(certificateStream: InputStream): ByteArray {
|
||||
val der: ByteArray
|
||||
val br: BufferedReader? = null
|
||||
|
||||
br.use {
|
||||
val buf = StringBuilder()
|
||||
certificateStream.reader().buffered().readLines().forEach { line ->
|
||||
if (!line.startsWith("--")) {
|
||||
buf.append(line)
|
||||
}
|
||||
}
|
||||
val pem = buf.toString()
|
||||
der = Base64.decode(pem, Base64.DEFAULT)
|
||||
}
|
||||
return der
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.compose.wanandroid.data.remote.ssl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
class SimpleX509TrustManager : X509TrustManager {
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||
return arrayOf()
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.compose.wanandroid.data.repository
|
||||
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.model.ListResponse
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
|
||||
interface ArticleListRepository {
|
||||
suspend fun getArticleList(page: Int): ListResponse<Article>
|
||||
}
|
||||
|
||||
class CategoryArticleListRepository(private val cid: Int) : ArticleListRepository {
|
||||
override suspend fun getArticleList(page: Int): ListResponse<Article> = ApiService.api.structArticles(page, cid)
|
||||
}
|
||||
|
||||
class WxArticleListRepository(private val cid: Int) : ArticleListRepository {
|
||||
override suspend fun getArticleList(page: Int): ListResponse<Article> = ApiService.api.wxArticles(page, cid)
|
||||
}
|
||||
|
||||
class ProjectArticleListRepository(private val cid: Int) : ArticleListRepository {
|
||||
override suspend fun getArticleList(page: Int): ListResponse<Article> = ApiService.api.projectArticles(page, cid)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.compose.wanandroid.data.repository
|
||||
|
||||
import com.compose.wanandroid.data.local.model.Bookmark
|
||||
import com.compose.wanandroid.data.local.dao.BookmarkDao
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
|
||||
class BookmarkRepository(private val dao: BookmarkDao) {
|
||||
|
||||
suspend fun add(link: String, title: String) {
|
||||
dao.insert(Bookmark(link, title, System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
suspend fun delete(link: String) {
|
||||
dao.delete(link)
|
||||
}
|
||||
|
||||
suspend fun deleteAll() {
|
||||
dao.deleteAll()
|
||||
}
|
||||
|
||||
suspend fun find(from: Int, count: Int) = dao.query(from, count)
|
||||
|
||||
suspend fun findByLink(link: String): List<Bookmark> {
|
||||
return dao.query(link)
|
||||
}
|
||||
|
||||
suspend fun findLately(count: Int) {
|
||||
dao.queryLately(count)
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.compose.wanandroid.data.repository
|
||||
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
|
||||
class CollectRepository {
|
||||
|
||||
suspend fun collect(article: Article): Result<Unit> {
|
||||
return try {
|
||||
val result = if (article.author.isEmpty()) {
|
||||
ApiService.api.collectLink(article.title, article.link)
|
||||
} else {
|
||||
ApiService.api.collectArticle(article.id)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
article.collect = true
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception(result.errorMsg))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unCollect(article: Article): Result<Unit> {
|
||||
return try {
|
||||
val result = if (article.author.isEmpty()) {
|
||||
ApiService.api.unCollectLink(article.id)
|
||||
} else {
|
||||
ApiService.api.unCollectArticle(article.id)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
article.collect = false
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception(result.errorMsg))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unCollectMine(article: Article): Result<Unit> {
|
||||
return try {
|
||||
val result = if (article.author.isEmpty()) {
|
||||
ApiService.api.unCollectLink(article.id)
|
||||
} else {
|
||||
ApiService.api.unCollectArticle(article.id, article.originId)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
article.collect = false
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception(result.errorMsg))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unCollectLink(id: Int): Result<Unit> {
|
||||
return try {
|
||||
val result = ApiService.api.unCollectLink(id)
|
||||
if (result.isSuccess) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception(result.errorMsg))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.compose.wanandroid.data.repository
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.compose.wanandroid.data.local.model.History
|
||||
import com.compose.wanandroid.data.local.dao.HistoryDao
|
||||
|
||||
class HistoryRepository(private val dao: HistoryDao) {
|
||||
|
||||
companion object {
|
||||
const val READ_RECORD_MAX_COUNT = 1000
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun add(
|
||||
link: String,
|
||||
title: String,
|
||||
percent: Float = 0f
|
||||
) {
|
||||
val time = System.currentTimeMillis()
|
||||
val model = History(
|
||||
link, title, time, time,
|
||||
(percent * History.MAX_PERCENT).toInt()
|
||||
)
|
||||
dao.insert(model)
|
||||
deleteIfMaxCount()
|
||||
}
|
||||
|
||||
suspend fun updatePercent(
|
||||
link: String,
|
||||
percent: Float,
|
||||
lastTime: Long
|
||||
) {
|
||||
val p = (percent.coerceIn(0f, 1f) * History.MAX_PERCENT).toInt()
|
||||
dao.updatePercent(link, p, lastTime)
|
||||
}
|
||||
|
||||
suspend fun delete(link: String) {
|
||||
dao.delete(link)
|
||||
}
|
||||
|
||||
suspend fun deleteAll() {
|
||||
dao.deleteAll()
|
||||
}
|
||||
|
||||
private suspend fun deleteIfMaxCount() {
|
||||
dao.deleteIfMaxCount(READ_RECORD_MAX_COUNT)
|
||||
}
|
||||
|
||||
suspend fun find(from: Int, count: Int): List<History> {
|
||||
return dao.query(from, count)
|
||||
}
|
||||
|
||||
suspend fun findByLinks(link: List<String>): List<History> {
|
||||
return dao.queryByLinks(link)
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
|
||||
abstract class PreferenceItem<T>(flow: Flow<T>) : Flow<T> by flow {
|
||||
abstract suspend fun update(value: T?)
|
||||
}
|
||||
|
||||
operator fun <T> DataStore<Preferences>.invoke(
|
||||
buildKey: (name: String) -> Preferences.Key<T>,
|
||||
defaultValue: T,
|
||||
key: String? = null,
|
||||
) = ReadOnlyProperty<Any?, PreferenceItem<T>> { _, property ->
|
||||
val pKey = buildKey(key ?: property.name)
|
||||
object : PreferenceItem<T>(data.map { it[pKey] ?: defaultValue }) {
|
||||
override suspend fun update(value: T?) {
|
||||
edit { pref ->
|
||||
if (value == null) {
|
||||
pref -= pKey
|
||||
} else {
|
||||
pref[pKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore("setting")
|
||||
|
||||
object Pref : KoinComponent {
|
||||
|
||||
val dataStore: DataStore<Preferences> by lazy {
|
||||
val context: Context by inject()
|
||||
context.dataStore
|
||||
}
|
||||
|
||||
fun getString(key: String, default: String = ""): Flow<String> {
|
||||
return dataStore.data.map { it[stringPreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getDouble(key: String, default: Double = 0.0): Flow<Double> {
|
||||
return dataStore.data.map { it[doublePreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getFloat(key: String, default: Float = 0.0f): Flow<Float> {
|
||||
return dataStore.data.map { it[floatPreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getLong(key: String, default: Long = 0L): Flow<Long> {
|
||||
return dataStore.data.map { it[longPreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getInt(key: String, default: Int = 0): Flow<Int> {
|
||||
return dataStore.data.map { it[intPreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getBoolean(key: String, default: Boolean = false): Flow<Boolean> {
|
||||
return dataStore.data.map { it[booleanPreferencesKey(key)] ?: default }
|
||||
}
|
||||
|
||||
fun getSet(key: String): Flow<Set<String>> {
|
||||
return dataStore.data.map { it[stringSetPreferencesKey(key)] ?: emptySet() }
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : Any> get(key: String, default: T): T {
|
||||
return when (T::class) {
|
||||
String::class -> dataStore.data.map {
|
||||
it[stringPreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
Double::class -> dataStore.data.map {
|
||||
it[doublePreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
Float::class -> dataStore.data.map {
|
||||
it[floatPreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
Long::class -> dataStore.data.map {
|
||||
it[longPreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
Int::class -> dataStore.data.map {
|
||||
it[intPreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
Boolean::class -> dataStore.data.map {
|
||||
it[booleanPreferencesKey(key)] ?: default
|
||||
}.first() as T
|
||||
|
||||
else -> throw IllegalArgumentException("This type can be read from DataStore")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T : Any> put(key: String, value: T) {
|
||||
dataStore.edit { map ->
|
||||
when (value) {
|
||||
is String -> map[stringPreferencesKey(key)] = value
|
||||
is Double -> map[doublePreferencesKey(key)] = value
|
||||
is Float -> map[floatPreferencesKey(key)] = value
|
||||
is Long -> map[longPreferencesKey(key)] = value
|
||||
is Int -> map[intPreferencesKey(key)] = value
|
||||
is Boolean -> map[booleanPreferencesKey(key)] = value
|
||||
else -> throw IllegalArgumentException("This type can be saved into DataStore")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun putSet(key: String, value: Set<String>) {
|
||||
dataStore.edit { map ->
|
||||
map[stringSetPreferencesKey(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
dataStore.edit {
|
||||
it.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val darkMode: PreferenceItem<String> by Pref.dataStore(::stringPreferencesKey, "", "dark_mode")
|
||||
val searchHistory: PreferenceItem<String> by Pref.dataStore(::stringPreferencesKey, "", "search_history")
|
||||
@@ -0,0 +1,95 @@
|
||||
@file:Suppress("Deprecation")
|
||||
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.zip
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val Activity.screenSize: Size
|
||||
get() {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Size(windowManager.currentWindowMetrics.bounds.width(), windowManager.currentWindowMetrics.bounds.height())
|
||||
} else {
|
||||
Size(windowManager.defaultDisplay.width, windowManager.defaultDisplay.height)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.isNetConnected(): Boolean {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetwork: NetworkInfo? = cm.activeNetworkInfo
|
||||
return activeNetwork?.isConnectedOrConnecting == true
|
||||
}
|
||||
|
||||
fun CharSequence.toast(context: Context, duration: Int = Toast.LENGTH_SHORT) {
|
||||
if (!isNullOrEmpty()) {
|
||||
if (Thread.currentThread() === Looper.getMainLooper().thread) {
|
||||
Toast.makeText(context, this, duration).show()
|
||||
} else {
|
||||
Handler(context.mainLooper).post { Toast.makeText(context, this@toast, duration).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.removeAllBank(count: Int): String {
|
||||
var s = ""
|
||||
if (isNotEmpty()) {
|
||||
val p = Pattern.compile("\\s{$count,}|\t|\r|\n");
|
||||
val m = p.matcher(this)
|
||||
s = m.replaceAll(" ");
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
inline fun Modifier.rippleClickable(
|
||||
bounded: Boolean = true,
|
||||
radius: Dp = Dp.Unspecified,
|
||||
color: Color = Color.Unspecified,
|
||||
crossinline onClick: () -> Unit
|
||||
): Modifier = composed {
|
||||
clickable(
|
||||
indication = rememberRipple(bounded, radius, color),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed {
|
||||
clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(aClass: Class<T>): T = f() as T
|
||||
}
|
||||
|
||||
inline fun <A, B, C, D> Flow<A>.zips(
|
||||
flowB: Flow<B>,
|
||||
flowC: Flow<C>,
|
||||
crossinline f: (A, B, C) -> D
|
||||
): Flow<D> = zip(flowB, ::Pair).zip(flowC) { (a, b), c -> f(a, b, c) }
|
||||
@@ -0,0 +1,331 @@
|
||||
@file:Suppress("Unused", "Deprecation")
|
||||
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import kotlin.math.min
|
||||
|
||||
//import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
* 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
* 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
*
|
||||
* Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
* Api21下调用无效
|
||||
*
|
||||
* @param float 需要悬浮在软键盘之上的视图
|
||||
* @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
* @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
* @param margin 悬浮视图和软键盘间距
|
||||
* @param onChanged 监听软键盘是否显示
|
||||
*
|
||||
* @see getSoftInputHeight 软键盘高度
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun Activity.setWindowSoftInput(
|
||||
float: View? = null,
|
||||
transition: View? = float?.parent as? View,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) = window.setWindowSoftInput(float, transition, editText, margin, onChanged)
|
||||
|
||||
/**
|
||||
* 如果Fragment不是立即创建, 请为Fragment所在的Activity配置[[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]]
|
||||
*
|
||||
* 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
* 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
*
|
||||
* Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
* Api21下调用无效
|
||||
*
|
||||
* @param float 需要悬浮在软键盘之上的视图
|
||||
* @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
* @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
* @param margin 悬浮视图和软键盘间距
|
||||
* @param onChanged 监听软键盘是否显示
|
||||
*
|
||||
* @see getSoftInputHeight 软键盘高度
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun Fragment.setWindowSoftInput(
|
||||
float: View? = null,
|
||||
transition: View? = view,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) = requireActivity().window.setWindowSoftInput(float, transition, editText, margin, onChanged)
|
||||
|
||||
/**
|
||||
* 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
* 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
*
|
||||
* Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
* Api21下调用无效
|
||||
*
|
||||
* @param float 需要悬浮在软键盘之上的视图
|
||||
* @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
* @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
* @param margin 悬浮视图和软键盘间距
|
||||
* @param onChanged 监听软键盘是否显示
|
||||
*
|
||||
* @see getSoftInputHeight 软键盘高度
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun DialogFragment.setWindowSoftInput(
|
||||
float: View? = null,
|
||||
transition: View? = view,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) = dialog?.window?.setWindowSoftInput(float, transition, editText, margin, onChanged)
|
||||
|
||||
///**
|
||||
// * 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
// * 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
// *
|
||||
// * Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
// * Api21下调用无效
|
||||
// *
|
||||
// * @param float 需要悬浮在软键盘之上的视图
|
||||
// * @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
// * @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
// * @param margin 悬浮视图和软键盘间距
|
||||
// * @param onChanged 监听软键盘是否显示
|
||||
// *
|
||||
// * @see getSoftInputHeight 软键盘高度
|
||||
// */
|
||||
//@JvmOverloads
|
||||
//fun BottomSheetDialogFragment.setWindowSoftInput(
|
||||
// float: View? = null,
|
||||
// transition: View? = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet),
|
||||
// editText: EditText? = null,
|
||||
// margin: Int = 0,
|
||||
// onChanged: (() -> Unit)? = null,
|
||||
//) = dialog?.window?.setWindowSoftInput(float, transition, editText, margin, onChanged)
|
||||
|
||||
/**
|
||||
* 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
* 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
*
|
||||
* Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
* Api21下调用无效
|
||||
*
|
||||
* @param float 需要悬浮在软键盘之上的视图
|
||||
* @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
* @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
* @param margin 悬浮视图和软键盘间距
|
||||
* @param onChanged 监听软键盘是否显示
|
||||
*
|
||||
* @see getSoftInputHeight 软键盘高度
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun Dialog.setWindowSoftInput(
|
||||
float: View? = null,
|
||||
transition: View? = window?.decorView,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) = window?.setWindowSoftInput(float, transition, editText, margin, onChanged)
|
||||
|
||||
/**
|
||||
* 软键盘弹出后要求指定视图[float]悬浮在软键盘之上
|
||||
* 本方法重复调用会互相覆盖, 例如Fragment调用会覆盖其Activity的调用
|
||||
*
|
||||
* Api21以上本方法使用[WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING]
|
||||
* Api21下调用无效
|
||||
*
|
||||
* @param float 需要悬浮在软键盘之上的视图
|
||||
* @param transition 当软键盘显示隐藏时需要移动的视图, 使用[View.setTranslationY]移动
|
||||
* @param editText 需要监听的EditText, 默认监听所有EditText
|
||||
* @param margin 悬浮视图和软键盘间距
|
||||
* @param onChanged 监听软键盘是否显示
|
||||
*
|
||||
* @see getSoftInputHeight 软键盘高度
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun Window.setWindowSoftInput(
|
||||
float: View? = null,
|
||||
transition: View? = null,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) {
|
||||
// 部分系统不支持WindowInsets使用兼容方案处理
|
||||
if (!decorView.isSystemInsetsAnimationSupport()) {
|
||||
return setWindowSoftInputCompatible(float, transition, editText, margin, onChanged)
|
||||
}
|
||||
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
var matchEditText = false
|
||||
var hasSoftInput = false
|
||||
var floatInitialBottom = 0
|
||||
var startAnimation: WindowInsetsAnimationCompat? = null
|
||||
var transitionY = 0f
|
||||
val callback = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
|
||||
override fun onStart(
|
||||
animation: WindowInsetsAnimationCompat,
|
||||
bounds: WindowInsetsAnimationCompat.BoundsCompat
|
||||
): WindowInsetsAnimationCompat.BoundsCompat {
|
||||
if (float == null || transition == null) return bounds
|
||||
hasSoftInput = ViewCompat.getRootWindowInsets(decorView)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||
startAnimation = animation
|
||||
if (hasSoftInput) matchEditText = editText == null || editText.hasFocus()
|
||||
if (hasSoftInput) {
|
||||
floatInitialBottom = run {
|
||||
val r = IntArray(2)
|
||||
float.getLocationInWindow(r)
|
||||
r[1] + float.height
|
||||
}
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
super.onEnd(animation)
|
||||
if (matchEditText) onChanged?.invoke()
|
||||
}
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val fraction = startAnimation?.fraction
|
||||
if (fraction == null || float == null || transition == null || !matchEditText) return insets
|
||||
val softInputHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
val softInputTop = decorView.bottom - softInputHeight
|
||||
val offset = (softInputTop - floatInitialBottom - margin).toFloat()
|
||||
if (hasSoftInput && softInputTop < floatInitialBottom) {
|
||||
transition.translationY = offset
|
||||
transitionY = transition.translationY
|
||||
} else if (!hasSoftInput) {
|
||||
transition.translationY = min(transitionY - transitionY * (fraction + 0.5f), 0f)
|
||||
}
|
||||
return insets
|
||||
}
|
||||
}
|
||||
ViewCompat.setWindowInsetsAnimationCallback(decorView, callback)
|
||||
}
|
||||
|
||||
/** 部分系统不支持WindowInsets使用兼容方案处理 */
|
||||
private fun Window.setWindowSoftInputCompatible(
|
||||
float: View? = null,
|
||||
transition: View? = float?.parent as? View,
|
||||
editText: EditText? = null,
|
||||
margin: Int = 0,
|
||||
onChanged: (() -> Unit)? = null,
|
||||
) {
|
||||
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
var shown = false
|
||||
var matchEditText = false
|
||||
decorView.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
val canTransition = float != null && transition != null
|
||||
val floatBottom = if (canTransition) {
|
||||
val r = IntArray(2)
|
||||
float!!.getLocationInWindow(r)
|
||||
r[1] + float.height
|
||||
} else 0
|
||||
val decorBottom = decorView.bottom
|
||||
val rootWindowInsets = ViewCompat.getRootWindowInsets(decorView) ?: return@addOnGlobalLayoutListener
|
||||
val softInputHeight = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
val hasSoftInput = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val offset = (decorBottom - floatBottom - softInputHeight - margin).toFloat()
|
||||
if (hasSoftInput) {
|
||||
matchEditText = editText == null || editText.hasFocus()
|
||||
if (!shown && matchEditText) {
|
||||
transition?.translationY = offset
|
||||
onChanged?.invoke()
|
||||
}
|
||||
shown = true
|
||||
} else {
|
||||
if (shown && matchEditText) {
|
||||
transition?.translationY = 0f
|
||||
onChanged?.invoke()
|
||||
}
|
||||
shown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var isSupportInsetsFiled: Boolean? = null
|
||||
|
||||
/** 判断系统是否支持[WindowInsetsAnimationCompat] */
|
||||
fun View.isSystemInsetsAnimationSupport(): Boolean {
|
||||
var supportInsets = isSupportInsetsFiled
|
||||
if (supportInsets == null) {
|
||||
supportInsets = ViewCompat.getWindowInsetsController(this) != null
|
||||
isSupportInsetsFiled = supportInsets
|
||||
}
|
||||
return supportInsets
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出软键盘
|
||||
* 如果要求页面显示立刻弹出软键盘,建议在onResume方法中调用
|
||||
*/
|
||||
fun EditText.showSoftInput() {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
requestFocus()
|
||||
if (isSystemInsetsAnimationSupport()) {
|
||||
ViewCompat.getWindowInsetsController(this)?.show(WindowInsetsCompat.Type.ime())
|
||||
} else {
|
||||
postDelayed({
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(this, 0)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
/** 隐藏软键盘 */
|
||||
fun Activity.hideSoftInput() {
|
||||
currentFocus?.let {
|
||||
val imm = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(it.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
} ?: let {
|
||||
ViewCompat.getWindowInsetsController(window.decorView)?.hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
/** 隐藏软键盘 */
|
||||
fun Fragment.hideSoftInput() = requireActivity().hideSoftInput()
|
||||
|
||||
/** 隐藏软键盘 */
|
||||
fun EditText.hideSoftInput() {
|
||||
ViewCompat.getWindowInsetsController(this)?.hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
/** 软键盘是否显示 */
|
||||
fun Activity.hasSoftInput(): Boolean {
|
||||
return ViewCompat.getRootWindowInsets(window.decorView)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||
}
|
||||
|
||||
/** 软键盘是否显示 */
|
||||
fun Fragment.hasSoftInput(): Boolean {
|
||||
return requireActivity().hasSoftInput()
|
||||
}
|
||||
|
||||
/** 当前软键盘显示高度 */
|
||||
fun Activity.getSoftInputHeight(): Int {
|
||||
val softInputHeight = ViewCompat.getRootWindowInsets(window.decorView)?.getInsets(WindowInsetsCompat.Type.ime())?.bottom
|
||||
return softInputHeight ?: 0
|
||||
}
|
||||
|
||||
/** 当前软键盘显示高度 */
|
||||
fun Fragment.getSoftInputHeight(): Int {
|
||||
return requireActivity().getSoftInputHeight()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object Logger {
|
||||
|
||||
/** 是否打开日志 */
|
||||
var enable = true
|
||||
|
||||
private const val DEFAULT_TAG = "Logger"
|
||||
|
||||
private const val TYPE_V = 0x01
|
||||
private const val TYPE_D = TYPE_V shl 1
|
||||
private const val TYPE_I = TYPE_D shl 1
|
||||
private const val TYPE_W = TYPE_I shl 1
|
||||
private const val TYPE_E = TYPE_W shl 1
|
||||
|
||||
// *******************Log with ERROR*******************/
|
||||
fun e(msg: String) {
|
||||
log(DEFAULT_TAG, msg, TYPE_E)
|
||||
}
|
||||
|
||||
fun e(e: Exception) {
|
||||
log(DEFAULT_TAG, Log.getStackTraceString(e), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tr: Throwable) {
|
||||
log(DEFAULT_TAG, Log.getStackTraceString(tr), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: String, msg: String) {
|
||||
log(tag, msg, TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: String, e: Exception) {
|
||||
log(tag, Log.getStackTraceString(e), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: String, tr: Throwable) {
|
||||
log(tag, Log.getStackTraceString(tr), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: Any, msg: String) {
|
||||
log(tag.javaClass.simpleName, msg, TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: Any, e: Exception) {
|
||||
log(tag.javaClass.simpleName, Log.getStackTraceString(e), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: Any, tr: Throwable) {
|
||||
log(tag.javaClass.simpleName, Log.getStackTraceString(tr), TYPE_E)
|
||||
}
|
||||
|
||||
fun e(tag: String, msg: String, tr: Throwable) {
|
||||
log(tag, msg + '\n'.toString() + Log.getStackTraceString(tr), TYPE_E)
|
||||
}
|
||||
|
||||
// *******************Log with WARNING*******************/
|
||||
fun w(msg: String) {
|
||||
log(DEFAULT_TAG, msg, TYPE_W)
|
||||
}
|
||||
|
||||
fun w(e: Exception) {
|
||||
log(DEFAULT_TAG, Log.getStackTraceString(e), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tr: Throwable) {
|
||||
log(DEFAULT_TAG, Log.getStackTraceString(tr), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: String, msg: String) {
|
||||
log(tag, msg, TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: String, e: Exception) {
|
||||
log(tag, Log.getStackTraceString(e), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: String, tr: Throwable) {
|
||||
log(tag, Log.getStackTraceString(tr), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: Any, msg: String) {
|
||||
log(tag.javaClass.simpleName, msg, TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: Any, e: Exception) {
|
||||
log(tag.javaClass.simpleName, Log.getStackTraceString(e), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: Any, tr: Throwable) {
|
||||
log(tag.javaClass.simpleName, Log.getStackTraceString(tr), TYPE_W)
|
||||
}
|
||||
|
||||
fun w(tag: String, msg: String, tr: Throwable) {
|
||||
log(tag, msg + '\n'.toString() + Log.getStackTraceString(tr), TYPE_W)
|
||||
}
|
||||
|
||||
// *******************Log with INFO*******************/
|
||||
fun i(msg: String) {
|
||||
log(DEFAULT_TAG, msg, TYPE_I)
|
||||
}
|
||||
|
||||
fun i(tag: String, msg: String) {
|
||||
log(tag, msg, TYPE_I)
|
||||
}
|
||||
|
||||
fun i(tag: Any, msg: String) {
|
||||
log(tag.javaClass.simpleName, msg, TYPE_I)
|
||||
}
|
||||
|
||||
// *******************Log with DEBUG*******************/
|
||||
fun d(msg: String) {
|
||||
log(DEFAULT_TAG, msg, TYPE_D)
|
||||
}
|
||||
|
||||
fun d(tag: String, msg: String) {
|
||||
log(tag, msg, TYPE_D)
|
||||
}
|
||||
|
||||
fun d(tag: Any, msg: String) {
|
||||
log(tag.javaClass.simpleName, msg, TYPE_D)
|
||||
}
|
||||
|
||||
// *******************Log with VERBOSE*******************/
|
||||
fun v(msg: String) {
|
||||
log(DEFAULT_TAG, msg, TYPE_V)
|
||||
}
|
||||
|
||||
fun v(tag: String, msg: String) {
|
||||
log(tag, msg, TYPE_V)
|
||||
}
|
||||
|
||||
fun v(tag: Any, msg: String) {
|
||||
log(tag.javaClass.simpleName, msg, TYPE_V)
|
||||
}
|
||||
|
||||
private fun log(tag: String, msg: String, logType: Int) {
|
||||
var message = msg
|
||||
if (enable) {
|
||||
val stackTrace = Thread.currentThread().stackTrace[4]
|
||||
val fileInfo = "[" + stackTrace.fileName + "(" + stackTrace.lineNumber + ") " + stackTrace.methodName + "] "
|
||||
|
||||
message = fileInfo + message
|
||||
|
||||
var index = 0
|
||||
val maxLength = 4000
|
||||
val countOfSub = message.length / maxLength
|
||||
if (countOfSub > 0) {
|
||||
for (i in 0 until countOfSub) {
|
||||
val sub = message.substring(index, index + maxLength)
|
||||
print(tag, sub, logType)
|
||||
index += maxLength
|
||||
}
|
||||
print(tag, message.substring(index, message.length), logType)
|
||||
} else {
|
||||
print(tag, message, logType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun print(tag: String, msg: String, logType: Int) {
|
||||
when (logType) {
|
||||
TYPE_V -> Log.v(tag, msg)
|
||||
TYPE_D -> Log.d(tag, msg)
|
||||
TYPE_I -> Log.i(tag, msg)
|
||||
TYPE_W -> Log.w(tag, msg)
|
||||
TYPE_E -> Log.e(tag, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.navigation.NavController
|
||||
import com.google.gson.Gson
|
||||
|
||||
fun Parcelable.toJson(): String {
|
||||
return Gson().toJson(this)
|
||||
}
|
||||
|
||||
inline fun <reified T> String.fromJson(): T? {
|
||||
return try {
|
||||
Gson().fromJson(this, T::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigate(
|
||||
route: String,
|
||||
// backRoute: String? = null,
|
||||
// isLaunchSingleTop: Boolean = true,
|
||||
// isRestoreState: Boolean = true,
|
||||
vararg args: Any,
|
||||
) {
|
||||
val params = if (args.isEmpty()) {
|
||||
route
|
||||
} else {
|
||||
args.joinToString("/") {
|
||||
if (it is Parcelable) {
|
||||
Uri.encode(it.toJson())
|
||||
} else {
|
||||
"$it"
|
||||
}
|
||||
}
|
||||
}
|
||||
val realRoute = "$route/$params"
|
||||
Logger.i("Navigate", realRoute)
|
||||
navigate(realRoute) {
|
||||
// if (backRoute != null) {
|
||||
// popUpTo(backRoute) { saveState = true }
|
||||
// }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.back() {
|
||||
navigateUp()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.*
|
||||
import com.compose.wanandroid.data.model.ListResponse
|
||||
import com.compose.wanandroid.data.remote.HttpResult
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
val defaultPage = PagingConfig(
|
||||
pageSize = 20,
|
||||
initialLoadSize = 20,
|
||||
prefetchDistance = 1,
|
||||
maxSize = PagingConfig.MAX_SIZE_UNBOUNDED,
|
||||
enablePlaceholders = false
|
||||
)
|
||||
|
||||
fun <K : Any, V : Any> ViewModel.pageFlow(
|
||||
config: PagingConfig = defaultPage,
|
||||
initialKey: K? = null,
|
||||
block: suspend (PagingSource.LoadParams<K>) -> PagingSource.LoadResult<K, V>
|
||||
): Flow<PagingData<V>> {
|
||||
return Pager(
|
||||
config = config,
|
||||
initialKey = initialKey
|
||||
) {
|
||||
object : PagingSource<K, V>() {
|
||||
override suspend fun load(params: LoadParams<K>): LoadResult<K, V> {
|
||||
return block(params)
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<K, V>): K? {
|
||||
return initialKey
|
||||
}
|
||||
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun <T : Any> ViewModel.pageLoading(
|
||||
config: PagingConfig = defaultPage,
|
||||
initialKey: Int = 0,
|
||||
block: suspend (page: Int) -> ListResponse<T>
|
||||
): Flow<PagingData<T>> {
|
||||
return pageFlow(config, initialKey) {
|
||||
val page = it.key ?: 0
|
||||
val response = try {
|
||||
HttpResult.Success(block(page))
|
||||
} catch (e: Exception) {
|
||||
HttpResult.Error(e)
|
||||
}
|
||||
|
||||
when (response) {
|
||||
is HttpResult.Success -> {
|
||||
val data = response.result.data
|
||||
if (data != null) {
|
||||
val hasNext = data.datas.size >= it.loadSize || !data.over
|
||||
PagingSource.LoadResult.Page(
|
||||
data = data.datas,
|
||||
prevKey = if (page - 1 > 0) page - 1 else null,
|
||||
nextKey = if (hasNext) page + 1 else null
|
||||
)
|
||||
} else {
|
||||
PagingSource.LoadResult.Invalid()
|
||||
}
|
||||
}
|
||||
is HttpResult.Error -> PagingSource.LoadResult.Error(response.e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Any> ViewModel.pageLoadingLocal(
|
||||
config: PagingConfig = defaultPage,
|
||||
initialKey: Int = 0,
|
||||
block: suspend (page: Int) -> List<T>
|
||||
): Flow<PagingData<T>> {
|
||||
return pageFlow(config, initialKey) {
|
||||
val page = it.key ?: 0
|
||||
try {
|
||||
val data = block(page)
|
||||
val hasNext = data.size >= it.loadSize
|
||||
PagingSource.LoadResult.Page(
|
||||
data = data,
|
||||
prevKey = if (page - it.loadSize > 0) page - it.loadSize else null,
|
||||
nextKey = if (hasNext) page + data.size else null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
PagingSource.LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.compose.wanandroid.logic
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.compose.wanandroid.data.model.CoinInfo
|
||||
import com.compose.wanandroid.data.model.UserInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
val Context.userStore: DataStore<Preferences> by preferencesDataStore("account")
|
||||
|
||||
object UserStore : KoinComponent {
|
||||
private val userStore: DataStore<Preferences> by lazy {
|
||||
val context: Context by inject()
|
||||
context.userStore
|
||||
}
|
||||
|
||||
val isLogin: PreferenceItem<Boolean> by userStore(::booleanPreferencesKey, false, "is_login")
|
||||
|
||||
private val user: PreferenceItem<String> by userStore(::stringPreferencesKey, "", "user_info")
|
||||
|
||||
private val coin: PreferenceItem<String> by userStore(::stringPreferencesKey, "", "coin_info")
|
||||
|
||||
val userInfo: Flow<UserInfo?>
|
||||
get() = user.map { it.fromJson<UserInfo>() }
|
||||
|
||||
val coinInfo: Flow<CoinInfo?>
|
||||
get() = user.map { it.fromJson<CoinInfo>() }
|
||||
|
||||
suspend fun login(userInfo: UserInfo) {
|
||||
isLogin.update(true)
|
||||
user.update(userInfo.toJson())
|
||||
}
|
||||
|
||||
suspend fun update(userInfo: UserInfo?, coinInfo: CoinInfo?) {
|
||||
user.update(userInfo?.toJson() ?: "")
|
||||
coin.update(coinInfo?.toJson() ?: "")
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
isLogin.update(false)
|
||||
user.update("")
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
userStore.edit {
|
||||
it.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.compose.wanandroid.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.ui.theme.LightColors
|
||||
|
||||
@Preview(widthDp = 200, heightDp = 200)
|
||||
@Composable
|
||||
private fun PreviewAppIcon() {
|
||||
AppIcon()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppIcon() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.background(LightColors.primary, RoundedCornerShape(40.dp))
|
||||
) {
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.compose.wanandroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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 com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.defaultContentColorFor
|
||||
import com.compose.wanandroid.ui.widget.CenterAppBar
|
||||
|
||||
suspend fun ScaffoldState.showSnackbar(message: String, actionLabel: String? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
|
||||
snackbarHostState.showSnackbar(message, actionLabel, duration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "",
|
||||
onBack: () -> Unit = {},
|
||||
topBar: @Composable () -> Unit = { AppTitleBar(text = title, onBack = onBack) },
|
||||
scaffoldState: ScaffoldState = rememberScaffoldState(),
|
||||
bottomBar: @Composable () -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = topBar,
|
||||
backgroundColor = AppTheme.colors.background,
|
||||
contentColor = defaultContentColorFor(backgroundColor = AppTheme.colors.background),
|
||||
snackbarHost = { AppSnackbar(scaffoldState) },
|
||||
scaffoldState = scaffoldState,
|
||||
bottomBar = bottomBar,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppTitleBar(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "",
|
||||
onBack: () -> Unit = {},
|
||||
elevation: Dp = AppBarDefaults.TopAppBarElevation,
|
||||
leadingActions: @Composable (RowScope.() -> Unit) = {
|
||||
IconButton(
|
||||
onClick = { onBack() },
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowBack,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
})
|
||||
},
|
||||
trailingActions: @Composable (RowScope.() -> Unit) = { },
|
||||
title: @Composable () -> Unit = {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 16.sp,
|
||||
color = AppTheme.colors.onPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
) {
|
||||
CenterAppBar(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = elevation,
|
||||
backgroundColor = AppTheme.colors.primary,
|
||||
contentColor = defaultContentColorFor(backgroundColor = AppTheme.colors.primary),
|
||||
leadingActions = leadingActions,
|
||||
title = title,
|
||||
trailingActions = trailingActions
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppSnackbar(scaffoldState: ScaffoldState) {
|
||||
SnackbarHost(hostState = scaffoldState.snackbarHostState) { msg ->
|
||||
Snackbar(
|
||||
snackbarData = msg,
|
||||
backgroundColor = AppTheme.colors.secondaryBackground,
|
||||
actionColor = AppTheme.colors.onBackground,
|
||||
contentColor = AppTheme.colors.textPrimary,
|
||||
shape = AppTheme.shapes.medium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.compose.wanandroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.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.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.data.model.*
|
||||
import com.compose.wanandroid.logic.noRippleClickable
|
||||
import com.compose.wanandroid.logic.rippleClickable
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.collectColor
|
||||
import com.compose.wanandroid.ui.theme.textThird
|
||||
import com.compose.wanandroid.ui.widget.html.asHTML
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ArticleItemPreview() {
|
||||
AppTheme {
|
||||
Column {
|
||||
ArticleItem(
|
||||
data = Article().apply {
|
||||
author = "扔物线"
|
||||
title = "属性动画为什么不能移植到 Jetpack Compose?"
|
||||
desc = "基于ViewPager2实现的自动(手动)无限轮播Banner,支持自定义ItemView、横竖滑动、转场动画、配合DiffUtil增量更新等"
|
||||
niceDate = "2020-03-23 16:36"
|
||||
link = "https://rengwuxian.com/jetpack-compose-animation/"
|
||||
publishTime = 1653753600000L
|
||||
chapterName = "干货资源"
|
||||
envelopePic = "https://www.wanandroid.com/resources/image/pc/default_project_img.jpg"
|
||||
fresh = true
|
||||
tags = mutableListOf(ArticleTag("项目", "https://www.wanandroid.com"))
|
||||
},
|
||||
isTop = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
StickyTitle(title = "分类标题")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArticleItem(
|
||||
data: Article,
|
||||
modifier: Modifier = Modifier,
|
||||
isTop: Boolean = false,
|
||||
onSelected: (Link) -> Unit = {},
|
||||
onCollectClick: (article: Article) -> Unit = {},
|
||||
onUserClick: (userId: Int) -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.rippleClickable {
|
||||
onSelected(Link(url = data.link, title = data.title))
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
if (data.fresh) {
|
||||
Text(
|
||||
text = "新",
|
||||
color = AppTheme.colors.primary,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = data.authorName,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.noRippleClickable {
|
||||
onUserClick(data.userId)
|
||||
}
|
||||
)
|
||||
|
||||
val tags = data.tags
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(
|
||||
text = tags[0].name,
|
||||
color = AppTheme.colors.primary,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.border(BorderStroke(1.dp, AppTheme.colors.primary), RoundedCornerShape(4.dp))
|
||||
.padding(start = 3.dp, end = 3.dp)
|
||||
.wrapContentSize()
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = data.niceDate,
|
||||
color = AppTheme.colors.textThird,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentWidth(Alignment.End)
|
||||
.wrapContentHeight()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (data.envelopePic.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = data.envelopePic,
|
||||
placeholder = painterResource(id = R.drawable.image_holder),
|
||||
modifier = Modifier
|
||||
.size(width = 120.dp, height = 80.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(AppTheme.colors.secondaryBackground),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.height(IntrinsicSize.Min)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
text = data.title.asHTML(fontSize = 15.sp),
|
||||
color = AppTheme.colors.textPrimary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (data.desc.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
Text(
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
text = data.desc.asHTML(fontSize = 13.sp),
|
||||
color = AppTheme.colors.textSecondary,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
if (isTop) {
|
||||
Text(
|
||||
text = "置顶",
|
||||
color = AppTheme.colors.collectColor,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val chapter = data.chapter
|
||||
if (chapter.isNotEmpty()) {
|
||||
Text(
|
||||
text = chapter,
|
||||
color = AppTheme.colors.textThird,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.wrapContentSize()
|
||||
)
|
||||
}
|
||||
|
||||
val favorite = if (data.collect) R.drawable.icon_favorite else R.drawable.icon_unfavorite
|
||||
Image(
|
||||
painter = painterResource(id = favorite),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Inside,
|
||||
colorFilter = ColorFilter.tint(AppTheme.colors.collectColor),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.wrapContentWidth(Alignment.End)
|
||||
.weight(1f)
|
||||
.rippleClickable(bounded = false) {
|
||||
onCollectClick(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StickyTitle(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(30.dp)
|
||||
.background(AppTheme.colors.background)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(6.dp)
|
||||
.height(16.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
.background(shape = RoundedCornerShape(3.dp), color = AppTheme.colors.textPrimary)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LinkItem(
|
||||
title: String,
|
||||
link: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
text = title.asHTML(fontSize = 15.sp),
|
||||
color = AppTheme.colors.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = link,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textThird,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.compose.wanandroid.ui.common
|
||||
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
|
||||
interface ViewAction
|
||||
|
||||
sealed class RefreshViewAction : ViewAction {
|
||||
object FetchData : RefreshViewAction()
|
||||
object Refresh : RefreshViewAction()
|
||||
}
|
||||
|
||||
sealed class CollectViewAction : ViewAction {
|
||||
data class Collect(val article: Article) : CollectViewAction()
|
||||
data class UnCollect(val article: Article) : CollectViewAction()
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.compose.wanandroid.ui.common
|
||||
|
||||
interface ViewEvent
|
||||
|
||||
data class SnackViewEvent(val message: String) : ViewEvent
|
||||
data class ProgressViewEvent(val show: Boolean, val message: String = "") : ViewEvent
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.compose.wanandroid.ui.page.category
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.compose.wanandroid.data.model.Struct
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.fromJson
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.AppTitleBar
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.google.accompanist.pager.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun NavGraphBuilder.categoryGraph(controller: NavController) {
|
||||
composable(
|
||||
route = Page.Category.route + "/{category}/{index}",
|
||||
arguments = listOf(
|
||||
navArgument("category") { type = NavType.StringType },
|
||||
navArgument("index") { type = NavType.IntType }
|
||||
)
|
||||
) {
|
||||
val args = it.arguments?.getString("category")?.fromJson<Struct>()
|
||||
val index = it.arguments?.getInt("index", 0) ?: 0
|
||||
if (args != null) {
|
||||
CategoryListPage(struct = args, index = index, controller = controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun CategoryListPage(
|
||||
struct: Struct,
|
||||
index: Int = 0,
|
||||
controller: NavController,
|
||||
viewModel: CategoryListViewModel = viewModel()
|
||||
) {
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
AppScaffold(
|
||||
topBar = { AppTitleBar(text = struct.name, onBack = { controller.back() }, elevation = 0.dp) },
|
||||
scaffoldState = scaffoldState
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = index,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val structs = struct.children
|
||||
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
backgroundColor = AppTheme.colors.primary,
|
||||
edgePadding = 0.dp,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier
|
||||
.pagerTabIndicatorOffset(pagerState, tabPositions)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
height = 3.dp,
|
||||
color = AppTheme.colors.onPrimary
|
||||
)
|
||||
},
|
||||
divider = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
) {
|
||||
structs.forEachIndexed { index, struct ->
|
||||
Tab(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
text = {
|
||||
Text(
|
||||
text = struct.name,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
fontWeight = if (index == pagerState.currentPage) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
selectedContentColor = AppTheme.colors.onPrimary,
|
||||
unselectedContentColor = AppTheme.colors.onPrimary.copy(0.7f),
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
count = structs.size,
|
||||
state = pagerState,
|
||||
modifier = Modifier.background(AppTheme.colors.background)
|
||||
) { page ->
|
||||
// Pager子页无法区分ViewModel,父页面统一创建和管理
|
||||
CategoryPage(controller, viewModel.getChildViewModel(structs[page]), scaffoldState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.compose.wanandroid.ui.page.category
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.compose.wanandroid.data.model.Struct
|
||||
import com.compose.wanandroid.data.repository.CategoryArticleListRepository
|
||||
import com.compose.wanandroid.data.repository.ProjectArticleListRepository
|
||||
import com.compose.wanandroid.data.repository.WxArticleListRepository
|
||||
|
||||
class CategoryListViewModel : ViewModel() {
|
||||
|
||||
private val viewModels: ArrayMap<Int, CategoryViewModel> = ArrayMap()
|
||||
|
||||
fun getChildViewModel(struct: Struct): CategoryViewModel {
|
||||
val cid = struct.id
|
||||
return viewModels.getOrElse(cid) {
|
||||
val repo = when (struct.type) {
|
||||
Struct.TYPE_PROJECT -> ProjectArticleListRepository(cid)
|
||||
Struct.TYPE_ACCOUNT -> WxArticleListRepository(cid)
|
||||
else -> CategoryArticleListRepository(cid)
|
||||
}
|
||||
CategoryViewModel(repo).apply {
|
||||
viewModels[cid] = this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.compose.wanandroid.ui.page.category
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CategoryPage(
|
||||
controller: NavController,
|
||||
viewModel: CategoryViewModel,
|
||||
scaffoldState: ScaffoldState
|
||||
) {
|
||||
val viewState = viewModel.viewState
|
||||
val pagingItems = viewState.pagingData.collectAsLazyPagingItems()
|
||||
val isRefreshing = viewState.isRefreshing
|
||||
val listState = if (pagingItems.itemCount > 0) viewState.listState else LazyListState()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isLogin = UserStore.isLogin.collectAsState(initial = false)
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isRefreshing = isRefreshing,
|
||||
listState = listState,
|
||||
onRefresh = {
|
||||
|
||||
},
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(
|
||||
data = value,
|
||||
onCollectClick = {
|
||||
if (isLogin.value) {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.compose.wanandroid.ui.page.category
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.data.repository.ArticleListRepository
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CategoryViewModel(
|
||||
private val repo: ArticleListRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading { repo.getArticleList(it) }
|
||||
}
|
||||
|
||||
var viewState by mutableStateOf(CategoryViewState(pagingData = pager))
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollect(article)
|
||||
if (result.isFailure) {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CategoryViewState(
|
||||
val pagingData: Flow<PagingData<Article>>,
|
||||
val isRefreshing: Boolean = false,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
) {
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package com.compose.wanandroid.ui.page.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import coil.compose.AsyncImage
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun HomePage(
|
||||
controller: NavController,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scaffoldState: ScaffoldState = rememberScaffoldState()
|
||||
|
||||
val viewState = viewModel.viewState
|
||||
val pagingItems = viewState.pagingData.collectAsLazyPagingItems()
|
||||
val banners = viewState.banners
|
||||
val tops = viewState.tops
|
||||
val isRefreshing = viewState.isRefreshing
|
||||
val listState = if (pagingItems.itemCount > 0) viewState.listState else LazyListState()
|
||||
|
||||
val isLogin = UserStore.isLogin.collectAsState(initial = false)
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
// 这里加一个ProgressDialog的目的是为了收藏状态能及时刷新,ps:LazyColumn结合SnapshotStateList本可以自动更新,
|
||||
// 但是LazyColumn结合PagingData之后界面并未自动更新
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
modifier = Modifier.padding(padding),
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "首页",
|
||||
leadingActions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// TODO 扫描
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PhotoCamera,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingActions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
controller.navigate(Page.Search.route)
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
val context = LocalContext.current
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
viewModel.dispatch(RefreshViewAction.Refresh)
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
isRefreshing = isRefreshing,
|
||||
listState = listState,
|
||||
onRefresh = {
|
||||
viewModel.dispatch(RefreshViewAction.Refresh)
|
||||
},
|
||||
itemContent = {
|
||||
if (banners.isNotEmpty()) {
|
||||
item {
|
||||
Banner(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.78f),
|
||||
count = banners.size,
|
||||
) { page ->
|
||||
AsyncImage(
|
||||
model = banners[page].imagePath,
|
||||
placeholder = painterResource(id = R.drawable.image_holder),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppTheme.colors.secondaryBackground)
|
||||
.clickable {
|
||||
with(banners[page]) {
|
||||
controller.navigate(Page.Web.route, Link(title = title, url = url))
|
||||
}
|
||||
},
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tops.isNotEmpty()) {
|
||||
itemsIndexed(tops) { index: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(
|
||||
data = value,
|
||||
isTop = true,
|
||||
modifier = Modifier.padding(top = if (index == 0) 5.dp else 0.dp),
|
||||
onCollectClick = {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(
|
||||
data = value,
|
||||
onCollectClick = {
|
||||
if (isLogin.value) {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.compose.wanandroid.ui.page.home
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.*
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.model.Banner
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading { ApiService.api.articles(it) }
|
||||
}
|
||||
|
||||
var viewState by mutableStateOf(HomeViewState(pagingData = pager))
|
||||
private set
|
||||
|
||||
// LazyColumn结合SnapshotStateList,界面会自动观察数据变化
|
||||
// private var _tops = mutableStateListOf<Article>()
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
init {
|
||||
dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is RefreshViewAction.FetchData -> fetchData()
|
||||
is RefreshViewAction.Refresh -> refresh()
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
private fun fetchData() {
|
||||
val banners = ApiService.api.banners().map { it.data ?: emptyList() }
|
||||
val tops = ApiService.api.topArticles().map { it.data ?: emptyList() }
|
||||
viewModelScope.launch {
|
||||
banners.zip(tops) { banners, tops ->
|
||||
// _tops = tops.toMutableStateList()
|
||||
viewState = viewState.copy(banners = banners, tops = tops, isRefreshing = false)
|
||||
}.onStart {
|
||||
viewState = viewState.copy(isRefreshing = true)
|
||||
}.catch {
|
||||
viewState = viewState.copy(isRefreshing = false)
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollect(article)
|
||||
if (result.isSuccess) {
|
||||
// val index = _tops.indexOf(article)
|
||||
// if (index >= 0) {
|
||||
// _tops.removeAt(index)
|
||||
// _tops.add(index, article)
|
||||
// }
|
||||
} else {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeViewState(
|
||||
val banners: List<Banner> = emptyList(),
|
||||
val tops: List<Article> = emptyList(),
|
||||
val pagingData: Flow<PagingData<Article>>,
|
||||
val isRefreshing: Boolean = false,
|
||||
val listState: LazyListState = LazyListState()
|
||||
) {
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = tops.isEmpty() && banners.isEmpty() && pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
package com.compose.wanandroid.ui.page.login
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.logic.noRippleClickable
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.textThird
|
||||
import com.compose.wanandroid.ui.widget.AppBarHeight
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.ProgressViewEvent
|
||||
import com.compose.wanandroid.ui.common.SnackViewEvent
|
||||
import com.compose.wanandroid.ui.common.showSnackbar
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoginPagePreview() {
|
||||
AppTheme {
|
||||
LoginPage()
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.loginGraph(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
composable(route = Page.Login.route) {
|
||||
LoginPage(onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LoginPage(
|
||||
viewModel: LoginViewModel = viewModel(),
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val viewState = viewModel.viewState
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableStateOf("加载中...") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect {
|
||||
when (it) {
|
||||
is LoginViewEvent.Back -> {
|
||||
showDialog = false
|
||||
onBack()
|
||||
}
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(it.message)
|
||||
}
|
||||
}
|
||||
is ProgressViewEvent -> {
|
||||
progress = it.message
|
||||
showDialog = it.show
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog(progress) {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
topBar = {},
|
||||
scaffoldState = scaffoldState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppTheme.colors.primary)
|
||||
.statusBarsPadding()
|
||||
.height(250.dp)
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(AppBarHeight),
|
||||
onClick = {
|
||||
keyboardController?.hide()
|
||||
onBack()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo),
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "欢迎使用",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppTheme.colors.onPrimary,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "账号服务由WanAndroid提供",
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.onPrimary.copy(0.8f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Input(
|
||||
text = viewState.account,
|
||||
label = "请输入用户名",
|
||||
leadingIcon = R.drawable.ic_account_normal,
|
||||
keyboardType = KeyboardType.Email
|
||||
) { account ->
|
||||
viewModel.dispatch(LoginViewAction.UpdateAccount(account))
|
||||
}
|
||||
|
||||
Input(
|
||||
text = viewState.password,
|
||||
label = "请输入密码",
|
||||
leadingIcon = R.drawable.ic_password_normal,
|
||||
keyboardType = KeyboardType.Password,
|
||||
isPassword = true
|
||||
) { password ->
|
||||
viewModel.dispatch(LoginViewAction.UpdatePassword(password))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
var checkedState by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 60.dp, end = 60.dp)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checkedState,
|
||||
onCheckedChange = {
|
||||
checkedState = !checkedState
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = AppTheme.colors.primary,
|
||||
uncheckedColor = AppTheme.colors.textThird,
|
||||
checkmarkColor = AppTheme.colors.onPrimary,
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "没有账号,注册一个?",
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textThird,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 50.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.width(200.dp)
|
||||
.height(40.dp)
|
||||
.background(AppTheme.colors.primary, RoundedCornerShape(20.dp))
|
||||
.clip(shape = RoundedCornerShape(20.dp))
|
||||
.clickable {
|
||||
viewModel.dispatch(LoginViewAction.Login(checkedState))
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (checkedState) "注册" else "登录",
|
||||
color = AppTheme.colors.onPrimary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Input(
|
||||
text: String,
|
||||
label: String = "",
|
||||
@DrawableRes leadingIcon: Int = 0,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
isPassword: Boolean = false,
|
||||
onValueChanged: (String) -> Unit
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(!isPassword) }
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 60.dp, end = 60.dp)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = onValueChanged,
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged {
|
||||
hasFocus = it.hasFocus
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = AppTheme.colors.onBackground,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
color = AppTheme.colors.textThird,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Image(
|
||||
painter = painterResource(id = leadingIcon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(if (hasFocus) AppTheme.colors.highlight else AppTheme.colors.textThird)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
AnimatedVisibility(
|
||||
visible = hasFocus && text.trim().isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row {
|
||||
if (isPassword) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_eye_normal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(if (passwordVisible) AppTheme.colors.highlight else AppTheme.colors.textThird),
|
||||
contentScale = ContentScale.Inside,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(25.dp)
|
||||
.noRippleClickable {
|
||||
passwordVisible = !passwordVisible
|
||||
},
|
||||
)
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_delete),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(AppTheme.colors.textThird),
|
||||
contentScale = ContentScale.Inside,
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.noRippleClickable {
|
||||
onValueChanged("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
cursorColor = AppTheme.colors.highlight,
|
||||
textColor = AppTheme.colors.onBackground,
|
||||
backgroundColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
shape = RectangleShape,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.height(0.2.dp)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
color = AppTheme.colors.textThird
|
||||
)
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.compose.wanandroid.ui.page.login
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.ui.common.ProgressViewEvent
|
||||
import com.compose.wanandroid.ui.common.SnackViewEvent
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginViewModel : ViewModel() {
|
||||
|
||||
var viewState by mutableStateOf(LoginViewState())
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
fun dispatch(action: LoginViewAction) {
|
||||
when (action) {
|
||||
is LoginViewAction.Login -> if (action.isRegister) register() else login()
|
||||
is LoginViewAction.UpdateAccount -> viewState = viewState.copy(account = action.account)
|
||||
is LoginViewAction.UpdatePassword -> viewState = viewState.copy(password = action.password)
|
||||
}
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_viewEvents.send(ProgressViewEvent(true, "登录中..."))
|
||||
val res = ApiService.api.login(viewState.account.trim(), viewState.password.trim())
|
||||
if (res.isSuccess) {
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
UserStore.login(res.data ?: throw Exception("登录失败,请稍后重试~"))
|
||||
_viewEvents.send(LoginViewEvent.Back)
|
||||
} else {
|
||||
throw Exception(res.errorMsg)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_viewEvents.send(SnackViewEvent(e.message ?: "登录失败,请稍后重试~"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun register() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_viewEvents.send(ProgressViewEvent(true, "注册中..."))
|
||||
val res = ApiService.api.register(viewState.account.trim(), viewState.password.trim(), viewState.password.trim())
|
||||
if (res.isSuccess) {
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
UserStore.login(res.data ?: throw Exception("注册失败,请稍后重试~"))
|
||||
_viewEvents.send(LoginViewEvent.Back)
|
||||
} else {
|
||||
throw Exception(res.errorMsg)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_viewEvents.send(SnackViewEvent(e.message ?: "注册失败,请稍后重试~"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LoginViewState(
|
||||
var account: String = "",
|
||||
var password: String = "",
|
||||
)
|
||||
|
||||
sealed class LoginViewAction {
|
||||
data class Login(val isRegister: Boolean) : LoginViewAction()
|
||||
data class UpdateAccount(val account: String) : LoginViewAction()
|
||||
data class UpdatePassword(val password: String) : LoginViewAction()
|
||||
}
|
||||
|
||||
sealed class LoginViewEvent : ViewEvent {
|
||||
object Back : LoginViewEvent()
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.compose.wanandroid.ui.page.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.page.home.HomePage
|
||||
import com.compose.wanandroid.ui.page.struct.StructHostPage
|
||||
import com.compose.wanandroid.ui.page.web.webGraph
|
||||
import com.compose.wanandroid.ui.page.login.loginGraph
|
||||
import com.compose.wanandroid.ui.page.category.categoryGraph
|
||||
import com.compose.wanandroid.ui.page.profile.ProfilePage
|
||||
import com.compose.wanandroid.ui.page.profile.bookmark.bookmarkGraph
|
||||
import com.compose.wanandroid.ui.page.profile.coin.coinGraph
|
||||
import com.compose.wanandroid.ui.page.profile.collect.collectGraph
|
||||
import com.compose.wanandroid.ui.page.profile.history.historyGraph
|
||||
import com.compose.wanandroid.ui.page.profile.opensource.opensourceGraph
|
||||
import com.compose.wanandroid.ui.page.profile.setting.settingGraph
|
||||
import com.compose.wanandroid.ui.page.profile.share.mineShareGraph
|
||||
import com.compose.wanandroid.ui.page.search.searchGraph
|
||||
import com.compose.wanandroid.ui.page.square.SquareHostPage
|
||||
import com.compose.wanandroid.ui.theme.*
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MainPagePreview() {
|
||||
AppTheme {
|
||||
MainPage()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage() {
|
||||
val controller = rememberNavController()
|
||||
val stackEntry by controller.currentBackStackEntryAsState()
|
||||
val currentRoute = stackEntry?.destination?.route
|
||||
|
||||
AppScaffold(
|
||||
topBar = {},
|
||||
content = { NavigationHost(controller, it) },
|
||||
bottomBar = {
|
||||
// 仅在主页展示BottomBar
|
||||
when (currentRoute) {
|
||||
null, Page.Home.route, Page.Square.route, Page.Struct.route, Page.Profile.route -> {
|
||||
BottomNavigation(controller, currentRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigationHost(
|
||||
controller: NavHostController,
|
||||
padding: PaddingValues
|
||||
) {
|
||||
NavHost(
|
||||
navController = controller,
|
||||
startDestination = Page.Home.route,
|
||||
modifier = Modifier.background(AppTheme.colors.background)
|
||||
) {
|
||||
composable(Page.Home.route) { HomePage(controller, padding) }
|
||||
composable(Page.Square.route) { SquareHostPage(controller, padding) }
|
||||
composable(Page.Struct.route) { StructHostPage(controller, padding) }
|
||||
composable(Page.Profile.route) { ProfilePage(controller, padding) }
|
||||
|
||||
webGraph { controller.back() }
|
||||
loginGraph { controller.back() }
|
||||
categoryGraph(controller)
|
||||
searchGraph(controller)
|
||||
coinGraph(controller)
|
||||
collectGraph(controller)
|
||||
mineShareGraph(controller)
|
||||
bookmarkGraph(controller)
|
||||
historyGraph(controller)
|
||||
opensourceGraph(controller)
|
||||
settingGraph { controller.back() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNavigation(
|
||||
controller: NavController,
|
||||
currentRoute: String?
|
||||
) {
|
||||
val pages = listOf(
|
||||
Page.Home,
|
||||
Page.Square,
|
||||
Page.Struct,
|
||||
Page.Profile
|
||||
)
|
||||
BottomNavigation(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
backgroundColor = AppTheme.colors.tabBackground,
|
||||
contentColor = defaultContentColorFor(backgroundColor = AppTheme.colors.background)
|
||||
) {
|
||||
pages.forEach { tab ->
|
||||
BottomNavigationItem(
|
||||
selected = tab.route == currentRoute,
|
||||
icon = { Icon(painter = painterResource(id = tab.icon), contentDescription = null) },
|
||||
label = { Text(text = tab.text, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) },
|
||||
onClick = {
|
||||
if (tab.route != currentRoute) {
|
||||
controller.navigate(tab.route) {
|
||||
popUpTo(controller.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
selectedContentColor = AppTheme.colors.highlight,
|
||||
unselectedContentColor = AppTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.compose.wanandroid.ui.page.main
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.compose.wanandroid.R
|
||||
|
||||
sealed class Page(
|
||||
val route: String,
|
||||
val text: String = "",
|
||||
@DrawableRes val icon: Int = 0
|
||||
) {
|
||||
object Home : Page("home", "首页", R.drawable.ic_bottom_bar_home)
|
||||
object Square : Page("square", "广场", R.drawable.ic_bottom_bar_ques)
|
||||
object Struct : Page("struct", "体系", R.drawable.ic_bottom_bar_navi)
|
||||
object Profile : Page("profile", "我的", R.drawable.ic_bottom_bar_mine)
|
||||
object Web : Page("web")
|
||||
object Login : Page("login")
|
||||
object Search : Page("search", "搜索")
|
||||
object Collect : Page("collect")
|
||||
object Setting : Page("setting", "设置")
|
||||
object Category : Page("category")
|
||||
object MineShare : Page("mine_share")
|
||||
object Bookmark : Page("bookmark")
|
||||
object History : Page("history")
|
||||
object Coin : Page("coin")
|
||||
object Opensource : Page("opensource")
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.compose.wanandroid.ui.page.profile
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
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.Icon
|
||||
import androidx.compose.material.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.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.textThird
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProfilePagePreview() {
|
||||
AppTheme {
|
||||
ProfilePage(rememberNavController())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfilePage(
|
||||
controller: NavController,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
viewModel: ProfileViewModel = viewModel()
|
||||
) {
|
||||
val viewState = viewModel.viewState
|
||||
val userInfo = viewState.userInfo
|
||||
val coinInfo = viewState.coinInfo
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(250.dp)
|
||||
.background(AppTheme.colors.primary)
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(top = 70.dp)
|
||||
.size(80.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.background(Color(0x22000000), CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (!viewState.isLogin) {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
model = userInfo?.avatar,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Text(
|
||||
text = userInfo?.nickname ?: "未登录",
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.clickable {
|
||||
if (!viewState.isLogin) {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
fontSize = 22.sp,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
if (coinInfo != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.wrapContentSize(Alignment.Center)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(
|
||||
text = "等级:${coinInfo.level}",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xaaffffff)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(15.dp))
|
||||
|
||||
Text(
|
||||
text = "排名:${coinInfo.rank}",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xaaffffff)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfileItem(icon = R.drawable.ic_coin, text = "我的积分", subText = "${coinInfo?.coinCount ?: ""}") {
|
||||
if (viewState.isLogin) {
|
||||
controller.navigate(Page.Coin.route, coinInfo?.coinCount ?: 0)
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_share_article, text = "我的分享") {
|
||||
if (viewState.isLogin) {
|
||||
controller.navigate(Page.MineShare.route)
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_collect, text = "我的收藏") {
|
||||
if (viewState.isLogin) {
|
||||
controller.navigate(Page.Collect.route)
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_read_later, text = "我的书签") {
|
||||
controller.navigate(Page.Bookmark.route)
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_read_record, text = "阅读历史") {
|
||||
controller.navigate(Page.History.route)
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_github, text = "开源项目") {
|
||||
controller.navigate(Page.Opensource.route)
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_about, text = "关于作者", subText = "请他喝杯☕️~") {
|
||||
controller.navigate(Page.Web.route, Link("https://github.com/MarkMjw", "MarkMjw的Github", false))
|
||||
}
|
||||
ProfileItem(icon = R.drawable.ic_setting, text = "系统设置") {
|
||||
controller.navigate(Page.Setting.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileItem(
|
||||
@DrawableRes icon: Int,
|
||||
text: String,
|
||||
subText: String = "",
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = icon),
|
||||
tint = AppTheme.colors.highlight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(start = 10.dp, end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subText,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 10.dp)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_enter),
|
||||
tint = AppTheme.colors.textThird,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.compose.wanandroid.ui.page.profile
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.model.CoinInfo
|
||||
import com.compose.wanandroid.data.model.UserInfo
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class ProfileViewModel : ViewModel() {
|
||||
var viewState by mutableStateOf(ProfileViewState())
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
init {
|
||||
UserStore.isLogin.onEach {
|
||||
viewState = viewState.copy(isLogin = it)
|
||||
if (it) {
|
||||
fetchUserInfo()
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
UserStore.userInfo.onEach {
|
||||
viewState = viewState.copy(userInfo = it)
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
UserStore.coinInfo.onEach {
|
||||
viewState = viewState.copy(coinInfo = it)
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun fetchUserInfo() {
|
||||
ApiService.api.userInfo().map {
|
||||
if (it.isSuccess) {
|
||||
it.data ?: throw Exception("接口返回数据为空")
|
||||
} else {
|
||||
throw Exception(it.errorMsg)
|
||||
}
|
||||
}.onEach { res ->
|
||||
viewState = viewState.copy(userInfo = res.userInfo, coinInfo = res.coinInfo)
|
||||
if (res.userInfo != null && res.coinInfo != null) {
|
||||
UserStore.update(res.userInfo, res.coinInfo)
|
||||
}
|
||||
}.catch {
|
||||
Logger.w(it.message ?: "")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
data class ProfileViewState(
|
||||
val isLogin: Boolean = false,
|
||||
val userInfo: UserInfo? = null,
|
||||
val coinInfo: CoinInfo? = null
|
||||
)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.compose.wanandroid.ui.page.profile.bookmark
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.local.model.Bookmark
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
fun NavGraphBuilder.bookmarkGraph(controller: NavController) {
|
||||
composable(route = Page.Bookmark.route) {
|
||||
BookmarkPage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkPage(
|
||||
controller: NavController,
|
||||
viewModel: BookmarkViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val formatter by remember {
|
||||
mutableStateOf(SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA))
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect {
|
||||
when (it) {
|
||||
is SnackViewEvent -> {
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = it.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
title = "我的书签",
|
||||
onBack = { controller.back() },
|
||||
) {
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, bookmark: Bookmark? ->
|
||||
if (bookmark != null) {
|
||||
LinkItem(
|
||||
title = bookmark.title,
|
||||
link = formatter.format(Date(bookmark.time)),
|
||||
modifier = Modifier.clickable {
|
||||
controller.navigate(Page.Web.route, Link(bookmark.link, bookmark.title))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.compose.wanandroid.ui.page.profile.bookmark
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.local.model.Bookmark
|
||||
import com.compose.wanandroid.data.repository.BookmarkRepository
|
||||
import com.compose.wanandroid.logic.pageLoadingLocal
|
||||
import com.compose.wanandroid.ui.common.ViewAction
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class BookmarkViewModel : ViewModel(), KoinComponent {
|
||||
|
||||
private val repo: BookmarkRepository by inject()
|
||||
|
||||
val pager: Flow<PagingData<Bookmark>> by lazy {
|
||||
pageLoadingLocal {
|
||||
repo.find(it, 20)
|
||||
}
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is BookmarkViewAction.Delete -> delete(action.bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(bookmark: Bookmark) {
|
||||
viewModelScope.launch {
|
||||
repo.delete(bookmark.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class BookmarkViewAction : ViewAction {
|
||||
data class Delete(val bookmark: Bookmark) : BookmarkViewAction()
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.compose.wanandroid.ui.page.profile.coin
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Coin
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.textThird
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
|
||||
fun NavGraphBuilder.coinGraph(controller: NavController) {
|
||||
composable(
|
||||
route = Page.Coin.route + "/{count}",
|
||||
arguments = listOf(navArgument("count") { type = NavType.IntType })
|
||||
) {
|
||||
val count = it.arguments?.getInt("count", 0) ?: 0
|
||||
CoinPage(controller, count)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CoinPage(
|
||||
controller: NavController,
|
||||
coinCount: Int,
|
||||
viewModel: CoinViewModel = viewModel()
|
||||
) {
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
text = "我的积分($coinCount)",
|
||||
onBack = { controller.back() },
|
||||
trailingActions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
controller.navigate(
|
||||
Page.Web.route,
|
||||
Link(title = "本站积分规则", url = "https://www.wanandroid.com/blog/show/2653", addHistory = false)
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.HelpOutline,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, coin: Coin? ->
|
||||
if (coin != null) {
|
||||
CoinItem(coin)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CoinItem(coin: Coin) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = coin.title,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = coin.time,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textThird,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = coin.count,
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.highlight,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.compose.wanandroid.ui.page.profile.coin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.*
|
||||
import com.compose.wanandroid.data.remote.*
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class CoinViewModel : ViewModel() {
|
||||
|
||||
val pager: Flow<PagingData<Coin>> by lazy {
|
||||
pageLoading(initialKey = 1) { ApiService.api.coinList(it) }
|
||||
}
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.compose.wanandroid.ui.page.profile.collect
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CollectArticlePage(
|
||||
controller: NavController,
|
||||
scaffoldState: ScaffoldState,
|
||||
viewModel: CollectArticleViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(data = value,
|
||||
onCollectClick = {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.compose.wanandroid.ui.page.profile.collect
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CollectArticleViewModel : ViewModel() {
|
||||
|
||||
val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading {
|
||||
ApiService.api.collectArticles(it).apply {
|
||||
data?.datas?.forEach { it.collect = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollectMine(article)
|
||||
if (result.isSuccess) {
|
||||
// TODO un collect success need remove from list
|
||||
} else {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.compose.wanandroid.ui.page.profile.collect
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun CollectLinkPage(
|
||||
controller: NavController,
|
||||
scaffoldState: ScaffoldState,
|
||||
viewModel: CollectLinkViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewState = viewModel.viewState
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState.pageState,
|
||||
onRetry = {
|
||||
viewModel.dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppTheme.colors.background),
|
||||
state = viewState.listState
|
||||
) {
|
||||
viewState.data.forEachIndexed { position, link ->
|
||||
item {
|
||||
val state = rememberRevealState()
|
||||
RevealSwipe(
|
||||
closeOnContentClick = true,
|
||||
directions = setOf(RevealDirection.EndToStart),
|
||||
maxRevealDp = 88.dp,
|
||||
hiddenContentEnd = {
|
||||
ActionMenu(
|
||||
actionIconSize = 56.dp,
|
||||
onDelete = {
|
||||
if (state.targetValue == RevealValue.FullyRevealedStart) {
|
||||
scope.launch {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
viewModel.dispatch(CollectLinkViewAction.UnCollect(link))
|
||||
}
|
||||
)
|
||||
},
|
||||
animateBackgroundCardColor = false,
|
||||
backgroundCardEndColor = AppTheme.colors.primary,
|
||||
state = state
|
||||
) {
|
||||
LinkItem(
|
||||
title = link.name,
|
||||
link = link.link,
|
||||
modifier = Modifier
|
||||
.background(AppTheme.colors.background)
|
||||
.clickable {
|
||||
controller.navigate(Page.Web.route, Link(title = link.name, url = link.link))
|
||||
}
|
||||
)
|
||||
}
|
||||
if (position <= viewState.size - 1) {
|
||||
Divider(
|
||||
startIndent = 10.dp,
|
||||
color = AppTheme.colors.secondaryBackground,
|
||||
thickness = 0.8f.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionMenu(
|
||||
actionIconSize: Dp,
|
||||
onDelete: () -> Unit,
|
||||
// onEdit: () -> Unit,
|
||||
// onFavorite: () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp)) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(actionIconSize),
|
||||
onClick = {
|
||||
onDelete()
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_bin),
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = "delete action",
|
||||
)
|
||||
}
|
||||
)
|
||||
// IconButton(
|
||||
// modifier = Modifier.size(actionIconSize),
|
||||
// onClick = onEdit,
|
||||
// content = {
|
||||
// Icon(
|
||||
// painter = painterResource(id = R.drawable.ic_edit),
|
||||
// tint = AppTheme.colors.onPrimary,
|
||||
// contentDescription = "edit action",
|
||||
// )
|
||||
// },
|
||||
// )
|
||||
// IconButton(
|
||||
// modifier = Modifier.size(actionIconSize),
|
||||
// onClick = onFavorite,
|
||||
// content = {
|
||||
// Icon(
|
||||
// painter = painterResource(id = R.drawable.ic_favorite),
|
||||
// tint = AppTheme.colors.onPrimary,
|
||||
// contentDescription = "Expandable Arrow",
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.compose.wanandroid.ui.page.profile.collect
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.model.CollectLink
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CollectLinkViewModel : ViewModel() {
|
||||
|
||||
private var _links = mutableStateListOf<CollectLink>()
|
||||
|
||||
var viewState by mutableStateOf(CollectLinkViewState())
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
init {
|
||||
dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectLinkViewAction.UnCollect -> unCollect(action.link)
|
||||
is RefreshViewAction.FetchData -> fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData() {
|
||||
ApiService.api.collectLinks()
|
||||
.map {
|
||||
it.data ?: emptyList()
|
||||
}.onStart {
|
||||
viewState = viewState.copy(pageState = PageState.Loading)
|
||||
}.onEach {
|
||||
_links = it.toMutableStateList()
|
||||
viewState = viewState.copy(
|
||||
data = _links,
|
||||
pageState = PageState.Success(it.isEmpty())
|
||||
)
|
||||
}.catch {
|
||||
viewState = viewState.copy(pageState = PageState.Error(it))
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun unCollect(link: CollectLink) {
|
||||
viewModelScope.launch {
|
||||
val result = collectRepo.unCollectLink(link.id)
|
||||
if (result.isSuccess) {
|
||||
_links.remove(link)
|
||||
} else {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CollectLinkViewState(
|
||||
val data: List<CollectLink> = emptyList(),
|
||||
val pageState: PageState = PageState.Loading,
|
||||
val listState: LazyListState = LazyListState()
|
||||
) {
|
||||
val size = data.size
|
||||
}
|
||||
|
||||
sealed class CollectLinkViewAction : ViewAction {
|
||||
data class UnCollect(val link: CollectLink) : CollectLinkViewAction()
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.compose.wanandroid.ui.page.profile.collect
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.AppTitleBar
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.TabText
|
||||
import com.google.accompanist.pager.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun NavGraphBuilder.collectGraph(controller: NavController) {
|
||||
composable(route = Page.Collect.route) {
|
||||
CollectPage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun CollectPage(
|
||||
controller: NavController,
|
||||
) {
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
)
|
||||
|
||||
val titles = listOf(
|
||||
TabText(0, "文章"),
|
||||
TabText(1, "网址"),
|
||||
)
|
||||
|
||||
AppScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
onBack = { controller.back() },
|
||||
title = {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
backgroundColor = Color.Transparent,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier
|
||||
.pagerTabIndicatorOffset(pagerState, tabPositions)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
height = 3.dp,
|
||||
color = AppTheme.colors.onPrimary
|
||||
)
|
||||
},
|
||||
divider = {},
|
||||
modifier = Modifier
|
||||
.width(130.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.fillMaxHeight(),
|
||||
text = {
|
||||
Text(
|
||||
text = title.text,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
fontWeight = if (index == pagerState.currentPage) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
selectedContentColor = AppTheme.colors.onPrimary,
|
||||
unselectedContentColor = AppTheme.colors.onPrimary.copy(0.7f),
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
HorizontalPager(
|
||||
count = titles.size, state = pagerState,
|
||||
modifier = Modifier.background(AppTheme.colors.background)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> CollectArticlePage(controller, scaffoldState)
|
||||
1 -> CollectLinkPage(controller, scaffoldState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.compose.wanandroid.ui.page.profile.history
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.local.model.History
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
fun NavGraphBuilder.historyGraph(controller: NavController) {
|
||||
composable(route = Page.History.route) {
|
||||
HistoryPage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HistoryPage(
|
||||
controller: NavController,
|
||||
viewModel: HistoryViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val formatter by remember {
|
||||
mutableStateOf(SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA))
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect {
|
||||
when (it) {
|
||||
is SnackViewEvent -> {
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = it.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
title = "阅读历史",
|
||||
onBack = { controller.back() },
|
||||
) {
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, history: History? ->
|
||||
if (history != null) {
|
||||
LinkItem(
|
||||
title = history.title,
|
||||
link = formatter.format(Date(history.lastTime)),
|
||||
modifier = Modifier.clickable {
|
||||
controller.navigate(Page.Web.route, Link(history.link, history.title))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.compose.wanandroid.ui.page.profile.history
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.local.model.History
|
||||
import com.compose.wanandroid.data.repository.HistoryRepository
|
||||
import com.compose.wanandroid.logic.pageLoadingLocal
|
||||
import com.compose.wanandroid.ui.common.ViewAction
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class HistoryViewModel : ViewModel(), KoinComponent {
|
||||
|
||||
private val repo: HistoryRepository by inject()
|
||||
|
||||
val pager: Flow<PagingData<History>> by lazy {
|
||||
pageLoadingLocal {
|
||||
repo.find(it, 20)
|
||||
}
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is HistoryViewAction.Delete -> delete(action.history)
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(history: History) {
|
||||
viewModelScope.launch {
|
||||
repo.delete(history.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HistoryViewAction : ViewAction {
|
||||
data class Delete(val history: History) : HistoryViewAction()
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.compose.wanandroid.ui.page.profile.opensource
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
|
||||
fun NavGraphBuilder.opensourceGraph(controller: NavController) {
|
||||
composable(route = Page.Opensource.route) {
|
||||
OpensourcePage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpensourcePage(
|
||||
controller: NavController,
|
||||
viewModel: OpensourceViewModel = viewModel()
|
||||
) {
|
||||
AppScaffold(
|
||||
title = "开源项目",
|
||||
onBack = { controller.back() },
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
viewModel.data.forEach {
|
||||
LinkItem(
|
||||
title = it.title,
|
||||
link = it.desc,
|
||||
modifier = Modifier.clickable {
|
||||
controller.navigate(Page.Web.route, Link(it.url, it.title, false))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.compose.wanandroid.ui.page.profile.opensource
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class OpensourceViewModel : ViewModel() {
|
||||
|
||||
val data = listOf(
|
||||
OpensourceProject(
|
||||
"google/accompanist",
|
||||
"A collection of extension libraries for Jetpack Compose.",
|
||||
"https://github.com/google/accompanist"
|
||||
),
|
||||
OpensourceProject(
|
||||
"google/gson",
|
||||
"A Java serialization/deserialization library to convert Java Objects into JSON and back.",
|
||||
"https://github.com/google/gson"
|
||||
),
|
||||
OpensourceProject(
|
||||
"coil-kt/coil",
|
||||
"Image loading for Android backed by Kotlin Coroutines.",
|
||||
"https://github.com/coil-kt/coil"
|
||||
),
|
||||
OpensourceProject(
|
||||
"InsertKoinIO/koin",
|
||||
"Koin - a pragmatic lightweight dependency injection framework for Kotlin.",
|
||||
"https://github.com/InsertKoinIO/koin"
|
||||
),
|
||||
OpensourceProject(
|
||||
"square/okhttp",
|
||||
"Square’s meticulous HTTP client for the JVM, Android, and GraalVM.",
|
||||
"https://github.com/square/okhttp"
|
||||
),
|
||||
OpensourceProject(
|
||||
"square/retrofit",
|
||||
"A type-safe HTTP client for Android and the JVM",
|
||||
"https://github.com/square/retrofit"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class OpensourceProject(
|
||||
val title: String,
|
||||
val desc: String,
|
||||
val url: String
|
||||
)
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package com.compose.wanandroid.ui.page.profile.setting
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.logic.darkMode
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.*
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.ProgressViewEvent
|
||||
import com.compose.wanandroid.ui.common.SnackViewEvent
|
||||
import com.compose.wanandroid.ui.common.showSnackbar
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingPagePreview() {
|
||||
AppTheme {
|
||||
SettingPage()
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.settingGraph(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
composable(route = Page.Setting.route) {
|
||||
SettingPage(onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingPage(
|
||||
viewModel: SettingViewModel = viewModel(),
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewState = viewModel.viewState
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableStateOf("加载中...") }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect {
|
||||
when (it) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(it.message)
|
||||
}
|
||||
}
|
||||
is ProgressViewEvent -> {
|
||||
progress = it.message
|
||||
showDialog = it.show
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog(progress) {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
title = Page.Setting.text,
|
||||
onBack = onBack
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
RadioGroup(viewState.darkModes, viewState.darkModes.indexOf(ThemeState.theme.value), title = "暗黑模式") {
|
||||
ThemeState.theme.value = it
|
||||
scope.launch {
|
||||
darkMode.update(it.name)
|
||||
}
|
||||
}
|
||||
SwitchItem(text = "书签提醒", subText = "打开应用时以通知方式提醒最新添加书签") {
|
||||
Logger.w("mjw", "switch:$it")
|
||||
}
|
||||
SettingItem(text = "关于我们")
|
||||
|
||||
if (viewState.isLogin) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 80.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.width(200.dp)
|
||||
.height(40.dp)
|
||||
.background(AppTheme.colors.primary, RoundedCornerShape(20.dp))
|
||||
.clip(shape = RoundedCornerShape(20.dp))
|
||||
.clickable {
|
||||
viewModel.dispatch(SettingViewAction.Logout)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "退出登录",
|
||||
color = AppTheme.colors.onPrimary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioGroup(
|
||||
options: List<Theme>,
|
||||
selectIndex: Int = 0,
|
||||
title: String = "",
|
||||
onSelected: (Theme) -> Unit = {}
|
||||
) {
|
||||
val (selectedOption, onOptionSelected) = remember {
|
||||
mutableStateOf(options[selectIndex])
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
if (title.isNotEmpty()) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
options.forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clickable {
|
||||
onOptionSelected(item)
|
||||
onSelected(item)
|
||||
}
|
||||
.padding(start = 21.dp, end = 21.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = AppTheme.colors.radioSelected,
|
||||
unselectedColor = AppTheme.colors.radio
|
||||
),
|
||||
selected = (item == selectedOption),
|
||||
onClick = null
|
||||
)
|
||||
|
||||
Text(
|
||||
text = item.name,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Start,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchItem(
|
||||
text: String,
|
||||
subText: String = "",
|
||||
onChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val checkedState = remember { mutableStateOf(true) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(70.dp)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subText,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.padding(top = 5.dp, end = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checkedState.value,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = AppTheme.colors.primary,
|
||||
uncheckedThumbColor = AppTheme.colors.switchThumbUnchecked,
|
||||
checkedTrackColor = AppTheme.colors.primary.copy(alpha = 0.35f),
|
||||
uncheckedTrackColor = AppTheme.colors.switchTrackUnchecked,
|
||||
),
|
||||
onCheckedChange = {
|
||||
checkedState.value = it
|
||||
onChanged(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingItem(
|
||||
text: String,
|
||||
subText: String = "",
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subText,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 10.dp)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_enter),
|
||||
tint = AppTheme.colors.textThird,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.compose.wanandroid.ui.page.profile.setting
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.logic.darkMode
|
||||
import com.compose.wanandroid.ui.common.ProgressViewEvent
|
||||
import com.compose.wanandroid.ui.common.SnackViewEvent
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import com.compose.wanandroid.ui.theme.Theme
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingViewModel : ViewModel() {
|
||||
|
||||
var viewState by mutableStateOf(SettingViewState())
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
init {
|
||||
UserStore.isLogin.onEach {
|
||||
viewState = viewState.copy(isLogin = it)
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun dispatch(action: SettingViewAction) {
|
||||
when (action) {
|
||||
is SettingViewAction.Logout -> logout()
|
||||
is SettingViewAction.Dark -> setDark(action.mode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logout() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_viewEvents.send(ProgressViewEvent(true, "退出中..."))
|
||||
val res = ApiService.api.logout()
|
||||
if (res.isSuccess) {
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
viewState = viewState.copy(isLogin = false)
|
||||
UserStore.logout()
|
||||
} else {
|
||||
throw Exception(res.errorMsg)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_viewEvents.send(SnackViewEvent(e.message ?: "退出登录失败,请稍后重试~"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDark(mode: String) {
|
||||
viewState = viewState.copy(dark = mode)
|
||||
viewModelScope.launch {
|
||||
darkMode.update(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SettingViewState(
|
||||
var isLogin: Boolean = false,
|
||||
var dark: String = "",
|
||||
val darkModes: List<Theme> = listOf(Theme.FollowSystem, Theme.Light, Theme.Dark)
|
||||
)
|
||||
|
||||
sealed class SettingViewAction {
|
||||
object Logout : SettingViewAction()
|
||||
data class Dark(val mode: String) : SettingViewAction()
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.compose.wanandroid.ui.page.profile.share
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun NavGraphBuilder.mineShareGraph(controller: NavController) {
|
||||
composable(route = Page.MineShare.route) {
|
||||
MineSharePage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MineSharePage(
|
||||
controller: NavController,
|
||||
viewModel: MineShareViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
title = "我的分享",
|
||||
onBack = { controller.back() },
|
||||
) {
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(data = value,
|
||||
onCollectClick = {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.compose.wanandroid.ui.page.profile.share
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.*
|
||||
import com.compose.wanandroid.data.remote.*
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.logic.defaultPage
|
||||
import com.compose.wanandroid.logic.pageFlow
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MineShareViewModel : ViewModel() {
|
||||
|
||||
val pager: Flow<PagingData<Article>> by lazy {
|
||||
loadPage(initialKey = 1) {
|
||||
// 从page=1开始
|
||||
ApiService.api.mineShareArticles(it)
|
||||
}
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollect(article)
|
||||
if (result.isFailure) {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPage(
|
||||
config: PagingConfig = defaultPage,
|
||||
initialKey: Int = 0,
|
||||
block: suspend (page: Int) -> Response<UserShareResponse>
|
||||
): Flow<PagingData<Article>> {
|
||||
return pageFlow(config, initialKey) {
|
||||
val page = it.key ?: 0
|
||||
val response = try {
|
||||
HttpResult.Success(block(page))
|
||||
} catch (e: Exception) {
|
||||
HttpResult.Error(e)
|
||||
}
|
||||
|
||||
when (response) {
|
||||
is HttpResult.Success -> {
|
||||
val data = response.result.data?.shareArticles
|
||||
if (data != null) {
|
||||
val hasNext = data.datas.size >= it.loadSize || !data.over
|
||||
PagingSource.LoadResult.Page(
|
||||
data = data.datas,
|
||||
prevKey = if (page - 1 > 0) page - 1 else null,
|
||||
nextKey = if (hasNext) page + 1 else null
|
||||
)
|
||||
} else {
|
||||
PagingSource.LoadResult.Invalid()
|
||||
}
|
||||
}
|
||||
is HttpResult.Error -> PagingSource.LoadResult.Error(response.e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
package com.compose.wanandroid.ui.page.search
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.back
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.noRippleClickable
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.*
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun NavGraphBuilder.searchGraph(controller: NavController) {
|
||||
composable(Page.Search.route) {
|
||||
SearchPage(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchPage(
|
||||
controller: NavController,
|
||||
viewModel: SearchViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val viewState = viewModel.viewState
|
||||
val pagingItems = viewState.pagingData.collectAsLazyPagingItems()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect {
|
||||
when (it) {
|
||||
is SnackViewEvent -> {
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(it.message)
|
||||
}
|
||||
}
|
||||
is SearchViewEvent.Searching -> pagingItems.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var keyword by remember { mutableStateOf("") }
|
||||
|
||||
AppScaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
onBack = {
|
||||
keyboardController?.hide()
|
||||
controller.back()
|
||||
},
|
||||
title = {
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = keyword,
|
||||
onValueChanged = {
|
||||
keyword = it
|
||||
},
|
||||
hint = "支持多个关键词,用空格隔开"
|
||||
)
|
||||
},
|
||||
trailingActions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
keyboardController?.hide()
|
||||
viewModel.dispatch(SearchViewAction.Search(keyword))
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
) {
|
||||
if (pagingItems.itemCount > 0) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
listState = viewState.listState,
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(data = value,
|
||||
onCollectClick = {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedVisibility(
|
||||
visible = viewState.hotKeys.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
HotKeyItem(title = "热门搜索", list = viewState.hotKeys) {
|
||||
keyword = it
|
||||
keyboardController?.hide()
|
||||
viewModel.dispatch(SearchViewAction.Search(keyword))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = viewState.historyKeys.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
HotKeyItem(
|
||||
title = "历史搜索",
|
||||
subTitle = "清除",
|
||||
onSubTitleClick = {
|
||||
viewModel.dispatch(SearchViewAction.ClearHistory)
|
||||
},
|
||||
list = viewState.historyKeys
|
||||
) {
|
||||
keyword = it
|
||||
keyboardController?.hide()
|
||||
viewModel.dispatch(SearchViewAction.Search(keyword))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
text: String,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hint: String = ""
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.background(Color.Black.copy(0.08f), RoundedCornerShape(20.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onValueChanged,
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
.padding(start = 10.dp, end = 10.dp)
|
||||
.wrapContentHeight()
|
||||
.weight(1f),
|
||||
textStyle = TextStyle(
|
||||
color = AppTheme.colors.onPrimary,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
cursorBrush = SolidColor(AppTheme.colors.onPrimary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(all = 0.dp),
|
||||
text = hint,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 14.sp,
|
||||
color = AppTheme.colors.onPrimary.copy(0.6f),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = text.trim().isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_remove),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(AppTheme.colors.onPrimary),
|
||||
contentScale = ContentScale.Inside,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(20.dp)
|
||||
.noRippleClickable {
|
||||
onValueChanged("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HotKeyItem(
|
||||
title: String,
|
||||
list: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
subTitle: String = "",
|
||||
onSubTitleClick: () -> Unit = {},
|
||||
onSelect: (key: String) -> Unit = { }
|
||||
) {
|
||||
if (list.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.weight(1f),
|
||||
fontSize = 15.sp,
|
||||
color = AppTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
|
||||
if (subTitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subTitle,
|
||||
fontSize = 12.sp,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(end = 10.dp)
|
||||
.clickable {
|
||||
onSubTitleClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 8.dp, start = 8.dp, end = 16.dp)
|
||||
) {
|
||||
list.forEach { item ->
|
||||
Box(modifier = modifier
|
||||
.padding(start = 10.dp, bottom = 10.dp)
|
||||
.height(28.dp)
|
||||
.background(color = AppTheme.colors.secondaryBackground, shape = RoundedCornerShape(14.dp))
|
||||
.clip(shape = RoundedCornerShape(14.dp))
|
||||
.clickable {
|
||||
onSelect(item)
|
||||
}
|
||||
.padding(
|
||||
horizontal = 10.dp,
|
||||
vertical = 3.dp
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.compose.wanandroid.ui.page.search
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.*
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.logic.searchHistory
|
||||
import com.compose.wanandroid.ui.common.RefreshViewAction
|
||||
import com.compose.wanandroid.ui.common.SnackViewEvent
|
||||
import com.compose.wanandroid.ui.common.ViewAction
|
||||
import com.compose.wanandroid.ui.common.ViewEvent
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private var keyword: String = ""
|
||||
private val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading {
|
||||
if (keyword.isNotEmpty()) {
|
||||
ApiService.api.search(it, keyword)
|
||||
} else {
|
||||
throw Exception("Search key is empty.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewState by mutableStateOf(SearchViewState(pagingData = pager))
|
||||
private set
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
init {
|
||||
dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is RefreshViewAction.FetchData -> fetchData()
|
||||
is SearchViewAction.Search -> search(action.key)
|
||||
is SearchViewAction.ClearHistory -> clearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData() {
|
||||
val hotKeys = ApiService.api.hotKey().map { it.data ?: emptyList() }
|
||||
val historyKeys = searchHistory.map { it.split(",") }
|
||||
viewModelScope.launch {
|
||||
hotKeys.zip(historyKeys) { hot, history ->
|
||||
viewState = viewState.copy(hotKeys = hot.map { it.name }, historyKeys = history.filter { it.isNotEmpty() })
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(key: String) {
|
||||
viewModelScope.launch {
|
||||
val keyword = key.trim()
|
||||
if (keyword.isEmpty()) {
|
||||
_viewEvents.send(SnackViewEvent("请输入有效关键字~"))
|
||||
} else {
|
||||
this@SearchViewModel.keyword = keyword
|
||||
viewState = viewState.copy(historyKeys = (viewState.historyKeys + keyword).distinct())
|
||||
_viewEvents.send(SearchViewEvent.Searching)
|
||||
|
||||
val history = viewState.historyKeys.joinToString(",")
|
||||
searchHistory.update(history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearHistory() {
|
||||
viewModelScope.launch {
|
||||
viewState = viewState.copy(historyKeys = emptyList())
|
||||
searchHistory.update("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchViewState(
|
||||
val hotKeys: List<String> = emptyList(),
|
||||
val historyKeys: List<String> = emptyList(),
|
||||
val pagingData: Flow<PagingData<Article>>,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
)
|
||||
|
||||
sealed class SearchViewAction : ViewAction {
|
||||
object ClearHistory : SearchViewAction()
|
||||
data class Search(val key: String) : SearchViewAction()
|
||||
}
|
||||
|
||||
sealed class SearchViewEvent : ViewEvent {
|
||||
object Searching : SearchViewEvent()
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.compose.wanandroid.ui.page.square
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun QuestionPage(
|
||||
controller: NavController,
|
||||
scaffoldState: ScaffoldState = rememberScaffoldState(),
|
||||
viewModel: QuestionViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isLogin = UserStore.isLogin.collectAsState(initial = false)
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(data = value,
|
||||
onCollectClick = {
|
||||
if (isLogin.value) {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.compose.wanandroid.ui.page.square
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuestionViewModel : ViewModel() {
|
||||
|
||||
val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading { ApiService.api.wendaList(it) }
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollect(article)
|
||||
if (result.isFailure) {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.compose.wanandroid.ui.page.square
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.navigation.NavController
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.AppTitleBar
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.TabText
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun SquareHostPage(
|
||||
controller: NavController,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val titles = listOf(
|
||||
TabText(0, "广场"),
|
||||
TabText(1, "问答"),
|
||||
)
|
||||
|
||||
AppScaffold(
|
||||
modifier = Modifier.padding(padding),
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
title = {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
backgroundColor = Color.Transparent,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier
|
||||
.pagerTabIndicatorOffset(pagerState, tabPositions)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
height = 3.dp,
|
||||
color = AppTheme.colors.onPrimary
|
||||
)
|
||||
},
|
||||
divider = {},
|
||||
modifier = Modifier
|
||||
.width(130.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.fillMaxHeight(),
|
||||
text = {
|
||||
Text(
|
||||
text = title.text,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
fontWeight = if (index == pagerState.currentPage) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
selectedContentColor = AppTheme.colors.onPrimary,
|
||||
unselectedContentColor = AppTheme.colors.onPrimary.copy(0.7f),
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingActions = {}
|
||||
)
|
||||
}
|
||||
) {
|
||||
HorizontalPager(
|
||||
count = titles.size, state = pagerState,
|
||||
modifier = Modifier.background(AppTheme.colors.background)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> SquarePage(controller, scaffoldState)
|
||||
1 -> QuestionPage(controller, scaffoldState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.compose.wanandroid.ui.page.square
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemsIndexed
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.logic.UserStore
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.logic.toast
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.RefreshList
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.widget.ProgressDialog
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SquarePage(
|
||||
controller: NavController,
|
||||
scaffoldState: ScaffoldState = rememberScaffoldState(),
|
||||
viewModel: SquareViewModel = viewModel()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isLogin = UserStore.isLogin.collectAsState(initial = false)
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.viewEvents.collect { event ->
|
||||
when (event) {
|
||||
is SnackViewEvent -> {
|
||||
showDialog = false
|
||||
scope.launch {
|
||||
scaffoldState.showSnackbar(message = event.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProgressViewEvent -> showDialog = event.show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ProgressDialog("加载中...") {
|
||||
showDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val pagingItems = viewModel.pager.collectAsLazyPagingItems()
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewModel.getPageState(pagingItems),
|
||||
onRetry = {
|
||||
pagingItems.retry()
|
||||
}
|
||||
) {
|
||||
RefreshList(
|
||||
lazyPagingItems = pagingItems,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
itemContent = {
|
||||
itemsIndexed(pagingItems) { _: Int, value: Article? ->
|
||||
if (value != null) {
|
||||
ArticleItem(data = value,
|
||||
onCollectClick = {
|
||||
if (isLogin.value) {
|
||||
viewModel.dispatch(CollectViewAction.Collect(it))
|
||||
} else {
|
||||
controller.navigate(Page.Login.route)
|
||||
}
|
||||
},
|
||||
onUserClick = { id ->
|
||||
"用户:$id".toast(context)
|
||||
},
|
||||
onSelected = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.compose.wanandroid.ui.page.square
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.data.model.Article
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.data.repository.CollectRepository
|
||||
import com.compose.wanandroid.logic.pageLoading
|
||||
import com.compose.wanandroid.ui.common.*
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SquareViewModel : ViewModel() {
|
||||
|
||||
val pager: Flow<PagingData<Article>> by lazy {
|
||||
pageLoading { ApiService.api.squareArticles(it) }
|
||||
}
|
||||
|
||||
private val _viewEvents = Channel<ViewEvent>(Channel.BUFFERED)
|
||||
val viewEvents = _viewEvents.receiveAsFlow()
|
||||
|
||||
private val collectRepo by lazy { CollectRepository() }
|
||||
|
||||
fun getPageState(pagingItems: LazyPagingItems<*>): PageState {
|
||||
val isEmpty = pagingItems.itemCount <= 0
|
||||
return when (pagingItems.loadState.refresh) {
|
||||
is LoadState.Loading -> PageState.Success()
|
||||
is LoadState.NotLoading -> PageState.Success(isEmpty)
|
||||
is LoadState.Error -> if (isEmpty) PageState.Error() else PageState.Success()
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: ViewAction) {
|
||||
when (action) {
|
||||
is CollectViewAction.Collect -> collect(action.article)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collect(article: Article) {
|
||||
viewModelScope.launch {
|
||||
_viewEvents.send(ProgressViewEvent(true))
|
||||
val result = if (!article.collect) collectRepo.collect(article) else collectRepo.unCollect(article)
|
||||
if (result.isFailure) {
|
||||
_viewEvents.send(SnackViewEvent("操作失败,请稍后重试~"))
|
||||
}
|
||||
_viewEvents.send(ProgressViewEvent(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
@file:OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
package com.compose.wanandroid.ui.page.struct
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.data.model.Navigate
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.StickyTitle
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
|
||||
@Composable
|
||||
fun NavigatePage(
|
||||
controller: NavController,
|
||||
viewModel: NavigateViewModel = viewModel()
|
||||
) {
|
||||
val viewState = viewModel.viewState
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState.pageState,
|
||||
onRetry = {
|
||||
viewModel.dispatch(NavigateViewAction.FetchData)
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppTheme.colors.background),
|
||||
state = viewState.listState,
|
||||
contentPadding = PaddingValues(vertical = 10.dp)
|
||||
) {
|
||||
viewState.data.forEachIndexed { position, navigate ->
|
||||
stickyHeader { StickyTitle(navigate.name) }
|
||||
item {
|
||||
NavigateItem(navigate, onSelect = {
|
||||
controller.navigate(Page.Web.route, it)
|
||||
})
|
||||
if (position <= viewState.size - 1) {
|
||||
Divider(
|
||||
startIndent = 10.dp,
|
||||
color = AppTheme.colors.secondaryBackground,
|
||||
thickness = 0.8f.dp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigateItem(
|
||||
navigate: Navigate,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: (Link) -> Unit = { },
|
||||
) {
|
||||
if (navigate.articles.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 8.dp, start = 8.dp, end = 16.dp)
|
||||
) {
|
||||
navigate.articles.forEach { item ->
|
||||
Text(
|
||||
text = item.title,
|
||||
modifier = modifier
|
||||
.padding(start = 8.dp, bottom = 8.dp)
|
||||
.height(25.dp)
|
||||
.background(color = AppTheme.colors.secondaryBackground, shape = RoundedCornerShape(25.dp / 2))
|
||||
.clip(shape = RoundedCornerShape(25.dp / 2))
|
||||
.clickable {
|
||||
onSelect(Link(item.link, item.title))
|
||||
}
|
||||
.padding(
|
||||
horizontal = 10.dp,
|
||||
vertical = 3.dp
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.compose.wanandroid.ui.page.struct
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.model.Navigate
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NavigateViewModel : ViewModel() {
|
||||
|
||||
var viewState by mutableStateOf(NavigateViewState())
|
||||
private set
|
||||
|
||||
init {
|
||||
dispatch(NavigateViewAction.FetchData)
|
||||
}
|
||||
|
||||
fun dispatch(action: NavigateViewAction) {
|
||||
when (action) {
|
||||
is NavigateViewAction.FetchData -> fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData() {
|
||||
viewModelScope.launch {
|
||||
flow {
|
||||
emit(ApiService.api.navigationList())
|
||||
}.map {
|
||||
it.data ?: emptyList()
|
||||
}.onStart {
|
||||
viewState = viewState.copy(pageState = PageState.Loading)
|
||||
}.onEach {
|
||||
viewState = viewState.copy(
|
||||
data = it,
|
||||
pageState = PageState.Success(it.isEmpty())
|
||||
)
|
||||
}.catch {
|
||||
viewState = viewState.copy(pageState = PageState.Error(it))
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NavigateViewState(
|
||||
val data: List<Navigate> = emptyList(),
|
||||
val pageState: PageState = PageState.Loading,
|
||||
val listState: LazyListState = LazyListState()
|
||||
) {
|
||||
val size = data.size
|
||||
}
|
||||
|
||||
sealed class NavigateViewAction {
|
||||
object FetchData : NavigateViewAction()
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.compose.wanandroid.ui.page.struct
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.navigation.NavController
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.AppTitleBar
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.TabText
|
||||
import com.google.accompanist.pager.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun StructHostPage(
|
||||
controller: NavController,
|
||||
padding: PaddingValues = PaddingValues(),
|
||||
index: Int = 0
|
||||
) {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = index,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val titles = listOf(
|
||||
TabText(0, "体系"),
|
||||
TabText(1, "导航"),
|
||||
)
|
||||
|
||||
AppScaffold(
|
||||
modifier = Modifier.padding(padding),
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
title = {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
backgroundColor = Color.Transparent,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier
|
||||
.pagerTabIndicatorOffset(pagerState, tabPositions)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
height = 3.dp,
|
||||
color = AppTheme.colors.onPrimary
|
||||
)
|
||||
},
|
||||
divider = {},
|
||||
modifier = Modifier
|
||||
.width(130.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.fillMaxHeight(),
|
||||
text = {
|
||||
Text(
|
||||
text = title.text,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
fontWeight = if (index == pagerState.currentPage) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
selectedContentColor = AppTheme.colors.onPrimary,
|
||||
unselectedContentColor = AppTheme.colors.onPrimary.copy(0.7f),
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingActions = {}
|
||||
)
|
||||
}
|
||||
) {
|
||||
HorizontalPager(
|
||||
count = titles.size, state = pagerState,
|
||||
modifier = Modifier.background(AppTheme.colors.background)
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> StructPage(controller)
|
||||
1 -> NavigatePage(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
@file:OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
package com.compose.wanandroid.ui.page.struct
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.wanandroid.ui.widget.StatePage
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.compose.wanandroid.data.model.Struct
|
||||
import com.compose.wanandroid.logic.navigate
|
||||
import com.compose.wanandroid.ui.common.RefreshViewAction
|
||||
import com.compose.wanandroid.ui.common.StickyTitle
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.widget.html.asHTML
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
|
||||
@Composable
|
||||
fun StructPage(
|
||||
controller: NavController,
|
||||
viewModel: StructViewModel = viewModel()
|
||||
) {
|
||||
val viewState = viewModel.viewState
|
||||
StatePage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = viewState.pageState,
|
||||
onRetry = {
|
||||
viewModel.dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.background(AppTheme.colors.background),
|
||||
state = viewState.listState,
|
||||
contentPadding = PaddingValues(vertical = 10.dp)
|
||||
) {
|
||||
viewState.data.forEachIndexed { position, struct ->
|
||||
stickyHeader { StickyTitle(title = struct.name) }
|
||||
item {
|
||||
StructItem(struct, onSelect = { struct, index ->
|
||||
controller.navigate(Page.Category.route, struct, index)
|
||||
})
|
||||
|
||||
if (position < viewState.size - 1) {
|
||||
Divider(
|
||||
startIndent = 10.dp,
|
||||
color = AppTheme.colors.secondaryBackground,
|
||||
thickness = 0.8f.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StructItem(
|
||||
struct: Struct,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: (parent: Struct, index: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
if (struct.children.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(struct, 0)
|
||||
}
|
||||
.padding(top = 16.dp, bottom = 8.dp, start = 8.dp, end = 16.dp)
|
||||
) {
|
||||
struct.children.forEachIndexed { index, item ->
|
||||
Box(modifier = modifier
|
||||
.padding(start = 10.dp, bottom = 10.dp)
|
||||
.height(28.dp)
|
||||
.background(color = AppTheme.colors.secondaryBackground, shape = RoundedCornerShape(14.dp))
|
||||
.clip(shape = RoundedCornerShape(14.dp))
|
||||
.clickable {
|
||||
onSelect(struct, index)
|
||||
}
|
||||
.padding(
|
||||
horizontal = 10.dp,
|
||||
vertical = 3.dp
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.name.asHTML(fontSize = 13.sp),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.textSecondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StructItemPreview() {
|
||||
AppTheme {
|
||||
Column {
|
||||
StickyTitle(title = "分类标题")
|
||||
StructItem(struct = Struct(name = "分类标题").apply {
|
||||
children = mutableListOf(
|
||||
Struct(name = "分类1"),
|
||||
Struct(name = "分类2"),
|
||||
Struct(name = "分类3"),
|
||||
Struct(name = "分类4"),
|
||||
Struct(name = "分类5"),
|
||||
Struct(name = "分类6"),
|
||||
Struct(name = "分类7"),
|
||||
Struct(name = "分类8"),
|
||||
Struct(name = "分类9"),
|
||||
Struct(name = "分类10")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.compose.wanandroid.ui.page.struct
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.compose.wanandroid.data.model.Struct
|
||||
import com.compose.wanandroid.data.remote.ApiService
|
||||
import com.compose.wanandroid.logic.zips
|
||||
import com.compose.wanandroid.ui.common.RefreshViewAction
|
||||
import com.compose.wanandroid.ui.widget.PageState
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StructViewModel : ViewModel() {
|
||||
|
||||
var viewState by mutableStateOf(StructViewState())
|
||||
private set
|
||||
|
||||
init {
|
||||
dispatch(RefreshViewAction.FetchData)
|
||||
}
|
||||
|
||||
fun dispatch(action: RefreshViewAction) {
|
||||
when (action) {
|
||||
is RefreshViewAction.FetchData -> fetchData()
|
||||
is RefreshViewAction.Refresh -> fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchData() {
|
||||
val accounts = ApiService.api.wxAccountList().map { it.data ?: emptyList() }
|
||||
val projects = ApiService.api.projectList().map { it.data ?: emptyList() }
|
||||
val structs = ApiService.api.structList().map { it.data ?: emptyList() }
|
||||
viewModelScope.launch {
|
||||
structs.zips(accounts, projects) { structs, accounts, projects ->
|
||||
val list = mutableListOf<Struct>()
|
||||
if (accounts.isNotEmpty()) {
|
||||
// 公众号当做一个特殊的类型
|
||||
list.add(Struct(name = "公众号", children = accounts.toMutableList(), type = Struct.TYPE_ACCOUNT))
|
||||
}
|
||||
if (projects.isNotEmpty()) {
|
||||
// 项目当做一个特殊的类型
|
||||
list.add(Struct(name = "项目", children = projects.toMutableList(), type = Struct.TYPE_PROJECT))
|
||||
}
|
||||
list.addAll(structs)
|
||||
viewState = viewState.copy(
|
||||
data = list,
|
||||
pageState = PageState.Success(list.isEmpty())
|
||||
)
|
||||
}.onStart {
|
||||
viewState = viewState.copy(pageState = PageState.Loading)
|
||||
}.catch {
|
||||
viewState = viewState.copy(pageState = PageState.Error(it))
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StructViewState(
|
||||
val data: List<Struct> = emptyList(),
|
||||
val pageState: PageState = PageState.Loading,
|
||||
val listState: LazyListState = LazyListState()
|
||||
) {
|
||||
val size = data.size
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package com.compose.wanandroid.ui.page.web
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.webkit.WebSettingsCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.compose.wanandroid.data.repository.HistoryRepository
|
||||
import com.compose.wanandroid.data.model.Link
|
||||
import com.compose.wanandroid.logic.Logger
|
||||
import com.compose.wanandroid.logic.fromJson
|
||||
import com.compose.wanandroid.ui.common.AppScaffold
|
||||
import com.compose.wanandroid.ui.common.AppTitleBar
|
||||
import com.compose.wanandroid.ui.page.main.Page
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.progress
|
||||
import com.compose.wanandroid.ui.widget.StatePageEmpty
|
||||
import com.google.accompanist.web.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.inject
|
||||
|
||||
fun NavGraphBuilder.webGraph(onBack: () -> Unit) {
|
||||
composable(
|
||||
route = Page.Web.route + "/{link}",
|
||||
arguments = listOf(navArgument("link") { type = NavType.StringType })
|
||||
) {
|
||||
val link = it.arguments?.getString("link")?.fromJson<Link>()
|
||||
if (link != null) {
|
||||
WebPage(link, onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WebPage(
|
||||
link: Link,
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val state = rememberWebViewState(url = link.url)
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val isDark = !AppTheme.colors.isLight
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val historyRepo: HistoryRepository by inject()
|
||||
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTitleBar(
|
||||
text = state.pageTitle ?: link.title,
|
||||
onBack = {
|
||||
if (navigator.canGoBack) {
|
||||
navigator.navigateBack()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
trailingActions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(link.url))
|
||||
},
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.OpenInBrowser,
|
||||
tint = AppTheme.colors.onPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (link.url.isNotEmpty()) {
|
||||
val loadingState = state.loadingState
|
||||
Logger.i("Web", link.url)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
WebView(
|
||||
state = state,
|
||||
onCreated = { webView ->
|
||||
webView.isVerticalScrollBarEnabled = false
|
||||
webView.settings.run {
|
||||
javaScriptEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
allowFileAccess = true
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
loadsImagesAutomatically = true
|
||||
defaultTextEncodingName = "UTF-8"
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // fix图片无法显示(https与http混合资源处理)
|
||||
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||
if (isDark) {
|
||||
WebSettingsCompat.setForceDark(this, WebSettingsCompat.FORCE_DARK_ON)
|
||||
} else {
|
||||
WebSettingsCompat.setForceDark(this, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigator = navigator,
|
||||
client = object : AccompanistWebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val url = request?.url?.scheme ?: ""
|
||||
return if (url.startsWith("http", ignoreCase = true)) {
|
||||
super.shouldOverrideUrlLoading(view, request)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
},
|
||||
chromeClient = object : AccompanistWebChromeClient() {
|
||||
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||
super.onReceivedTitle(view, title)
|
||||
if (link.addHistory) {
|
||||
scope.launch {
|
||||
if (view?.url == link.url) {
|
||||
historyRepo.add(link.url, title ?: link.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (loadingState is LoadingState.Loading) {
|
||||
LinearProgressIndicator(
|
||||
progress = loadingState.progress,
|
||||
color = AppTheme.colors.progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StatePageEmpty(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
textColor = AppTheme.colors.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.compose.wanandroid.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
class AppColors(
|
||||
primary: Color,
|
||||
secondary: Color,
|
||||
textPrimary: Color,
|
||||
textSecondary: Color,
|
||||
background: Color,
|
||||
secondaryBackground: Color,
|
||||
highlight: Color,
|
||||
error: Color,
|
||||
onPrimary: Color,
|
||||
onBackground: Color,
|
||||
isLight: Boolean
|
||||
) {
|
||||
|
||||
var primary by mutableStateOf(primary)
|
||||
private set
|
||||
|
||||
var secondary by mutableStateOf(secondary)
|
||||
private set
|
||||
|
||||
var textPrimary by mutableStateOf(textPrimary)
|
||||
private set
|
||||
|
||||
var textSecondary by mutableStateOf(textSecondary)
|
||||
private set
|
||||
|
||||
var background by mutableStateOf(background)
|
||||
private set
|
||||
|
||||
var secondaryBackground by mutableStateOf(secondaryBackground)
|
||||
private set
|
||||
|
||||
var highlight by mutableStateOf(highlight)
|
||||
private set
|
||||
|
||||
var error by mutableStateOf(error)
|
||||
private set
|
||||
|
||||
var onPrimary by mutableStateOf(onPrimary)
|
||||
private set
|
||||
|
||||
var onBackground by mutableStateOf(onBackground)
|
||||
private set
|
||||
|
||||
var isLight by mutableStateOf(isLight)
|
||||
private set
|
||||
|
||||
fun copy(
|
||||
primary: Color = this.primary,
|
||||
secondary: Color = this.secondary,
|
||||
textPrimary: Color = this.textPrimary,
|
||||
textSecondary: Color = this.textSecondary,
|
||||
background: Color = this.background,
|
||||
secondaryBackground: Color = this.secondaryBackground,
|
||||
highlight: Color = this.highlight,
|
||||
error: Color = this.error,
|
||||
onPrimary: Color = this.onPrimary,
|
||||
onBackground: Color = this.onBackground,
|
||||
isLight: Boolean = this.isLight
|
||||
): AppColors = AppColors(
|
||||
primary,
|
||||
secondary,
|
||||
textPrimary,
|
||||
textSecondary,
|
||||
background,
|
||||
secondaryBackground,
|
||||
highlight,
|
||||
error,
|
||||
onPrimary,
|
||||
onBackground,
|
||||
isLight
|
||||
)
|
||||
|
||||
fun updateColorsFrom(other: AppColors) {
|
||||
primary = other.primary
|
||||
secondary = other.secondary
|
||||
textPrimary = other.textPrimary
|
||||
textSecondary = other.textSecondary
|
||||
background = other.background
|
||||
secondaryBackground = other.secondaryBackground
|
||||
highlight = other.highlight
|
||||
error = other.error
|
||||
onPrimary = other.onPrimary
|
||||
onBackground = other.onBackground
|
||||
isLight = other.isLight
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalColors = staticCompositionLocalOf { LightColors }
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.compose.wanandroid.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val LocalShapes = staticCompositionLocalOf {
|
||||
Shapes(
|
||||
small = RoundedCornerShape(size = 5.dp),
|
||||
medium = RoundedCornerShape(size = 10.dp),
|
||||
large = RoundedCornerShape(size = 20.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.compose.wanandroid.ui.theme
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ripple.LocalRippleTheme
|
||||
import androidx.compose.material.ripple.RippleTheme
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
|
||||
// material dark
|
||||
//val primary: Color = Color(0xFFBB86FC)
|
||||
//val primaryVariant: Color = Color(0xFF3700B3)
|
||||
//val secondary: Color = Color(0xFF03DAC6)
|
||||
//val secondaryVariant: Color = secondary
|
||||
//val background: Color = Color(0xFF121212)
|
||||
//val surface: Color = Color(0xFF121212)
|
||||
//val error: Color = Color(0xFFCF6679)
|
||||
//val onPrimary: Color = Color.Black
|
||||
//val onSecondary: Color = Color.Black
|
||||
//val onBackground: Color = Color.White
|
||||
//val onSurface: Color = Color.White
|
||||
//val onError: Color = Color.Black
|
||||
|
||||
// material light
|
||||
//val primary: Color = Color(0xFF6200EE)
|
||||
//val primaryVariant: Color = Color(0xFF3700B3)
|
||||
//val secondary: Color = Color(0xFF03DAC6)
|
||||
//val secondaryVariant: Color = Color(0xFF018786)
|
||||
//val background: Color = Color.White
|
||||
//val surface: Color = Color.White
|
||||
//val error: Color = Color(0xFFB00020)
|
||||
//val onPrimary: Color = Color.White
|
||||
//val onSecondary: Color = Color.Black
|
||||
//val onBackground: Color = Color.Black
|
||||
//val onSurface: Color = Color.Black
|
||||
//val onError: Color = Color.White
|
||||
|
||||
val LightColors = AppColors(
|
||||
primary = Color(0xff3cdc86),
|
||||
secondary = Color(0xff67db9d),
|
||||
textPrimary = Color(0xff333333),
|
||||
textSecondary = Color(0xff666666),
|
||||
background = Color.White,
|
||||
secondaryBackground = Color(0xfff5f5f5),
|
||||
highlight = Color(0xff3cdc86),
|
||||
error = Color(0xffb00020),
|
||||
onPrimary = Color.White,
|
||||
onBackground = Color(0xff333333),
|
||||
isLight = true
|
||||
)
|
||||
|
||||
val DarkColors = AppColors(
|
||||
primary = Color(0xff3cdc86),
|
||||
secondary = Color(0xff67db9d),
|
||||
textPrimary = Color(0xffcfcfd1),
|
||||
textSecondary = Color(0x88ffffff),
|
||||
background = Color(0xff121212),
|
||||
secondaryBackground = Color(0xff222226),
|
||||
highlight = Color(0xff3cdc86),
|
||||
error = Color(0xffcf6679),
|
||||
onPrimary = Color.White,
|
||||
onBackground = Color(0xffcfcfd1),
|
||||
isLight = false
|
||||
)
|
||||
|
||||
val AppColors.collectColor: Color
|
||||
get() = if (isLight) Color(0xfff78c65) else Color(0xfff86734)
|
||||
|
||||
val AppColors.textThird: Color
|
||||
get() = if (isLight) Color(0xff999999) else Color(0x54ffffff)
|
||||
|
||||
val AppColors.tabBackground: Color
|
||||
get() = if (isLight) Color.White else Color(0xff181818)
|
||||
|
||||
val AppColors.progress: Color
|
||||
get() = if (isLight) Color(0xff3cdc86) else Color(0xff67db9d)
|
||||
|
||||
val AppColors.radioSelected: Color
|
||||
get() = if (isLight) Color(0xff3cdc86) else Color(0xff67db9d)
|
||||
|
||||
val AppColors.radio: Color
|
||||
get() = if (isLight) Color(0xff666666) else Color(0x88ffffff)
|
||||
|
||||
val AppColors.switchThumbUnchecked: Color
|
||||
get() = if (isLight) Color(0xffececec) else Color(0xff3B3B3B)
|
||||
|
||||
val AppColors.switchTrackUnchecked: Color
|
||||
get() = if (isLight) Color(0xffb2b2b2) else Color(0xff242424)
|
||||
|
||||
|
||||
object ThemeState {
|
||||
var theme: MutableState<Theme?> = mutableStateOf(null)
|
||||
|
||||
fun read(name: String) {
|
||||
theme.value = when (name) {
|
||||
Theme.Light.name -> Theme.Light
|
||||
Theme.Dark.name -> Theme.Dark
|
||||
else -> Theme.FollowSystem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Theme(val name: String) {
|
||||
object FollowSystem : Theme("跟随系统")
|
||||
object Light : Theme("关闭")
|
||||
object Dark : Theme("打开")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
typography: AppTypography = AppTheme.typography,
|
||||
shapes: Shapes = AppTheme.shapes,
|
||||
theme: Theme = ThemeState.theme.value ?: Theme.Dark,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = when (theme) {
|
||||
is Theme.Light -> LightColors
|
||||
is Theme.Dark -> DarkColors
|
||||
is Theme.FollowSystem -> {
|
||||
if (isSystemInDarkTheme()) {
|
||||
DarkColors
|
||||
} else {
|
||||
LightColors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// creating a new object for colors to not mutate the initial colors set when updating the values
|
||||
val rememberedColors = remember { colors.copy() }.apply { updateColorsFrom(colors) }
|
||||
val rippleIndication = rememberRipple()
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalColors provides rememberedColors,
|
||||
LocalShapes provides shapes,
|
||||
LocalTypography provides typography,
|
||||
LocalIndication provides rippleIndication,
|
||||
LocalRippleTheme provides MaterialRippleTheme,
|
||||
) {
|
||||
ProvideTextStyle(value = typography.body1, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RectangleShape,
|
||||
color: Color = AppTheme.colors.background,
|
||||
contentColor: Color = defaultContentColorFor(color),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
shape = shape,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
object AppTheme {
|
||||
|
||||
val colors: AppColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalColors.current
|
||||
|
||||
val typography: AppTypography
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalTypography.current
|
||||
|
||||
// We use the default material shapes
|
||||
val shapes: Shapes
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
get() = LocalShapes.current
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private object MaterialRippleTheme : RippleTheme {
|
||||
@Composable
|
||||
override fun defaultColor() = RippleTheme.defaultRippleColor(
|
||||
contentColor = LocalContentColor.current,
|
||||
lightTheme = AppTheme.colors.isLight
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun rippleAlpha() = RippleTheme.defaultRippleAlpha(
|
||||
contentColor = LocalContentColor.current,
|
||||
lightTheme = AppTheme.colors.isLight
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun defaultContentColorFor(backgroundColor: Color): Color =
|
||||
AppTheme.colors.contentColorFor(backgroundColor).takeOrElse { LocalContentColor.current }
|
||||
|
||||
private fun AppColors.contentColorFor(backgroundColor: Color): Color {
|
||||
return when (backgroundColor) {
|
||||
primary -> onPrimary
|
||||
secondary -> onPrimary
|
||||
background -> onBackground
|
||||
secondaryBackground -> onBackground
|
||||
else -> Color.Unspecified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.compose.wanandroid.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Immutable
|
||||
data class AppTypography(
|
||||
val title1: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 24.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
val title2: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
val body1: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
val body1Bold: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
val body2: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
val body2Bold: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
val caption: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
val captionBold: TextStyle = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 0.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
)
|
||||
|
||||
internal val LocalTypography = staticCompositionLocalOf { AppTypography() }
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
@file:OptIn(ExperimentalPagerApi::class, ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.textThird
|
||||
import com.google.accompanist.pager.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun Banner(
|
||||
modifier: Modifier = Modifier,
|
||||
count: Int,
|
||||
loop: Boolean = false,
|
||||
timeMillis: Long = 3000,
|
||||
direction: Direction = Direction.Horizontal,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 0.dp),
|
||||
itemSpacing: Dp = 0.dp,
|
||||
reverseLayout: Boolean = false,
|
||||
state: BannerState = rememberBannerState(),
|
||||
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||
content: @Composable (BannerScope.(page: Int) -> Unit),
|
||||
) {
|
||||
|
||||
val realCount = if (loop) Int.MAX_VALUE else count
|
||||
val startIndex = if (loop) Int.MAX_VALUE / 2 else state.initialPage
|
||||
|
||||
val pagerState = rememberPagerState(initialPage = startIndex)
|
||||
|
||||
val bannerScope = remember(state, startIndex, count) {
|
||||
state.pageState = pagerState
|
||||
state.showPageCount = count
|
||||
state.initialPage = startIndex
|
||||
BannerScopeImpl(state)
|
||||
}
|
||||
|
||||
LaunchedEffect(loop) {
|
||||
pagerState.scrollToPage(startIndex)
|
||||
}
|
||||
|
||||
// 监听动画执行
|
||||
var executeChangePage by remember { mutableStateOf(false) }
|
||||
var currentPageIndex = 0
|
||||
|
||||
// 自动滚动
|
||||
LaunchedEffect(pagerState.currentPage, executeChangePage) {
|
||||
if (pagerState.pageCount > 0) {
|
||||
delay(timeMillis)
|
||||
// 这里直接+1就可以循环,前提是infiniteLoop == true
|
||||
// pagerState.animateScrollToPage((pagerState.currentPage + 1) % (pagerState.pageCount))
|
||||
pagerState.scrollToPage((pagerState.currentPage + 1) % (pagerState.pageCount))
|
||||
}
|
||||
}
|
||||
|
||||
when (direction) {
|
||||
is Direction.Horizontal -> {
|
||||
Box(modifier = modifier) {
|
||||
HorizontalPager(
|
||||
modifier = Modifier.pointerInput(pagerState.currentPage) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
// PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
// 获取到第一根按下的手指
|
||||
val dragEvent = event.changes.firstOrNull() ?: return@awaitPointerEventScope
|
||||
when {
|
||||
// 当前移动手势是否已被消费
|
||||
dragEvent.isConsumed -> return@awaitPointerEventScope
|
||||
// 是否已经按下(忽略按下手势已消费标记)
|
||||
dragEvent.changedToDownIgnoreConsumed() -> {
|
||||
// 记录下当前的页面索引值
|
||||
currentPageIndex = pagerState.currentPage
|
||||
}
|
||||
// 是否已经抬起(忽略按下手势已消费标记)
|
||||
dragEvent.changedToUpIgnoreConsumed() -> {
|
||||
// 当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
|
||||
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
|
||||
executeChangePage = !executeChangePage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
count = realCount,
|
||||
state = pagerState,
|
||||
contentPadding = contentPadding,
|
||||
itemSpacing = itemSpacing,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalAlignment = verticalAlignment
|
||||
) { index ->
|
||||
val page = if (loop) (index - startIndex).floorMod(count) else index
|
||||
bannerScope.content(page)
|
||||
}
|
||||
|
||||
HorizontalPagerIndicator(
|
||||
pagerState = pagerState,
|
||||
activeColor = AppTheme.colors.primary,
|
||||
inactiveColor = AppTheme.colors.textThird,
|
||||
indicatorWidth = 7.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 10.dp, start = 10.dp, end = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Direction.Vertical -> {
|
||||
Box(modifier = modifier) {
|
||||
VerticalPager(
|
||||
count = realCount,
|
||||
state = pagerState,
|
||||
contentPadding = contentPadding,
|
||||
itemSpacing = itemSpacing,
|
||||
reverseLayout = reverseLayout,
|
||||
modifier = modifier,
|
||||
horizontalAlignment = horizontalAlignment
|
||||
) { index ->
|
||||
val page = if (loop) (index - startIndex).floorMod(count) else index
|
||||
bannerScope.content(page)
|
||||
}
|
||||
|
||||
HorizontalPagerIndicator(
|
||||
pagerState = pagerState,
|
||||
activeColor = AppTheme.colors.primary,
|
||||
inactiveColor = AppTheme.colors.textThird,
|
||||
indicatorWidth = 7.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(bottom = 10.dp, top = 10.dp, end = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
interface BannerScope {
|
||||
val currentPage: Int
|
||||
val currentPageOffset: Float
|
||||
val initialPage: Int
|
||||
val showPageCount: Int
|
||||
}
|
||||
|
||||
@ExperimentalPagerApi
|
||||
private class BannerScopeImpl(
|
||||
private val state: BannerState,
|
||||
) : BannerScope {
|
||||
override val currentPage: Int get() = state.currentPage
|
||||
override val currentPageOffset: Float get() = state.currentPageOffset
|
||||
override val initialPage: Int
|
||||
get() = state.initialPage
|
||||
override val showPageCount: Int
|
||||
get() = state.showPageCount
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBannerState(
|
||||
@IntRange(from = 0) initialPage: Int = 0,
|
||||
): BannerState = rememberSaveable(saver = BannerState.Saver) {
|
||||
BannerState(
|
||||
initialPage = initialPage,
|
||||
)
|
||||
}
|
||||
|
||||
class BannerState(
|
||||
@IntRange(from = 0) var initialPage: Int = 0,
|
||||
) {
|
||||
|
||||
internal lateinit var pageState: PagerState
|
||||
|
||||
private var _pageCount: Int by mutableStateOf(0)
|
||||
|
||||
val realPageCount: Int
|
||||
get() = pageState.pageCount
|
||||
|
||||
val currentPage: Int
|
||||
get() = pageState.currentPage
|
||||
|
||||
val currentPageOffset: Float
|
||||
get() = pageState.currentPageOffset
|
||||
|
||||
@get:IntRange(from = 0)
|
||||
var showPageCount: Int
|
||||
get() = _pageCount
|
||||
internal set(value) {
|
||||
if (value != _pageCount) {
|
||||
_pageCount = value
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scrollToPage(
|
||||
@IntRange(from = 0) page: Int,
|
||||
@FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
|
||||
) {
|
||||
pageState.scrollToPage(page, pageOffset)
|
||||
}
|
||||
|
||||
suspend fun animateScrollToPage(
|
||||
@IntRange(from = 0) page: Int,
|
||||
@FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
|
||||
) {
|
||||
pageState.animateScrollToPage(page, pageOffset)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Saver: Saver<BannerState, *> = Saver(
|
||||
save = {
|
||||
it.initialPage
|
||||
},
|
||||
restore = {
|
||||
BannerState(
|
||||
initialPage = it,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Direction {
|
||||
object Vertical : Direction()
|
||||
object Horizontal : Direction()
|
||||
}
|
||||
|
||||
fun BannerScope.calculateCurrentOffsetForPage(page: Int): Float {
|
||||
return abs(currentPage - initialPage - page) % showPageCount + currentPageOffset
|
||||
}
|
||||
|
||||
fun Int.floorMod(other: Int): Int = when (other) {
|
||||
0 -> this
|
||||
else -> this - floorDiv(other) * other
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
val AppBarHeight = 48.dp
|
||||
private val AppBarHorizontalPadding = 4.dp
|
||||
private val ContentPadding = PaddingValues(
|
||||
start = AppBarHorizontalPadding,
|
||||
end = AppBarHorizontalPadding
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CenterAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = MaterialTheme.colors.primarySurface,
|
||||
contentColor: Color = contentColorFor(backgroundColor),
|
||||
elevation: Dp = AppBarDefaults.TopAppBarElevation,
|
||||
contentPadding: PaddingValues = ContentPadding,
|
||||
controller: NavController? = null,
|
||||
leadingActions: @Composable (RowScope.() -> Unit)? = null,
|
||||
trailingActions: @Composable (RowScope.() -> Unit) = { },
|
||||
title: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundColor,
|
||||
contentColor = contentColor,
|
||||
elevation = elevation,
|
||||
modifier = modifier
|
||||
) {
|
||||
var leftSectionWidth by remember { mutableStateOf(0.dp) }
|
||||
var rightSectionWidth by remember { mutableStateOf(0.dp) }
|
||||
var titlePadding by remember { mutableStateOf(PaddingValues()) }
|
||||
|
||||
val calculateTitlePadding = fun() {
|
||||
val dx = leftSectionWidth - rightSectionWidth
|
||||
var start = 0.dp
|
||||
var end = 0.dp
|
||||
if (dx < 0.dp) start += -dx else end += dx
|
||||
titlePadding = PaddingValues(start = start, end = end)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding)
|
||||
.statusBarsPadding()
|
||||
.height(AppBarHeight),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
// left
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
|
||||
with(LocalDensity.current) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val width = coordinates.size.width.toDp()
|
||||
if (width != leftSectionWidth) {
|
||||
leftSectionWidth = width
|
||||
calculateTitlePadding()
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = leadingActions ?: {
|
||||
val previous = controller?.previousBackStackEntry
|
||||
if (previous != null) {
|
||||
IconButton(onClick = { controller.popBackStack() }) {
|
||||
Icon(Icons.Outlined.ArrowBack, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// title
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.padding(titlePadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ProvideTextStyle(value = MaterialTheme.typography.h6) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentAlpha provides ContentAlpha.high,
|
||||
content = title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// right
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
|
||||
with(LocalDensity.current) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val width = coordinates.size.width.toDp()
|
||||
if (width != rightSectionWidth) {
|
||||
rightSectionWidth = width
|
||||
calculateTitlePadding()
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = trailingActions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.progress
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProgressDialogPreview() {
|
||||
AppTheme {
|
||||
ProgressDialog {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressDialog(text: String = "加载中...", onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.background(AppTheme.colors.secondaryBackground, shape = RoundedCornerShape(10.dp))
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(36.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = AppTheme.colors.progress
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = AppTheme.typography.body1,
|
||||
color = AppTheme.colors.textPrimary.copy(ContentAlpha.disabled),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import com.compose.wanandroid.R
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.progress
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.SwipeRefreshIndicator
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
|
||||
private val ItemHeight = 48.dp
|
||||
|
||||
@Composable
|
||||
fun <T : Any> RefreshList(
|
||||
lazyPagingItems: LazyPagingItems<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
isRefreshing: Boolean = false,
|
||||
onRefresh: (() -> Unit) = {},
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
itemContent: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val refreshState = rememberSwipeRefreshState(isRefreshing = false)
|
||||
SwipeRefresh(
|
||||
modifier = modifier,
|
||||
state = refreshState,
|
||||
indicator = { s, trigger ->
|
||||
SwipeRefreshIndicator(
|
||||
s, trigger,
|
||||
backgroundColor = AppTheme.colors.secondaryBackground,
|
||||
contentColor = AppTheme.colors.progress
|
||||
)
|
||||
},
|
||||
onRefresh = {
|
||||
onRefresh()
|
||||
lazyPagingItems.refresh()
|
||||
}) {
|
||||
refreshState.isRefreshing = (lazyPagingItems.loadState.refresh is LoadState.Loading) || isRefreshing
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = listState
|
||||
) {
|
||||
itemContent()
|
||||
|
||||
if (!refreshState.isRefreshing) {
|
||||
item {
|
||||
val state = lazyPagingItems.loadState
|
||||
when (state.append) {
|
||||
is LoadState.Loading -> LoadingItem()
|
||||
is LoadState.Error -> ErrorItem {
|
||||
lazyPagingItems.retry()
|
||||
}
|
||||
is LoadState.NotLoading -> {
|
||||
if (state.append.endOfPaginationReached) {
|
||||
EndItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> LoadList(
|
||||
lazyPagingItems: LazyPagingItems<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
itemContent: LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = listState
|
||||
) {
|
||||
itemContent()
|
||||
|
||||
item {
|
||||
val state = lazyPagingItems.loadState
|
||||
when (state.append) {
|
||||
is LoadState.Loading -> LoadingItem()
|
||||
is LoadState.Error -> ErrorItem {
|
||||
lazyPagingItems.retry()
|
||||
}
|
||||
is LoadState.NotLoading -> {
|
||||
if (state.append.endOfPaginationReached) {
|
||||
EndItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingItem() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ItemHeight),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = AppTheme.colors.progress,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(25.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Text(
|
||||
text = "加载中...",
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorItem(retry: () -> Unit = {}) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ItemHeight)
|
||||
.clickable(onClick = retry),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = AppTheme.colors.textSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Text(
|
||||
text = "加载失败,请重试",
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EndItem() {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.icon_load_end),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.height(ItemHeight)
|
||||
.fillMaxWidth(),
|
||||
contentScale = ContentScale.Inside,
|
||||
alignment = Alignment.Center
|
||||
)
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.SnapSpec
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FractionalThreshold
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ResistanceConfig
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.SwipeableDefaults
|
||||
import androidx.compose.material.SwipeableState
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Star
|
||||
import androidx.compose.material.rememberSwipeableState
|
||||
import androidx.compose.material.swipeable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
typealias RevealState = SwipeableState<RevealValue>
|
||||
|
||||
/**
|
||||
* Return an alternative value if whenClosure is true. Replaces if/else
|
||||
*/
|
||||
private fun <T> T.or(orValue: T, whenClosure: T.() -> Boolean): T {
|
||||
return if (whenClosure()) orValue else this
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onContentClick called on click
|
||||
* @param closeOnContentClick if true, returns to unrevealed state on content click
|
||||
*/
|
||||
@ExperimentalMaterialApi
|
||||
@Composable
|
||||
fun RevealSwipe(
|
||||
modifier: Modifier = Modifier,
|
||||
enableSwipe: Boolean = true,
|
||||
onContentClick: (() -> Unit)? = null,
|
||||
onBackgroundStartClick: () -> Unit = {},
|
||||
onBackgroundEndClick: () -> Unit = {},
|
||||
closeOnContentClick: Boolean = true,
|
||||
closeOnBackgroundClick: Boolean = true,
|
||||
animateBackgroundCardColor: Boolean = true,
|
||||
shape: CornerBasedShape = MaterialTheme.shapes.medium,
|
||||
alphaEasing: Easing = CubicBezierEasing(0.4f, 0.4f, 0.17f, 0.9f),
|
||||
maxRevealDp: Dp = 75.dp,
|
||||
maxAmountOfOverflow: Dp = 250.dp,
|
||||
directions: Set<RevealDirection> = setOf(
|
||||
RevealDirection.StartToEnd,
|
||||
RevealDirection.EndToStart
|
||||
),
|
||||
contentColor: Color = LocalContentColor.current,
|
||||
backgroundCardModifier: Modifier = modifier,
|
||||
backgroundCardElevation: Dp = 0.dp,
|
||||
backgroundCardStartColor: Color = MaterialTheme.colors.secondaryVariant,
|
||||
backgroundCardEndColor: Color = MaterialTheme.colors.secondary,
|
||||
backgroundCardContentColor: Color = MaterialTheme.colors.onSecondary,
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
state: RevealState = rememberRevealState(),
|
||||
hiddenContentEnd: @Composable RowScope.() -> Unit = {},
|
||||
hiddenContentStart: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable (Shape) -> Unit
|
||||
) {
|
||||
val closeOnContentClickHandler = remember(coroutineScope, state) {
|
||||
{
|
||||
if (state.targetValue != RevealValue.Default) {
|
||||
coroutineScope.launch {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundStartClick = remember(coroutineScope, state, onBackgroundStartClick) {
|
||||
{
|
||||
if (state.targetValue == RevealValue.FullyRevealedEnd && closeOnBackgroundClick) {
|
||||
coroutineScope.launch {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
onBackgroundStartClick()
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundEndClick = remember(coroutineScope, state, onBackgroundEndClick) {
|
||||
{
|
||||
if (state.targetValue == RevealValue.FullyRevealedStart && closeOnBackgroundClick) {
|
||||
coroutineScope.launch {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
onBackgroundEndClick()
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
var shapeSize: Size by remember { mutableStateOf(Size(0f, 0f)) }
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val cornerRadiusBottomEnd = remember(shapeSize, density) {
|
||||
shape.bottomEnd.toPx(
|
||||
shapeSize = shapeSize,
|
||||
density = density
|
||||
)
|
||||
}
|
||||
val cornerRadiusTopEnd = remember(shapeSize, density) {
|
||||
shape.topEnd.toPx(
|
||||
shapeSize = shapeSize,
|
||||
density = density
|
||||
)
|
||||
}
|
||||
|
||||
val cornerRadiusBottomStart = remember(shapeSize, density) {
|
||||
shape.bottomStart.toPx(
|
||||
shapeSize = shapeSize,
|
||||
density = density
|
||||
)
|
||||
}
|
||||
val cornerRadiusTopStart = remember(shapeSize, density) {
|
||||
shape.topStart.toPx(
|
||||
shapeSize = shapeSize,
|
||||
density = density
|
||||
)
|
||||
}
|
||||
|
||||
val minDragAmountForStraightCorner =
|
||||
kotlin.math.max(cornerRadiusTopEnd, cornerRadiusBottomEnd)
|
||||
|
||||
val cornerFactorEnd =
|
||||
(-state.offset.value / minDragAmountForStraightCorner).nonNaNorZero().coerceIn(0f, 1f).or(0f) {
|
||||
directions.contains(RevealDirection.EndToStart).not()
|
||||
}
|
||||
|
||||
val cornerFactorStart =
|
||||
(state.offset.value / minDragAmountForStraightCorner).nonNaNorZero().coerceIn(0f, 1f).or(0f) {
|
||||
directions.contains(RevealDirection.StartToEnd).not()
|
||||
}
|
||||
|
||||
val animatedCornerRadiusTopEnd: Float = lerp(cornerRadiusTopEnd, 0f, cornerFactorEnd)
|
||||
val animatedCornerRadiusBottomEnd: Float = lerp(cornerRadiusBottomEnd, 0f, cornerFactorEnd)
|
||||
|
||||
val animatedCornerRadiusTopStart: Float = lerp(cornerRadiusTopStart, 0f, cornerFactorStart)
|
||||
val animatedCornerRadiusBottomStart: Float = lerp(cornerRadiusBottomStart, 0f, cornerFactorStart)
|
||||
|
||||
val animatedShape = shape.copy(
|
||||
bottomStart = CornerSize(animatedCornerRadiusBottomStart),
|
||||
bottomEnd = CornerSize(animatedCornerRadiusBottomEnd),
|
||||
topStart = CornerSize(animatedCornerRadiusTopStart),
|
||||
topEnd = CornerSize(animatedCornerRadiusTopEnd)
|
||||
)
|
||||
|
||||
// alpha for background
|
||||
val maxRevealPx = with(LocalDensity.current) { maxRevealDp.toPx() }
|
||||
val draggedRatio =
|
||||
(state.offset.value.absoluteValue / maxRevealPx.absoluteValue).coerceIn(0f, 1f)
|
||||
|
||||
// cubic parameters can be evaluated here https://cubic-bezier.com/
|
||||
val alpha = alphaEasing.transform(draggedRatio)
|
||||
|
||||
val animatedBackgroundEndColor = if (alpha in 0f..1f && animateBackgroundCardColor) backgroundCardEndColor.copy(
|
||||
alpha = alpha
|
||||
) else backgroundCardEndColor
|
||||
|
||||
val animatedBackgroundStartColor = if (alpha in 0f..1f && animateBackgroundCardColor) backgroundCardStartColor.copy(
|
||||
alpha = alpha
|
||||
) else backgroundCardStartColor
|
||||
|
||||
// non swipable with hidden content
|
||||
Card(
|
||||
contentColor = backgroundCardContentColor,
|
||||
backgroundColor = Color.Transparent,
|
||||
modifier = backgroundCardModifier
|
||||
.matchParentSize(),
|
||||
shape = shape,
|
||||
elevation = backgroundCardElevation
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(alpha),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.fillMaxHeight()
|
||||
.background(if (directions.contains(RevealDirection.StartToEnd)) animatedBackgroundStartColor else Color.Transparent)
|
||||
.clickable(onClick = backgroundStartClick),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = hiddenContentStart
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (directions.contains(RevealDirection.EndToStart)) animatedBackgroundEndColor else Color.Transparent)
|
||||
.clickable(onClick = backgroundEndClick),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = hiddenContentEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (enableSwipe) Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
state.offset.value.roundToInt(),
|
||||
0
|
||||
)
|
||||
}
|
||||
.revealSwipable(
|
||||
state = state,
|
||||
maxRevealPx = maxRevealPx,
|
||||
maxAmountOfOverflow = maxAmountOfOverflow,
|
||||
directions = directions
|
||||
) else Modifier
|
||||
)
|
||||
.then(
|
||||
if (onContentClick != null && !closeOnContentClick) {
|
||||
Modifier.clickable(
|
||||
onClick = onContentClick
|
||||
)
|
||||
} else if (onContentClick == null && closeOnContentClick) {
|
||||
// if no onContentClick handler passed, add click handler with no indication to enable close on content click
|
||||
Modifier.clickable(
|
||||
onClick = closeOnContentClickHandler,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
} else if (onContentClick != null && closeOnContentClick) {
|
||||
// decide based on state:
|
||||
// 1. if open, just close without indication
|
||||
// 2. if closed, call click handler
|
||||
Modifier.clickable(
|
||||
onClick =
|
||||
{
|
||||
val isOpen = state.targetValue != RevealValue.Default
|
||||
// if open, just close. No click event.
|
||||
if (isOpen) {
|
||||
closeOnContentClickHandler()
|
||||
} else {
|
||||
onContentClick()
|
||||
}
|
||||
},
|
||||
// no indication if just closing
|
||||
indication = if (state.targetValue != RevealValue.Default) null else LocalIndication.current,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
content(animatedShape)
|
||||
}
|
||||
// This box is used to determine shape size.
|
||||
// The box is sized to match it's parent, which in turn is sized according to its first child - the card.
|
||||
BoxWithConstraints(modifier = Modifier.matchParentSize()) {
|
||||
shapeSize = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
private fun Modifier.revealSwipable(
|
||||
maxRevealPx: Float,
|
||||
maxAmountOfOverflow: Dp,
|
||||
directions: Set<RevealDirection>,
|
||||
state: RevealState,
|
||||
) = composed {
|
||||
|
||||
val maxAmountOfOverflowPx = with(LocalDensity.current) { maxAmountOfOverflow.toPx() }
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
val anchors = mutableMapOf(0f to RevealValue.Default)
|
||||
|
||||
if (RevealDirection.StartToEnd in directions) anchors += maxRevealPx to RevealValue.FullyRevealedEnd
|
||||
if (RevealDirection.EndToStart in directions) anchors += -maxRevealPx to RevealValue.FullyRevealedStart
|
||||
|
||||
val thresholds = { _: RevealValue, _: RevealValue ->
|
||||
FractionalThreshold(0.5f)
|
||||
}
|
||||
|
||||
val minFactor =
|
||||
if (RevealDirection.EndToStart in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
|
||||
val maxFactor =
|
||||
if (RevealDirection.StartToEnd in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
|
||||
|
||||
Modifier.swipeable(
|
||||
state = state,
|
||||
anchors = anchors,
|
||||
thresholds = thresholds,
|
||||
orientation = Orientation.Horizontal,
|
||||
enabled = true, // state.value == RevealValue.Default,
|
||||
reverseDirection = isRtl,
|
||||
resistance = ResistanceConfig(
|
||||
basis = maxAmountOfOverflowPx,
|
||||
factorAtMin = minFactor,
|
||||
factorAtMax = maxFactor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Float.nonNaNorZero() = if (isNaN()) 0f else this
|
||||
|
||||
enum class RevealDirection {
|
||||
/**
|
||||
* Can be dismissed by swiping in the reading direction.
|
||||
*/
|
||||
StartToEnd,
|
||||
|
||||
/**
|
||||
* Can be dismissed by swiping in the reverse of the reading direction.
|
||||
*/
|
||||
EndToStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible values of [RevealState].
|
||||
*/
|
||||
enum class RevealValue {
|
||||
/**
|
||||
* Indicates the component has not been revealed yet.
|
||||
*/
|
||||
Default,
|
||||
|
||||
/**
|
||||
* Fully revealed to end
|
||||
*/
|
||||
FullyRevealedEnd,
|
||||
|
||||
/**
|
||||
* Fully revealed to start
|
||||
*/
|
||||
FullyRevealedStart,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [RevealState] with the default animation clock.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun rememberRevealState(
|
||||
initialValue: RevealValue = RevealValue.Default,
|
||||
confirmStateChange: (RevealValue) -> Boolean = { true },
|
||||
): RevealState {
|
||||
return rememberSwipeableState(
|
||||
initialValue = initialValue,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position, with an animation.
|
||||
*/
|
||||
@ExperimentalMaterialApi
|
||||
suspend fun RevealState.reset() {
|
||||
animateTo(
|
||||
targetValue = RevealValue.Default,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position, with an animation.
|
||||
*/
|
||||
@ExperimentalMaterialApi
|
||||
suspend fun RevealState.resetFast() {
|
||||
animateTo(
|
||||
targetValue = RevealValue.Default,
|
||||
anim = SnapSpec(1)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RevealSwipegPreview() {
|
||||
MaterialTheme {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.height(400.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(10.dp)
|
||||
) {
|
||||
item {
|
||||
RevealSwipe(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
directions = setOf(
|
||||
RevealDirection.StartToEnd,
|
||||
RevealDirection.EndToStart
|
||||
),
|
||||
hiddenContentStart = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Star,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
hiddenContentEnd = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.requiredHeight(80.dp),
|
||||
backgroundColor = Color(0xFF505160),
|
||||
shape = it,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 20.dp),
|
||||
text = "Both directions"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
RevealSwipe(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
closeOnContentClick = false,
|
||||
closeOnBackgroundClick = false,
|
||||
directions = setOf(
|
||||
RevealDirection.StartToEnd,
|
||||
RevealDirection.EndToStart
|
||||
),
|
||||
hiddenContentStart = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Star,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
hiddenContentEnd = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.requiredHeight(80.dp),
|
||||
backgroundColor = Color(0xFF68829E),
|
||||
shape = it,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 20.dp),
|
||||
text = "Both directions.\ncloseOnClick = false"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
RevealSwipe(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
directions = setOf(
|
||||
RevealDirection.StartToEnd,
|
||||
),
|
||||
hiddenContentStart = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Star,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
hiddenContentEnd = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.requiredHeight(80.dp),
|
||||
backgroundColor = Color(0xFFAEBD38),
|
||||
shape = it,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 20.dp),
|
||||
text = "StartToEnd"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
RevealSwipe(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
animateBackgroundCardColor = false,
|
||||
directions = setOf(
|
||||
RevealDirection.EndToStart,
|
||||
),
|
||||
hiddenContentStart = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Star,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
hiddenContentEnd = {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 25.dp),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.requiredHeight(80.dp),
|
||||
backgroundColor = Color(0xFF598234),
|
||||
shape = it,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 20.dp),
|
||||
text = "EndToStart"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ButtonDefaults.buttonColors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.FolderOpen
|
||||
import androidx.compose.material.icons.rounded.WifiOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.defaultContentColorFor
|
||||
import com.compose.wanandroid.ui.theme.progress
|
||||
|
||||
sealed class PageState {
|
||||
object Loading : PageState()
|
||||
data class Success(val isEmpty: Boolean = false) : PageState()
|
||||
data class Error(val exception: Throwable? = null) : PageState()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StatePagePreview() {
|
||||
AppTheme {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
StatePage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
state = PageState.Loading
|
||||
) {}
|
||||
|
||||
StatePage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
state = PageState.Error(Throwable())
|
||||
) {}
|
||||
|
||||
StatePage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
state = PageState.Success(true)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatePage(
|
||||
state: PageState,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: () -> Unit = {},
|
||||
content: @Composable () -> Unit
|
||||
) = when (state) {
|
||||
is PageState.Loading -> StatePageLoading(modifier)
|
||||
is PageState.Error -> StatePageError(modifier, onClick = onRetry)
|
||||
is PageState.Success -> {
|
||||
if (state.isEmpty) {
|
||||
StatePageEmpty(modifier)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatePageLoading(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "加载中...",
|
||||
textStyle: TextStyle = AppTheme.typography.body1,
|
||||
textColor: Color = AppTheme.colors.textPrimary.copy(ContentAlpha.disabled),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(36.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = AppTheme.colors.progress
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatePageError(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "加载失败",
|
||||
textStyle: TextStyle = AppTheme.typography.body1,
|
||||
secondaryText: String? = "请检查网络连接后重试",
|
||||
secondaryTextStyle: TextStyle = AppTheme.typography.body1.copy(fontSize = 13.sp),
|
||||
textColor: Color = AppTheme.colors.textPrimary.copy(ContentAlpha.medium),
|
||||
secondaryTextColor: Color = AppTheme.colors.textPrimary.copy(ContentAlpha.disabled),
|
||||
image: @Composable () -> Unit = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.WifiOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp),
|
||||
tint = textColor
|
||||
)
|
||||
},
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val clickState = rememberUpdatedState(onClick)
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
image()
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
secondaryText?.let {
|
||||
Text(
|
||||
text = secondaryText,
|
||||
style = secondaryTextStyle,
|
||||
color = secondaryTextColor,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { clickState.value() },
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp, 0.dp),
|
||||
colors = buttonColors(
|
||||
backgroundColor = AppTheme.colors.secondaryBackground,
|
||||
contentColor = defaultContentColorFor(AppTheme.colors.secondaryBackground),
|
||||
disabledBackgroundColor = AppTheme.colors.secondaryBackground.copy(alpha = 0.12f)
|
||||
.compositeOver(AppTheme.colors.secondaryBackground),
|
||||
disabledContentColor = AppTheme.colors.onBackground.copy(alpha = ContentAlpha.disabled)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp)
|
||||
.width(80.dp)
|
||||
) {
|
||||
Text(text = "重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatePageEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "空空如也~",
|
||||
textStyle: TextStyle = AppTheme.typography.body1,
|
||||
textColor: Color = AppTheme.colors.textPrimary.copy(ContentAlpha.disabled),
|
||||
image: @Composable () -> Unit = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.FolderOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp),
|
||||
tint = textColor
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
image()
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.compose.wanandroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.compose.wanandroid.ui.theme.AppTheme
|
||||
import com.compose.wanandroid.ui.theme.defaultContentColorFor
|
||||
|
||||
@Composable
|
||||
fun TextTabBar(
|
||||
index: Int,
|
||||
titles: List<TabText>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlign: Arrangement.Horizontal = Arrangement.Center,
|
||||
backgroundColor: Color = AppTheme.colors.primary,
|
||||
contentColor: Color = defaultContentColorFor(backgroundColor),
|
||||
unselectedContentColor: Color = defaultContentColorFor(backgroundColor),
|
||||
onTabSelected: (index: Int) -> Unit = { }
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.statusBarsPadding()
|
||||
.height(AppBarHeight),
|
||||
horizontalArrangement = contentAlign,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
titles.forEachIndexed { i, title ->
|
||||
Text(
|
||||
text = title.text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (index == i) FontWeight.SemiBold else FontWeight.Normal,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(horizontal = 10.dp)
|
||||
.clickable {
|
||||
onTabSelected(i)
|
||||
},
|
||||
color = if (index == i) contentColor else unselectedContentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class TabText(
|
||||
val id: Int,
|
||||
val text: String,
|
||||
var cachePosition: Int = 0,
|
||||
var selected: Boolean = false
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.compose.wanandroid.ui.widget.html
|
||||
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
internal fun UnderlineSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
|
||||
internal fun ForegroundColorSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(color = Color(foregroundColor))
|
||||
|
||||
internal fun StrikethroughSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.compose.wanandroid.ui.widget.html
|
||||
|
||||
import android.text.style.*
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
private const val URL_TAG = "url_tag"
|
||||
|
||||
@Composable
|
||||
fun HtmlText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
linkClicked: ((String) -> Unit)? = null,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = 14.sp,
|
||||
flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
|
||||
urlSpanStyle: SpanStyle = SpanStyle(
|
||||
color = linkTextColor(),
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
) {
|
||||
val content = text.asHTML(fontSize, flags, urlSpanStyle)
|
||||
if (linkClicked != null) {
|
||||
ClickableText(
|
||||
modifier = modifier,
|
||||
text = content,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout,
|
||||
onClick = {
|
||||
content
|
||||
.getStringAnnotations(URL_TAG, it, it)
|
||||
.firstOrNull()
|
||||
?.let { stringAnnotation -> linkClicked(stringAnnotation.item) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = content,
|
||||
style = style,
|
||||
color = color,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun linkTextColor() = Color(
|
||||
TextView(LocalContext.current).linkTextColors.defaultColor
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun String.asHTML(
|
||||
fontSize: TextUnit,
|
||||
flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
|
||||
URLSpanStyle: SpanStyle = SpanStyle(textDecoration = null)
|
||||
) = buildAnnotatedString {
|
||||
val spanned = HtmlCompat.fromHtml(this@asHTML, flags)
|
||||
val spans = spanned.getSpans(0, spanned.length, Any::class.java)
|
||||
|
||||
append(spanned.toString())
|
||||
|
||||
spans
|
||||
.filter { it !is BulletSpan }
|
||||
.forEach { span ->
|
||||
val start = spanned.getSpanStart(span)
|
||||
val end = spanned.getSpanEnd(span)
|
||||
when (span) {
|
||||
is RelativeSizeSpan -> span.spanStyle(fontSize)
|
||||
is StyleSpan -> span.spanStyle()
|
||||
is UnderlineSpan -> span.spanStyle()
|
||||
is ForegroundColorSpan -> span.spanStyle()
|
||||
is TypefaceSpan -> span.spanStyle()
|
||||
is StrikethroughSpan -> span.spanStyle()
|
||||
is SuperscriptSpan -> span.spanStyle()
|
||||
is SubscriptSpan -> span.spanStyle()
|
||||
is URLSpan -> {
|
||||
addStringAnnotation(
|
||||
tag = URL_TAG,
|
||||
annotation = span.url,
|
||||
start = start,
|
||||
end = end
|
||||
)
|
||||
URLSpanStyle
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}?.let { spanStyle ->
|
||||
addStyle(spanStyle, start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.compose.wanandroid.ui.widget.html
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.*
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
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.BaselineShift
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.io.File
|
||||
|
||||
private const val PATH_SYSTEM_FONTS_FILE = "/system/etc/fonts.xml"
|
||||
private const val PATH_SYSTEM_FONTS_DIR = "/system/fonts/"
|
||||
|
||||
internal fun RelativeSizeSpan.spanStyle(fontSize: TextUnit): SpanStyle =
|
||||
SpanStyle(fontSize = (fontSize.value * sizeChange).sp)
|
||||
|
||||
internal fun StyleSpan.spanStyle(): SpanStyle? = when (style) {
|
||||
Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
Typeface.BOLD_ITALIC -> SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun SubscriptSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(baselineShift = BaselineShift.Subscript)
|
||||
|
||||
internal fun SuperscriptSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(baselineShift = BaselineShift.Superscript)
|
||||
|
||||
internal fun TypefaceSpan.spanStyle(): SpanStyle? {
|
||||
val xmlContent = File(PATH_SYSTEM_FONTS_FILE).readText()
|
||||
return if (xmlContent.contains("""<family name="$family""")) {
|
||||
val familyChunkXml = xmlContent.substringAfter("""<family name="$family""")
|
||||
.substringBefore("""</family>""")
|
||||
val fontName = familyChunkXml.substringAfter("""<font weight="400" style="normal">""")
|
||||
.substringBefore("</font>")
|
||||
SpanStyle(fontFamily = FontFamily(Typeface.createFromFile("$PATH_SYSTEM_FONTS_DIR$fontName")))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user