This commit is contained in:
coco
2026-07-03 16:23:31 +08:00
commit 7a4fb0e6ae
1979 changed files with 101570 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+104
View File
@@ -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"
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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()
}
}
}
}
}
@@ -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
}
@@ -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()) }
}
@@ -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>
}
@@ -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>
}
@@ -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
)
@@ -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)
}
@@ -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("·")
@@ -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"
}
@@ -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>>
@@ -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
)
@@ -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) }
}
@@ -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()
}
@@ -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>()
}
@@ -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()
}
}
@@ -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
}
@@ -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()
}
}
@@ -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.
}
}
}
@@ -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())
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
)
}
}
@@ -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)
)
}
}
@@ -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()
}
@@ -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
@@ -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)
}
}
}
}
@@ -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
}
}
}
}
@@ -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)
})
}
}
})
}
}
@@ -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()
}
}
}
@@ -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的目的是为了收藏状态能及时刷新,psLazyColumn结合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)
})
}
}
})
}
}
}
@@ -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()
}
}
}
@@ -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
)
}
}
@@ -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()
}
@@ -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,
)
}
}
}
@@ -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")
}
@@ -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)
)
}
}
@@ -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
)
@@ -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))
})
}
}
}
)
}
}
}
@@ -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()
}
@@ -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)
)
}
}
@@ -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()
}
}
}
@@ -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)
})
}
}
})
}
}
@@ -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))
}
}
}
@@ -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",
// )
// }
// )
}
}
@@ -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()
}
@@ -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)
}
}
}
}
@@ -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))
})
}
}
})
}
}
}
@@ -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()
}
@@ -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))
}
)
}
}
}
}
}
@@ -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",
"Squares 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
)
@@ -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)
)
}
}
@@ -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()
}
@@ -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)
})
}
}
})
}
}
}
@@ -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)
}
}
}
}
@@ -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,
)
}
}
}
}
}
}
@@ -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()
}
@@ -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)
})
}
}
})
}
}
@@ -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))
}
}
}
@@ -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)
}
}
}
}
@@ -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)
})
}
}
})
}
}
@@ -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))
}
}
}
@@ -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,
)
}
}
}
}
@@ -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()
}
@@ -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)
}
}
}
}
@@ -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")
)
})
}
}
}
@@ -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
}
@@ -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() }
@@ -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
}
@@ -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
)
}
}
}
}
}
@@ -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)
)
}
}
}
@@ -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
)
}
@@ -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"
)
}
}
}
}
}
}
}
}
@@ -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)
)
}
}
@@ -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
)
@@ -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)
@@ -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)
}
}
}
@@ -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