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
+16
View File
@@ -0,0 +1,16 @@
*.iml
.gradle
.idea
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+219
View File
@@ -0,0 +1,219 @@
## 前言
今年七月底,`Google` 正式发布了 `Jetpack Compose``1.0` 稳定版本,这说明`Google`认为`Compose`已经可以用于生产环境了。相信`Compose`的广泛应用就在不远的将来,现在应该是学习`Compose`的一个比较好的时机
在了解了`Compose`的基本知识与原理之后,通过一个完整的项目继续学习`Compose`应该是一个比较好的方式。本文主要基于`Compose`,`MVI`架构,单`Activity`架构等,快速实现一个`wanAndroid`客户端,如果对您有所帮助可以点个`Star`: [wanAndroid-compose](https://github.com/shenzhen2017/wanandroid-compose)
## 效果图
首先看下效果图
| ![请添加图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p13.png) | ![在这里插入图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p14.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
| ![请添加图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p15.png) | ![在这里插入图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p16.png) |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
| ![请添加图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p17.png) | ![请添加图片描述](https://raw.githubusercontents.com/shenzhen2017/resource/main/2021/november/p18.png) |
## 主要实现介绍
各个页面的具体实现可以查看源码,这里主要介绍一些主要的实现与原理
### 使用`MVI`架构
`MVI``MVVM` 很相似,其借鉴了前端框架的思想,更加强调数据的单向流动和唯一数据源,架构图如下所示
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bb3fe9361e244430bd2f69b70c7b0e75~tplv-k3u1fbpfcp-watermark.awebp)
其主要分为以下几部分
1. `Model`: 与`MVVM`中的`Model`不同的是,`MVI``Model`主要指`UI`状态(`State`)。例如页面加载状态、控件位置等都是一种`UI`状态
2. `View`: 与其他`MVX`中的`View`一致,可能是一个`Activity`或者任意`UI`承载单元。`MVI`中的`View`通过订阅`Model`的变化实现界面刷新
3. `Intent`: 此`Intent`不是`Activity``Intent`,用户的任何操作都被包装成`Intent`后发送给`Model`层进行数据请求
例如登录页面的`Model``Intent`定义如下
```kotlin
/**
* 页面所有状态
/
data class LoginViewState(
val account: String = "",
val password: String = "",
val isLogged: Boolean = false
)
/**
* 一次性事件
*/
sealed class LoginViewEvent {
object PopBack : LoginViewEvent()
data class ErrorMessage(val message: String) : LoginViewEvent()
}
/**
* 页面Intent,即用户的操作
/
sealed class LoginViewAction {
object Login : LoginViewAction()
object ClearAccount : LoginViewAction()
object ClearPassword : LoginViewAction()
data class UpdateAccount(val account: String) : LoginViewAction()
data class UpdatePassword(val password: String) : LoginViewAction()
}
```
如上所示
1. 通过`ViewState`定义页面所有状态
2. `ViewEvent`定义一次性事件如`Toast`,页面关闭事件等
3. 通过`ViewAction`定义所有用户操作
`MVI`架构与`MVVM`架构的主要区别在于:
1. `MVVM`并没有约束`View`层与`ViewModel`的交互方式,具体来说就是`View`层可以随意调用`ViewModel`中的方法,而`MVI`架构下`ViewModel`的实现对`View`层屏蔽,只能通过发送`Intent`来驱动事件。
2. `MVVM``ViewModle` 中分散定义了多个 `State` `MVI` 使用 `ViewState``State` 集中管理,只需要订阅一个 `ViewState` 便可获取页面的所有状态,相对 `MVVM` 减少了不少模板代码
`Compose` 的声明式`UI`思想来自 `React`,理论上同样来自 `Redux` 思想的 `MVI` 应该是 `Compose` 的最佳伴侣
但是`MVI`也只是在`MVVM`的基础上做了一定的改良,`MVVM` 也可以很好地配合 `Compose` 使用,各位可根据自己的需要选择合适的架构
关于`Compose`的架构选择可参考:[Jetpack Compose 架构如何选? MVP, MVVM, MVI](https://juejin.cn/post/6969382803112722446)
### 单`Activity`架构
早在`View`时代,就有不少推荐单`Activity`+多`Fragment`架构的文章,`Google`也推出了`Jetpack Navigation`库来支持这种单`Activity`架构
对于`Compose`来说,因为`Activity``Compose`是通过`AndroidComposeView`来中转的,`Activity`越多,就需要创建出越多的`AndroidComposeView`,对性能有一定影响
而使用单`Activity`架构,所有变换页面跳转都在`Compose`内部完成,可能也是出于这个原因,目前`Google`的示例项目都是基于单`Activity`+`Navigation`+多`Compose`架构的
但是使用单`Activity`架构也需要解决一些问题
1. 所有的`viewModel`都在一个`Activity``ViewModelStoreOwner`中,那么当一个页面销毁了,此页面用过的`viewModel`应该什么时候销毁呢?
2. 有时候页面需要监听自己这个页面的`onResume``onPause`等生命周期,单`Activity`架构下如何监听生命周期呢?
我们下面就一起来看下如何解决单`Activity`架构下的这两个问题
### 页面`ViewModel`何时销毁?
`Compose`中一般可以通过以下两种方式获取`ViewModel`
```kotlin
//方式1
@Composable
fun LoginPage(
loginViewModel: LoginViewModel = viewModel()
) {
//...
}
//方式2
@Composable
fun LoginPage(
loginViewModel: LoginViewModel = hiltViewModel()
) {
//...
}
```
如上所示:
1. 方式1将返回一个与`ViewModelStoreOwner`(一般是`Activity``Fragment`)绑定的`ViewModel`,如果不存在则创建,已存在则直接返回。很明显通过这种方式创建的`ViewModel`的生命周期将与`Activity`一致,在单`Activity`架构中将一直存在,不会释放。
2. 方式2通过`Hilt`实现,可以在`Composable`中获取`NavGraph Scope``Destination Scope``ViewModel`,并自动依赖 `Hilt` 构建。`Destination Scope``ViewModel` 会跟随 `BackStack` 的弹出自动 `Clear` ,避免泄露。
总得来说,通过`hiltViewModel``Navigation`配合,是一个更好的选择
### `Compose`如何获取生命周期?
为了在`Compose`中获取生命周期,我们需要先了解下[副作用](https://developer.android.google.cn/jetpack/compose/side-effects?hl=zh-cn)
用一句话概括副作用:一个函数的执行过程中,除了返回函数值之外,对调用方还会带来其他附加影响,例如修改全局变量或修改参数等。
副作用必须在合适的时机执行,我们首先需要明确一下`Composable`的生命周期:
1. `onActiveor onEnter`:当`Composable`首次进入组件树时
2. `onCommitor onUpdate``UI`随着`recomposition`发生更新时
3. `onDisposeor onLeave`:当`Composable`从组件树移除时
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/07432c8e4f5c4492baff11f3f1fb4802~tplv-k3u1fbpfcp-watermark.awebp)
了解了`Compose`的生命周期后,我们可以发现,如果我们在`onActive`时监听`Activity`的生命周期,在`onDispose`时取消监听,不就可以实现在`Compose`中获取生命周期了吗?
`DisposableEffect`可以帮助我们实现这个需求,`DisposableEffect`在其监听的`Key`发生变化,或`onDispose`时会执行
我们还可以通过添加参数,让其仅在`onActive``onDispose`时执行:例如`DisposableEffect(true)``DisposableEffect(Unit)`
通过以下方式,就可以实现在`Compose`中监听页面生命周期
```kotlin
@Composable
fun LoginPage(
loginViewModel: LoginViewModel = hiltViewModel()
) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = Unit) {
val observer = object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
viewModel.dispatch(Action.Resume)
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {
viewModel.dispatch(Action.Pause)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
```
当然有时也不需要这么复杂,比如我们需要在进入或返回`ProfilePage`页面时刷新登录状态,并根据登录状态确认页面`UI`,就可以通过以下方式实现
```kotlin
@Composable
fun ProfilePage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: ProfileViewModel = hiltViewModel()
) {
//...
DisposableEffect(Unit) {
Log.i("debug", "onStart")
viewModel.dispatch(ProfileViewAction.OnStart)
onDispose {
}
}
}
```
如上所示,每当进入页面或返回该页面时,我们就可以刷新页面登录状态了
### `Compose`如何保存`LazyColumn`列表状态
相信使用过`LazyColumn`的同学都碰到过下面的问题
> 使用`Paging3`加载分页数据,并显示到页面`A`的`LazyColumn`上,向下滑动`LazyColumn`,然后`navigation.navigate`跳转到页面`B`,接着再`navigatUp`回到页面`A`,页面`A`的`LazyColumn`又回到了列表顶部
但是我们可以看到,`LazyListState`其实是通过`rememberLazyListState`做了持久化保存的,如下图所示
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/291ab6de0d274da2bb1d875faa3c7d7e~tplv-k3u1fbpfcp-watermark.awebp?)
既然做了持久化保存,那为什么返回时的位置还有问题呢?其实纯粹使用 `Paging` + `LazyColumn`,当页面切换时,会记录当前页面位置,但如果通过`item`加上`Header``Footer`就不行了
这是因为`rememberLazyListState`会在列表中至少有一项时`restore`滚动位置,同时`Paging`是通过`Flow`获取数据的,当返回到页面重组时并不能马上获取到`Paging`数据,第一帧时`Paging``itemCount`为0
但同时因为`LazyColumn`中已经有了一个`Header`,这时便会还原保存的位置,但因为这时`Paging`中的数据还为空,不能滚动到正确的位置,于是便又滚动到顶部了
而当`LazyColumn`中没有`Header`时,列表中至少有一项时便是`Paging`数据成功填充的时候,这个时候还原的位置就是对的,所以没有问题
既然原因在于`LazyListState`没有在正确的时机被还原,那我们将`LazyListSate`保存在`ViewModel`中,并且在`Paging`中有数据时再还原`listState`,如下所示:
```kotlin
@HiltViewModel
class SquareViewModel @Inject constructor(
private var service: HttpService,
) : ViewModel() {
private val pager by lazy { simplePager { service.getSquareData(it) }.cachedIn(viewModelScope) }
val listState: LazyListState = LazyListState()
}
@Composable
fun SquarePage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: SquareViewModel = hiltViewModel()
) {
val squareData = viewStates.pagingData.collectAsLazyPagingItems()
// 当`Paging`有数据时,返回`ViewModel`中的`listState`
val listState = if (squareData.itemCount > 0) viewStates.listState else LazyListState()
RefreshList(squareData, listState = listState) {
itemsIndexed(squareData) { _, item ->
//...
}
}
}
```
总得来说,对于一般的页面,`rememberLazyListState`已经足够,但是对于有`Header``Footer``Paging`页面,需要一些特殊处理
关于`LazyColumn`滚动丢失的问题,更详细的讨论可参考:[Scroll position of LazyColumn built with collectAsLazyPagingItems is lost when using Navigation
](https://issuetracker.google.com/issues/177245496?pli=1)
## 总结
### 项目地址
[https://github.com/shenzhen2017/wanandroid-compose](https://github.com/shenzhen2017/wanandroid-compose)
开源不易,如果项目对你有所帮助,欢迎点赞,`Star`,收藏~
### 参考资料
[https://github.com/manqianzhuang/HamApp](https://github.com/manqianzhuang/HamApp)
[https://github.com/linxiangcheer/PlayAndroid](https://github.com/linxiangcheer/PlayAndroid)
[从零到一写一个完整的 Compose 版本的天气](https://juejin.cn/post/7030986229512404999)
@@ -0,0 +1,16 @@
*.iml
.gradle
.idea
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
@@ -0,0 +1,112 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id "kotlin-kapt"
id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "androidx.navigation.safeargs.kotlin"
}
android {
compileSdk 31
defaultConfig {
applicationId "com.zj.wanandroid"
minSdk 21
targetSdk 31
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'
useIR = true
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion kotlin_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
testImplementation 'junit:junit:4.+'
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"
implementation "androidx.navigation:navigation-compose:2.4.0-beta02"
/** accompanist辅助插件 */
implementation "com.google.accompanist:accompanist-insets:$accompanistVersion"
implementation "com.google.accompanist:accompanist-coil:0.15.0"
//系统ui控制器
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion"
//glide
implementation "com.google.accompanist:accompanist-glide:0.15.0"
//viewPager
implementation "com.google.accompanist:accompanist-pager:0.20.2"
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanistVersion"
//下拉刷新
implementation "com.google.accompanist:accompanist-swiperefresh:$accompanistVersion"
//流式布局
implementation "com.google.accompanist:accompanist-flowlayout:$accompanistVersion"
//placeholder
implementation "com.google.accompanist:accompanist-placeholder-material:$accompanistVersion"
//hilt inject framework
implementation "com.google.dagger:hilt-android:$hiltVersion"
kapt "com.google.dagger:hilt-compiler:$hiltVersion"
implementation "androidx.hilt:hilt-navigation-compose:$hiltComposeVersion"
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hiltComposeVersion"
kapt "androidx.hilt:hilt-compiler:$hiltCompilerVersion"
//http request
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.google.code.gson:gson:$gsonVersion"
//数据保存,用于cookie持久化
implementation "androidx.datastore:datastore-preferences:$datastoreVersion"
implementation "androidx.datastore:datastore-core:$datastoreVersion"
implementation "org.jetbrains.kotlin:kotlin-reflect:1.5.31"
//paging分页库
implementation "androidx.paging:paging-runtime:$pagingVersion"
testImplementation "androidx.paging:paging-common:$pagingVersion"
implementation "androidx.paging:paging-compose:$pagingComposeVersion"
//约束布局
implementation "androidx.constraintlayout:constraintlayout-compose:$constraintComposeVersion"
}
+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.zj.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.zj.wanandroid", appContext.packageName)
}
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.zj.wanandroid">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name=".MyApp"
android:theme="@style/Theme.Wanandroidcompose">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,23 @@
package com.zj.wanandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.ExperimentalComposeUiApi
import com.google.accompanist.pager.ExperimentalPagerApi
import com.zj.wanandroid.ui.page.common.HomeEntry
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ExperimentalFoundationApi
@ExperimentalComposeUiApi
@ExperimentalPagerApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HomeEntry()
}
}
}
@@ -0,0 +1,29 @@
package com.zj.wanandroid
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import com.zj.wanandroid.data.store.DataStoreUtils
import dagger.hilt.android.HiltAndroidApp
/**
* 1. 所有使用 Hilt 的 App 必须包含 一个使用 @HiltAndroidApp 注解的 Application
* 2. @HiltAndroidApp 将会触发 Hilt 代码的生成,包括用作应用程序依赖项容器的基类
* 3. 生成的 Hilt 组件依附于 Application 的生命周期,它也是 App 的父组件,提供其他组件访问的依赖
* 4. 在 Application 中设置好 @HiltAndroidApp 之后,就可以使用 Hilt 提供的组件了,
* Hilt 提供的 @AndroidEntryPoint 注解用于提供 Android 类的依赖(Activity、Fragment、View、Service、BroadcastReceiver)等等
* Application 使用 @HiltAndroidApp 注解
*/
@HiltAndroidApp
class MyApp : Application() {
companion object {
@SuppressLint("StaticFieldLeak")
lateinit var CONTEXT: Context
}
override fun onCreate() {
super.onCreate()
CONTEXT = this
DataStoreUtils.init(this)
}
}
@@ -0,0 +1,24 @@
package com.zj.wanandroid.common.di
import com.zj.wanandroid.data.http.ApiCall
import com.zj.wanandroid.data.http.HttpService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import javax.inject.Singleton
//这里使用了SingletonComponent,因此 NetworkModule 绑定到 Application 的生命周期
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
@Singleton
@Provides
fun provideApiService(): HttpService = ApiCall.retrofit
@Singleton
@Provides
fun provideOkHttp(): OkHttpClient = ApiCall.okHttp
}
@@ -0,0 +1,11 @@
package com.zj.wanandroid.common.paging
import androidx.paging.PagingConfig
data class AppPagingConfig(
val pageSize: Int = 20,
val initialLoadSize: Int = 20,
val prefetchDistance:Int = 1,
val maxSize:Int = PagingConfig.MAX_SIZE_UNBOUNDED,
val enablePlaceholders:Boolean = false
)
@@ -0,0 +1,74 @@
package com.zj.wanandroid.common.paging
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.*
import com.zj.wanandroid.MyApp
import com.zj.wanandroid.data.bean.BasicBean
import com.zj.wanandroid.data.bean.ListWrapper
import com.zj.wanandroid.data.http.HttpResult
import com.zj.wanandroid.utils.NetCheckUtil
import com.zj.wanandroid.utils.showToast
import kotlinx.coroutines.flow.Flow
fun <T : Any> ViewModel.simplePager(
config: AppPagingConfig = AppPagingConfig(),
callAction: suspend (page: Int) -> BasicBean<ListWrapper<T>>
): Flow<PagingData<T>> {
return pager(config, 0) {
val page = it.key ?: 0
val response = try {
HttpResult.Success(callAction.invoke(page))
} catch (e: Exception) {
if (NetCheckUtil.checkNet(MyApp.CONTEXT).not()) {
showToast("没有网络,请重试")
} else {
showToast("请求失败,请重试")
}
HttpResult.Error(e)
}
when (response) {
is HttpResult.Success -> {
val data = response.result.data
val hasNotNext = (data!!.datas.size < it.loadSize) && (data.over)
PagingSource.LoadResult.Page(
data = response.result.data!!.datas,
prevKey = if (page - 1 > 0) page - 1 else null,
nextKey = if (hasNotNext) null else page + 1
)
}
is HttpResult.Error -> {
PagingSource.LoadResult.Error(response.exception)
}
}
}
}
fun <K : Any, V : Any> ViewModel.pager(
config: AppPagingConfig = AppPagingConfig(),
initialKey: K? = null,
loadData: suspend (PagingSource.LoadParams<K>) -> PagingSource.LoadResult<K, V>
): Flow<PagingData<V>> {
val baseConfig = PagingConfig(
config.pageSize,
initialLoadSize = config.initialLoadSize,
prefetchDistance = config.prefetchDistance,
maxSize = config.maxSize,
enablePlaceholders = config.enablePlaceholders
)
return Pager(
config = baseConfig,
initialKey = initialKey
) {
object : PagingSource<K, V>() {
override suspend fun load(params: LoadParams<K>): LoadResult<K, V> {
return loadData.invoke(params)
}
override fun getRefreshKey(state: PagingState<K, V>): K? {
return initialKey
}
}
}.flow.cachedIn(viewModelScope)
}
@@ -0,0 +1,215 @@
package com.zj.wanandroid.data.bean
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.zj.wanandroid.theme.AppTheme
import kotlinx.android.parcel.Parcelize
data class HomeThemeBean(
var theme: AppTheme.Theme
)
data class Hotkey(
var link: String?,
var name: String?,
var order: Int,
var visible: Int
)
data class BasicBean<T>(
var data: T?,
var errorCode: Int,
var errorMsg: String
)
data class ListWrapper<T>(
var curPage: Int,
var offset: Int,
var over: Boolean,
var pageCount: Int,
var size: Int,
var total: Int,
var datas: ArrayList<T>
)
data class BannerBean(
var desc: String?,
var id: Int,
var imagePath: String?,
var isVisible: Int,
var order: Int,
var title: String?,
var type: Int,
var url: String?
)
data class SharerBean<T>(
val coinInfo: PointsBean,
val shareArticles: ListWrapper<T>
)
data class BasicUserInfo(
val coinInfo: PointsBean,
val userInfo: UserInfo
)
@Parcelize
data class ParentBean(
var children: MutableList<ParentBean>?,
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? = null,
var link: String? = null
) : Parcelable
data class NaviWrapper(
var articles: MutableList<Article>?,
var cid: Int,
var name: String?
)
@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? = "描述Md",
var envelopePic: String? = "图片1",
var fresh: Boolean = false,
var host: String? = "https://www.wanandroid.com",
var id: Int = -1,
var link: String? = "https://www.wanandroid.com",
var niceDate: String? = "1970-0-0",
var niceShareDate: String? = "1970-0-0",
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
) : Parcelable
@Parcelize
data class ArticleTag(
var name: String,
var url: String
) : Parcelable
@Parcelize
data class WebData(
var title: String?,
var url: String
) : Parcelable
@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
data class PointsBean(
var id: Int?,
var coinCount: String,
var level: Int?,
var nickname: String,
var rank: String?,
var userId: Int,
var username: String,
var date: String?,
var desc: String?,
var reason: String?,
var type: Int?,
)
data class CollectBean(
var author: String = "",
var chapterId: Int = 0,
var chapterName: String = "",
var courseId: Int = 0,
var desc: String = "",
var envelopePic: String = "",
var id: Int = 0,
var link: String = "",
var niceDate: String = "",
var origin: String = "",
var originId: Int = 0,
var publishTime: Long = 0,
var title: String = "",
var userId: Int = 0,
var visible: Int = 0,
var zan: Int = 0
)
/********************* Begin: Welfare Data ***********************/
abstract class GankBasedBean<T : Any?> {
val page: Int = 1
@SerializedName("page_count")
val pageSize: Int = 20
val status: Int = 100
@SerializedName("total_counts")
val totalSize: Int = 96
abstract var data: T?
}
data class WelfareBean(
override var data: List<WelfareData>?
) : GankBasedBean<List<WelfareData>>()
@Parcelize
data class WelfareData(
@SerializedName("_id") var id: String?,
var author: String?,
var category: String?,
var createAt: String?,
var desc: String?,
var images: MutableList<String>,
var likeCounts: Int,
var publishedAt: String?,
var stars: Int,
var title: String?,
var type: String?,
var url: String?,
var views: Int,
) : Parcelable
/********************* Begin: Welfare Data ***********************/
@@ -0,0 +1,86 @@
package com.zj.wanandroid.data.http
import android.annotation.SuppressLint
import com.zj.wanandroid.data.http.interceptor.CacheCookieInterceptor
import com.zj.wanandroid.data.http.interceptor.LogInterceptor
import com.zj.wanandroid.data.http.interceptor.SetCookieInterceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.*
/**
* Created by Superman. 19/5/27
*/
object ApiCall {
/**
* 请求超时时间
*/
private const val DEFAULT_TIMEOUT = 30000
private lateinit var SERVICE: HttpService
//手动创建一个OkHttpClient并设置超时时间
val retrofit: HttpService
get() {
if (!ApiCall::SERVICE.isInitialized) {
SERVICE = Retrofit.Builder()
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(HttpService.url)
.build()
.create(HttpService::class.java)
}
return SERVICE
}
//手动创建一个OkHttpClient并设置超时时间
val okHttp: OkHttpClient
get() {
return OkHttpClient.Builder().run {
connectTimeout(DEFAULT_TIMEOUT.toLong(), TimeUnit.MILLISECONDS)
readTimeout(DEFAULT_TIMEOUT.toLong(), TimeUnit.MILLISECONDS)
writeTimeout(DEFAULT_TIMEOUT.toLong(), TimeUnit.MILLISECONDS)
addInterceptor(SetCookieInterceptor())
addInterceptor(CacheCookieInterceptor())
addInterceptor(LogInterceptor())
//不验证证书
sslSocketFactory(createSSLSocketFactory())
hostnameVerifier(TrustAllNameVerifier())
build()
}
}
private fun createSSLSocketFactory(): SSLSocketFactory {
lateinit var ssfFactory: SSLSocketFactory
try {
val sslFactory = SSLContext.getInstance("TLS")
sslFactory.init(null, arrayOf(TrustAllCerts()), SecureRandom());
ssfFactory = sslFactory.socketFactory
} catch (e: Exception) {
print("SSL错误:${e.message}")
}
return ssfFactory
}
}
class TrustAllNameVerifier: HostnameVerifier {
@SuppressLint("BadHostnameVerifier")
override fun verify(hostname: String?, session: SSLSession?): Boolean = true
}
@SuppressLint("CustomX509TrustManager")
class TrustAllCerts : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
@@ -0,0 +1,10 @@
package com.zj.wanandroid.data.http
import java.lang.Exception
sealed class HttpResult<out T> {
data class Success<T>(val result: T): HttpResult<T>()
data class Error(val exception: Exception): HttpResult<Nothing>()
}
@@ -0,0 +1,245 @@
package com.zj.wanandroid.data.http
import com.zj.wanandroid.data.bean.*
import retrofit2.http.*
/**
* 网络请求接口
* 注意:接口前无需加斜杠
* create by Mqz at 4/19
*/
interface HttpService {
companion object {
const val url = "https://www.wanandroid.com"
}
//首页
@GET("/article/list/{page}/json")
suspend fun getIndexList(@Path("page") page: Int): BasicBean<ListWrapper<Article>>
//广场
@GET("/user_article/list/{page}/json")
suspend fun getSquareData(@Path("page") page: Int): BasicBean<ListWrapper<Article>>
//问答
@GET("/wenda/list/{page}/json")
suspend fun getWendaData(@Path("page") page: Int): BasicBean<ListWrapper<Article>>
//置顶文章
@GET("/article/top/json")
suspend fun getTopArticles(): BasicBean<MutableList<Article>>
//搜索热词
@GET("/hotkey/json")
suspend fun getHotkeys(): BasicBean<MutableList<Hotkey>>
//banner
@GET("/banner/json")
suspend fun getBanners(): BasicBean<MutableList<BannerBean>>
//体系
@GET("/tree/json")
suspend fun getStructureList(): BasicBean<MutableList<ParentBean>>
//导航
@GET("/navi/json")
suspend fun getNavigationList(): BasicBean<MutableList<NaviWrapper>>
//公众号列表
@GET("/wxarticle/chapters/json")
suspend fun getPublicInformation(): BasicBean<MutableList<ParentBean>>
//某作者的公众号文章列表
@GET("/wxarticle/list/{id}/{page}/json")
suspend fun getPublicArticles(
@Path("id") publicId: Int,
@Path("page") page: Int
): BasicBean<ListWrapper<Article>>
//某公众号作者的文章下搜索
@GET("/wxarticle/list/{id}/{page}/json")
suspend fun getPublicArticlesWithKey(
@Path("id") publicId: Int,
@Path("page") page: Int,
@Query("k") key: String
): BasicBean<ListWrapper<Article>>
//项目分类
@GET("/project/tree/json")
suspend fun getProjectCategory(): BasicBean<MutableList<ParentBean>>
//某个项目分类下的列表
@GET("/project/list/{page}/json")
suspend fun getProjects(
@Path("page") page: Int,
@Query("cid") cid: Int
): BasicBean<ListWrapper<Article>>
//某个体系下的文章列表
@GET("/article/list/{page}/json")
suspend fun getStructureArticles(
@Path("page") page: Int,
@Query("cid") cid: Int
): BasicBean<ListWrapper<Article>>
//在某个体系下搜索某作者的文章
@GET("/article/list/{page}/json")
suspend fun getArticlesByAuthor(
@Path("page") page: Int,
@Query("author") author: String
): BasicBean<ListWrapper<Article>>
//热门项目
@GET("/article/listproject/{page}/json")
suspend fun getHotProjects(@Path("page") page: Int): BasicBean<ListWrapper<Article>>
//首页查询文章
@FormUrlEncoded
@POST("/article/query/{page}/json")
suspend fun queryArticle(
@Path("page") page: Int,
@Field("k") key: String
): BasicBean<ListWrapper<Article>>
//注册
@FormUrlEncoded
@POST("/user/register")
suspend fun register(
@Field("username") userName: String,
@Field("password") password: String,
@Field("repassword") repassword: String,
): BasicBean<UserInfo>
//登录
@FormUrlEncoded
@POST("/user/login")
suspend fun login(
@Field("username") userName: String,
@Field("password") password: String,
): BasicBean<UserInfo>
//退出登录
@GET("/user/logout/json")
suspend fun logout(): BasicBean<Any>
//排行榜
@GET("/coin/rank/{page}/json")
suspend fun getPointsRankings(@Path("page") page: Int): BasicBean<ListWrapper<PointsBean>>
//积分记录
@GET("/lg/coin/list/{page}/json")
suspend fun getPointsRecords(@Path("page") page: Int): BasicBean<ListWrapper<PointsBean>>
//我的积分
@GET("/lg/coin/userinfo/json")
suspend fun getMyPointsRanking(): BasicBean<PointsBean>
//消息数量
@GET("/message/lg/count_unread/json")
suspend fun getMessageCount(): BasicBean<Int>
//未读消息列表
@GET("/message/lg/unread_list/{page}/json")
suspend fun getUnreadMessage(@Path("page") page: Int): BasicBean<ListWrapper<Any>>
//已读消息列表
@GET("/message/lg/readed_list/{page}/json")
suspend fun getReadedMessage(@Path("page") page: Int): BasicBean<ListWrapper<Any>>
//收藏列表
@GET("/lg/collect/list/{page}/json")
suspend fun getCollectionList(@Path("page") page: Int): BasicBean<ListWrapper<CollectBean>>
//收藏的网站
@GET("/lg/collect/usertools/json")
suspend fun getCollectUrls(): BasicBean<MutableList<ParentBean>>
//收藏站内文章
@POST("/lg/collect/{id}/json")
suspend fun collectInnerArticle(@Path("id") id: Int): BasicBean<Any>
//取消收藏站内文章(在首页等列表里取消)
@POST("/lg/uncollect_originId/{id}/json")
suspend fun uncollectInnerArticle(@Path("id") id: Int): BasicBean<Any>
//取消收藏站内文章(在收藏列表里取消)
@POST("/lg/uncollect/{id}/json")
suspend fun uncollectArticleById(
@Path("id") id: Int,
@Query("originId") originId: Int
): BasicBean<Any>
//添加收藏网站
@FormUrlEncoded
@POST("/lg/collect/addtool/json")
suspend fun addNewWebsiteCollect(
@Field("name") title: String,
@Field("link") linkUrl: String
): BasicBean<ParentBean>
//添加站外文章
@FormUrlEncoded
@POST("/lg/collect/add/json")
suspend fun addNewArticleCollect(
@Field("title") title: String,
@Field("link") linkUrl: String,
@Field("author") author: String,
): BasicBean<CollectBean>
//删除收藏网站
@FormUrlEncoded
@POST("/lg/collect/deletetool/json")
suspend fun deleteWebsite(@Field("id") id: Int): BasicBean<Any>
//编辑收藏的网站
@FormUrlEncoded
@POST("/lg/collect/updatetool/json")
suspend fun editCollectWebsite(
@Field("id") id: Int,
@Field("name") title: String,
@Field("link") linkUrl: String,
): BasicBean<Any>
//自己分享的文章
@GET("/user/lg/private_articles/{page}/json")
suspend fun getMyShareArticles(
@Path("page") page: Int,
@Query("page_size") size: Int = 40,
): BasicBean<SharerBean<Article>>
//根据文章id删除自己分享的文章
@POST("/lg/user_article/delete/{id}/json")
suspend fun deleteMyShareArticle(@Path("id") id: Int): BasicBean<Any>
//分享文章
@FormUrlEncoded
@POST("/lg/user_article/add/json")
suspend fun addMyShareArticle(
@Field("title") title: String,
@Field("link") linkUrl: String,
@Field("shareUser") shareUser: String,
): BasicBean<Any>
//其他作者分享的文章
@GET("/user/{userId}/share_articles/{page}/json")
suspend fun getAuthorShareArticles(
@Path("userId") userId: Int,
@Path("page") page: Int,
@Query("page_size") size: Int = 40,
): BasicBean<SharerBean<Article>>
@GET("/user/lg/userinfo/json")
suspend fun getBasicUserInfo(): BasicBean<BasicUserInfo>
// 福利
@GET("https://gank.io/api/v2/data/category/{category}/type/{type}/page/{page}/count/{pageSize}")
suspend fun getWelfareList(
@Path(value = "category", encoded = false) category: String = "Girl",
@Path(value = "type", encoded = false) type: String = "Girl",
@Path("page") page: Int,
@Path("pageSize") pageCount: Int = 40
): WelfareBean
}
@@ -0,0 +1,7 @@
package com.zj.wanandroid.data.http
sealed class PageState {
object Loading : PageState()
data class Success(val isEmpty: Boolean) : PageState()
data class Error(val exception: Throwable) : PageState()
}
@@ -0,0 +1,57 @@
package com.zj.wanandroid.data.http.interceptor
import com.zj.wanandroid.data.store.DataStoreUtils
import okhttp3.Interceptor
import okhttp3.Response
class CacheCookieInterceptor: Interceptor {
private val loginUrl = "user/login"
private val registerUrl = "user/register"
private val SET_COOKIE_KEY = "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(SET_COOKIE_KEY)
if (cookies.isNotEmpty()) {
//cookie可能有多个,都保存下来
DataStoreUtils.putSyncData(domain, encodeCookie(cookies))
}
}
return response
}
private fun aboutUser(url: String): Boolean = url.contains(loginUrl) or url.contains(registerUrl)
}
/**
* 整理cookie
*/
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,133 @@
package com.zj.wanandroid.data.http.interceptor
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import okhttp3.*
import java.io.IOException
import java.net.URLDecoder
import javax.inject.Inject
/**
* Created by Superman on 2021/1/29.
*/
class LogInterceptor @Inject constructor() : Interceptor {
private val logTag = "http ## "
@RequiresApi(Build.VERSION_CODES.N)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response? {
val request = chain.request()
return kotlin.runCatching { chain.proceed(request) }
.onSuccess {
logRequest(request, chain.connection())
if (it.isSuccessful) {
logResponse(it)
} else {
logThat(ColorLevel.WARN(it.message() ?: "未知异常"))
}
}
.onFailure {
logThat(ColorLevel.ERROR(it.message ?: "未知异常"))
}
.getOrThrow()
}
private fun logResponse(response: Response) {
val strb = StringBuffer()
strb.appendLine("\r\n")
strb.appendLine("<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-")
var headerText = ""
response.headers().toMultimap().forEach { header->
headerText += "请求 Header:{${header.key}=${header.value}}\n"
}
strb.appendln(headerText)
kotlin.runCatching {
//peek类似于clone数据流,监视,窥探,不能直接用原来的body的string流数据作为日志,会消费掉io,所有这里是peek,监测
val peekBody: ResponseBody = response.peekBody(1024 * 1024)
strb.appendln(peekBody.string())
}.getOrNull()
strb.appendLine(
"<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<" +
"-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-<<-"
)
logThat(ColorLevel.INFO(strb.toString()))
}
/**
* 打印日志
*/
private fun logThat(tempLevel: ColorLevel) {
when (tempLevel) {
is ColorLevel.VERBOSE -> Log.v(logTag, tempLevel.logText)
is ColorLevel.DEBUG -> Log.d(logTag, tempLevel.logText)
is ColorLevel.INFO -> Log.i(logTag, tempLevel.logText)
is ColorLevel.WARN -> Log.w(logTag, tempLevel.logText)
is ColorLevel.ERROR -> Log.e(logTag, tempLevel.logText)
}
}
private fun logRequest(request: Request, connection: Connection?) {
val strb = StringBuilder()
strb.appendLine("\r\n")
strb.appendLine(
"->->->->->->->->->->->->->->->->->->->->->->->->->->->->->->->->->"
)
logHeaders(strb, request, connection)
strb.appendLine("RequestBody:${request.body().toString()}")
logThat(ColorLevel.VERBOSE(strb.toString()))
}
private fun logHeaders(strb: StringBuilder, request: Request, connection: Connection?) {
logBasic(strb, request, connection)
var headerStr = ""
request.headers().toMultimap().forEach { header->
headerStr += "请求 Header:{${header.key}=${header.value}}\n"
}
strb.appendln(headerStr)
}
private fun logBasic(strb: StringBuilder, request: Request, connection: Connection?) {
strb.appendLine(
"请求 method${request.method()} url:${decodeUrlStr(request.url().toString())} tag:" +
"${request.tag()} protocol:${connection?.protocol() ?: Protocol.HTTP_1_1}"
)
}
/**
* 对于url编码的string解码
*/
private fun decodeUrlStr(url: String): String? {
return kotlin.runCatching {
URLDecoder.decode(url, "utf-8")
}.onFailure { it.printStackTrace() }.getOrNull()
}
/**
* 打印日志范围
*/
enum class LogLevel {
NONE,//不打印
BASIC,//只打印行首,请求/响应
HEADERS, //打印请求和响应的所有header
BODY //打印所有
}
/**
* Log颜色等级,应用于android Logcat分为 v、d、i、w、e
*/
sealed class ColorLevel {
data class VERBOSE(val logText: String): ColorLevel()
data class DEBUG(val logText: String): ColorLevel()
data class INFO(val logText: String): ColorLevel()
data class WARN(val logText: String): ColorLevel()
data class ERROR(val logText: String): ColorLevel()
}
}
@@ -0,0 +1,22 @@
package com.zj.wanandroid.data.http.interceptor
import com.zj.wanandroid.data.store.DataStoreUtils
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: String = DataStoreUtils.readStringData(domain, "")
if (cookie.isNotEmpty()) {
builder.addHeader("Cookie", cookie)
}
}
return chain.proceed(builder.build())
}
}
@@ -0,0 +1,304 @@
package com.zj.wanandroid.data.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import com.zj.wanandroid.data.store.DataStoreUtils.clear
import com.zj.wanandroid.data.store.DataStoreUtils.clearSync
import com.zj.wanandroid.data.store.DataStoreUtils.getData
import com.zj.wanandroid.data.store.DataStoreUtils.getSyncData
import com.zj.wanandroid.data.store.DataStoreUtils.putData
import com.zj.wanandroid.data.store.DataStoreUtils.putSyncData
import com.zj.wanandroid.data.store.DataStoreUtils.readBooleanData
import com.zj.wanandroid.data.store.DataStoreUtils.readBooleanFlow
import com.zj.wanandroid.data.store.DataStoreUtils.readFloatData
import com.zj.wanandroid.data.store.DataStoreUtils.readFloatFlow
import com.zj.wanandroid.data.store.DataStoreUtils.readIntData
import com.zj.wanandroid.data.store.DataStoreUtils.readIntFlow
import com.zj.wanandroid.data.store.DataStoreUtils.readLongData
import com.zj.wanandroid.data.store.DataStoreUtils.readLongFlow
import com.zj.wanandroid.data.store.DataStoreUtils.readStringData
import com.zj.wanandroid.data.store.DataStoreUtils.readStringFlow
import com.zj.wanandroid.data.store.DataStoreUtils.saveBooleanData
import com.zj.wanandroid.data.store.DataStoreUtils.saveFloatData
import com.zj.wanandroid.data.store.DataStoreUtils.saveIntData
import com.zj.wanandroid.data.store.DataStoreUtils.saveLongData
import com.zj.wanandroid.data.store.DataStoreUtils.saveStringData
import com.zj.wanandroid.data.store.DataStoreUtils.saveSyncBooleanData
import com.zj.wanandroid.data.store.DataStoreUtils.saveSyncFloatData
import com.zj.wanandroid.data.store.DataStoreUtils.saveSyncIntData
import com.zj.wanandroid.data.store.DataStoreUtils.saveSyncLongData
import com.zj.wanandroid.data.store.DataStoreUtils.saveSyncStringData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import java.io.IOException
/**
* 版权:Zhujiang 个人版权
*
* @author zhujiang
* 创建日期:12/3/20
*
* 异步获取数据
* [getData] [readBooleanFlow] [readFloatFlow] [readIntFlow] [readLongFlow] [readStringFlow]
* 同步获取数据
* [getSyncData] [readBooleanData] [readFloatData] [readIntData] [readLongData] [readStringData]
*
* 异步写入数据
* [putData] [saveBooleanData] [saveFloatData] [saveIntData] [saveLongData] [saveStringData]
* 同步写入数据
* [putSyncData] [saveSyncBooleanData] [saveSyncFloatData] [saveSyncIntData] [saveSyncLongData] [saveSyncStringData]
*
* 异步清除数据
* [clear]
* 同步清除数据
* [clearSync]
*
* 描述:DataStore 工具类
*
*/
object DataStoreUtils {
private const val preferenceName = "WanAndroidDataStore"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(preferenceName)
private lateinit var dataStore: DataStore<Preferences>
/**
* init Context
* @param context Context
*/
fun init(context: Context) {
dataStore = context.dataStore
}
@Suppress("UNCHECKED_CAST")
fun <U> getSyncData(key: String, default: U): U {
val res = when (default) {
is Long -> readLongData(key, default)
is String -> readStringData(key, default)
is Int -> readIntData(key, default)
is Boolean -> readBooleanData(key, default)
is Float -> readFloatData(key, default)
else -> throw IllegalArgumentException("This type can be saved into DataStore")
}
return res as U
}
@Suppress("UNCHECKED_CAST")
fun <U> getData(key: String, default: U): Flow<U> {
val data = when (default) {
is Long -> readLongFlow(key, default)
is String -> readStringFlow(key, default)
is Int -> readIntFlow(key, default)
is Boolean -> readBooleanFlow(key, default)
is Float -> readFloatFlow(key, default)
else -> throw IllegalArgumentException("This type can be saved into DataStore")
}
return data as Flow<U>
}
suspend fun <U> putData(key: String, value: U) {
when (value) {
is Long -> saveLongData(key, value)
is String -> saveStringData(key, value)
is Int -> saveIntData(key, value)
is Boolean -> saveBooleanData(key, value)
is Float -> saveFloatData(key, value)
else -> throw IllegalArgumentException("This type can be saved into DataStore")
}
}
fun <U> putSyncData(key: String, value: U) {
when (value) {
is Long -> saveSyncLongData(key, value)
is String -> saveSyncStringData(key, value)
is Int -> saveSyncIntData(key, value)
is Boolean -> saveSyncBooleanData(key, value)
is Float -> saveSyncFloatData(key, value)
else -> throw IllegalArgumentException("This type can be saved into DataStore")
}
}
fun readBooleanFlow(key: String, default: Boolean = false): Flow<Boolean> =
dataStore.data
.catch {
//当读取数据遇到错误时,如果是 `IOException` 异常,发送一个 emptyPreferences 来重新使用
//但是如果是其他的异常,最好将它抛出去,不要隐藏问题
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map {
it[booleanPreferencesKey(key)] ?: default
}
fun readBooleanData(key: String, default: Boolean = false): Boolean {
var value = false
runBlocking {
dataStore.data.first {
value = it[booleanPreferencesKey(key)] ?: default
true
}
}
return value
}
fun readIntFlow(key: String, default: Int = 0): Flow<Int> =
dataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map {
it[intPreferencesKey(key)] ?: default
}
fun readIntData(key: String, default: Int = 0): Int {
var value = 0
runBlocking {
dataStore.data.first {
value = it[intPreferencesKey(key)] ?: default
true
}
}
return value
}
fun readStringFlow(key: String, default: String = ""): Flow<String> =
dataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map {
it[stringPreferencesKey(key)] ?: default
}
fun readStringData(key: String, default: String = ""): String {
var value = ""
runBlocking {
dataStore.data.first {
value = it[stringPreferencesKey(key)] ?: default
true
}
}
return value
}
fun readFloatFlow(key: String, default: Float = 0f): Flow<Float> =
dataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map {
it[floatPreferencesKey(key)] ?: default
}
fun readFloatData(key: String, default: Float = 0f): Float {
var value = 0f
runBlocking {
dataStore.data.first {
value = it[floatPreferencesKey(key)] ?: default
true
}
}
return value
}
fun readLongFlow(key: String, default: Long = 0L): Flow<Long> =
dataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map {
it[longPreferencesKey(key)] ?: default
}
fun readLongData(key: String, default: Long = 0L): Long {
var value = 0L
runBlocking {
dataStore.data.first {
value = it[longPreferencesKey(key)] ?: default
true
}
}
return value
}
suspend fun saveBooleanData(key: String, value: Boolean) {
dataStore.edit { mutablePreferences ->
mutablePreferences[booleanPreferencesKey(key)] = value
}
}
fun saveSyncBooleanData(key: String, value: Boolean) =
runBlocking { saveBooleanData(key, value) }
suspend fun saveIntData(key: String, value: Int) {
dataStore.edit { mutablePreferences ->
mutablePreferences[intPreferencesKey(key)] = value
}
}
fun saveSyncIntData(key: String, value: Int) = runBlocking { saveIntData(key, value) }
suspend fun saveStringData(key: String, value: String) {
dataStore.edit { mutablePreferences ->
mutablePreferences[stringPreferencesKey(key)] = value
}
}
fun saveSyncStringData(key: String, value: String) = runBlocking { saveStringData(key, value) }
suspend fun saveFloatData(key: String, value: Float) {
dataStore.edit { mutablePreferences ->
mutablePreferences[floatPreferencesKey(key)] = value
}
}
fun saveSyncFloatData(key: String, value: Float) = runBlocking { saveFloatData(key, value) }
suspend fun saveLongData(key: String, value: Long) {
dataStore.edit { mutablePreferences ->
mutablePreferences[longPreferencesKey(key)] = value
}
}
fun saveSyncLongData(key: String, value: Long) = runBlocking { saveLongData(key, value) }
suspend fun clear() {
dataStore.edit {
it.clear()
}
}
fun clearSync() {
runBlocking {
dataStore.edit {
it.clear()
}
}
}
}
@@ -0,0 +1,45 @@
package com.zj.wanandroid.theme
import androidx.compose.ui.graphics.Color
import com.zj.wanandroid.MyApp
import com.zj.wanandroid.R
val Transparent = Color(0x00000000)
val themeColor = Color(MyApp.CONTEXT.resources.getColor(R.color.primary))
val splashText = Color(0x25000000)
val white = Color(0xFFFFFFFF)
val white1 = Color(0xFFF7F7F7)
val white2 = Color(0xFFEDEDED)
val white3 = Color(0xFFE5E5E5)
val white4 = Color(0xFFD5D5D5)
val white5 = Color(0xFFCCCCCC)
val black = Color(0xFF000000)
val black1 = Color(0xFF1E1E1E)
val black2 = Color(0xFF111111)
val black3 = Color(0xFF191919)
val black4 = Color(0xFF252525)
val black5 = Color(0xFF2C2C2C)
val black6 = Color(0xFF07130A)
val black7 = Color(0xFF292929)
val grey1 = Color(0xFF888888)
val grey2 = Color(0xFFCCC7BF)
val grey3 = Color(0xFF767676)
val grey4 = Color(0xFFB2B2B2)
val grey5 = Color(0xFF5E5E5E)
val green1 = Color(0xFFB0EB6E)
val green2 = Color(0xFF6DB476)
val green3 = Color(0xFF67BF63)
val red = Color(0xFFFF0000)
val red1 = Color(0xFFDF5554)
val red2 = Color(0xFFDD302E)
val red3 = Color(0xFFF77B7A)
val red4 = Color(0xFFD42220)
val red5 = Color(0xFFC51614)
val red6 = Color(0xFFF74D4B)
val red7 = Color(0xFFDC514E)
val red8 = Color(0xFFCBC7BF)
val yellow1 = Color(0xFFF6CA23)
val blue = Color(0xFF0000FF)
val info = Color(0xFF018786)
val warn = Color(0xFFD87831)
@@ -0,0 +1,29 @@
package com.zj.wanandroid.theme
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
val ToolBarHeight = 48.dp
val TabBarHeight = 48.dp
val SearchBarHeight = 42.dp
val BottomNavBarHeight = 56.dp
val ListTitleHeight = 30.dp
val PrimaryButtonHeight = 36.dp
val MediumButtonHeight = 28.dp
val SmallButtonHeight = 28.dp
val H1 = 48.sp //超大号标题
val H2 = 36.sp //大号标题
val H3 = 24.sp //主标题
val H4 = 20.sp //普通标题
val H5 = 16.sp //内容文本
val H6 = 14.sp //普通文字尺寸
val H7 = 12.sp //提示语尺寸
val ToolBarTitleSize = 18.sp
val cardCorner = 5.dp //卡片的圆角
val buttonCorner = 3.dp //按钮的圆角
val buttonHeight = 36.dp //按钮的高度
@@ -0,0 +1,11 @@
package com.zj.wanandroid.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val AppShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
@@ -0,0 +1,185 @@
package com.zj.wanandroid.theme
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.TweenSpec
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.systemuicontroller.rememberSystemUiController
//夜色主题
private val DarkColorPalette = AppColors(
themeUi = black1,
background = black2,
listItem = black3,
divider = black4,
textPrimary = white4,
textSecondary = grey1,
mainColor = white,
card = white1,
icon = white4,
info = info,
warn = warn,
success = green3,
error = red2,
primaryBtnBg = black1,
secondBtnBg = white1,
hot = red,
placeholder = grey1,
)
//白天主题
private val LightColorPalette = AppColors(
themeUi = themeColor,
background = white2,
listItem = white,
divider = white3,
textPrimary = black3,
textSecondary = grey1,
mainColor = white,
card = white1,
icon = white4,
info = info,
warn = warn,
success = green3,
error = red2,
primaryBtnBg = themeColor,
secondBtnBg = white3,
hot = red,
placeholder = white3,
)
var LocalAppColors = compositionLocalOf {
LightColorPalette
}
@Stable
object AppTheme {
val colors: AppColors
@Composable
get() = LocalAppColors.current
enum class Theme {
Light, Dark
}
}
@Stable
class AppColors(
themeUi: Color,
background: Color,
listItem: Color,
divider: Color,
textPrimary: Color,
textSecondary: Color,
mainColor: Color,
card: Color,
icon: Color,
info: Color,
warn: Color,
success: Color,
error: Color,
primaryBtnBg: Color,
secondBtnBg: Color,
hot: Color,
placeholder: Color,
) {
var themeUi: Color by mutableStateOf(themeUi)
internal set
var background: Color by mutableStateOf(background)
private set
var listItem: Color by mutableStateOf(listItem)
private set
var divider: Color by mutableStateOf(divider)
private set
var textPrimary: Color by mutableStateOf(textPrimary)
internal set
var textSecondary: Color by mutableStateOf(textSecondary)
private set
var mainColor: Color by mutableStateOf(mainColor)
internal set
var card: Color by mutableStateOf(card)
private set
var icon: Color by mutableStateOf(icon)
private set
var info: Color by mutableStateOf(info)
private set
var warn: Color by mutableStateOf(warn)
private set
var success: Color by mutableStateOf(success)
private set
var error: Color by mutableStateOf(error)
private set
var primaryBtnBg: Color by mutableStateOf(primaryBtnBg)
internal set
var secondBtnBg: Color by mutableStateOf(secondBtnBg)
private set
var hot: Color by mutableStateOf(hot)
private set
var placeholder: Color by mutableStateOf(placeholder)
private set
}
@Composable
fun AppTheme(
theme: AppTheme.Theme = AppTheme.Theme.Light,
content: @Composable () -> Unit
) {
val targetColors = when (theme) {
AppTheme.Theme.Light -> {
LightColorPalette.themeUi = themeColor
LightColorPalette.primaryBtnBg = themeColor
LightColorPalette
}
AppTheme.Theme.Dark -> DarkColorPalette
}
val themeUi = animateColorAsState(targetColors.themeUi, TweenSpec(600))
val background = animateColorAsState(targetColors.background, TweenSpec(600))
val listItem = animateColorAsState(targetColors.listItem, TweenSpec(600))
val divider = animateColorAsState(targetColors.divider, TweenSpec(600))
val textPrimary = animateColorAsState(targetColors.textPrimary, TweenSpec(600))
val textSecondary = animateColorAsState(targetColors.textSecondary, TweenSpec(600))
val mainColor = animateColorAsState(targetColors.mainColor, TweenSpec(600))
val card = animateColorAsState(targetColors.card, TweenSpec(600))
val icon = animateColorAsState(targetColors.icon, TweenSpec(600))
val info = animateColorAsState(targetColors.info, TweenSpec(600))
val warn = animateColorAsState(targetColors.warn, TweenSpec(600))
val success = animateColorAsState(targetColors.success, TweenSpec(600))
val error = animateColorAsState(targetColors.error, TweenSpec(600))
val primaryBtnBg = animateColorAsState(targetColors.primaryBtnBg, TweenSpec(600))
val secondBtnBg = animateColorAsState(targetColors.secondBtnBg, TweenSpec(600))
val hot = animateColorAsState(targetColors.hot, TweenSpec(600))
val placeholder = animateColorAsState(targetColors.placeholder, TweenSpec(600))
val appColors = AppColors(
themeUi = themeUi.value,
background = background.value,
listItem = listItem.value,
divider = divider.value,
textPrimary = textPrimary.value,
textSecondary = textSecondary.value,
mainColor = mainColor.value,
card = card.value,
icon = icon.value,
primaryBtnBg = primaryBtnBg.value,
secondBtnBg = secondBtnBg.value,
info = info.value,
warn = warn.value,
success = success.value,
error = error.value,
hot = hot.value,
placeholder = placeholder.value
)
val systemUiCtrl = rememberSystemUiController()
systemUiCtrl.setStatusBarColor(appColors.themeUi)
systemUiCtrl.setNavigationBarColor(appColors.themeUi)
systemUiCtrl.setSystemBarsColor(appColors.themeUi)
ProvideWindowInsets {
CompositionLocalProvider(LocalAppColors provides appColors, content = content)
}
}
@@ -0,0 +1,28 @@
package com.zj.wanandroid.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
/* Other default text styles to override
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)
*/
)
@@ -0,0 +1,120 @@
package com.zj.wanandroid.ui.page.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarHost
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsPadding
import com.google.accompanist.pager.ExperimentalPagerApi
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.ui.page.login.LoginPage
import com.zj.wanandroid.ui.page.main.category.CategoryPage
import com.zj.wanandroid.ui.page.main.collect.CollectPage
import com.zj.wanandroid.ui.page.main.home.HomePage
import com.zj.wanandroid.ui.page.main.profile.ProfilePage
import com.zj.wanandroid.ui.page.search.SearchPage
import com.zj.wanandroid.ui.page.webview.WebViewPage
import com.zj.wanandroid.ui.widgets.AppSnackBar
import com.zj.wanandroid.ui.widgets.BottomNavBarView
import com.zj.wanandroid.utils.RouteUtils
import com.zj.wanandroid.utils.fromJson
@ExperimentalComposeUiApi
@ExperimentalFoundationApi
@ExperimentalPagerApi
@Composable
fun AppScaffold() {
val navCtrl = rememberNavController()
val navBackStackEntry by navCtrl.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val scaffoldState = rememberScaffoldState()
Scaffold(
modifier = Modifier
.statusBarsPadding()
.navigationBarsPadding(),
bottomBar = {
when (currentDestination?.route) {
RouteName.HOME -> BottomNavBarView(navCtrl = navCtrl)
RouteName.CATEGORY -> BottomNavBarView(navCtrl = navCtrl)
RouteName.COLLECTION -> BottomNavBarView(navCtrl = navCtrl)
RouteName.PROFILE -> BottomNavBarView(navCtrl = navCtrl)
}
},
content = {
var homeIndex = remember { 0 }
var categoryIndex = remember { 0 }
NavHost(
modifier = Modifier.background(MaterialTheme.colors.background),
navController = navCtrl,
startDestination = RouteName.HOME
) {
//首页
composable(route = RouteName.HOME) {
HomePage(navCtrl, scaffoldState)
}
//分类
composable(route = RouteName.CATEGORY) {
CategoryPage(navCtrl)
}
//收藏
composable(route = RouteName.COLLECTION) {
CollectPage(navCtrl, scaffoldState)
}
//我的
composable(route = RouteName.PROFILE) {
ProfilePage(navCtrl, scaffoldState)
}
//WebView
composable(route = RouteName.WEB_VIEW + "/{webData}",
arguments = listOf(navArgument("webData") { type = NavType.StringType })
) {
val args = it.arguments?.getString("webData")?.fromJson<WebData>()
if (args != null) {
WebViewPage(webData = args, navCtrl = navCtrl)
}
}
//登录
composable(route = RouteName.LOGIN) {
LoginPage(navCtrl, scaffoldState)
}
//文章搜索页
composable(
route = RouteName.ARTICLE_SEARCH + "/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType })
) {
SearchPage(navCtrl, scaffoldState)
}
}
},
snackbarHost = {
SnackbarHost(
hostState = scaffoldState.snackbarHostState
) { data ->
println("actionLabel = ${data.actionLabel}")
AppSnackBar(data = data)
}
}
)
}
@@ -0,0 +1,18 @@
package com.zj.wanandroid.ui.page.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.zj.wanandroid.R
sealed class BottomNavRoute(
var routeName: String,
@StringRes var stringId: Int,
var icon: ImageVector
) {
object Home: BottomNavRoute(RouteName.HOME, R.string.home, Icons.Default.Home)
object Category: BottomNavRoute(RouteName.CATEGORY, R.string.category, Icons.Default.Menu)
object Collection: BottomNavRoute(RouteName.COLLECTION, R.string.collection, Icons.Default.Favorite)
object Profile: BottomNavRoute(RouteName.PROFILE, R.string.profile, Icons.Default.Person)
}
@@ -0,0 +1,26 @@
package com.zj.wanandroid.ui.page.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import com.google.accompanist.pager.ExperimentalPagerApi
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.ui.page.splash.SplashPage
@ExperimentalPagerApi
@ExperimentalFoundationApi
@ExperimentalComposeUiApi
@Composable
fun HomeEntry() {
//是否闪屏页
var isSplash by remember { mutableStateOf(true) }
AppTheme {
if (isSplash) {
SplashPage { isSplash = false }
} else {
AppScaffold()
}
}
}
@@ -0,0 +1,11 @@
package com.zj.wanandroid.ui.page.common
object RouteName {
const val HOME = "home"
const val CATEGORY = "category"
const val COLLECTION = "collection"
const val PROFILE = "profile"
const val WEB_VIEW = "web_view"
const val LOGIN = "login"
const val ARTICLE_SEARCH = "article_search"
}
@@ -0,0 +1,122 @@
package com.zj.wanandroid.ui.page.login
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Icon
import androidx.compose.material.ScaffoldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.ToolBarHeight
import com.zj.wanandroid.ui.widgets.*
import com.zj.wanandroid.utils.RouteUtils.back
import kotlinx.coroutines.flow.collect
@ExperimentalComposeUiApi
@Composable
fun LoginPage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: LoginViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val keyboardController = LocalSoftwareKeyboardController.current
val coroutineState = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.viewEvents.collect {
if (it is LoginViewEvent.PopBack) {
navCtrl.popBackStack()
} else if (it is LoginViewEvent.ErrorMessage) {
popupSnackBar(coroutineState, scaffoldState, label = SNACK_ERROR, it.message)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(AppTheme.colors.themeUi)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
keyboardController?.hide()
}
)
},
) {
item {
Box(
modifier = Modifier
.padding(bottom = 80.dp)
.fillMaxWidth()
.height(ToolBarHeight)
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = null,
tint = AppTheme.colors.mainColor,
modifier = Modifier
.padding(start = 20.dp)
.align(Alignment.CenterStart)
.clickable { navCtrl.back() }
)
}
}
item {
Box(Modifier.fillMaxWidth()) {
LargeTitle(
title = "WanAndroid",
modifier = Modifier
.padding(bottom = 50.dp)
.align(Alignment.Center),
color = AppTheme.colors.mainColor
)
}
}
item {
LoginEditView(
text = viewStates.account,
labelText = "账号",
hintText = "请输入用户名",
onValueChanged = { viewModel.dispatch(LoginViewAction.UpdateAccount(it)) },
onDeleteClick = { viewModel.dispatch(LoginViewAction.ClearAccount) }
)
}
item {
LoginEditView(
text = viewStates.password,
labelText = "密码",
hintText = "请输入密码",
onValueChanged = { viewModel.dispatch(LoginViewAction.UpdatePassword(it)) },
onDeleteClick = { viewModel.dispatch(LoginViewAction.ClearPassword) },
modifier = Modifier.padding(top = 20.dp, bottom = 20.dp),
isPassword = true
)
}
item {
AppButton(
text = "登录",
modifier = Modifier.padding(horizontal = 20.dp)
) {
keyboardController?.hide()
viewModel.dispatch(LoginViewAction.Login)
}
}
}
}
@@ -0,0 +1,97 @@
package com.zj.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.zj.wanandroid.data.http.HttpResult
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.utils.AppUserUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
var viewStates by mutableStateOf(LoginViewState())
private set
private val _viewEvents = Channel<LoginViewEvent>(Channel.BUFFERED)
val viewEvents = _viewEvents.receiveAsFlow()
fun dispatch(action: LoginViewAction) {
when (action) {
is LoginViewAction.Login -> login()
is LoginViewAction.ClearAccount -> clearAccount()
is LoginViewAction.ClearPassword -> clearPassword()
is LoginViewAction.UpdateAccount -> updateAccount(action.account)
is LoginViewAction.UpdatePassword -> updatePassword(action.password)
}
}
private fun login() {
viewModelScope.launch {
flow {
emit(service.login(viewStates.account.trim(), viewStates.password.trim()))
}.map {
if (it.errorCode == 0) {
if (it.data != null) {
HttpResult.Success(it.data!!)
} else {
throw Exception("the result of remote's request is null")
}
} else {
throw Exception(it.errorMsg)
}
}.onEach {
AppUserUtil.onLogin(it.result)
_viewEvents.send(LoginViewEvent.PopBack)
}.catch {
_viewEvents.send(LoginViewEvent.ErrorMessage(it.message ?: ""))
}.collect()
}
}
private fun clearAccount() {
viewStates = viewStates.copy(account = "")
}
private fun clearPassword() {
viewStates = viewStates.copy(password = "")
}
private fun updateAccount(account: String) {
viewStates = viewStates.copy(account = account)
}
private fun updatePassword(password: String) {
viewStates = viewStates.copy(password = password)
}
}
data class LoginViewState(
val account: String = "",
val password: String = "",
val isLogged: Boolean = false
)
/**
* 一次性事件
*/
sealed class LoginViewEvent {
object PopBack : LoginViewEvent()
data class ErrorMessage(val message: String) : LoginViewEvent()
}
sealed class LoginViewAction {
object Login : LoginViewAction()
object ClearAccount : LoginViewAction()
object ClearPassword : LoginViewAction()
data class UpdateAccount(val account: String) : LoginViewAction()
data class UpdatePassword(val password: String) : LoginViewAction()
}
@@ -0,0 +1,72 @@
package com.zj.wanandroid.ui.page.main.category
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.BottomNavBarHeight
import com.zj.wanandroid.ui.page.main.category.navi.NaviPage
import com.zj.wanandroid.ui.page.main.category.pubaccount.PublicAccountPage
import com.zj.wanandroid.ui.page.main.category.stucture.StructurePage
import com.zj.wanandroid.ui.widgets.TextTabBar
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
@OptIn(ExperimentalPagerApi::class)
@Composable
fun CategoryPage(
navCtrl: NavHostController,
categoryIndex: Int = 0,
viewModel: CategoryViewModel = hiltViewModel()
) {
val titles = viewModel.titles
Box(modifier = Modifier.padding(bottom = BottomNavBarHeight)) {
Column {
val pagerState = rememberPagerState(
initialPage = categoryIndex,
)
val scopeState = rememberCoroutineScope()
Row {
TextTabBar(
index = pagerState.currentPage,
tabTexts = titles,
modifier = Modifier.weight(1f),
contentAlign = Alignment.CenterStart,
onTabSelected = { index ->
scopeState.launch {
pagerState.scrollToPage(index)
}
}
)
}
HorizontalPager(
count = titles.size,
state = pagerState,
modifier = Modifier.background(AppTheme.colors.background)
) { page ->
when (page) {
0 -> StructurePage(navCtrl)
1 -> NaviPage(navCtrl)
2 -> PublicAccountPage(navCtrl)
}
}
}
}
}
@@ -0,0 +1,27 @@
package com.zj.wanandroid.ui.page.main.category
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.zj.wanandroid.ui.widgets.TabTitle
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class CategoryViewModel @Inject constructor() : ViewModel() {
val titles by mutableStateOf(
mutableListOf(
TabTitle(201, "体系"),
TabTitle(202, "导航"),
TabTitle(203, "公众号"),
)
)
override fun onCleared() {
super.onCleared()
println("CategoryViewModel ==> onClear")
}
}
@@ -0,0 +1,98 @@
package com.zj.wanandroid.ui.page.main.category.navi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.google.accompanist.flowlayout.FlowRow
import com.zj.wanandroid.data.bean.NaviWrapper
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.ui.widgets.LabelTextButton
import com.zj.wanandroid.ui.widgets.LcePage
import com.zj.wanandroid.ui.widgets.ListTitle
@ExperimentalFoundationApi
@Composable
fun NaviPage(navCtrl: NavHostController, viewModel: NaviViewModel = hiltViewModel()) {
val viewStates = viewModel.viewStates
LcePage(pageState = viewStates.pageState, onRetry = {
viewModel.dispatch(NaviViewAction.FetchData)
}) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = viewStates.listState,
contentPadding = PaddingValues(vertical = 10.dp)
) {
viewStates.dataList.forEachIndexed { index, naviBean ->
stickyHeader { ListTitle(title = naviBean.name ?: "标题") }
item {
NaviItem(naviBean, onSelected = {
})
if (index <= viewStates.size - 1) {
Divider(
startIndent = 10.dp,
color = AppTheme.colors.divider,
thickness = 0.8f.dp
)
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
}
@Composable
fun NaviItem(
wrapper: NaviWrapper,
isLoading: Boolean = false,
onSelected: (WebData) -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
if (isLoading) {
ListTitle(title = "我是标题")
FlowRow(
modifier = Modifier.padding(top = 10.dp)
) {
for (i in 0..7) {
LabelTextButton(
text = "android",
modifier = Modifier.padding(start = 5.dp, bottom = 5.dp),
isLoading = true
)
}
}
Spacer(modifier = Modifier.height(10.dp))
} else {
if (!wrapper.articles.isNullOrEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 10.dp)
) {
for (item in wrapper.articles!!) {
LabelTextButton(
text = item.title ?: "android",
modifier = Modifier.padding(start = 5.dp, bottom = 5.dp),
onClick = {
val webData = WebData(item.title, item.link!!)
onSelected(webData)
}
)
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@@ -0,0 +1,64 @@
package com.zj.wanandroid.ui.page.main.category.navi
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.zj.wanandroid.data.bean.NaviWrapper
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.data.http.PageState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class NaviViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
var viewStates by mutableStateOf(NaviViewState())
private set
init {
dispatch(NaviViewAction.FetchData)
}
fun dispatch(action: NaviViewAction) {
when (action) {
is NaviViewAction.FetchData -> fetchData()
}
}
private fun fetchData() {
viewModelScope.launch {
flow {
emit(service.getNavigationList())
}.map {
it.data ?: emptyList()
}.onStart {
viewStates = viewStates.copy(pageState = PageState.Loading)
}.onEach {
viewStates = viewStates.copy(
dataList = it,
pageState = PageState.Success(it.isEmpty())
)
}.catch {
viewStates = viewStates.copy(pageState = PageState.Error(it))
}.collect()
}
}
}
data class NaviViewState(
val dataList: List<NaviWrapper> = emptyList(),
val pageState: PageState = PageState.Loading,
val listState: LazyListState = LazyListState()
) {
val size = dataList.size
}
sealed class NaviViewAction() {
object FetchData : NaviViewAction()
}
@@ -0,0 +1,64 @@
package com.zj.wanandroid.ui.page.main.category.pubaccount
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.zj.wanandroid.data.bean.ParentBean
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.data.http.PageState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PubAccountViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
var viewStates by mutableStateOf(PubAccountViewState())
private set
init {
dispatch(PubAccountViewAction.FetchData)
}
fun dispatch(action: PubAccountViewAction) {
when (action) {
is PubAccountViewAction.FetchData -> fetchData()
}
}
private fun fetchData() {
viewModelScope.launch {
flow {
emit(service.getPublicInformation())
}.map {
it.data ?: emptyList()
}.onStart {
viewStates = viewStates.copy(pageState = PageState.Loading)
}.onEach {
viewStates = viewStates.copy(
dataList = it,
pageState = PageState.Success(it.isEmpty())
)
}.catch {
viewStates = viewStates.copy(pageState = PageState.Error(it))
}.collect()
}
}
}
data class PubAccountViewState(
val dataList: List<ParentBean> = emptyList(),
val pageState: PageState = PageState.Loading,
val listState: LazyListState = LazyListState()
) {
val size = dataList.size
}
sealed class PubAccountViewAction() {
object FetchData : PubAccountViewAction()
}
@@ -0,0 +1,124 @@
package com.zj.wanandroid.ui.page.main.category.pubaccount
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.GridCells
import androidx.compose.foundation.lazy.LazyVerticalGrid
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.zj.wanandroid.data.bean.ParentBean
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.white1
import com.zj.wanandroid.ui.widgets.LcePage
@ExperimentalFoundationApi
@Composable
fun PublicAccountPage(
navCtrl: NavHostController,
viewModel: PubAccountViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
LcePage(pageState = viewStates.pageState, onRetry = {
viewModel.dispatch(PubAccountViewAction.FetchData)
}) {
LazyVerticalGrid(
cells = GridCells.Fixed(2),
modifier = Modifier
.background(AppTheme.colors.background)
.wrapContentHeight()
.padding(10.dp),
state = viewStates.listState
) {
itemsIndexed(viewStates.dataList) { index, item ->
Box(Modifier.padding(vertical = 5.dp)) {
when (index % 4) {
0 -> PublicAccountItem(
parent = item,
click = {},
isPrimary = true,
roundedCorner = RoundedCornerShape(topStart = 5.dp, bottomStart = 5.dp)
)
1 -> PublicAccountItem(
parent = item,
click = {},
isPrimary = false,
roundedCorner = RoundedCornerShape(topEnd = 5.dp, bottomEnd = 5.dp)
)
2 -> PublicAccountItem(
parent = item,
click = {},
isPrimary = false,
roundedCorner = RoundedCornerShape(topStart = 5.dp, bottomStart = 5.dp)
)
3 -> PublicAccountItem(
parent = item,
click = {},
isPrimary = true,
roundedCorner = RoundedCornerShape(topEnd = 5.dp, bottomEnd = 5.dp)
)
}
}
}
}
}
}
@Composable
fun SpecialText(
parent: ParentBean,
textColor: Color,
bgColor: Color,
shape: RoundedCornerShape,
onClick: (ParentBean) -> Unit
) {
Text(
text = parent.name!!,
textAlign = TextAlign.Center,
fontSize = 18.sp,
color = textColor,
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(
color = bgColor,
shape = shape
)
.padding(top = 30.dp)
.clickable {
onClick(parent)
},
fontWeight = FontWeight.W500,
)
}
@Composable
private fun PublicAccountItem(
parent: ParentBean,
click: (ParentBean) -> Unit,
isPrimary: Boolean = true,
roundedCorner: RoundedCornerShape = RoundedCornerShape(5.dp)
) {
SpecialText(
parent = parent,
textColor = if (isPrimary) white1 else AppTheme.colors.textSecondary,
bgColor = if (isPrimary) AppTheme.colors.themeUi else white1,
shape = roundedCorner
) {
click(it)
}
}
@@ -0,0 +1,108 @@
package com.zj.wanandroid.ui.page.main.category.stucture
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.google.accompanist.flowlayout.FlowRow
import com.zj.wanandroid.data.bean.ParentBean
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.LabelTextButton
import com.zj.wanandroid.ui.widgets.LcePage
import com.zj.wanandroid.ui.widgets.ListTitle
import com.zj.wanandroid.utils.RouteUtils
@ExperimentalFoundationApi
@Composable
fun StructurePage(
navCtrl: NavHostController,
viewModel: StructureViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
LcePage(pageState = viewStates.pageState, onRetry = {
viewModel.dispatch(StructureViewAction.FetchData)
}) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(AppTheme.colors.background),
state = viewStates.listState,
contentPadding = PaddingValues(vertical = 10.dp)
) {
viewStates.dataList.forEachIndexed { position, chapter1 ->
stickyHeader { ListTitle(title = chapter1.name ?: "标题") }
item {
StructureItem(chapter1, onSelect = { parent ->
})
if (position <= viewStates.size - 1) {
Divider(
startIndent = 10.dp,
color = AppTheme.colors.divider,
thickness = 0.8f.dp
)
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
}
@Composable
fun StructureItem(
bean: ParentBean,
isLoading: Boolean = false,
onSelect: (parent: ParentBean) -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
) {
if (isLoading) {
ListTitle(title = "我都标题", isLoading = true)
FlowRow(
modifier = Modifier.padding(horizontal = 10.dp)
) {
for (i in 0..7) {
LabelTextButton(
text = "android",
modifier = Modifier.padding(start = 5.dp, bottom = 5.dp),
isLoading = true
)
}
}
Spacer(modifier = Modifier.height(10.dp))
} else {
if (!bean.children.isNullOrEmpty()) {
FlowRow(
modifier = Modifier.padding(horizontal = 10.dp)
) {
for (item in bean.children!!) {
LabelTextButton(
text = item.name ?: "android",
modifier = Modifier.padding(start = 5.dp, bottom = 5.dp),
onClick = {
onSelect(item)
}
)
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
}
@@ -0,0 +1,64 @@
package com.zj.wanandroid.ui.page.main.category.stucture
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.zj.wanandroid.data.bean.ParentBean
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.data.http.PageState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StructureViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
var viewStates by mutableStateOf(StructureViewState())
private set
init {
dispatch(StructureViewAction.FetchData)
}
fun dispatch(action: StructureViewAction) {
when (action) {
is StructureViewAction.FetchData -> fetchData()
}
}
private fun fetchData() {
viewModelScope.launch {
flow {
emit(service.getStructureList())
}.map {
it.data ?: emptyList()
}.onStart {
viewStates = viewStates.copy(pageState = PageState.Loading)
}.onEach {
viewStates = viewStates.copy(
dataList = it,
pageState = PageState.Success(it.isEmpty())
)
}.catch {
viewStates = viewStates.copy(pageState = PageState.Error(it))
}.collect()
}
}
}
data class StructureViewState(
val dataList: List<ParentBean> = emptyList(),
val pageState: PageState = PageState.Loading,
val listState: LazyListState = LazyListState()
) {
val size = dataList.size
}
sealed class StructureViewAction() {
object FetchData : StructureViewAction()
}
@@ -0,0 +1,111 @@
package com.zj.wanandroid.ui.page.main.collect
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.ScaffoldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.google.accompanist.flowlayout.FlowRow
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.BottomNavBarHeight
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.*
import com.zj.wanandroid.utils.RouteUtils
@ExperimentalFoundationApi
@Composable
fun CollectPage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: CollectViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val collectPaging = viewStates.pagingData?.collectAsLazyPagingItems()
val webUrls = viewStates.urlList
val titles = viewStates.titles
val isRefreshing = viewStates.isRefreshing
val listState =
if ((collectPaging?.itemCount ?: 0) > 0) viewStates.listState else LazyListState()
DisposableEffect(Unit) {
Log.i("debug", "onStart")
viewModel.dispatch(CollectViewAction.OnStart)
onDispose {
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = BottomNavBarHeight)
) {
AppToolsBar(title = "我的收藏")
if (!viewStates.isLogged) {
EmptyView(
tips = "点击登录",
imageVector = Icons.Default.Face
) {
RouteUtils.navTo(navCtrl, RouteName.LOGIN)
}
} else {
collectPaging?.let {
RefreshList(it, listState = listState, isRefreshing = isRefreshing, onRefresh = {
viewModel.dispatch(CollectViewAction.Refresh)
}) {
if (!webUrls.isNullOrEmpty()) {
stickyHeader {
ListTitle(title = titles[1].text)
}
item {
FlowRow(
modifier = Modifier.padding(10.dp)
) {
webUrls?.forEachIndexed { index, website ->
LabelTextButton(
text = website.name ?: "标签",
modifier = Modifier.padding(end = 10.dp, bottom = 10.dp),
onClick = {
RouteUtils.navTo(
navCtrl,
RouteName.WEB_VIEW,
WebData(website.name, website.link!!)
)
}
)
}
}
}
}
stickyHeader {
ListTitle(title = titles[0].text)
}
if (collectPaging.itemCount > 0) {
items(collectPaging) { collectItem ->
CollectListItemView(
collectItem!!,
onClick = {
RouteUtils.navTo(
navCtrl,
RouteName.WEB_VIEW,
WebData(collectItem.title, collectItem.link)
)
})
}
}
}
}
}
}
}
@@ -0,0 +1,94 @@
package com.zj.wanandroid.ui.page.main.collect
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.PagingData
import androidx.paging.cachedIn
import com.zj.wanandroid.common.paging.simplePager
import com.zj.wanandroid.data.bean.CollectBean
import com.zj.wanandroid.data.bean.ParentBean
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.ui.widgets.TabTitle
import com.zj.wanandroid.utils.AppUserUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CollectViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
private val pager by lazy {
simplePager {
service.getCollectionList(it)
}.cachedIn(viewModelScope)
}
var viewStates by mutableStateOf(CollectViewState())
private set
fun dispatch(action: CollectViewAction) {
when (action) {
is CollectViewAction.OnStart -> onStart()
is CollectViewAction.Refresh -> refresh()
}
}
private fun onStart() {
viewStates = viewStates.copy(isLogged = AppUserUtil.isLogged)
if (viewStates.isLogged && viewStates.isInit.not()) {
viewStates = viewStates.copy(isInit = true)
initData()
}
}
private fun initData() {
viewStates = viewStates.copy(pagingData = pager)
fetchUrls()
}
private fun refresh() {
fetchUrls()
}
private fun fetchUrls() {
viewModelScope.launch {
flow {
emit(service.getCollectUrls())
}.onStart {
viewStates = viewStates.copy(isRefreshing = true)
}.map {
it.data ?: emptyList()
}.onEach {
viewStates = viewStates.copy(urlList = it, isRefreshing = false)
}.catch {
viewStates = viewStates.copy(isRefreshing = false)
}.collect()
}
}
}
data class CollectViewState(
val isInit: Boolean = false,
val isRefreshing: Boolean = false,
val listState: LazyListState = LazyListState(),
val urlList: List<ParentBean> = emptyList(),
val pagingData: PagingCollect? = null,
val isLogged: Boolean = AppUserUtil.isLogged,
val titles: List<TabTitle> = listOf(
TabTitle(301, "文章列表"),
TabTitle(302, "我的网址"),
)
)
sealed class CollectViewAction {
object OnStart : CollectViewAction()
object Refresh : CollectViewAction()
}
typealias PagingCollect = Flow<PagingData<CollectBean>>
@@ -0,0 +1,66 @@
package com.zj.wanandroid.ui.page.main.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.page.main.home.question.QuestionPage
import com.zj.wanandroid.ui.page.main.home.recommend.RecommendPage
import com.zj.wanandroid.ui.page.main.home.square.SquarePage
import com.zj.wanandroid.ui.widgets.HomeSearchBar
import com.zj.wanandroid.ui.widgets.TextTabBar
import com.zj.wanandroid.utils.RouteUtils
import kotlinx.coroutines.launch
@ExperimentalPagerApi
@Composable
fun HomePage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: HomeViewModel = hiltViewModel()
) {
val titles = viewModel.viewStates.titles
val scopeState = rememberCoroutineScope()
Column {
val pagerState = rememberPagerState(
initialPage = 0
)
TextTabBar(
index = pagerState.currentPage,
tabTexts = titles,
onTabSelected = { index ->
scopeState.launch {
pagerState.scrollToPage(index)
}
}
)
HomeSearchBar(
onSearchClick = {
RouteUtils.navTo(navCtrl, RouteName.ARTICLE_SEARCH + "/111")
}
)
HorizontalPager(
count = titles.size,
state = pagerState,
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 50.dp)
) { page ->
when (page) {
0 -> SquarePage(navCtrl, scaffoldState)
1 -> RecommendPage(navCtrl, scaffoldState)
2 -> QuestionPage(navCtrl, scaffoldState)
}
}
}
}
@@ -0,0 +1,27 @@
package com.zj.wanandroid.ui.page.main.home
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.zj.wanandroid.ui.widgets.TabTitle
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {
var viewStates by mutableStateOf(HomeViewState())
private set
init {
viewStates = viewStates.copy(
titles = listOf(
TabTitle(101, "广场"),
TabTitle(102, "推荐"),
TabTitle(103, "问答")
)
)
}
}
data class HomeViewState(val titles: List<TabTitle> = emptyList())
@@ -0,0 +1,123 @@
package com.zj.wanandroid.ui.page.main.home.question
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.Card
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemsIndexed
import com.zj.wanandroid.data.bean.Article
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.MainTitle
import com.zj.wanandroid.ui.widgets.MiniTitle
import com.zj.wanandroid.ui.widgets.RefreshList
import com.zj.wanandroid.ui.widgets.TextContent
import com.zj.wanandroid.utils.RegexUtils
import com.zj.wanandroid.utils.RouteUtils
@Composable
fun QuestionPage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: QuestionViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val questionData = viewStates.pagingData.collectAsLazyPagingItems()
val listState = if (questionData.itemCount > 0) viewStates.listState else LazyListState()
RefreshList(questionData,listState = listState) {
itemsIndexed(questionData) { _, item ->
WenDaItem(item!!) {
item.run {
RouteUtils.navTo(
navCtrl,
RouteName.WEB_VIEW,
WebData(title, link!!)
)
}
}
}
}
}
@Composable
private fun WenDaItem(article: Article, isLoading: Boolean = false, onClick: () -> Unit = {}) {
Card(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(5.dp)
.clickable(enabled = !isLoading) {
onClick.invoke()
}
) {
Column(
modifier = Modifier.padding(10.dp)
) {
//标题
MainTitle(
title = titleSubstring(article.title) ?: "每日一问",
maxLine = 2,
isLoading = isLoading,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(vertical = 5.dp)
) {
//UserIcon(isLoading = isLoading)
MiniTitle(
text = "作者:${article.author ?: "xxx"}",
color = AppTheme.colors.textSecondary,
modifier = Modifier
.padding(start = if (isLoading) 5.dp else 0.dp)
.align(Alignment.CenterVertically),
isLoading = isLoading
)
Spacer(modifier = Modifier.width(10.dp))
//发布时间
//TimerIcon(isLoading = isLoading)
MiniTitle(
modifier = Modifier
.padding(start = if (isLoading) 5.dp else 0.dp)
.align(Alignment.CenterVertically),
text = "日期:${RegexUtils().timestamp(article.niceDate) ?: "2020"}",
color = AppTheme.colors.textSecondary,
maxLines = 1,
isLoading = isLoading
)
}
TextContent(
text = RegexUtils().symbolClear(article.desc),
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 60.dp),
isLoading = isLoading
)
}
}
}
private fun titleSubstring(oldText: String?): String? {
return oldText?.run {
var newText = this
if (startsWith("每日一问") && contains(" | ")) {
newText = substring(indexOf(" | ") + 3, length)
}
newText
}
}
@@ -0,0 +1,31 @@
package com.zj.wanandroid.ui.page.main.home.question
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 com.zj.wanandroid.common.paging.simplePager
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.ui.page.main.home.square.PagingArticle
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class QuestionViewModel @Inject constructor(
private var service: HttpService,
) : ViewModel() {
private val pager by lazy {
simplePager {
service.getWendaData(it)
}
}
var viewStates by mutableStateOf(QuestionViewState(pagingData = pager))
private set
}
data class QuestionViewState(
val isRefreshing: Boolean = false,
val listState: LazyListState = LazyListState(),
val pagingData: PagingArticle
)
@@ -0,0 +1,70 @@
package com.zj.wanandroid.ui.page.main.home.recommend
import android.util.Log
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemsIndexed
import com.google.accompanist.pager.ExperimentalPagerApi
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.Banner
import com.zj.wanandroid.ui.widgets.MultiStateItemView
import com.zj.wanandroid.ui.widgets.RefreshList
import com.zj.wanandroid.utils.RouteUtils
@ExperimentalPagerApi
@Composable
fun RecommendPage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: RecommendViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val recommendData = viewStates.pagingData.collectAsLazyPagingItems()
val banners = viewStates.imageList
val topArticle = viewStates.topArticles
val isRefreshing = viewStates.isRefreshing
val listState = if (recommendData.itemCount > 0) viewStates.listState else LazyListState()
RefreshList(recommendData, listState = listState, isRefreshing = isRefreshing, onRefresh = {
viewModel.dispatch(RecommendViewAction.Refresh)
}) {
if (banners.isNotEmpty()) {
item {
Banner(list = banners) { url, title ->
RouteUtils.navTo(navCtrl, RouteName.WEB_VIEW, WebData(title, url))
}
}
}
if (topArticle.isNotEmpty()) {
itemsIndexed(topArticle) { index, item ->
MultiStateItemView(
modifier = Modifier.padding(top = if (index == 0) 5.dp else 0.dp),
data = item,
isTop = true,
onSelected = { data ->
RouteUtils.navTo(navCtrl, RouteName.WEB_VIEW, data)
}
)
}
}
itemsIndexed(recommendData) { _, item ->
MultiStateItemView(
data = item!!,
onSelected = {
RouteUtils.navTo(navCtrl, RouteName.WEB_VIEW, it)
})
}
}
}
@@ -0,0 +1,88 @@
package com.zj.wanandroid.ui.page.main.home.recommend
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.cachedIn
import com.zj.wanandroid.common.paging.simplePager
import com.zj.wanandroid.data.bean.Article
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.ui.page.main.home.square.PagingArticle
import com.zj.wanandroid.ui.widgets.BannerData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RecommendViewModel @Inject constructor(
private var service: HttpService,
) : ViewModel() {
private val pager by lazy {
simplePager {
service.getIndexList(it)
}.cachedIn(viewModelScope)
}
var viewStates by mutableStateOf(RecommendViewState(pagingData = pager))
private set
init {
dispatch(RecommendViewAction.FetchData)
}
fun dispatch(action: RecommendViewAction) {
when (action) {
is RecommendViewAction.FetchData -> fetchData()
is RecommendViewAction.Refresh -> refresh()
}
}
private fun fetchData() {
val imageListFlow = flow {
kotlinx.coroutines.delay(2000)
emit(service.getBanners())
}.map { bean ->
val result = mutableListOf<BannerData>()
bean.data?.forEach {
result.add(BannerData(it.title ?: "", it.imagePath ?: "", it.url ?: ""))
}
result
}
val topListFlow = flow {
emit(service.getTopArticles())
}.map {
it.data ?: emptyList()
}
viewModelScope.launch {
imageListFlow.zip(topListFlow) { banners, tops ->
viewStates =
viewStates.copy(imageList = banners, topArticles = tops, isRefreshing = false)
}.onStart {
viewStates = viewStates.copy(isRefreshing = true)
}.catch {
viewStates = viewStates.copy(isRefreshing = false)
}.collect()
}
}
private fun refresh() {
fetchData()
}
}
data class RecommendViewState(
val pagingData: PagingArticle,
val isRefreshing: Boolean = false,
val imageList: List<BannerData> = emptyList(),
val topArticles: List<Article> = emptyList(),
val listState: LazyListState = LazyListState()
)
sealed class RecommendViewAction {
object FetchData : RecommendViewAction()
object Refresh : RecommendViewAction()
}
@@ -0,0 +1,35 @@
package com.zj.wanandroid.ui.page.main.home.square
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemsIndexed
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.MultiStateItemView
import com.zj.wanandroid.ui.widgets.RefreshList
import com.zj.wanandroid.utils.RouteUtils
@Composable
fun SquarePage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: SquareViewModel = hiltViewModel()
) {
val viewStates = remember { viewModel.viewStates }
val squareData = viewStates.pagingData.collectAsLazyPagingItems()
val listState = if (squareData.itemCount > 0) viewStates.listState else LazyListState()
RefreshList(squareData, listState = listState) {
itemsIndexed(squareData) { _, item ->
MultiStateItemView(
data = item!!,
onSelected = {
RouteUtils.navTo(navCtrl, RouteName.WEB_VIEW, it)
})
}
}
}
@@ -0,0 +1,46 @@
package com.zj.wanandroid.ui.page.main.home.square
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.PagingData
import androidx.paging.cachedIn
import com.zj.wanandroid.common.paging.simplePager
import com.zj.wanandroid.data.bean.Article
import com.zj.wanandroid.data.http.HttpService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@HiltViewModel
class SquareViewModel @Inject constructor(
private var service: HttpService,
) : ViewModel() {
private val pager by lazy {
simplePager {
if (it >= 3) {
throw NullPointerException("")
}
delay(2000)
service.getSquareData(it)
}.cachedIn(viewModelScope)
}
var viewStates by mutableStateOf(SquareViewState(pagingData = pager))
private set
}
data class SquareViewState(
val isRefreshing: Boolean = false,
val listState: LazyListState = LazyListState(),
val pagingData: PagingArticle
)
sealed class SquareViewAction {
object Refresh : SquareViewAction()
}
typealias PagingArticle = Flow<PagingData<Article>>
@@ -0,0 +1,417 @@
package com.zj.wanandroid.ui.page.main.profile
import android.util.Log
import androidx.compose.foundation.Image
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.Icon
import androidx.compose.material.ScaffoldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.R
import com.zj.wanandroid.data.bean.PointsBean
import com.zj.wanandroid.data.bean.UserInfo
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.BottomNavBarHeight
import com.zj.wanandroid.theme.ToolBarHeight
import com.zj.wanandroid.theme.white
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.*
import com.zj.wanandroid.utils.RouteUtils
@Composable
fun ProfilePage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: ProfileViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val isLogged = viewStates.isLogged
val userInfo = viewStates.userInfo
DisposableEffect(Unit) {
Log.i("debug", "onStart")
viewModel.dispatch(ProfileViewAction.OnStart)
onDispose {
}
}
if (viewStates.showLogout) {
SampleAlertDialog(
title = "提示",
content = "退出后,将无法查看我的文章、消息、收藏、积分、浏览记录等功能,确定退出登录吗?",
onConfirmClick = {
viewModel.dispatch(ProfileViewAction.Logout)
},
onDismiss = {
viewModel.dispatch(ProfileViewAction.DismissLogoutDialog)
}
)
}
Column(
modifier = Modifier
.padding(bottom = BottomNavBarHeight)
.fillMaxSize()
.background(color = AppTheme.colors.mainColor)
) {
if (isLogged && userInfo != null) {
LazyColumn() {
item {
HeaderPart(
navCtrl = navCtrl,
userInfo = userInfo,
messageCount = 0
)
}
item {
FooterPart(
navCtrl = navCtrl,
messageCount = 0,
onLogout = {
viewModel.dispatch(ProfileViewAction.ShowLogoutDialog)
})
}
}
} else {
AppToolsBar(title = "我的")
EmptyView(
tips = "点击登录",
imageVector = Icons.Default.Face
) {
RouteUtils.navTo(navCtrl, RouteName.LOGIN)
}
}
}
}
//基本操作
@Composable
fun FooterPart(
navCtrl: NavHostController,
messageCount: Int,
onLogout: () -> Unit,
) {
Column(
modifier = Modifier.padding(bottom = 10.dp)
) {
ArrowRightListItem(
iconRes = painterResource(R.drawable.ic_message),
title = "消息",
msgCount = messageCount
) {
//TODO
}
ArrowRightListItem(
iconRes = painterResource(R.drawable.ic_menu_settings),
title = "设置"
) {
onLogout.invoke()
}
ArrowRightListItem(
iconRes = painterResource(R.drawable.ic_feedback),
title = "WanAndroid"
) {
RouteUtils.navTo(
navCtrl = navCtrl,
destinationName = RouteName.WEB_VIEW,
args = WebData(title = "官方网站", url = "https://www.wanandroid.com/index")
)
}
ArrowRightListItem(
iconRes = painterResource(R.drawable.ic_data),
title = "积分规则"
) {
RouteUtils.navTo(
navCtrl = navCtrl,
destinationName = RouteName.WEB_VIEW,
args = WebData(title = "积分规则", url = "https://www.wanandroid.com/blog/show/2653")
)
}
ArrowRightListItem(
iconRes = painterResource(R.drawable.ic_message),
title = "退出登录"
) {
onLogout.invoke()
}
}
}
//用户信息
@Composable
fun HeaderPart(
navCtrl: NavHostController,
userInfo: UserInfo,
messageCount: Int,
) {
Column {
ProfileToolBar(
msgCount = messageCount,
onMessageIconClick = {
//TODO
},
onDeleteIconClick = {
//TODO
},
onDashboardIconClick = {
//TODO
},
)
UserInfoItem(null, userInfo)
UserOptionsItem(
onCollectClick = {
try {
navCtrl.navigate(RouteName.COLLECTION) {
popUpTo(navCtrl.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
} catch (ex: Exception) {
ex.printStackTrace()
}
},
onMyShareClick = {
//TODO
},
onHistoryClick = {
//TODO
},
onRankingClick = {
//TODO
}
)
}
}
//调色板、清理缓存、消息的ToolBar
@Composable
private fun ProfileToolBar(
msgCount: Int,
onMessageIconClick: () -> Unit,
onDeleteIconClick: () -> Unit,
onDashboardIconClick: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(ToolBarHeight)
.background(AppTheme.colors.themeUi)
) {
Row(modifier = Modifier.align(Alignment.CenterEnd)) {
Box(
modifier = Modifier.wrapContentSize()
) {
NotificationIcon(
modifier = Modifier
.size(24.dp)
.clickable { onMessageIconClick.invoke() }
)
if (msgCount > 0) {
DotView(modifier = Modifier.align(Alignment.TopEnd))
}
}
Icon(
Icons.Default.Delete,
contentDescription = "Clear cache",
modifier = Modifier
.padding(start = 10.dp)
.clickable {
onDeleteIconClick.invoke()
},
tint = white
)
Icon(
painter = painterResource(id = R.drawable.ic_theme),
contentDescription = "Switch theme",
modifier = Modifier
.padding(horizontal = 10.dp)
.clickable {
onDashboardIconClick.invoke()
},
tint = white
)
}
}
}
//用户基本信息
@Composable
private fun UserInfoItem(myPoints: PointsBean?, userInfo: UserInfo?) {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(AppTheme.colors.themeUi)
) {
val (icon, info) = createRefs()
Image(
painter = painterResource(id = R.mipmap.ic_account),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 20.dp)
.width(48.dp)
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.placeholder(
visible = userInfo == null,
color = AppTheme.colors.placeholder
)
.constrainAs(icon) {
top.linkTo(parent.top)
start.linkTo(parent.start)
},
)
Column(
modifier = Modifier
.padding(start = 10.dp, bottom = 20.dp)
.constrainAs(info) {
start.linkTo(icon.end)
top.linkTo(parent.top)
}
) {
MainTitle(
title = userInfo?.username ?: "",
modifier = Modifier.placeholder(
visible = userInfo == null,
color = AppTheme.colors.placeholder
)
)
if (userInfo?.email?.isNotEmpty() == true) {
MiniTitle(
text = "email: ${userInfo.email}",
modifier = Modifier.padding(top = 5.dp)
)
}
Row(
modifier = Modifier.padding(top = 5.dp)
) {
TagView(
tagText = "Lv${myPoints?.level ?: "99"}",
tagBgColor = AppTheme.colors.themeUi,
borderColor = AppTheme.colors.textSecondary,
isLoading = false
)
TagView(
modifier = Modifier.padding(start = 5.dp),
tagText = "积分${myPoints?.coinCount ?: "10086"}",
tagBgColor = AppTheme.colors.themeUi,
borderColor = AppTheme.colors.textSecondary,
isLoading = false
)
}
}
}
}
//我的收藏、我的分享、历史记录、积分排行榜
@Composable
private fun UserOptionsItem(
onCollectClick: () -> Unit,
onMyShareClick: () -> Unit,
onHistoryClick: () -> Unit,
onRankingClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 20.dp)
) {
ProfileOptionItem(
modifier = Modifier.weight(1f),
title = "我的收藏",
iconRes = Icons.Default.FavoriteBorder
) {
onCollectClick.invoke()
}
ProfileOptionItem(
modifier = Modifier.weight(1f),
title = "我的文章",
iconRes = painterResource(R.drawable.ic_article)
) {
onMyShareClick.invoke()
}
ProfileOptionItem(
modifier = Modifier.weight(1f),
title = "历史浏览",
iconRes = painterResource(R.drawable.ic_history_record)
) {
onHistoryClick.invoke()
}
ProfileOptionItem(
modifier = Modifier.weight(1f),
title = "积分排行",
iconRes = painterResource(R.drawable.ic_ranking)
) {
onRankingClick.invoke()
}
}
}
@Composable
private fun ProfileOptionItem(
modifier: Modifier,
title: String,
iconRes: Any,
onClick: () -> Unit
) {
Column(modifier = modifier.clickable { onClick() }) {
when (iconRes) {
is Painter -> {
Icon(
painter = iconRes,
contentDescription = null,
tint = AppTheme.colors.icon,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterHorizontally)
)
}
is ImageVector -> {
Icon(
imageVector = iconRes,
contentDescription = null,
tint = AppTheme.colors.icon,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
MiniTitle(
text = title,
modifier = Modifier
.padding(top = 5.dp)
.align(Alignment.CenterHorizontally)
)
}
}
@@ -0,0 +1,56 @@
package com.zj.wanandroid.ui.page.main.profile
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.zj.wanandroid.data.bean.UserInfo
import com.zj.wanandroid.utils.AppUserUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor() : ViewModel() {
var viewStates by mutableStateOf(ProfileViewState())
private set
fun dispatch(action: ProfileViewAction) {
when (action) {
is ProfileViewAction.OnStart -> onStart()
is ProfileViewAction.ShowLogoutDialog -> showLogout()
is ProfileViewAction.DismissLogoutDialog -> dismissLogout()
is ProfileViewAction.Logout -> logout()
}
}
private fun logout() {
AppUserUtil.onLogOut()
viewStates = viewStates.copy(isLogged = false, showLogout = false, userInfo = null)
}
private fun dismissLogout() {
viewStates = viewStates.copy(showLogout = false)
}
private fun showLogout() {
viewStates = viewStates.copy(showLogout = true)
}
private fun onStart() {
viewStates =
viewStates.copy(isLogged = AppUserUtil.isLogged, userInfo = AppUserUtil.userInfo)
}
}
data class ProfileViewState(
val isLogged: Boolean = AppUserUtil.isLogged,
val userInfo: UserInfo? = AppUserUtil.userInfo,
val showLogout: Boolean = false
)
sealed class ProfileViewAction {
object OnStart : ProfileViewAction()
object ShowLogoutDialog : ProfileViewAction()
object DismissLogoutDialog : ProfileViewAction()
object Logout : ProfileViewAction()
}
@@ -0,0 +1,194 @@
package com.zj.wanandroid.ui.page.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemsIndexed
import com.google.accompanist.flowlayout.FlowRow
import com.zj.wanandroid.R
import com.zj.wanandroid.data.bean.Hotkey
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.ToolBarHeight
import com.zj.wanandroid.ui.page.common.RouteName
import com.zj.wanandroid.ui.widgets.LabelTextButton
import com.zj.wanandroid.ui.widgets.ListTitle
import com.zj.wanandroid.ui.widgets.MediumTitle
import com.zj.wanandroid.ui.widgets.MultiStateItemView
import com.zj.wanandroid.utils.RouteUtils
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun SearchPage(
navCtrl: NavHostController,
scaffoldState: ScaffoldState,
viewModel: SearchViewModel = hiltViewModel()
) {
val viewStates = viewModel.viewStates
val keyboardCtrl = LocalSoftwareKeyboardController.current
val hotkeys = viewStates.hotKeys
val queries = viewStates.searchResult?.collectAsLazyPagingItems()
Column(Modifier.fillMaxSize()) {
SearchHead(
keyWord = viewStates.searchContent,
onTextChange = {
viewModel.dispatch(SearchViewAction.SetSearchKey(it))
},
onSearchClick = {
if (it.trim().isNotEmpty()) {
viewModel.dispatch(SearchViewAction.Search)
}
keyboardCtrl?.hide()
},
navController = navCtrl
)
LazyColumn {
// part1. 搜索热词
stickyHeader {
ListTitle(title = "搜索热词")
}
item {
if (hotkeys.isNotEmpty()) {
Box {
HotkeyItem(
hotkeys = hotkeys,
onSelected = { text ->
viewModel.dispatch(SearchViewAction.SetSearchKey(text))
viewModel.dispatch(SearchViewAction.Search)
})
}
}
}
// part2. 搜索列表
stickyHeader {
ListTitle(title = "搜索结果")
}
if (queries != null) {
itemsIndexed(queries) { index, item ->
MultiStateItemView(
data = item!!,
onSelected = { data ->
RouteUtils.navTo(navCtrl, RouteName.WEB_VIEW, data)
})
}
}
}
}
}
/**
* 搜索框
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SearchHead(
keyWord: String,
onTextChange: (text: String) -> Unit,
onSearchClick: (key: String) -> Unit,
navController: NavHostController
) {
Box(
Modifier
.fillMaxWidth()
.height(ToolBarHeight)
.background(AppTheme.colors.themeUi)
) {
Row(
Modifier
.align(Alignment.Center)
.padding(10.dp)
) {
Icon(
painterResource(R.drawable.icon_back_white),
null,
Modifier
.clickable(onClick = {
navController.popBackStack()
})
.align(Alignment.CenterVertically)
.size(20.dp)
.padding(end = 10.dp),
tint = AppTheme.colors.mainColor
)
BasicTextField(
value = keyWord,
onValueChange = { onTextChange(it) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.height(28.dp)
.background(
color = AppTheme.colors.mainColor,
shape = RoundedCornerShape(14.dp),
)
.padding(start = 10.dp, top = 4.dp)
.align(Alignment.CenterVertically),
maxLines = 1,
singleLine = true,
textStyle = TextStyle(
color = AppTheme.colors.textSecondary,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearchClick(keyWord) }),
)
MediumTitle(
title = "搜索",
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(start = 10.dp)
.combinedClickable(onClick = { onSearchClick(keyWord) }),
color = AppTheme.colors.mainColor,
)
}
}
}
/**
* 搜索热词的item
*/
@Composable
fun HotkeyItem(
hotkeys: List<Hotkey>,
onSelected: (key: String) -> Unit
) {
FlowRow(Modifier.padding(10.dp)) {
hotkeys.forEach {
LabelTextButton(
text = it.name ?: "",
isSelect = false,
modifier = Modifier.padding(end = 5.dp, bottom = 5.dp),
onClick = {
onSelected(it.name!!)
}
)
}
}
}
@@ -0,0 +1,75 @@
package com.zj.wanandroid.ui.page.search
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.cachedIn
import com.zj.wanandroid.common.paging.simplePager
import com.zj.wanandroid.data.bean.Hotkey
import com.zj.wanandroid.data.http.HttpService
import com.zj.wanandroid.ui.page.main.home.square.PagingArticle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private var service: HttpService
) : ViewModel() {
var viewStates by mutableStateOf(SearchViewState())
private set
init {
dispatch(SearchViewAction.GetHotKeys)
}
fun dispatch(action: SearchViewAction) {
when (action) {
is SearchViewAction.Search -> search()
is SearchViewAction.GetHotKeys -> getHotKeys()
is SearchViewAction.SetSearchKey -> setSearchKey(action.key)
}
}
private fun setSearchKey(key: String) {
viewStates = viewStates.copy(searchContent = key)
}
private fun getHotKeys() {
viewModelScope.launch {
flow {
emit(service.getHotkeys())
}.map {
it.data ?: emptyList()
}.onEach {
viewStates = viewStates.copy(hotKeys = it)
}.catch {
}.collect()
}
}
private fun search() {
val key = viewStates.searchContent
val paging = simplePager {
service.queryArticle(page = it, key = key)
}.cachedIn(viewModelScope)
viewStates = viewStates.copy(searchResult = paging)
}
}
data class SearchViewState(
val searchResult: PagingArticle? = null,
val searchContent: String = "",
val hotKeys: List<Hotkey> = emptyList()
)
sealed class SearchViewAction {
object GetHotKeys : SearchViewAction()
object Search : SearchViewAction()
data class SetSearchKey(val key: String) : SearchViewAction()
}
@@ -0,0 +1,38 @@
package com.zj.wanandroid.ui.page.splash
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.white
import kotlinx.coroutines.delay
@Composable
fun SplashPage(onNextPage: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(AppTheme.colors.themeUi), contentAlignment = Alignment.TopCenter
) {
LaunchedEffect(Unit) {
delay(500)
onNextPage.invoke()
}
Text(
text = "Wan Android",
fontSize = 32.sp,
color = white,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(0.dp, 150.dp, 0.dp, 0.dp)
)
}
}
@@ -0,0 +1,77 @@
package com.zj.wanandroid.ui.page.webview
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.view.ViewGroup
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.ProgressBar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavHostController
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.ToolBarHeight
import com.zj.wanandroid.utils.SizeUtils
import com.zj.wanandroid.R
import com.zj.wanandroid.ui.widgets.AppToolsBar
import com.zj.wanandroid.utils.RouteUtils.back
@SuppressLint("UseCompatLoadingForDrawables")
@Composable
fun WebViewPage(
webData: WebData,
navCtrl: NavHostController
) {
var ctrl: WebViewCtrl? by remember { mutableStateOf(null) }
Box {
var isRefreshing: Boolean by remember { mutableStateOf(false) }
val refreshState = rememberSwipeRefreshState(isRefreshing)
AndroidView(
modifier = Modifier
.padding(top = ToolBarHeight)
.fillMaxSize(),
factory = { context ->
FrameLayout(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT
)
val progressView = ProgressBar(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
SizeUtils.dp2px(2f)
)
progressDrawable =
context.resources.getDrawable(R.drawable.horizontal_progressbar)
indeterminateTintList =
ColorStateList.valueOf(context.resources.getColor(R.color.teal_200))
}
val webView = WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
}
addView(webView)
addView(progressView)
ctrl = WebViewCtrl(this, webData.url, onWebCall = { isFinish ->
isRefreshing = !isFinish
})
ctrl?.initSettings()
}
},
update = {
}
)
AppToolsBar(title = webData.title ?: "标题", onBack = {
ctrl?.onDestroy()
navCtrl.back()
})
}
}
@@ -0,0 +1,112 @@
package com.zj.wanandroid.ui.page.webview
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.view.View
import android.webkit.*
import android.widget.FrameLayout
import android.widget.ProgressBar
import androidx.annotation.RequiresApi
class WebViewCtrl(
private val mView: FrameLayout,
private var linkUrl: String,
private val onWebCall: (isFinish: Boolean) -> Unit
) {
private val webView by lazy { mView.getChildAt(0) as WebView }
private val progressBar by lazy { mView.getChildAt(1) as ProgressBar }
fun initSettings() {
onWebCall(false)
setWebSettings()
setupWebClient()
}
fun onDestroy() {
mView.removeAllViews()
webView.destroy()
}
private fun setWebSettings() {
val webSettings = webView.settings
//如果访问的页面中要与Javascript交互,则webview必须设置支持Javascript
webSettings.javaScriptEnabled = false
//设置自适应屏幕,两者合用
webSettings.useWideViewPort = true //将图片调整到适合webview的大小
webSettings.loadWithOverviewMode = true // 缩放至屏幕的大小
//缩放操作
webSettings.setSupportZoom(true) //支持缩放,默认为true。是下面那个的前提。
webSettings.builtInZoomControls = true //设置内置的缩放控件。若为false,则该WebView不可缩放
webSettings.displayZoomControls = false //隐藏原生的缩放控件
//其他细节操作
webSettings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK //关闭webview中缓存
webSettings.allowFileAccess = true //设置可以访问文件
webSettings.javaScriptCanOpenWindowsAutomatically = true //支持通过JS打开新窗口
webSettings.loadsImagesAutomatically = true //支持自动加载图片
webSettings.defaultTextEncodingName = "UTF-8"//设置编码格式
}
private fun setupWebClient() {
webView.webViewClient = NewWebViewClient()
webView.webChromeClient = ProgressWebViewChromeClient()
refresh()
}
fun refresh() {
webView.loadUrl(linkUrl)
}
inner class ProgressWebViewChromeClient : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
progressBar.progress = newProgress
}
override fun onReceivedTitle(view: WebView?, title: String?) {
super.onReceivedTitle(view, title)
}
}
inner class NewWebViewClient : WebViewClient() {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
linkUrl = request?.url.toString()
return super.shouldOverrideUrlLoading(view, request)
}
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
linkUrl = url?:"NullUrlString"
return super.shouldOverrideUrlLoading(view, url)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
progressBar.visibility = View.VISIBLE
super.onPageStarted(view, url, favicon)
}
override fun onPageFinished(view: WebView?, url: String?) {
progressBar.visibility = View.GONE
onWebCall(true)
super.onPageFinished(view, url)
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed()
}
}
}
@@ -0,0 +1,182 @@
package com.zj.wanandroid.ui.widgets
import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
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.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.coil.rememberCoilPainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import com.zj.wanandroid.R
import com.zj.wanandroid.theme.AppShapes
import kotlinx.coroutines.delay
/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.drawable.no_banner,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String, title: String) -> Unit
) {
Box(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxWidth()
.padding(16.dp)
.height(120.dp)
) {
if (list == null) {
//加载中的图片
Image(
painterResource(loadImage),
modifier = Modifier.fillMaxSize().clip(shape = RoundedCornerShape(16.dp)),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
val pagerState = rememberPagerState(
//初始页面
initialPage = 0
)
//监听动画执行
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))
}
}
HorizontalPager(
count = list.size,
state = pagerState,
modifier = Modifier
.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
.clickable {
with(list[pagerState.currentPage]) {
onClick.invoke(linkUrl, title)
}
}
.fillMaxSize(),
) { page ->
Image(
painter = rememberCoilPainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize().clip(shape = RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
Box(
modifier = Modifier
.align(indicatorAlignment)
.padding(bottom = 6.dp, start = 6.dp, end = 6.dp)
) {
//指示点
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (i in list.indices) {
//大小
var size by remember { mutableStateOf(5.dp) }
size = if (pagerState.currentPage == i) 7.dp else 5.dp
//颜色
val color =
if (pagerState.currentPage == i) MaterialTheme.colors.primary else Color.Gray
Box(
modifier = Modifier
.clip(CircleShape)
.background(color)
//当size改变的时候以动画的形式改变
.animateContentSize()
.size(size)
)
//指示点间的间隔
if (i != list.lastIndex) Spacer(
modifier = Modifier
.height(0.dp)
.width(4.dp)
)
}
}
}
}
}
}
/**
* 轮播图数据
*/
data class BannerData(
val title: String,
val imageUrl: String,
val linkUrl: String
)
@@ -0,0 +1,122 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.Color
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.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.buttonCorner
import com.zj.wanandroid.theme.buttonHeight
import com.zj.wanandroid.theme.white1
import org.jetbrains.annotations.NotNull
@Composable
fun AppButton(
text: String,
modifier: Modifier = Modifier,
bgColor: Color = AppTheme.colors.secondBtnBg,
textColor: Color = AppTheme.colors.textPrimary,
onClick: () -> Unit
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(buttonHeight)
.background(color = bgColor, shape = RoundedCornerShape(buttonCorner))
.clickable {
onClick()
}
) {
TextContent(text = text, color = textColor, modifier = Modifier.align(Alignment.Center))
}
}
@Composable
fun PrimaryButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
AppButton(
text = text,
modifier = modifier,
textColor = AppTheme.colors.textPrimary,
onClick = onClick,
bgColor = AppTheme.colors.themeUi
)
}
@Composable
fun SecondlyButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
AppButton(
text = text,
modifier = modifier,
textColor = AppTheme.colors.textSecondary,
onClick = onClick
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LabelTextButton(
@NotNull text: String,
modifier: Modifier = Modifier,
isSelect: Boolean = true,
specTextColor: Color? = null,
cornerValue: Dp = 25.dp / 2,
isLoading: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
Text(
text = text,
modifier = modifier
.height(25.dp)
.clip(shape = RoundedCornerShape(cornerValue))
.background(
color = if (isSelect && !isLoading) AppTheme.colors.themeUi else AppTheme.colors.secondBtnBg,
)
.padding(
horizontal = 10.dp,
vertical = 3.dp
)
.combinedClickable(
enabled = !isLoading,
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() }
)
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
),
fontSize = 13.sp,
textAlign = TextAlign.Center,
color = specTextColor ?: if (isSelect) white1 else AppTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
@@ -0,0 +1,397 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.theme.*
import com.zj.wanandroid.ui.page.common.BottomNavRoute
/**
* 普通标题栏头部
*/
@Composable
fun AppToolsBar(
title: String,
rightText: String? = null,
onBack: (() -> Unit)? = null,
onRightClick: (() -> Unit)? = null,
imageVector: ImageVector? = null,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(ToolBarHeight)
.background(AppTheme.colors.themeUi)
) {
Row(modifier = Modifier.fillMaxSize()) {
if (onBack != null) {
Icon(
Icons.Default.ArrowBack,
null,
Modifier
.clickable(onClick = onBack)
.align(Alignment.CenterVertically)
.padding(12.dp),
tint = AppTheme.colors.mainColor
)
}
Spacer(modifier = Modifier.weight(1f))
if (!rightText.isNullOrEmpty() && imageVector == null) {
TextContent(
text = rightText,
color = AppTheme.colors.mainColor,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 20.dp)
.clickable { onRightClick?.invoke() }
)
}
if (imageVector != null) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = AppTheme.colors.mainColor,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(end = 12.dp)
.clickable {
onRightClick?.invoke()
})
}
}
Text(
text = title,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 40.dp),
color = AppTheme.colors.mainColor,
textAlign = TextAlign.Center,
fontSize = if (title.length > 14) H5 else ToolBarTitleSize,
fontWeight = FontWeight.W500,
maxLines = 1
)
}
}
@Composable
fun HomeSearchBar(
onSearchClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(SearchBarHeight)
.background(color = AppTheme.colors.themeUi)
) {
Row(
modifier = Modifier
.padding(horizontal = 32.dp)
.height(30.dp)
.align(alignment = Alignment.Top)
.weight(1f)
.background(
color = AppTheme.colors.mainColor,
shape = RoundedCornerShape(12.5.dp)
)
.clickable { onSearchClick() }
) {
Icon(
painter = painterResource(id = com.zj.wanandroid.R.drawable.ic_search),
contentDescription = "搜索",
tint = AppTheme.colors.themeUi,
modifier = Modifier
.size(25.dp)
.padding(start = 10.dp)
.align(Alignment.CenterVertically)
)
Box(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(start = 10.dp)
) {
Text(text = "搜索关键词以空格形式隔开", fontSize = 13.sp, color = AppTheme.colors.textSecondary)
}
}
}
}
/**
* TabLayout
*/
@Composable
fun TextTabBar(
index: Int,
tabTexts: List<TabTitle>,
modifier: Modifier = Modifier,
contentAlign: Alignment = Alignment.Center,
bgColor: Color = AppTheme.colors.themeUi,
contentColor: Color = Color.White,
onTabSelected: ((index: Int) -> Unit)? = null
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(54.dp)
.background(bgColor)
.horizontalScroll(state = rememberScrollState())
) {
Row(
modifier = Modifier
.align(contentAlign)
) {
tabTexts.forEachIndexed { i, tabTitle ->
Text(
text = tabTitle.text,
fontSize = if (index == i) 20.sp else 15.sp,
fontWeight = if (index == i) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 10.dp)
.clickable {
onTabSelected?.invoke(i)
},
color = contentColor
)
}
}
}
}
data class TabTitle(
val id: Int,
val text: String,
var cachePosition: Int = 0,
var selected: Boolean = false
)
@Composable
fun TabBar(
index: Int = 0,
tabTexts: List<String>,
modifier: Modifier = Modifier,
bgColor: Color,
contentColor: Color,
onTabSelected: ((index: Int) -> Unit)?,
isScrollable: Boolean = true
) {
if (isScrollable) {
ScrollableTabRow(
selectedTabIndex = index,
modifier = modifier.height(TabBarHeight),
edgePadding = 0.dp,
backgroundColor = bgColor,
contentColor = contentColor,
) {
//var offset: Float by remember { mutableStateOf(0f) }
tabTexts.forEachIndexed { i, tabText ->
Text(
text = tabText,
fontSize = if (index == i) 20.sp else 15.sp,
fontWeight = if (index == i) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.padding(horizontal = 10.dp)
.clickable {
if (onTabSelected != null) {
onTabSelected(i)
}
},
color = contentColor
)
}
}
} else {
TabRow(
selectedTabIndex = index,
modifier = modifier.height(TabBarHeight),
backgroundColor = bgColor,
contentColor = contentColor,
) {
tabTexts.forEachIndexed { i, tabText ->
Text(
text = tabText,
fontSize = if (index == i) 20.sp else 15.sp,
fontWeight = if (index == i) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.padding(horizontal = 10.dp)
.clickable {
if (onTabSelected != null) {
onTabSelected(i)
}
},
color = contentColor
)
}
}
}
}
@Composable
fun BottomNavBarView(navCtrl: NavHostController) {
val bottomNavList = listOf(
BottomNavRoute.Home,
BottomNavRoute.Category,
BottomNavRoute.Collection,
BottomNavRoute.Profile
)
BottomNavigation {
val navBackStackEntry by navCtrl.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
bottomNavList.forEach { screen ->
BottomNavigationItem(
modifier = Modifier.background(AppTheme.colors.themeUi),
icon = {
Icon(
imageVector = screen.icon,
contentDescription = null
)
},
label = { Text(text = stringResource(screen.stringId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.routeName } == true,
onClick = {
println("BottomNavView当前路由 ===> ${currentDestination?.hierarchy?.toList()}")
println("当前路由栈 ===> ${navCtrl.graph.nodes}")
if (currentDestination?.route != screen.routeName) {
navCtrl.navigate(screen.routeName) {
popUpTo(navCtrl.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
})
}
}
}
@Composable
fun EmptyView(
tips: String = "啥都没有~",
imageVector: ImageVector = Icons.Default.Info,
onClick: () -> Unit = {}
) {
Box(
modifier = Modifier
.fillMaxSize(1f)
.defaultMinSize(minHeight = 480.dp)
) {
Column(
Modifier
.wrapContentSize()
.align(Alignment.Center)
.clickable { onClick.invoke() }
) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = AppTheme.colors.textSecondary,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
TextContent(text = tips, modifier = Modifier.padding(top = 10.dp))
}
}
}
@Composable
fun SwitchTabBar(
titles: MutableList<TabTitle>,
heightValue: Dp? = null,
selectIndex: Int,
onSwitchClick: (index: Int) -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(heightValue ?: ToolBarHeight)
.background(color = AppTheme.colors.themeUi)
.padding(vertical = 5.dp)
) {
Row(
modifier = Modifier.align(Alignment.Center)
) {
titles.forEachIndexed { index, tabTitle ->
MediumTitle(
title = tabTitle.text,
color = if (index == selectIndex) AppTheme.colors.textPrimary
else AppTheme.colors.textSecondary,
modifier = Modifier
.defaultMinSize(100.dp, 24.dp)
.padding(horizontal = 10.dp)
.clickable { onSwitchClick.invoke(index) },
textAlign = TextAlign.Center
)
}
}
Box(
modifier = Modifier
.width(1.dp)
.height(16.dp)
.background(color = AppTheme.colors.textSecondary)
.align(Alignment.Center)
)
}
}
@Composable
fun TagView(
modifier: Modifier = Modifier,
tagText: String,
tagBgColor: Color = AppTheme.colors.background,
borderColor: Color = AppTheme.colors.themeUi,
tagTextColor: Color = AppTheme.colors.textSecondary,
isLoading: Boolean = false,
) {
Box(
modifier = modifier
.wrapContentSize()
.background(color = tagBgColor)
.clip(RoundedCornerShape(2.dp))
.border(width = 1.dp, color = borderColor)
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
)
) {
MiniTitle(
text = tagText,
color = tagTextColor,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 5.dp, vertical = 0.dp),
)
}
}
@@ -0,0 +1,117 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.zj.wanandroid.theme.AppTheme
@Composable
fun SampleAlertDialog(
title: String,
content: String,
cancelText: String = "取消",
confirmText: String = "继续",
onConfirmClick: () -> Unit,
//onCancelClick: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
title = {
MediumTitle(title = title)
},
text = {
TextContent(text = content)
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onConfirmClick.invoke()
onDismiss.invoke()
}) {
TextContent(text = confirmText, color = AppTheme.colors.textPrimary)
}
},
dismissButton = {
TextButton(onClick = { onDismiss.invoke() }) {
TextContent(text = cancelText)
}
},
modifier = Modifier
.padding(horizontal = 30.dp)
.fillMaxWidth()
.wrapContentHeight()
)
}
@Composable
fun SelectAlertDialog(
title: String,
content: String,
primaryButtonText: String,
secondButtonText: String,
onPrimaryButtonClick: () -> Unit,
onSecondButtonClick: () -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card {
Column(Modifier.padding(20.dp)) {
MediumTitle(title = title, modifier = Modifier.padding(bottom = 20.dp))
TextContent(text = content, modifier = Modifier.padding(bottom = 20.dp))
PrimaryButton(text = primaryButtonText, Modifier.padding(bottom = 10.dp)) {
onPrimaryButtonClick.invoke()
onDismiss.invoke()
}
SecondlyButton(text = secondButtonText) {
onSecondButtonClick.invoke()
onDismiss.invoke()
}
}
}
}
}
@Composable
fun InfoDialog(
title: String = "关于我",
vararg content: String,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnClickOutside = true),
title = {
MediumTitle(title = title)
},
text = {
Column(
Modifier.defaultMinSize(minWidth = 300.dp)
) {
content.forEach {
TextContent(
text = it,
modifier = Modifier.padding(bottom = 10.dp),
canCopy = true
)
}
}
},
confirmButton = {
TextContent(
text = "关闭",
modifier = Modifier
.padding(end = 18.dp, bottom = 18.dp)
.clickable { onDismiss.invoke() }
)
}
)
}
@@ -0,0 +1,138 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextInputSession
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zj.wanandroid.theme.AppTheme
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginEditView(
text: String,
labelText: String,
hintText: String?,
onValueChanged: (String) -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier,
isPassword: Boolean = false
) {
BasicLabelEditView(
modifier = modifier,
text = text,
labelText = labelText,
labelTextColor = AppTheme.colors.mainColor,
hintText = hintText ?: "",
onValueChanged = { onValueChanged.invoke(it) },
deleteIconColor = AppTheme.colors.mainColor,
onDeleteClick = { onDeleteClick.invoke() },
inputCursorColor = AppTheme.colors.mainColor,
inputTextColor = AppTheme.colors.mainColor,
borderColor = AppTheme.colors.mainColor,
keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text
)
}
@Composable
fun LabelEditView(
text: String,
labelText: String,
hintText: String?,
onValueChanged: (String) -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BasicLabelEditView(
modifier = modifier,
text = text,
labelText = labelText,
hintText = hintText ?: "",
onValueChanged = { onValueChanged.invoke(it) },
onDeleteClick = { onDeleteClick.invoke() }
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun BasicLabelEditView(
modifier: Modifier = Modifier,
text: String,
labelText: String,
labelTextColor: Color = AppTheme.colors.textPrimary,
hintText: String = "",
deleteIconColor: Color = AppTheme.colors.themeUi,
onValueChanged: (String) -> Unit,
onDeleteClick: () -> Unit,
inputCursorColor: Color = AppTheme.colors.themeUi,
inputTextColor: Color = AppTheme.colors.textPrimary,
borderColor: Color = AppTheme.colors.textSecondary,
keyboardType: KeyboardType = KeyboardType.Text,
isHideKeyboard: Boolean = true,
) {
val keyboardService = LocalTextInputService.current
TextField(
value = text,
onValueChange = { onValueChanged(it) },
textStyle = TextStyle(lineHeight = 24.sp, fontSize = 16.sp),
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 20.dp)
.pointerInteropFilter { false },
label = {
MediumTitle(
labelText,
color = labelTextColor,
modifier = Modifier.padding(bottom = 10.dp)
)
},
placeholder = {
TextContent(hintText)
},
trailingIcon = {
if (text.isNotEmpty()) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null,
tint = deleteIconColor,
modifier = Modifier.clickable { onDeleteClick() }
)
}
},
singleLine = true,
maxLines = 1,
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = borderColor,
unfocusedBorderColor = borderColor,
textColor = inputTextColor,
placeholderColor = AppTheme.colors.textSecondary,
cursorColor = inputCursorColor
),
keyboardActions = if (!isHideKeyboard) KeyboardActions.Default
else KeyboardActions { keyboardService?.hideSoftwareKeyboard() },
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Next
)
)
}
@@ -0,0 +1,81 @@
package com.zj.wanandroid.ui.widgets
import android.R
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
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.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.zj.wanandroid.data.http.PageState
import com.zj.wanandroid.theme.AppTheme
/**
* 通过State进行控制的Loading、Content、Error页面
*
* @param pageState 数据State
* @param onRetry 错误时的点击事件
* @param content 数据加载成功时应显示的可组合项
*/
@Composable
fun LcePage(
pageState: PageState,
onRetry: () -> Unit,
content: @Composable () -> Unit
) = when (pageState) {
is PageState.Loading -> PageLoading()
is PageState.Error -> ErrorContent(onRetry)
is PageState.Success -> {
if (pageState.isEmpty) {
PageEmpty()
} else {
content()
}
}
}
@Composable
fun PageLoading() {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.align(Alignment.Center)) {
CircularProgressIndicator(
color = AppTheme.colors.themeUi,
modifier = Modifier
.padding(10.dp)
.width(50.dp)
.height(50.dp)
)
Text(
text = "正在加载中",
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 10.dp)
)
}
}
}
@Composable
fun PageEmpty() {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
painter = painterResource(id = R.drawable.stat_notify_error),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.Red),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = "暂无相关内容",
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 10.dp)
)
}
}
}
@@ -0,0 +1,470 @@
package com.zj.wanandroid.ui.widgets
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.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
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.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.data.bean.Article
import com.zj.wanandroid.data.bean.CollectBean
import com.zj.wanandroid.data.bean.WebData
import com.zj.wanandroid.theme.*
import com.zj.wanandroid.utils.RegexUtils
@Composable
fun ListTitle(
modifier: Modifier = Modifier,
title: String,
subTitle: String = "",
isLoading: Boolean = false,
onSubtitleClick: () -> Unit = {}
) {
Row(
modifier = modifier
.placeholder(false)
.fillMaxWidth()
.height(ListTitleHeight)
.background(color = AppTheme.colors.background)
) {
Box(
modifier = Modifier
.padding(horizontal = 10.dp)
.width(5.dp)
.height(16.dp)
.align(Alignment.CenterVertically)
.background(color = AppTheme.colors.textPrimary)
)
MediumTitle(
title = title,
modifier = Modifier.align(Alignment.CenterVertically),
isLoading = isLoading
)
Spacer(modifier = Modifier.weight(1f))
TextContent(
text = subTitle,
modifier = Modifier
.padding(end = 10.dp)
.clickable {
onSubtitleClick.invoke()
},
isLoading = isLoading
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CollectListItemView(
collect: CollectBean,
isLoading: Boolean = false,
onClick: () -> Unit = {},
onDeleteClick: () -> Unit = {}
) {
Card(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 5.dp)
.fillMaxWidth()
.wrapContentWidth()
.background(color = AppTheme.colors.card)
.clickable(enabled = !isLoading) { onClick.invoke() }
) {
ConstraintLayout(
modifier = Modifier.padding(20.dp)
) {
val (name, publishIcon, publishTime, title, delete) = createRefs()
MediumTitle(
title = if (collect.author.isNotEmpty()) collect.author else "无名",
modifier = Modifier
.defaultMinSize(minWidth = 100.dp)
.constrainAs(name) {
top.linkTo(parent.top)
start.linkTo(parent.start)
},
isLoading = isLoading
)
MiniTitle(
text = RegexUtils().timestamp(collect.niceDate) ?: "2021",
modifier = Modifier
.constrainAs(publishTime) {
top.linkTo(parent.top)
end.linkTo(parent.end)
bottom.linkTo(publishIcon.bottom)
}
.defaultMinSize(minWidth = 40.dp),
isLoading = isLoading
)
TimerIcon(
modifier = Modifier
.constrainAs(publishIcon) {
top.linkTo(parent.top, margin = 2.5.dp)
end.linkTo(publishTime.start)
},
isLoading = isLoading
)
TextContent(
text = collect.title,
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 10.dp, bottom = 20.dp)
.constrainAs(title) {
top.linkTo(name.bottom)
end.linkTo(parent.end)
},
isLoading = isLoading
)
if (!isLoading) {
DeleteIcon(
modifier = Modifier
.padding(top = 5.dp)
.constrainAs(delete) {
top.linkTo(title.bottom)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
onClick = onDeleteClick
)
}
}
}
}
@Composable
fun SimpleListItemView(
data: Article,
isLoading: Boolean = false,
onClick: () -> Unit = {},
onCollectClick: (articleId: Int) -> Unit = {},
) {
Card(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 5.dp)
.background(color = AppTheme.colors.card)
.fillMaxWidth()
.wrapContentWidth()
.clickable(enabled = !isLoading) {
onClick.invoke()
}
) {
ConstraintLayout(
modifier = Modifier.padding(20.dp),
) {
val (name, publishIcon, publishTime, title, favourite) = createRefs()
MediumTitle(
title = data.author ?: data.shareUser ?: "作者",
modifier = Modifier
.defaultMinSize(minWidth = 100.dp)
.constrainAs(name) {
top.linkTo(parent.top)
start.linkTo(parent.start)
},
isLoading = isLoading
)
MiniTitle(
text = RegexUtils().timestamp(data.niceDate) ?: "",
modifier = Modifier.constrainAs(publishTime) {
top.linkTo(parent.top)
end.linkTo(parent.end)
bottom.linkTo(publishIcon.bottom)
},
isLoading = isLoading
)
TimerIcon(
modifier = Modifier
.constrainAs(publishIcon) {
top.linkTo(parent.top)
end.linkTo(publishTime.start)
},
isLoading = isLoading
)
TextContent(
text = data.title ?: "",
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 10.dp, bottom = 20.dp)
.constrainAs(title) {
top.linkTo(name.bottom)
end.linkTo(parent.end)
},
isLoading = isLoading
)
FavouriteIcon(
modifier = Modifier.constrainAs(favourite) {
top.linkTo(title.bottom)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
isFavourite = data.collect,
onClick = {
onCollectClick.invoke(data.id)
},
isLoading = isLoading
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MultiStateItemView(
modifier: Modifier = Modifier,
data: Article,
isTop: Boolean = false,
onSelected: (data: WebData) -> Unit = {},
onCollectClick: (articleId: Int) -> Unit = {},
onUserClick: (userId: Int) -> Unit = {},
isLoading: Boolean = false,
) {
Card(
modifier = modifier
.padding(vertical = 5.dp, horizontal = 10.dp)
.background(color = AppTheme.colors.card)
.fillMaxWidth()
.clickable(enabled = !isLoading) {
onSelected.invoke(WebData(data.title!!, data.link!!))
},
shape = AppShapes.medium,
backgroundColor = AppTheme.colors.listItem,
) {
ConstraintLayout(
modifier = Modifier.padding(20.dp),
) {
val (circleText, name, publishIcon, publishTime, title, chip1, chip2, tag, favourite) = createRefs()
Text(
text = getFirstCharFromName(data),
modifier = Modifier
.width(20.dp)
.height(20.dp)
.clip(RoundedCornerShape(20.dp / 2))
.background(color = AppTheme.colors.themeUi)
.constrainAs(circleText) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
),
textAlign = TextAlign.Center,
fontSize = H6,
color = white1,
)
val titleModifier =
if (isLoading) Modifier.width(80.dp) else Modifier.wrapContentWidth()
MediumTitle(
title = getAuthorName(data),
modifier = titleModifier
.constrainAs(name) {
top.linkTo(parent.top)
start.linkTo(circleText.end)
}
.padding(start = 5.dp)
.clickable {
onUserClick.invoke(data.userId)
}
.pointerInteropFilter { false },
isLoading = isLoading
)
val dateModifier =
if (isLoading) Modifier.width(80.dp) else Modifier.wrapContentWidth()
MiniTitle(
text = RegexUtils().timestamp(data.niceDate!!) ?: "1970-1-1",
modifier = dateModifier
.constrainAs(publishTime) {
top.linkTo(parent.top)
end.linkTo(parent.end)
},
isLoading = isLoading
)
TimerIcon(
modifier = Modifier
.padding(end = if (isLoading) 5.dp else 0.dp)
.constrainAs(publishIcon) {
top.linkTo(parent.top)
end.linkTo(publishTime.start)
bottom.linkTo(publishTime.bottom)
},
isLoading = isLoading
)
TextContent(
text = data.title ?: "",
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 10.dp, bottom = 20.dp)
.constrainAs(title) {
top.linkTo(circleText.bottom)
end.linkTo(parent.end)
},
isLoading = isLoading,
)
LabelTextButton(
text = data.superChapterName ?: "热门",
modifier = Modifier
.constrainAs(chip1) {
top.linkTo(title.bottom)
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
},
isLoading = isLoading
)
LabelTextButton(
text = data.chapterName ?: "android",
modifier = Modifier
.constrainAs(chip2) {
top.linkTo(title.bottom)
start.linkTo(chip1.end, margin = 5.dp)
bottom.linkTo(parent.bottom)
},
isLoading = isLoading,
)
FavouriteIcon(
modifier = Modifier.constrainAs(favourite) {
top.linkTo(title.bottom)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
isFavourite = data.collect,
onClick = {
onCollectClick.invoke(data.id)
},
isLoading = isLoading
)
Row(
modifier = Modifier
.defaultMinSize(minHeight = 20.dp)
.constrainAs(tag) {
top.linkTo(parent.top)
start.linkTo(name.end, margin = 5.dp)
}
) {
if (isTop) {
HotIcon()
}
if (data.fresh) {
TagView(
tagText = "最新",
modifier = Modifier
.padding(start = 5.dp)
.align(Alignment.Bottom)
)
}
}
}
}
}
@Composable
fun ArrowRightListItem(
iconRes: Any,
title: String,
msgCount: Int? = null,
valueText: String = "",
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.padding(horizontal = 10.dp)
.clickable {
onClick.invoke()
}
) {
when (iconRes) {
is Painter -> {
Icon(
painter = iconRes,
contentDescription = null,
tint = AppTheme.colors.icon,
modifier = Modifier
.size(30.dp)
.align(Alignment.CenterVertically)
.padding(end = 10.dp)
)
}
is ImageVector -> {
Icon(
imageVector = iconRes,
contentDescription = null,
tint = AppTheme.colors.icon,
modifier = Modifier
.size(30.dp)
.align(Alignment.CenterVertically)
.padding(end = 10.dp)
)
}
}
Row(
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f)
) {
TextContent(text = title, modifier = Modifier.align(Alignment.CenterVertically))
if (msgCount != null) {
Text(
text = "$msgCount",
fontSize = H7,
color = AppTheme.colors.error,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
if (valueText.isNotEmpty()) {
TextContent(
text = valueText,
modifier = Modifier
.padding(end = 5.dp)
.align(Alignment.CenterVertically)
)
}
Icon(
Icons.Default.KeyboardArrowRight,
null,
tint = AppTheme.colors.textSecondary,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
Divider()
}
fun getAuthorName(data: Article?): String {
return if (data?.shareUser.isNullOrEmpty()) {
data?.author ?: ""
} else {
data?.shareUser ?: ""
}
}
fun getFirstCharFromName(data: Article?): String {
val author = getAuthorName(data)
return if (author.isNotEmpty()) author.trim().substring(0, 1) else "?"
}
@@ -0,0 +1,146 @@
package com.zj.wanandroid.ui.widgets
import android.R
import androidx.compose.foundation.Image
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.Button
import androidx.compose.material.ButtonDefaults.buttonColors
import androidx.compose.material.CircularProgressIndicator
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.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.zj.wanandroid.theme.AppTheme
@Composable
fun <T : Any> RefreshList(
lazyPagingItems: LazyPagingItems<T>,
isRefreshing: Boolean = false,
onRefresh: (() -> Unit) = {},
listState: LazyListState = rememberLazyListState(),
itemContent: LazyListScope.() -> Unit,
) {
val rememberSwipeRefreshState = rememberSwipeRefreshState(isRefreshing = false)
//错误页
val err = lazyPagingItems.loadState.refresh is LoadState.Error
if (err) {
ErrorContent { lazyPagingItems.retry() }
return
}
SwipeRefresh(
state = rememberSwipeRefreshState,
onRefresh = {
onRefresh.invoke()
lazyPagingItems.refresh()
}
) {
//刷新状态
rememberSwipeRefreshState.isRefreshing =
((lazyPagingItems.loadState.refresh is LoadState.Loading) || isRefreshing)
//列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
//条目布局
itemContent()
//加载更多状态:加载中和加载错误,没有更多
if (!rememberSwipeRefreshState.isRefreshing) {
item {
lazyPagingItems.apply {
when (loadState.append) {
is LoadState.Loading -> LoadingItem()
is LoadState.Error -> ErrorItem { retry() }
is LoadState.NotLoading -> {
if (loadState.append.endOfPaginationReached) {
NoMoreItem()
}
}
}
}
}
}
}
}
}
@Composable
fun ErrorContent(retry: () -> Unit) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
painter = painterResource(id = R.drawable.stat_notify_error),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.Red),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = "请求出错啦",
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 10.dp)
)
Button(
onClick = { retry() },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(10.dp),
colors = buttonColors(backgroundColor = AppTheme.colors.themeUi)
) {
Text(text = "重试")
}
}
}
}
@Composable
fun ErrorItem(retry: () -> Unit) {
Button(
onClick = { retry() },
modifier = Modifier.padding(10.dp),
colors = buttonColors(backgroundColor = AppTheme.colors.themeUi)
) {
Text(text = "重试")
}
}
@Composable
fun NoMoreItem() {
Text(
text = "没有更多了",
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
@Composable
fun LoadingItem() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp), contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = AppTheme.colors.themeUi,
modifier = Modifier
.padding(10.dp)
.height(50.dp)
)
}
}
@@ -0,0 +1,45 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Snackbar
import androidx.compose.material.SnackbarData
import androidx.compose.runtime.Composable
import com.zj.wanandroid.theme.AppTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
const val SNACK_INFO = ""
const val SNACK_WARN = " "
const val SNACK_ERROR = " "
const val SNACK_SUCCESS = "OK"
@Composable
fun AppSnackBar(data: SnackbarData) {
Snackbar(
snackbarData = data,
backgroundColor = when (data.actionLabel) {
SNACK_INFO -> AppTheme.colors.themeUi
SNACK_WARN -> AppTheme.colors.warn
SNACK_ERROR -> AppTheme.colors.error
SNACK_SUCCESS -> AppTheme.colors.success
else -> AppTheme.colors.themeUi
},
actionColor = AppTheme.colors.textPrimary,
contentColor = AppTheme.colors.textPrimary,
)
}
fun popupSnackBar(
scope: CoroutineScope,
scaffoldState: ScaffoldState,
label: String,
message: String,
onDismissCallback: () -> Unit = {}
) {
scope.launch {
scaffoldState.snackbarHostState.showSnackbar(actionLabel = label, message = message)
onDismissCallback.invoke()
}
}
@@ -0,0 +1,178 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
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.input.pointer.pointerInteropFilter
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 com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.R
import com.zj.wanandroid.theme.AppTheme
import com.zj.wanandroid.theme.white
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HotIcon(
modifier: Modifier = Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_hot),
contentDescription = null,
tint = AppTheme.colors.hot,
modifier = modifier
.size(20.dp)
.pointerInteropFilter { false }
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ShareIcon(
modifier: Modifier = Modifier,
) {
Icon(
painter = painterResource(id = R.drawable.ic_share),
contentDescription = null,
modifier = modifier
.width(25.dp)
.height(25.dp)
.pointerInteropFilter { false }
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun FavouriteIcon(
modifier: Modifier = Modifier,
isFavourite: Boolean = false,
onClick: () -> Unit,
isLoading: Boolean = false
) {
Icon(
imageVector = if (isFavourite && !isLoading) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (isFavourite && !isLoading) AppTheme.colors.themeUi else AppTheme.colors.textSecondary,
modifier = modifier
.width(25.dp)
.height(25.dp)
.clickable(enabled = !isLoading) { onClick.invoke() }
.pointerInteropFilter { false }
)
}
@Composable
fun TimerIcon(
modifier: Modifier = Modifier,
isLoading: Boolean = false
) {
Icon(
painter = painterResource(id = R.drawable.ic_time),
contentDescription = "",
tint = AppTheme.colors.textSecondary,
modifier = modifier
.width(15.dp)
.height(15.dp)
.clip(RoundedCornerShape(15.dp / 2))
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
)
)
}
@Composable
fun UserIcon(
modifier: Modifier = Modifier,
isLoading: Boolean = false
) {
Icon(
painter = painterResource(id = R.drawable.ic_author),
contentDescription = "",
modifier = modifier
.width(15.dp)
.height(15.dp)
.clip(RoundedCornerShape(15.dp / 2))
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
),
tint = AppTheme.colors.textSecondary
)
}
@Composable
fun AddIcon(
modifier: Modifier,
color: Color = AppTheme.colors.textPrimary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = color,
modifier = modifier
)
}
@Composable
fun NotificationIcon(
modifier: Modifier,
tintColor: Color = white
) {
Icon(
Icons.Default.Notifications,
contentDescription = "New message",
modifier = modifier,
tint = tintColor
)
}
@Composable
fun DotView(
modifier: Modifier = Modifier,
) {
Text(
text = "",
modifier = modifier
.size(10.dp)
.background(color = AppTheme.colors.hot, RoundedCornerShape(5.dp)),
color = white,
textAlign = TextAlign.Center,
maxLines = 1,
fontSize = 5.sp
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DeleteIcon(
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = AppTheme.colors.textSecondary,
modifier = modifier
.clickable {
onClick.invoke()
}
.pointerInteropFilter { false }
)
}
@@ -0,0 +1,151 @@
package com.zj.wanandroid.ui.widgets
import androidx.compose.foundation.text.selection.SelectionContainer
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import com.google.accompanist.placeholder.material.placeholder
import com.zj.wanandroid.theme.*
@Composable
fun LargeTitle(
title: String,
modifier: Modifier = Modifier,
color: Color? = null,
isLoading: Boolean = false
) {
Title(
title = title,
modifier = modifier,
fontSize = H3,
color = color ?: AppTheme.colors.textPrimary,
fontWeight = FontWeight.Bold,
isLoading = isLoading
)
}
@Composable
fun MainTitle(
title: String,
modifier: Modifier = Modifier,
maxLine: Int = 1,
textAlign: TextAlign = TextAlign.Start,
color: Color = AppTheme.colors.textPrimary,
isLoading: Boolean = false
) {
Title(
title = title,
modifier = modifier,
fontSize = H4,
color = color,
fontWeight = FontWeight.SemiBold,
maxLine = maxLine,
textAlign = textAlign,
isLoading = isLoading
)
}
@Composable
fun MediumTitle(
title: String,
modifier: Modifier = Modifier,
color: Color = AppTheme.colors.textPrimary,
textAlign: TextAlign = TextAlign.Start,
isLoading: Boolean = false
) {
Title(
title = title,
fontSize = H5,
modifier = modifier,
color = color,
textAlign = textAlign,
isLoading = isLoading
)
}
@Composable
fun TextContent(
text: String,
modifier: Modifier = Modifier,
color: Color = AppTheme.colors.textSecondary,
maxLines: Int = 99,
textAlign: TextAlign = TextAlign.Start,
canCopy: Boolean = false,
isLoading: Boolean = false
) {
if (canCopy) {
SelectionContainer {
Title(
title = text,
modifier = modifier,
fontSize = H6,
color = color,
maxLine = maxLines,
textAlign = textAlign,
isLoading = isLoading
)
}
} else {
Title(
title = text,
modifier = modifier,
fontSize = H6,
color = color,
maxLine = maxLines,
textAlign = textAlign,
isLoading = isLoading
)
}
}
@Composable
fun MiniTitle(
text: String,
modifier: Modifier = Modifier,
color: Color = AppTheme.colors.textSecondary,
maxLines: Int = 1,
textAlign: TextAlign = TextAlign.Start,
isLoading: Boolean = false
) {
Title(
title = text,
modifier = modifier,
fontSize = H7,
color = color,
maxLine = maxLines,
textAlign = textAlign,
isLoading = isLoading,
)
}
@Composable
fun Title(
title: String,
modifier: Modifier = Modifier,
fontSize: TextUnit,
color: Color = AppTheme.colors.textSecondary,
fontWeight: FontWeight = FontWeight.Normal,
maxLine: Int = 1,
textAlign: TextAlign = TextAlign.Start,
isLoading: Boolean = false
) {
Text(
text = title,
modifier = modifier
.placeholder(
visible = isLoading,
color = AppTheme.colors.placeholder
),
fontSize = fontSize,
color = color,
maxLines = maxLine,
overflow = TextOverflow.Ellipsis,
textAlign = textAlign
)
}
@@ -0,0 +1,26 @@
package com.zj.wanandroid.utils
import com.zj.wanandroid.data.bean.UserInfo
import com.zj.wanandroid.data.store.DataStoreUtils
object AppUserUtil {
private const val LOGGED_FLAG = "logged_flag"
private const val USER_INFO = "user_info"
var isLogged: Boolean
get() = DataStoreUtils.readBooleanData(LOGGED_FLAG, false)
set(value) = DataStoreUtils.saveSyncBooleanData(LOGGED_FLAG, value = value)
var userInfo: UserInfo?
get() = DataStoreUtils.readStringData(USER_INFO).fromJson()
set(value) = DataStoreUtils.saveSyncStringData(USER_INFO, value = value?.toJson() ?: "")
fun onLogin(userInfo: UserInfo) {
isLogged = true
this.userInfo = userInfo
}
fun onLogOut() {
isLogged = false
this.userInfo = null
}
}
@@ -0,0 +1,2 @@
package com.zj.wanandroid.utils
@@ -0,0 +1,16 @@
package com.zj.wanandroid.utils
import android.os.Parcelable
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
}
}
@@ -0,0 +1,43 @@
package com.zj.wanandroid.utils
import android.content.Context
import android.net.ConnectivityManager
/**
* 网络状态
*/
object NetCheckUtil {
fun checkNet(context: Context): Boolean {
// 判断是否具有可以用于通信渠道
val mobileConnection = isMobileConnection(context)
val wifiConnection = isWIFIConnection(context)
return !(!mobileConnection && !wifiConnection)
}
/**
* 判断手机接入点(APN)是否处于可以使用的状态
*
* @param context
* @return
*/
private fun isMobileConnection(context: Context): Boolean {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE)
return networkInfo != null && networkInfo.isConnected
}
/**
* 判断当前wifi是否是处于可以使用状态
*
* @param context
* @return
*/
private fun isWIFIConnection(context: Context): Boolean {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI)
return networkInfo != null && networkInfo.isConnected
}
}
@@ -0,0 +1,26 @@
package com.zj.wanandroid.utils
import java.text.SimpleDateFormat
class RegexUtils {
fun symbolClear(text: String?): String {
// val pattern = Pattern.compile()
if (text.isNullOrEmpty()) {
return ""
}
val regex = Regex("<[a-z]+>|</[a-z]+>|<[a-z]+/>")
if (text.contains(regex)) {
return text.replace(regex, "")
}
return text
}
fun timestamp(time: String?): String? {
time ?: return null
return kotlin.runCatching {
SimpleDateFormat("yyyy-MM-dd HH:mm").parse(time)
time.substring(0, time.indexOf(" "))
}.getOrDefault(time)
}
}
@@ -0,0 +1,90 @@
package com.zj.wanandroid.utils
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
/**
* 路由名称
*/
object RouteUtils {
const val STEAD_SYMBOL = "^0^"
//初始化Bundle参数
fun initBundle(params: Parcelable) = Bundle().apply { putParcelable(ARGS, params) }
/**
* 导航到某个页面
*/
fun navTo(
navCtrl: NavHostController,
destinationName: String,
args: Any? = null,
backStackRouteName: String? = null,
isLaunchSingleTop: Boolean = true,
needToRestoreState: Boolean = true,
) {
var singleArgument = ""
if (args != null) {
when (args) {
is Parcelable -> {
singleArgument = String.format("/%s", Uri.encode(args.toJson()))
}
is String -> {
singleArgument = String.format("/%s", args)
}
is Int -> {
singleArgument = String.format("/%s", args)
}
is Float -> {
singleArgument = String.format("/%s", args)
}
is Double -> {
singleArgument = String.format("/%s", args)
}
is Boolean -> {
singleArgument = String.format("/%s", args)
}
is Long -> {
singleArgument = String.format("/%s", args)
}
}
}
println("导航到: $destinationName")
navCtrl.navigate("$destinationName$singleArgument") {
if (backStackRouteName != null) {
popUpTo(backStackRouteName) { saveState = true }
}
launchSingleTop = isLaunchSingleTop
restoreState = needToRestoreState
}
}
fun NavHostController.back() {
navigateUp()
}
private fun getPopUpId(navCtrl: NavHostController, routeName: String?): Int {
val defaultId = navCtrl.graph.findStartDestination().id
return if (routeName == null) {
defaultId
} else {
navCtrl.findDestination(routeName)?.id ?: defaultId
}
}
fun <T> getArguments(navCtrl: NavHostController): T? {
return navCtrl.previousBackStackEntry?.arguments?.getParcelable(ARGS)
}
/**
* 各个序列化的参数类的key名
*/
private const val ARGS = "args"
}
@@ -0,0 +1,10 @@
package com.zj.wanandroid.utils
import android.content.res.Resources
object SizeUtils {
fun dp2px(dpValue: Float): Int {
val scale = Resources.getSystem().displayMetrics.density
return (dpValue * scale + 0.5f).toInt()
}
}
@@ -0,0 +1,97 @@
package com.zj.wanandroid.utils
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.widget.Toast
import com.zj.wanandroid.MyApp
private var mToast: Toast? = null
/**
* 显示时间较短的吐司
*
* @param text String,显示的内容
*/
fun showToast(text: String?) {
showToast(context = MyApp.CONTEXT, text = text)
}
fun showToast(context: Context = MyApp.CONTEXT, text: String?) {
if (TextUtils.isEmpty(text)) return
if (Thread.currentThread() === Looper.getMainLooper().thread) {
showToast(context, text, Toast.LENGTH_SHORT)
} else {
Handler(context.mainLooper).post { showToast(context, text, Toast.LENGTH_SHORT) }
}
}
/**
* 显示时间较短的吐司
*
* @param resId int,显示内容的字符串索引
*/
fun showToast(context: Context = MyApp.CONTEXT, resId: Int) {
if (Thread.currentThread() === Looper.getMainLooper().thread) {
showToast(context, resId, Toast.LENGTH_SHORT)
} else {
Handler(context.mainLooper).post { showToast(context, resId, Toast.LENGTH_SHORT) }
}
}
/**
* 显示时间较长的吐司
*
* @param text String,显示的内容
*/
fun showLongToast(context: Context? = MyApp.CONTEXT, text: String?) {
if (context == null || TextUtils.isEmpty(text)) return
if (Thread.currentThread() === Looper.getMainLooper().thread) {
showToast(context, text, Toast.LENGTH_LONG)
} else {
Handler(context.mainLooper).post { showToast(context, text, Toast.LENGTH_LONG) }
}
}
/**
* 显示时间较长的吐司
*
* @param resId int,显示内容的字符串索引
*/
fun showLongToast(context: Context? = MyApp.CONTEXT, resId: Int) {
if (context == null) return
if (Thread.currentThread() === Looper.getMainLooper().thread) {
showToast(context, resId, Toast.LENGTH_LONG)
} else {
Handler(context.mainLooper).post { showToast(context, resId, Toast.LENGTH_LONG) }
}
}
private fun showToast(context: Context? = MyApp.CONTEXT, text: String?, duration: Int) {
if (TextUtils.isEmpty(text)) return
cancelToast()
if (mToast == null) {
mToast = Toast.makeText(context, null as CharSequence?, duration)
}
mToast?.apply {
setText(text)
this.duration = duration
show()
}
}
fun showToast(context: Context? = MyApp.CONTEXT, res: Int, duration: Int) {
cancelToast()
if (mToast == null) {
mToast = Toast.makeText(context, res, duration)
} else {
mToast?.setText(res)
mToast?.duration = duration
}
mToast?.show()
}
fun cancelToast() {
mToast?.cancel()
}
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="49.59793"
android:startX="42.9492"
android:endY="92.4963"
android:endX="85.84757"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<solid android:color="@color/grey_cd" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<solid android:color="?colorPrimaryDark" />
</shape>
</clip>
</item>
</layer-list>
@@ -0,0 +1,12 @@
<vector android:height="32dp" android:viewportHeight="165"
android:viewportWidth="165" android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:fillType="nonZero"
android:pathData="M79.967,12.11C41.726,13.238 11.475,44.858 12.039,83.112C12.603,121.365 43.774,152.08 82.031,152.08C120.289,152.08 151.46,121.365 152.024,83.112C152.588,44.858 122.336,13.238 84.095,12.11L82.031,12.078L79.967,12.11ZM84.287,0.11C128.527,1.31 164.031,37.55 164.031,82.078C164.031,127.366 127.319,164.078 82.031,164.078C36.743,164.078 0.031,127.366 0.031,82.078C0.031,37.542 35.527,1.302 79.775,0.11L82.031,0.078L84.287,0.11Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#000000" android:fillType="nonZero"
android:pathData="M82.031,42.438C85.343,42.438 88.031,45.766 88.031,49.878L88.031,114.278C88.031,118.39 85.343,121.718 82.031,121.718C78.719,121.718 76.031,118.39 76.031,114.278L76.031,49.878C76.031,45.766 78.719,42.438 82.031,42.438Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#000000" android:fillType="nonZero"
android:pathData="M42.391,82.078C42.391,78.766 45.719,76.078 49.831,76.078L114.231,76.078C118.343,76.078 121.671,78.766 121.671,82.078C121.671,85.39 118.343,88.078 114.231,88.078L49.831,88.078C45.719,88.078 42.391,85.39 42.391,82.078Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>
@@ -0,0 +1,4 @@
<vector android:height="32dp" android:viewportHeight="1024"
android:viewportWidth="1024" android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M268.37,908.63l60.59,60.07 456.7,-460.37L328.7,55.04l-60.08,60.59 396.37,393.22z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M895.95,734.05l1.07,1.01a29.82,29.82 0,0 1,0 43.41l-162.26,152.96a31.93,31.93 0,0 1,-22.76 8.7,31.93 31.93,0 0,1 -22.77,-8.7l-93.18,-87.84a29.82,29.82 0,0 1,0 -43.41l1.08,-1.01a32,32 0,0 1,43.9 0l70.98,66.9 140.05,-132.02a32,32 0,0 1,43.9 0zM768,85.33c64.8,0 117.33,52.53 117.33,117.33v394.67a32,32 0,0 1,-64 0L821.33,202.67a53.33,53.33 0,0 0,-53.33 -53.33L256,149.33a53.33,53.33 0,0 0,-53.33 53.33v618.67a53.33,53.33 0,0 0,53.33 53.33h234.67a32,32 0,0 1,0 64L256,938.67c-64.8,0 -117.33,-52.53 -117.33,-117.33L138.67,202.67c0,-64.8 52.53,-117.33 117.33,-117.33zM554.67,544a32,32 0,0 1,0 64L341.33,608a32,32 0,0 1,0 -64zM682.67,373.33a32,32 0,0 1,0 64L341.33,437.33a32,32 0,0 1,0 -64z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M659.93,128a74.67,74.67 0,0 1,71.34 52.62L754.56,256L821.33,256c64.8,0 117.33,52.53 117.33,117.33v426.67c0,64.8 -52.53,117.33 -117.33,117.33L202.67,917.33c-64.8,0 -117.33,-52.53 -117.33,-117.33L85.33,373.33c0,-64.8 52.53,-117.33 117.33,-117.33h66.77l23.3,-75.38A74.67,74.67 0,0 1,364.07 128h295.85zM512,405.33c-88.36,0 -160,71.64 -160,160 0,88.36 71.64,160 160,160 88.36,0 160,-71.64 160,-160 0,-88.36 -71.64,-160 -160,-160zM512,661.33a96,96 0,1 0,0 -192,96 96,0 0,0 0,192z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M512,938.67C276.36,938.67 85.33,747.64 85.33,512S276.36,85.33 512,85.33s426.67,191.03 426.67,426.67 -191.03,426.67 -426.67,426.67zM512,874.67c200.3,0 362.67,-162.37 362.67,-362.67S712.3,149.33 512,149.33 149.33,311.7 149.33,512s162.37,362.67 362.67,362.67zM368.86,684.31a32,32 0,1 1,40.92 -49.21A159.19,159.19 0,0 0,512 672c37.89,0 73.67,-13.17 102.19,-36.89a32,32 0,0 1,40.92 49.22A223.18,223.18 0,0 1,512 736a223.18,223.18 0,0 1,-143.14 -51.69z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M512,938.67C276.36,938.67 85.33,747.64 85.33,512S276.36,85.33 512,85.33s426.67,191.03 426.67,426.67 -191.03,426.67 -426.67,426.67zM512,874.67c200.3,0 362.67,-162.37 362.67,-362.67S712.3,149.33 512,149.33 149.33,311.7 149.33,512s162.37,362.67 362.67,362.67zM520.21,608.99l-81.43,-88.7 -95.75,99.26a32,32 0,1 1,-46.06 -44.44l119.36,-123.73a32,32 0,0 1,46.61 0.58l81.79,89.11 136.61,-136.99a32,32 0,1 1,45.31 45.18l-160.21,160.68a32,32 0,0 1,-46.24 -0.96z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M202.67,256h-42.67a32,32 0,0 1,0 -64h704a32,32 0,0 1,0 64L266.67,256v565.33a53.33,53.33 0,0 0,53.33 53.33h384a53.33,53.33 0,0 0,53.33 -53.33L757.33,352a32,32 0,0 1,64 0v469.33c0,64.8 -52.53,117.33 -117.33,117.33L320,938.67c-64.8,0 -117.33,-52.53 -117.33,-117.33L202.67,256zM426.67,149.33a32,32 0,0 1,0 -64h170.67a32,32 0,0 1,0 64L426.67,149.33zM394.67,437.33a32,32 0,0 1,64 0v256a32,32 0,0 1,-64 0L394.67,437.33zM565.33,437.33a32,32 0,0 1,64 0v256a32,32 0,0 1,-64 0L565.33,437.33z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M896,170.67v85.33L128,256L128,170.67h768zM896,469.33v85.33L128,554.67v-85.33h768zM896,768v85.33L128,853.33v-85.33h768z"
android:fillColor="#cdcdcd"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10.09,15.59L11.5,17l5,-5 -5,-5 -1.41,1.41L12.67,11H3v2h9.67l-2.58,2.59zM19,3H5c-1.11,0 -2,0.9 -2,2v4h2V5h14v14H5v-4H3v4c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M778.67,117.33c64.8,0 117.33,52.53 117.33,117.33v640c0,25.56 -28.48,40.8 -49.75,26.62l-77.16,-51.43L708.27,895.47a42.67,42.67 0,0 1,-51.2 0l-59.73,-44.8 -59.73,44.8a42.67,42.67 0,0 1,-51.2 0l-59.73,-44.8 -59.73,44.8a42.67,42.67 0,0 1,-51.2 0l-60.82,-45.61 -77.16,51.43C156.48,915.48 128,900.22 128,874.67L128,234.67c0,-64.8 52.53,-117.33 117.33,-117.33h533.33zM778.67,181.33L245.33,181.33a53.33,53.33 0,0 0,-53.33 53.33v580.2l39.97,-26.65a42.67,42.67 0,0 1,49.27 1.38L341.33,834.67l59.73,-44.8a42.67,42.67 0,0 1,51.2 0l59.73,44.8 59.73,-44.8a42.67,42.67 0,0 1,51.2 0l59.73,44.8 60.1,-45.07a42.67,42.67 0,0 1,49.27 -1.38L832,814.87L832,234.67a53.33,53.33 0,0 0,-53.33 -53.33zM565.33,522.67a32,32 0,0 1,0 64L352,586.67a32,32 0,0 1,0 -64zM672,352a32,32 0,0 1,0 64L352,416a32,32 0,0 1,0 -64z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M878.08,731.27a32,32 0,0 1,-54.88 -32.94A360.79,360.79 0,0 0,874.67 512c0,-200.3 -162.37,-362.67 -362.67,-362.67S149.33,311.7 149.33,512s162.37,362.67 362.67,362.67a360.79,360.79 0,0 0,186.31 -51.45,32 32,0 0,1 32.93,54.88A424.78,424.78 0,0 1,512 938.67C276.36,938.67 85.33,747.64 85.33,512S276.36,85.33 512,85.33s426.67,191.03 426.67,426.67c0,78.29 -21.15,153.57 -60.59,219.27zM650.67,437.33c0,65.9 -46.72,120.85 -109.19,135.08V608a32,32 0,0 1,-64 0v-64a32,32 0,0 1,32 -32C552.27,512 586.67,478.4 586.67,437.33s-34.4,-74.67 -77.19,-74.67c-26.77,0 -51.08,13.25 -65.17,34.62a73.09,73.09 0,0 0,-8.52 17.72,32 32,0 0,1 -60.89,-19.69c3.8,-11.75 9.17,-22.93 15.98,-33.24 25.86,-39.25 70.19,-63.41 118.61,-63.41C587.27,298.67 650.67,360.58 650.67,437.33zM512,736a32,32 0,1 1,0 -64,32 32,0 0,1 0,64z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M822.5,473.15l52.05,29.29C869.46,306.56 709.1,149.33 512,149.33c-200.3,0 -362.67,162.37 -362.67,362.67s162.37,362.67 362.67,362.67c122.54,0 234.65,-61.19 301.58,-161.15a32,32 0,1 1,53.17 35.62C788.06,866.63 656.12,938.67 512,938.67 276.36,938.67 85.33,747.64 85.33,512S276.36,85.33 512,85.33s426.67,191.03 426.67,426.67c0,10.95 -0.85,26.36 -2.52,46.53 -1.93,23.24 -27.27,36.68 -47.59,25.25l-97.45,-54.85a32,32 0,1 1,31.39 -55.79zM329.38,649.37L480,498.76L480,320a32,32 0,0 1,64 0v192a32,32 0,0 1,-9.38 22.62l-160,160a32,32 0,1 1,-45.25 -45.25z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M423.48,938.67S45.05,855.42 214.19,442.28c0,0 38.4,45.91 33.12,68 0,0 30.1,-104.28 95.07,-166.57C398.17,290.19 454.85,139.71 402.57,85.33c0,0 258.93,54.38 287.75,326.38 0,0 33.12,-86.67 101.12,-95.23 0,0 -20.91,47.62 0,119.04 0,0 214.49,367.15 -155.16,491.24 0,0 110.81,-125.81 -124.18,-341.72 0,0 -55.4,115.63 -88.53,156.37 -0.1,0.11 -92.52,103.72 -0.1,197.25z"
android:fillColor="#c51614"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98s-0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.39,-0.3 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98s0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.39,0.3 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20,6h-2.18c0.11,-0.31 0.18,-0.65 0.18,-1 0,-1.66 -1.34,-3 -3,-3 -1.05,0 -1.96,0.54 -2.5,1.35l-0.5,0.67 -0.5,-0.68C10.96,2.54 10.05,2 9,2 7.34,2 6,3.34 6,5c0,0.35 0.07,0.69 0.18,1L4,6c-1.11,0 -1.99,0.89 -1.99,2L2,19c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM15,4c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM9,4c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM20,19L4,19v-2h16v2zM20,14L4,14L4,8h5.08L7,10.83 8.62,12 11,8.76l1,-1.36 1,1.36L15.38,12 17,10.83 14.92,8L20,8v6z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M821.33,800H547.58l-86.46,96.07a32,32 0,1 1,-47.57 -42.82l96,-106.67A32,32 0,0 1,533.33 736h288a53.33,53.33 0,0 0,53.33 -53.33V234.67a53.33,53.33 0,0 0,-53.33 -53.33H202.67a53.33,53.33 0,0 0,-53.33 53.33v448a53.33,53.33 0,0 0,53.33 53.33h138.67a32,32 0,0 1,0 64H202.67c-64.8,0 -117.33,-52.53 -117.33,-117.33V234.67c0,-64.8 52.53,-117.33 117.33,-117.33h618.67c64.8,0 117.33,52.53 117.33,117.33v448c0,64.8 -52.53,117.33 -117.33,117.33zM704,341.33a32,32 0,0 1,0 64H320a32,32 0,0 1,0 -64h384zM512,512a32,32 0,0 1,0 64H320a32,32 0,0 1,0 -64h192z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M544,661.33a32,32 0,0 1,-64 0L480,362.67a32,32 0,0 1,64 0v298.67zM704,661.33a32,32 0,0 1,-64 0L640,490.67a32,32 0,0 1,64 0v170.67zM384,661.33a32,32 0,0 1,-64 0L320,448a32,32 0,0 1,64 0v213.33zM202.67,138.67h618.67c64.8,0 117.33,52.53 117.33,117.33v512c0,64.8 -52.53,117.33 -117.33,117.33L202.67,885.33c-64.8,0 -117.33,-52.53 -117.33,-117.33L85.33,256c0,-64.8 52.53,-117.33 117.33,-117.33zM202.67,202.67a53.33,53.33 0,0 0,-53.33 53.33v512a53.33,53.33 0,0 0,53.33 53.33h618.67a53.33,53.33 0,0 0,53.33 -53.33L874.67,256a53.33,53.33 0,0 0,-53.33 -53.33L202.67,202.67z"
android:fillColor="#e6e6e6"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M797.53,752.27c62.07,-72.74 97.28,-165 97.28,-262.19C894.82,266.53 713.62,85.33 490.08,85.33 266.54,85.33 85.33,266.54 85.33,490.07 85.33,713.61 266.54,894.83 490.07,894.83a404.69,404.69 0,0 0,118.21 -17.55,32 32,0 0,0 -18.67,-61.22 340.69,340.69 0,0 1,-99.54 14.76C301.89,830.82 149.33,678.26 149.33,490.07 149.33,301.89 301.89,149.33 490.07,149.33 678.26,149.33 830.83,301.89 830.83,490.07c0,89.28 -35.38,173.7 -97.14,237.32a36.99,36.99 0,0 0,0.38 51.93l149.97,149.97a32,32 0,0 0,45.26 -45.25L797.53,752.27z"
android:fillColor="@color/white"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More