diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2e4fc5..3c9dfa8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,6 +96,8 @@ android { // signingConfig = signingConfigs.getByName("common") // debug 模式下包名后缀 applicationIdSuffix = ".debug" + buildConfigField("String", "BASE_URL", "\"https://box.dusksnow.top/app/\"") + buildConfigField("Boolean", "DEBUG", "true") } // release 构建类型 @@ -111,6 +113,8 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("String", "BASE_URL", "\"https://box.dusksnow.top/app/\"") + buildConfigField("Boolean", "DEBUG", "false") } } @@ -123,6 +127,8 @@ android { buildFeatures { // 开启 Compose 支持 compose = true + // 开启 BuildConfig 支持 + buildConfig = true } } @@ -162,6 +168,15 @@ dependencies { debugImplementation(libs.chucker) releaseImplementation(libs.chucker.no.op) + // 权限 + implementation(libs.xxpermissions) + + // toast + implementation(libs.toaster) + + // 数据存储 + implementation(libs.mmkv) + // 日志 implementation(libs.timber) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0aa5f6..224a5ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ - + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + // 设置应用的导航宿主,并传入导航管理器和路由注册器 + // 这样所有页面都可以通过导航管理器进行导航操作 + AppNavHost(navigator = navigator) } } - } -} -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - AppTheme { - Greeting("Android") + // 不让启动界面一直显示 + splashScreen.setKeepOnScreenCondition { + false + } } } \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/MainActivityViewModel.kt b/app/src/main/java/com/joker/kit/MainActivityViewModel.kt new file mode 100644 index 0000000..b1e3143 --- /dev/null +++ b/app/src/main/java/com/joker/kit/MainActivityViewModel.kt @@ -0,0 +1,14 @@ +package com.joker.kit + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * 主界面 ViewModel + * + * @author Joker.X + */ +@HiltViewModel +class MainActivityViewModel @Inject constructor( +) : ViewModel() {} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkListUiState.kt b/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkListUiState.kt new file mode 100644 index 0000000..b238df7 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkListUiState.kt @@ -0,0 +1,30 @@ +package com.joker.kit.core.base.state + +/** + * 列表页UI状态 + * + * 封装列表页面的四种状态:加载中、成功、错误和空数据 + * + * @author Joker.X + */ +sealed class BaseNetWorkListUiState { + /** + * 加载中状态 + */ + object Loading : BaseNetWorkListUiState() + + /** + * 成功状态 + */ + object Success : BaseNetWorkListUiState() + + /** + * 错误状态 + */ + object Error : BaseNetWorkListUiState() + + /** + * 空数据状态 + */ + object Empty : BaseNetWorkListUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkUiState.kt b/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkUiState.kt new file mode 100644 index 0000000..1a53637 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/state/BaseNetWorkUiState.kt @@ -0,0 +1,31 @@ +package com.joker.kit.core.base.state + +/** + * 网络请求UI状态 + * 只包含最基本的网络请求状态 + * + * @param T 数据类型 + * @author Joker.X + */ +sealed class BaseNetWorkUiState { + /** + * 加载中状态 + */ + data object Loading : BaseNetWorkUiState() + + /** + * 成功状态 + * + * @param data 成功返回的数据 + */ + data class Success(var data: T) : BaseNetWorkUiState() + + /** + * 错误状态 + * + * @param message 错误信息,可为空 + * @param exception 异常信息 + */ + data class Error(val message: String? = null, val exception: Throwable? = null) : + BaseNetWorkUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/state/LoadMoreState.kt b/app/src/main/java/com/joker/kit/core/base/state/LoadMoreState.kt new file mode 100644 index 0000000..a783b1a --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/state/LoadMoreState.kt @@ -0,0 +1,33 @@ +package com.joker.kit.core.base.state + +/** + * 加载更多状态 + * + * @author Joker.X + */ +sealed class LoadMoreState { + /** + * 可上拉加载更多状态 + */ + object PullToLoad : LoadMoreState() + + /** + * 加载中状态 + */ + object Loading : LoadMoreState() + + /** + * 成功状态 + */ + object Success : LoadMoreState() + + /** + * 错误状态 + */ + object Error : LoadMoreState() + + /** + * 没有更多数据状态 + */ + object NoMore : LoadMoreState() +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkListViewModel.kt b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkListViewModel.kt new file mode 100644 index 0000000..b1d643d --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkListViewModel.kt @@ -0,0 +1,307 @@ +package com.joker.kit.core.base.viewmodel + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavBackStackEntry +import com.joker.kit.core.base.state.BaseNetWorkListUiState +import com.joker.kit.core.base.state.LoadMoreState +import com.joker.kit.core.model.network.NetworkPageData +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.result.ResultHandler +import com.joker.kit.core.result.asResult +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import com.joker.kit.navigation.NavigationResultKey +import com.joker.kit.navigation.RefreshResultKey +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 网络请求列表ViewModel基类 + * + * 专门处理列表数据的加载、分页、刷新和加载更多功能 + * 封装了常见的列表操作逻辑,简化子类实现 + * + * @param T 列表项数据类型 + * @param navigator 导航控制器 + * @param userState 用户状态 + * @author Joker.X + */ +abstract class BaseNetWorkListViewModel( + navigator: AppNavigator, + userState: UserState +) : BaseViewModel(navigator, userState) { + + /** + * 当前页码 + */ + protected var currentPage = 1 + + /** + * 每页数量 + */ + protected val pageSize = 10 + + /** + * 网络请求UI状态 + */ + val _uiState = MutableStateFlow(BaseNetWorkListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * 列表数据 + */ + protected val _listData = MutableStateFlow>(emptyList()) + val listData: StateFlow> = _listData.asStateFlow() + + /** + * 加载更多状态 + */ + protected val _loadMoreState = MutableStateFlow(LoadMoreState.PullToLoad) + val loadMoreState: StateFlow = _loadMoreState.asStateFlow() + + /** + * 下拉刷新状态 (仅用于PullToRefresh组件) + */ + val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + /** + * 是否启用最少加载时间(240毫秒) + * 子类可重写此属性以启用最少加载时间功能 + */ + protected open val enableMinLoadingTime: Boolean = false + + /** + * 请求开始时间,用于计算最少加载时间(仅首次加载) + */ + private var requestStartTime: Long = 0 + + /** + * 子类必须实现此方法,提供分页API请求的Flow + * + * @return 返回包含分页数据的Flow + */ + protected abstract fun requestListData(): Flow>> + + /** + * 初始化函数,在子类init块中调用 + */ + protected fun initLoad() { + loadListData() + } + + /** + * 加载列表数据 + */ + protected fun loadListData() { + + val isFirstLoading = _loadMoreState.value == LoadMoreState.Loading && currentPage == 1 + + // 记录请求开始时间(仅首次加载)并且启用最少加载时间功能 + if (isFirstLoading && enableMinLoadingTime) { + requestStartTime = System.currentTimeMillis() + } + + // 设置UI状态 - 仅首次加载显示加载中状态 + if (isFirstLoading) { + _uiState.value = BaseNetWorkListUiState.Loading + } + + ResultHandler.handleResult( + showToast = false, + scope = viewModelScope, + flow = requestListData().asResult(), + onSuccess = { response -> + handleSuccess(response.data) + }, + onError = { message, exception -> + handleError(message, exception) + } + ) + } + + /** + * 处理成功响应 + */ + protected open fun handleSuccess(data: NetworkPageData?) { + val newList = data?.list ?: emptyList() + val pagination = data?.pagination + + // 计算是否还有下一页数据 + val hasNextPage = if (pagination != null) { + val total = pagination.total ?: 0 + val size = pagination.size ?: pageSize + val currentPageNum = pagination.page ?: currentPage + + // 当前页的数据量 * 当前页码 < 总数据量,说明还有下一页 + size * currentPageNum < total + } else { + false + } + + when { + currentPage == 1 -> { + // 刷新或首次加载 - 重置列表 + _listData.value = newList + _isRefreshing.value = false + + // 判断是否需要最少加载时间延迟 + if (enableMinLoadingTime) { + val elapsedTime = System.currentTimeMillis() - requestStartTime + val minLoadingTime = 240L + + if (elapsedTime < minLoadingTime) { + // 延迟设置成功状态 + viewModelScope.launch { + delay(minLoadingTime - elapsedTime) + setFirstLoadSuccessState(newList, hasNextPage) + } + } else { + setFirstLoadSuccessState(newList, hasNextPage) + } + } else { + setFirstLoadSuccessState(newList, hasNextPage) + } + } + + else -> { + // 加载更多 - 先显示加载成功,延迟更新数据 + viewModelScope.launch { + _loadMoreState.value = LoadMoreState.Success + delay(400) + _listData.value += newList + _loadMoreState.value = + if (hasNextPage) LoadMoreState.PullToLoad else LoadMoreState.NoMore + } + } + } + } + + /** + * 处理错误响应 + */ + protected open fun handleError(message: String?, exception: Throwable?) { + _isRefreshing.value = false + + if (currentPage == 1) { + // 首次加载或刷新失败 + if (_listData.value.isEmpty()) { + _uiState.value = BaseNetWorkListUiState.Error + } + _loadMoreState.value = LoadMoreState.PullToLoad + } else { + // 加载更多失败,回退页码 + currentPage-- + _loadMoreState.value = LoadMoreState.Error + } + } + + /** + * 重试请求 + */ + fun retryRequest() { + currentPage = 1 + _loadMoreState.value = LoadMoreState.Loading + loadListData() + } + + /** + * 触发下拉刷新 + */ + open fun onRefresh() { + // 如果正在加载中,则不重复请求 + if (_loadMoreState.value == LoadMoreState.Loading) { + return + } + + _isRefreshing.value = true + currentPage = 1 + loadListData() + } + + /** + * 加载更多数据 + */ + open fun onLoadMore() { + // 只有在可加载更多和加载失败状态下才能触发加载 + if (_loadMoreState.value == LoadMoreState.Loading || + _loadMoreState.value == LoadMoreState.NoMore || + _loadMoreState.value == LoadMoreState.Success + ) { + return + } + + _loadMoreState.value = LoadMoreState.Loading + currentPage++ + loadListData() + } + + /** + * 判断是否应该触发加载更多 + * 显示的最后一项索引接近列表末尾(倒数第3个) + * + * @param lastIndex 当前可见的最后一项索引 + * @param totalCount 列表总项数 + * @return 是否应该触发加载更多 + */ + fun shouldTriggerLoadMore(lastIndex: Int, totalCount: Int): Boolean { + return lastIndex >= totalCount - 3 && + loadMoreState.value != LoadMoreState.Loading && + loadMoreState.value != LoadMoreState.NoMore && + listData.value.isNotEmpty() + } + + /** + * 设置首次加载成功状态 + */ + private fun setFirstLoadSuccessState(newList: List, hasNextPage: Boolean) { + // 更新加载状态 + if (newList.isEmpty()) { + _uiState.value = BaseNetWorkListUiState.Empty + } else { + _uiState.value = BaseNetWorkListUiState.Success + _loadMoreState.value = + if (hasNextPage) LoadMoreState.PullToLoad else LoadMoreState.NoMore + } + } + + /** + * 视图层调用此方法,监听页面刷新信号(基于 NavigationResultKey)。 + * + * @param backStackEntry 当前页面的 NavBackStackEntry + * @param key 刷新结果的类型安全 Key,默认使用全局的 [RefreshResultKey] + * + * 用法:在 Composable 中调用 + * ```kotlin + * val backStackEntry = navController.currentBackStackEntry + * LaunchedEffect(backStackEntry) { + * viewModel.observeRefreshState(backStackEntry) + * } + * ``` + * + * 只需调用一次,自动去重和解绑,无内存泄漏。 + * 语义等价于旧方案中的 "refresh" 布尔标记。 + */ + fun observeRefreshState( + backStackEntry: NavBackStackEntry?, + key: NavigationResultKey = RefreshResultKey + ) { + if (backStackEntry == null) return + val owner: LifecycleOwner = backStackEntry + backStackEntry.savedStateHandle + .getLiveData(key.key) + .observe(owner, Observer { value -> + if (value) { + onRefresh() + // 只刷新一次 + backStackEntry.savedStateHandle[key.key] = false + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkViewModel.kt b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkViewModel.kt new file mode 100644 index 0000000..5f046c1 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseNetWorkViewModel.kt @@ -0,0 +1,209 @@ +package com.joker.kit.core.base.viewmodel + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavBackStackEntry +import com.joker.kit.core.base.state.BaseNetWorkUiState +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.result.ResultHandler +import com.joker.kit.core.result.asResult +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import com.joker.kit.navigation.NavigationResultKey +import com.joker.kit.navigation.RefreshResultKey +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 网络请求ViewModel基类 + * + * 基于Flow的异步数据流模式,子类只需重写requestApiFlow方法 + * 支持自动从SavedStateHandle获取路由参数ID + * + * @param T 数据类型 + * @param navigator 导航控制器 + * @param userState 用户状态 + * @param savedStateHandle 保存状态句柄,用于获取路由参数 + * @author Joker.X + */ +abstract class BaseNetWorkViewModel( + navigator: AppNavigator, + userState: UserState, + protected val savedStateHandle: SavedStateHandle? = null, +) : BaseViewModel(navigator, userState) { + + /** + * 通用网络请求UI状态 + * 初始为加载中状态 + */ + val _uiState: MutableStateFlow> = + MutableStateFlow(BaseNetWorkUiState.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + /** + * 控制请求失败时是否显示Toast提示 + * 子类可重写此属性以自定义行为 + */ + protected open val showErrorToast: Boolean = false + + /** + * 是否启用最少加载时间(240毫秒) + * 子类可重写此属性以启用最少加载时间功能 + */ + protected open val enableMinLoadingTime: Boolean = false + + /** + * 请求开始时间,用于计算最少加载时间 + */ + private var requestStartTime: Long = 0 + + /** + * 子类必须重写此方法,提供API请求的Flow + * 适用于各种网络操作:GET、POST、PUT、DELETE等 + * + * 注意:此方法不应在基类构造函数中调用,以避免子类属性初始化问题 + */ + protected abstract fun requestApiFlow(): Flow> + + /** + * 加载或刷新数据 + * 使用ResultHandler自动处理状态管理和错误处理 + */ + fun executeRequest() { + // 记录请求开始时间 + if (enableMinLoadingTime) requestStartTime = System.currentTimeMillis() + + ResultHandler.handleResultWithData( + scope = viewModelScope, + flow = requestApiFlow().asResult(), + showToast = showErrorToast, + onLoading = { onRequestStart() }, + onData = { data -> onRequestSuccess(data) }, + onError = { message, exception -> onRequestError(message, exception) } + ) + } + + /** + * 请求开始前执行的方法 + * 子类可重写此方法以在请求开始前执行自定义逻辑 + */ + protected open fun onRequestStart() { + setLoadingState() + } + + /** + * 处理成功结果,子类可重写此方法自定义处理逻辑 + */ + protected open fun onRequestSuccess(data: T) { + if (enableMinLoadingTime) { + val elapsedTime = System.currentTimeMillis() - requestStartTime + val minLoadingTime = 240L + + if (elapsedTime < minLoadingTime) { + // 延迟设置成功状态 + viewModelScope.launch { + delay(minLoadingTime - elapsedTime) + setSuccessState(data) + } + } else { + setSuccessState(data) + } + } else { + setSuccessState(data) + } + } + + /** + * 处理错误结果,子类可重写此方法自定义处理逻辑 + */ + protected open fun onRequestError(message: String, exception: Throwable?) { + setErrorState(message, exception) + } + + /** + * 重试请求 + */ + fun retryRequest() { + setLoadingState() + executeRequest() + } + + /** + * 设置网络状态为加载中 + */ + protected open fun setLoadingState() { + _uiState.value = BaseNetWorkUiState.Loading + } + + /** + * 设置网络状态为成功 + * + * @param data 成功返回的数据 + */ + protected open fun setSuccessState(data: T) { + _uiState.value = BaseNetWorkUiState.Success(data) + } + + /** + * 设置网络状态为错误 + * + * @param message 错误信息 + * @param exception 异常信息 + */ + protected open fun setErrorState(message: String? = null, exception: Throwable? = null) { + _uiState.value = BaseNetWorkUiState.Error(message, exception) + } + + /** + * 获取当前页面 uiState 成功以后的数据 + * 注意:此方法仅适用于当前页面的 uiState 为成功状态时 + * + * @return 成功状态下的 T 类型数据 + * @throws IllegalStateException 当 uiState 不为成功状态时抛出异常 + */ + fun getSuccessData(): T { + return (uiState.value as? BaseNetWorkUiState.Success)?.data + ?: throw IllegalStateException("Current page uiState is not in Success state, unable to retrieve data") + } + + + /** + * 视图层调用此方法,监听页面刷新信号(基于 NavigationResultKey)。 + * + * @param backStackEntry 当前页面的 NavBackStackEntry + * @param key 刷新结果的类型安全 Key,默认使用全局的 [RefreshResultKey] + * + * 用法:在 Composable 中调用 + * ```kotlin + * val backStackEntry = navController.currentBackStackEntry + * LaunchedEffect(backStackEntry) { + * viewModel.observeRefreshState(backStackEntry) + * } + * ``` + * + * 只需调用一次,自动去重和解绑,无内存泄漏。 + * 语义等价于旧方案中的 "refresh" 布尔标记。 + */ + fun observeRefreshState( + backStackEntry: NavBackStackEntry?, + key: NavigationResultKey = RefreshResultKey + ) { + if (backStackEntry == null) return + val owner: LifecycleOwner = backStackEntry + backStackEntry.savedStateHandle + .getLiveData(key.key) + .observe(owner, Observer { value -> + if (value) { + executeRequest() + // 只刷新一次 + backStackEntry.savedStateHandle[key.key] = false + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseViewModel.kt b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..b7b63cb --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/base/viewmodel/BaseViewModel.kt @@ -0,0 +1,193 @@ +package com.joker.kit.core.base.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptions +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import com.joker.kit.navigation.NavigationResultKey +import com.joker.kit.navigation.RouteInterceptor +import kotlinx.coroutines.launch + +/** + * 基础ViewModel(类型安全版本) + * + * 提供所有ViewModel通用的功能: + * 1. 类型安全的导航 + * 2. 路由拦截(登录检查) + * 3. 类型安全的结果返回 + * + * 使用示例: + * ```kotlin + * class MyViewModel @Inject constructor( + * navigator: AppNavigator, + * userState: UserState + * ) : BaseViewModel(navigator, userState) { + * fun onItemClick(id: Long) { + * navigate(GoodsRoutes.Detail(goodsId = id)) + * } + * + * fun onSuccess() { + * navigateBack(RefreshResult) + * } + * } + * ``` + * + * @param navigator 导航控制器 + * @param userState 应用状态 + * @param routeInterceptor 路由拦截器 + * @author Joker.X + */ +abstract class BaseViewModel( + protected val navigator: AppNavigator, + protected val userState: UserState, + protected val routeInterceptor: RouteInterceptor = RouteInterceptor() +) : ViewModel() { + + // ==================== 基础导航方法 ==================== + + /** + * 导航到指定路由(类型安全) + * 自动处理登录拦截逻辑 + * + * @param route 目标路由对象(必须是 @Serializable) + * @param navOptions 导航选项(可选) + * + * 使用示例: + * ```kotlin + * // 简单导航 + * navigate(MainRoutes.Home) + * + * // 带参数导航 + * navigate(GoodsRoutes.Detail(goodsId = 123)) + * + * // 带 NavOptions + * navigate(UserRoutes.Profile, navOptions) + * ``` + * + * @author Joker.X + */ + fun navigate(route: Any, navOptions: NavOptions? = null) { + viewModelScope.launch { + val targetRoute = checkRouteInterception(route) + navigator.navigateTo(targetRoute, navOptions) + } + } + + /** + * 导航到指定路由并关闭当前页面 + * 自动处理登录拦截逻辑 + * + * @param route 目标路由对象 + * @param currentRoute 当前页面路由对象,将被关闭 + * + * 使用示例: + * ```kotlin + * navigateAndCloseCurrent( + * route = MainRoutes.Home, + * currentRoute = AuthRoutes.Login + * ) + * ``` + * + * @author Joker.X + */ + fun navigateAndCloseCurrent(route: Any, currentRoute: Any) { + viewModelScope.launch { + val targetRoute = checkRouteInterception(route) + val navOptions = NavOptions.Builder() + .setPopUpTo( + route = currentRoute, + inclusive = true, // 设为true表示当前页面也会被弹出 + saveState = false // 不保存状态 + ) + .build() + navigator.navigateTo(targetRoute, navOptions) + } + } + + // ==================== 返回导航方法 ==================== + + /** + * 返回上一页 + * + * 使用示例: + * ```kotlin + * navigateBack() + * ``` + * + * @author Joker.X + */ + fun navigateBack() { + viewModelScope.launch { + navigator.navigateBack() + } + } + + /** + * 返回上一页并携带类型安全的结果(使用 NavigationResultKey) + * + * 这是 V3.2 版本的最终方案,实现了端到端的类型安全。 + * + * @param key 类型安全的结果 Key + * @param result 要传递的结果对象 + * + * 使用示例: + * ```kotlin + * // 1. 定义返回结果数据类型 + * @Serializable + * data class Address(val id: Long, val fullAddress: String) + * + * // 2. 定义 ResultKey + * object SelectAddressResultKey : NavigationResultKey
+ * + * // 3. 返回时携带结果 + * popBackStackWithResult(SelectAddressResultKey, address) + * ``` + * + * @author Joker.X + */ + fun popBackStackWithResult(key: NavigationResultKey, result: T) { + viewModelScope.launch { + navigator.popBackStackWithResult(key, result) + } + } + + /** + * 返回到指定路由 + * + * @param route 目标路由对象 + * @param inclusive 是否包含目标路由本身 + * + * 使用示例: + * ```kotlin + * // 返回到主页并保留主页 + * navigateBackTo(MainRoutes.Main, inclusive = false) + * ``` + * + * @author Joker.X + */ + fun navigateBackTo(route: Any, inclusive: Boolean = false) { + viewModelScope.launch { + navigator.navigateBackTo(route, inclusive) + } + } + + // ==================== 内部方法 ==================== + + /** + * 检查路由是否需要登录拦截(类型安全) + * + * @param route 目标路由对象 + * @return 如果需要拦截返回登录页面路由,否则返回原路由 + * @author Joker.X + */ + private fun checkRouteInterception(route: Any): Any { + return if (routeInterceptor.requiresLogin(route) && !userState.isLoggedIn.value) { + // 需要登录但未登录,跳转到登录页面 + routeInterceptor.getLoginRoute() + } else { + // 不需要登录或已登录,正常跳转 + route + } + } +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/AuthRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/AuthRepository.kt new file mode 100644 index 0000000..9c99c83 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/AuthRepository.kt @@ -0,0 +1,32 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.network.datasource.auth.AuthNetworkDataSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +/** + * 认证相关仓库 + * + * @param authNetworkDataSource 认证网络数据源 + * @author Joker.X + */ +class AuthRepository @Inject constructor( + private val authNetworkDataSource: AuthNetworkDataSource +) { + + /** + * 密码登录 + * + * @param params 登录参数 + * @return 认证信息Flow + * @author Joker.X + */ + fun loginByPassword(params: Map): Flow> = flow { + emit(authNetworkDataSource.loginByPassword(params)) + }.flowOn(Dispatchers.IO) +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/AuthStoreRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/AuthStoreRepository.kt new file mode 100644 index 0000000..ed1e004 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/AuthStoreRepository.kt @@ -0,0 +1,78 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.datastore.datasource.auth.AuthStoreDataSource +import com.joker.kit.core.model.entity.Auth +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 用户认证本地存储仓库 + * 负责处理认证相关信息的本地存储操作 + * + * @param authStoreDataSource 认证本地数据源 + * @author Joker.X + */ +@Singleton +class AuthStoreRepository @Inject constructor( + private val authStoreDataSource: AuthStoreDataSource +) { + /** + * 保存认证信息到本地 + * + * @param auth 认证信息 + * @author Joker.X + */ + suspend fun saveAuth(auth: Auth) { + authStoreDataSource.saveAuth(auth) + } + + /** + * 从本地获取认证信息 + * + * @return 认证信息,如不存在则返回null + * @author Joker.X + */ + suspend fun getAuth(): Auth? { + return authStoreDataSource.getAuth() + } + + /** + * 从本地获取Token + * + * @return Token,如不存在则返回null + * @author Joker.X + */ + suspend fun getToken(): String? { + return authStoreDataSource.getToken() + } + + /** + * 清除本地认证信息 + * + * @author Joker.X + */ + suspend fun clearAuth() { + authStoreDataSource.clearAuth() + } + + /** + * 检查用户是否已登录 + * + * @return 是否已登录 + * @author Joker.X + */ + suspend fun isLoggedIn(): Boolean { + return authStoreDataSource.isLoggedIn() + } + + /** + * 检查Token是否需要刷新 + * + * @return 是否需要刷新Token + * @author Joker.X + */ + suspend fun shouldRefreshToken(): Boolean { + val auth = authStoreDataSource.getAuth() ?: return false + return auth.shouldRefresh() + } +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/DemoRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/DemoRepository.kt new file mode 100644 index 0000000..1b1dbf5 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/DemoRepository.kt @@ -0,0 +1,84 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.database.datasource.demo.DemoDataSource +import com.joker.kit.core.database.entity.DemoEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Demo 仓库 + * 暴露对 Demo 表的增删改查,供示例代码调用 + * + * @param demoDataSource Demo 数据源 + * @author Joker.X + */ +@Singleton +class DemoRepository @Inject constructor( + private val demoDataSource: DemoDataSource +) { + + /** + * 新增记录 + * + * @param title 标题 + * @param description 描述 + * @return 记录 ID + * @author Joker.X + */ + suspend fun createItem(title: String, description: String = ""): Long { + return demoDataSource.createItem(title, description) + } + + /** + * 更新记录 + * + * @param item Demo 实体 + * @author Joker.X + */ + suspend fun updateItem(item: DemoEntity) { + demoDataSource.updateItem(item) + } + + /** + * 根据 id 删除 + * + * @param id 记录主键 + * @author Joker.X + */ + suspend fun deleteItem(id: Long) { + demoDataSource.deleteItem(id) + } + + /** + * 清空示例表 + * + * @author Joker.X + */ + suspend fun clearAll() { + demoDataSource.clearAll() + } + + /** + * 监听 Demo 表的全部数据 + * + * @return Demo 列表 Flow + * @author Joker.X + */ + fun observeItems(): Flow> { + return demoDataSource.observeItems().flowOn(Dispatchers.IO) + } + + /** + * 查询单条记录 + * + * @param id 记录主键 + * @return Demo 实体或 null + * @author Joker.X + */ + suspend fun getItem(id: Long): DemoEntity? { + return demoDataSource.getItem(id) + } +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/GoodsRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/GoodsRepository.kt new file mode 100644 index 0000000..0ff7e5c --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/GoodsRepository.kt @@ -0,0 +1,46 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.model.entity.Goods +import com.joker.kit.core.model.network.NetworkPageData +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.model.request.GoodsSearchRequest +import com.joker.kit.core.network.datasource.goods.GoodsNetworkDataSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +/** + * 商品相关仓库 + * + * @param goodsNetworkDataSource 商品网络数据源 + * @author Joker.X + */ +class GoodsRepository @Inject constructor( + private val goodsNetworkDataSource: GoodsNetworkDataSource +) { + /** + * 分页查询商品 + * + * @param params 搜索请求参数 + * @return 商品分页数据Flow + * @author Joker.X + */ + fun getGoodsPage(params: GoodsSearchRequest): Flow>> = + flow { + emit(goodsNetworkDataSource.getGoodsPage(params)) + }.flowOn(Dispatchers.IO) + + /** + * 获取商品信息 + * + * @param id 商品ID + * @return 商品信息Flow + * @author Joker.X + */ + fun getGoodsInfo(id: String): Flow> = flow { + emit(goodsNetworkDataSource.getGoodsInfo(id)) + }.flowOn(Dispatchers.IO) + +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/UserInfoRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/UserInfoRepository.kt new file mode 100644 index 0000000..6de0542 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/UserInfoRepository.kt @@ -0,0 +1,30 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.network.datasource.userinfo.UserInfoNetworkDataSource +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.model.network.NetworkResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +/** + * 用户信息相关仓库 + * + * @param userInfoNetworkDataSource 用户信息网络数据源 + * @author Joker.X + */ +class UserInfoRepository @Inject constructor( + private val userInfoNetworkDataSource: UserInfoNetworkDataSource +) { + /** + * 获取用户个人信息 + * + * @return 用户信息Flow + * @author Joker.X + */ + fun getPersonInfo(): Flow> = flow { + emit(userInfoNetworkDataSource.getPersonInfo()) + }.flowOn(Dispatchers.IO) +} diff --git a/app/src/main/java/com/joker/kit/core/data/repository/UserInfoStoreRepository.kt b/app/src/main/java/com/joker/kit/core/data/repository/UserInfoStoreRepository.kt new file mode 100644 index 0000000..feb545c --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/data/repository/UserInfoStoreRepository.kt @@ -0,0 +1,87 @@ +package com.joker.kit.core.data.repository + +import com.joker.kit.core.datastore.datasource.userinfo.UserInfoStoreDataSource +import com.joker.kit.core.model.entity.User +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 用户信息本地存储仓库 + * 负责处理用户个人信息的本地存储操作 + * + * @param userInfoStoreDataSource 用户信息本地数据源 + * @author Joker.X + */ +@Singleton +class UserInfoStoreRepository @Inject constructor( + private val userInfoStoreDataSource: UserInfoStoreDataSource +) { + /** + * 保存用户信息到本地 + * + * @param user 用户信息 + * @author Joker.X + */ + suspend fun saveUserInfo(user: User) { + userInfoStoreDataSource.saveUserInfo(user) + } + + /** + * 从本地获取用户信息 + * + * @return 用户信息,如不存在则返回null + * @author Joker.X + */ + suspend fun getUserInfo(): User? { + return userInfoStoreDataSource.getUserInfo() + } + + /** + * 更新本地用户信息中的特定字段 + * + * @param updates 需要更新的字段映射 + * @author Joker.X + */ + suspend fun updateUserInfo(updates: Map) { + userInfoStoreDataSource.updateUserInfo(updates) + } + + /** + * 清除本地用户信息 + * + * @author Joker.X + */ + suspend fun clearUserInfo() { + userInfoStoreDataSource.clearUserInfo() + } + + /** + * 获取用户ID + * + * @return 用户ID,如不存在则返回0 + * @author Joker.X + */ + suspend fun getUserId(): Long { + return userInfoStoreDataSource.getUserId() + } + + /** + * 获取用户昵称 + * + * @return 用户昵称,如不存在则返回null + * @author Joker.X + */ + suspend fun getNickName(): String? { + return userInfoStoreDataSource.getNickName() + } + + /** + * 获取用户头像URL + * + * @return 用户头像URL,如不存在则返回null + * @author Joker.X + */ + suspend fun getAvatarUrl(): String? { + return userInfoStoreDataSource.getAvatarUrl() + } +} diff --git a/app/src/main/java/com/joker/kit/core/database/AppDatabase.kt b/app/src/main/java/com/joker/kit/core/database/AppDatabase.kt new file mode 100644 index 0000000..62e196e --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/database/AppDatabase.kt @@ -0,0 +1,33 @@ +package com.joker.kit.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.joker.kit.core.database.dao.DemoDao +import com.joker.kit.core.database.entity.DemoEntity + +/** + * 应用数据库,仅保留一张 Demo 表用于演示 Room 的基本 CRUD + * + * @author Joker.X + */ +@Database( + entities = [ + DemoEntity::class + ], + version = 2, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + /** + * Demo DAO:用于演示最基础的数据库操作 + * + * @return DemoDao + * @author Joker.X + */ + abstract fun demoDao(): DemoDao + + companion object { + const val DATABASE_NAME = "app-database" + } +} diff --git a/app/src/main/java/com/joker/kit/core/database/dao/DemoDao.kt b/app/src/main/java/com/joker/kit/core/database/dao/DemoDao.kt new file mode 100644 index 0000000..43ceffc --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/database/dao/DemoDao.kt @@ -0,0 +1,83 @@ +package com.joker.kit.core.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.joker.kit.core.database.entity.DemoEntity +import kotlinx.coroutines.flow.Flow + +/** + * Demo 表 DAO:演示最基础的增删改查 + * + * @author Joker.X + */ +@Dao +interface DemoDao { + + /** + * 新增一条记录 + * + * @param item Demo 实体 + * @return 插入后的行 ID + * @author Joker.X + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: DemoEntity): Long + + /** + * 更新整条记录 + * + * @param item Demo 实体 + * @author Joker.X + */ + @Update + suspend fun updateItem(item: DemoEntity) + + /** + * 根据主键删除 + * + * @param id 记录主键 + * @author Joker.X + */ + @Query("DELETE FROM demo_items WHERE id = :id") + suspend fun deleteById(id: Long) + + /** + * 也可以直接删除整个实体 + * + * @param item Demo 实体 + * @author Joker.X + */ + @Delete + suspend fun delete(item: DemoEntity) + + /** + * 清空演示表 + * + * @author Joker.X + */ + @Query("DELETE FROM demo_items") + suspend fun clearAll() + + /** + * 查询所有记录,按更新时间倒序 + * + * @return Demo 列表 Flow + * @author Joker.X + */ + @Query("SELECT * FROM demo_items ORDER BY updatedAt DESC") + fun getAllItems(): Flow> + + /** + * 根据 ID 查询 + * + * @param id 记录主键 + * @return Demo 实体或 null + * @author Joker.X + */ + @Query("SELECT * FROM demo_items WHERE id = :id") + suspend fun getItemById(id: Long): DemoEntity? +} diff --git a/app/src/main/java/com/joker/kit/core/database/datasource/demo/DemoDataSource.kt b/app/src/main/java/com/joker/kit/core/database/datasource/demo/DemoDataSource.kt new file mode 100644 index 0000000..c716a24 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/database/datasource/demo/DemoDataSource.kt @@ -0,0 +1,85 @@ +package com.joker.kit.core.database.datasource.demo + +import com.joker.kit.core.database.dao.DemoDao +import com.joker.kit.core.database.entity.DemoEntity +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Demo 数据源:演示最基础的增删改查写法 + * + * @author Joker.X + */ +@Singleton +class DemoDataSource @Inject constructor( + private val demoDao: DemoDao +) { + + /** + * 新增一条记录 + * + * @param title 标题 + * @param description 描述 + * @return 新建记录的 ID + * @author Joker.X + */ + suspend fun createItem(title: String, description: String = ""): Long { + return demoDao.insertItem( + DemoEntity( + title = title, + description = description + ) + ) + } + + /** + * 更新记录(会刷新更新时间) + * + * @param item Demo 实体 + * @author Joker.X + */ + suspend fun updateItem(item: DemoEntity) { + demoDao.updateItem(item.copy(updatedAt = System.currentTimeMillis())) + } + + /** + * 根据 id 删除 + * + * @param id 记录主键 + * @author Joker.X + */ + suspend fun deleteItem(id: Long) { + demoDao.deleteById(id) + } + + /** + * 演示清空表 + * + * @author Joker.X + */ + suspend fun clearAll() { + demoDao.clearAll() + } + + /** + * 监听所有数据 + * + * @return Demo 列表 Flow + * @author Joker.X + */ + fun observeItems(): Flow> { + return demoDao.getAllItems() + } + + /** + * 查询单条示例 + * + * @param id 记录主键 + * @return Demo 实体或 null + * @author Joker.X + */ + suspend fun getItem(id: Long): DemoEntity? { + return demoDao.getItemById(id) + } +} diff --git a/app/src/main/java/com/joker/kit/core/database/di/DatabaseModule.kt b/app/src/main/java/com/joker/kit/core/database/di/DatabaseModule.kt new file mode 100644 index 0000000..bc37e0b --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/database/di/DatabaseModule.kt @@ -0,0 +1,55 @@ +package com.joker.kit.core.database.di + +import android.content.Context +import androidx.room.Room +import com.joker.kit.core.database.AppDatabase +import com.joker.kit.core.database.dao.DemoDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * 数据库模块 + * 负责提供数据库实例及相关DAO的依赖注入 + * + * @author Joker.X + */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + /** + * 提供数据库实例 + * + * @param context 应用上下文 + * @return 应用数据库实例 + * @author Joker.X + */ + @Provides + @Singleton + fun provideDatabase( + @ApplicationContext context: Context + ): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + AppDatabase.DATABASE_NAME + ).build() + } + + /** + * 提供 Demo DAO + * + * @param database Room 数据库 + * @return Demo DAO + * @author Joker.X + */ + @Provides + @Singleton + fun provideDemoDao(database: AppDatabase): DemoDao { + return database.demoDao() + } +} diff --git a/app/src/main/java/com/joker/kit/core/database/entity/DemoEntity.kt b/app/src/main/java/com/joker/kit/core/database/entity/DemoEntity.kt new file mode 100644 index 0000000..4dff112 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/database/entity/DemoEntity.kt @@ -0,0 +1,22 @@ +package com.joker.kit.core.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Demo 表:示例用途的数据结构 + * + * @param id 自增主键 + * @param title 标题 + * @param description 说明 + * @param updatedAt 更新时间戳 + * @author Joker.X + */ +@Entity(tableName = "demo_items") +data class DemoEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val title: String, + val description: String = "", + val updatedAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSource.kt b/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSource.kt new file mode 100644 index 0000000..f892e6b --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSource.kt @@ -0,0 +1,51 @@ +package com.joker.kit.core.datastore.datasource.auth + +import com.joker.kit.core.model.entity.Auth + + +/** + * 本地用户认证相关数据源接口 + * + * @author Joker.X + */ +interface AuthStoreDataSource { + + /** + * 保存认证信息 + * + * @param auth 认证信息对象 + * @author Joker.X + */ + suspend fun saveAuth(auth: Auth) + + /** + * 获取认证信息 + * + * @return 认证信息对象,如不存在则返回null + * @author Joker.X + */ + suspend fun getAuth(): Auth? + + /** + * 获取用户 token + * + * @return token字符串,如不存在则返回null + * @author Joker.X + */ + suspend fun getToken(): String? + + /** + * 清除认证信息 + * + * @author Joker.X + */ + suspend fun clearAuth() + + /** + * 检查是否已登录(有认证信息且未过期) + * + * @return 是否已登录 + * @author Joker.X + */ + suspend fun isLoggedIn(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSourceImpl.kt b/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSourceImpl.kt new file mode 100644 index 0000000..40cdf6f --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/datastore/datasource/auth/AuthStoreDataSourceImpl.kt @@ -0,0 +1,79 @@ +package com.joker.kit.core.datastore.datasource.auth + +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.util.storage.MMKVUtils +import jakarta.inject.Inject +import kotlinx.serialization.json.Json + +/** + * 本地用户认证相关数据源实现类 + * 负责处理所有与用户认证相关的本地存储 + * + * @author Joker.X + */ +class AuthStoreDataSourceImpl @Inject constructor() : AuthStoreDataSource { + + companion object { + private const val KEY_AUTH = "auth_info" + } + + private val json = Json { ignoreUnknownKeys = true } + + /** + * 保存认证信息 + * + * @param auth 认证信息对象 + * @author Joker.X + */ + override suspend fun saveAuth(auth: Auth) { + val authJson = json.encodeToString(auth) + MMKVUtils.putString(KEY_AUTH, authJson) + } + + /** + * 获取认证信息 + * + * @return 认证信息对象,如不存在则返回null + * @author Joker.X + */ + override suspend fun getAuth(): Auth? { + val authJson = MMKVUtils.getString(KEY_AUTH, "") + if (authJson.isEmpty()) return null + + return try { + json.decodeFromString(authJson) + } catch (e: Exception) { + null + } + } + + /** + * 获取用户 token + * + * @return token字符串,如不存在则返回null + * @author Joker.X + */ + override suspend fun getToken(): String? { + return getAuth()?.token + } + + /** + * 清除认证信息 + * + * @author Joker.X + */ + override suspend fun clearAuth() { + MMKVUtils.remove(KEY_AUTH) + } + + /** + * 检查是否已登录(有认证信息且未过期) + * + * @return 是否已登录 + * @author Joker.X + */ + override suspend fun isLoggedIn(): Boolean { + val auth = getAuth() ?: return false + return !auth.isExpired() && auth.token.isNotEmpty() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSource.kt b/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSource.kt new file mode 100644 index 0000000..83d97c4 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSource.kt @@ -0,0 +1,67 @@ +package com.joker.kit.core.datastore.datasource.userinfo + +import com.joker.kit.core.model.entity.User + + +/** + * 本地用户信息相关数据源接口 + * + * @author Joker.X + */ +interface UserInfoStoreDataSource { + + /** + * 保存用户信息 + * + * @param user 用户信息对象 + * @author Joker.X + */ + suspend fun saveUserInfo(user: User) + + /** + * 获取用户信息 + * + * @return 用户信息对象,如不存在则返回null + * @author Joker.X + */ + suspend fun getUserInfo(): User? + + /** + * 更新用户信息中的特定字段 + * + * @param updates 需要更新的字段映射 + * @author Joker.X + */ + suspend fun updateUserInfo(updates: Map) + + /** + * 清除用户信息 + * + * @author Joker.X + */ + suspend fun clearUserInfo() + + /** + * 获取用户ID + * + * @return 用户ID,如不存在则返回0 + * @author Joker.X + */ + suspend fun getUserId(): Long + + /** + * 获取用户昵称 + * + * @return 用户昵称,如不存在则返回null + * @author Joker.X + */ + suspend fun getNickName(): String? + + /** + * 获取用户头像URL + * + * @return 用户头像URL,如不存在则返回null + * @author Joker.X + */ + suspend fun getAvatarUrl(): String? +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSourceImpl.kt b/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSourceImpl.kt new file mode 100644 index 0000000..c9af6c6 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/datastore/datasource/userinfo/UserInfoStoreDataSourceImpl.kt @@ -0,0 +1,124 @@ +package com.joker.kit.core.datastore.datasource.userinfo + +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.util.storage.MMKVUtils +import jakarta.inject.Inject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +/** + * 本地用户信息相关数据源实现类 + * 负责处理所有与用户信息相关的本地存储 + * + * @author Joker.X + */ +class UserInfoStoreDataSourceImpl @Inject constructor() : UserInfoStoreDataSource { + + companion object { + private const val KEY_USER_INFO = "user_info" + } + + private val json = Json { ignoreUnknownKeys = true } + + /** + * 保存用户信息 + * + * @param user 用户信息对象 + * @author Joker.X + */ + override suspend fun saveUserInfo(user: User) { + val userJson = json.encodeToString(user) + MMKVUtils.putString(KEY_USER_INFO, userJson) + } + + /** + * 获取用户信息 + * + * @return 用户信息对象,如不存在则返回null + * @author Joker.X + */ + override suspend fun getUserInfo(): User? { + val userJson = MMKVUtils.getString(KEY_USER_INFO, "") + if (userJson.isEmpty()) return null + + return try { + json.decodeFromString(userJson) + } catch (e: Exception) { + null + } + } + + /** + * 更新用户信息中的特定字段 + * + * @param updates 需要更新的字段映射 + * @author Joker.X + */ + override suspend fun updateUserInfo(updates: Map) { + val currentUser = getUserInfo() ?: return + val userJson = MMKVUtils.getString(KEY_USER_INFO, "") + if (userJson.isEmpty()) return + + try { + // 解析当前用户JSON为可变映射 + val userObject = json.parseToJsonElement(userJson).jsonObject.toMutableMap() + + // 应用更新 + updates.forEach { (key, value) -> + when (value) { + is String -> userObject[key] = JsonPrimitive(value) + is Number -> userObject[key] = JsonPrimitive(value) + is Boolean -> userObject[key] = JsonPrimitive(value) + null -> userObject.remove(key) + } + } + + // 保存更新后的JSON + val updatedJson = JsonObject(userObject).toString() + MMKVUtils.putString(KEY_USER_INFO, updatedJson) + } catch (e: Exception) { + // 如果更新失败,至少保留原始数据 + } + } + + /** + * 清除用户信息 + * + * @author Joker.X + */ + override suspend fun clearUserInfo() { + MMKVUtils.remove(KEY_USER_INFO) + } + + /** + * 获取用户ID + * + * @return 用户ID,如不存在则返回0 + * @author Joker.X + */ + override suspend fun getUserId(): Long { + return getUserInfo()?.id ?: 0L + } + + /** + * 获取用户昵称 + * + * @return 用户昵称,如不存在则返回null + * @author Joker.X + */ + override suspend fun getNickName(): String? { + return getUserInfo()?.nickName + } + + /** + * 获取用户头像URL + * + * @return 用户头像URL,如不存在则返回null + * @author Joker.X + */ + override suspend fun getAvatarUrl(): String? { + return getUserInfo()?.avatarUrl + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/datastore/di/DataStoreModule.kt b/app/src/main/java/com/joker/kit/core/datastore/di/DataStoreModule.kt new file mode 100644 index 0000000..da7f51f --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,48 @@ +package com.joker.kit.core.datastore.di + +import com.joker.kit.core.datastore.datasource.auth.AuthStoreDataSource +import com.joker.kit.core.datastore.datasource.auth.AuthStoreDataSourceImpl +import com.joker.kit.core.datastore.datasource.userinfo.UserInfoStoreDataSource +import com.joker.kit.core.datastore.datasource.userinfo.UserInfoStoreDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * 数据存储模块 + * 提供用户认证和用户信息相关的数据源依赖 + * + * @author Joker.X + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DataStoreModule { + + /** + * 提供认证数据源接口实现 + * + * @param impl 认证数据源实现类 + * @return 认证数据源接口 + * @author Joker.X + */ + @Binds + @Singleton + abstract fun bindAuthStoreDataSource( + impl: AuthStoreDataSourceImpl + ): AuthStoreDataSource + + /** + * 提供用户信息数据源接口实现 + * + * @param impl 用户信息数据源实现类 + * @return 用户信息数据源接口 + * @author Joker.X + */ + @Binds + @Singleton + abstract fun bindUserInfoStoreDataSource( + impl: UserInfoStoreDataSourceImpl + ): UserInfoStoreDataSource +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/common/Id.kt b/app/src/main/java/com/joker/kit/core/model/common/Id.kt new file mode 100644 index 0000000..3213210 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/common/Id.kt @@ -0,0 +1,18 @@ +package com.joker.kit.core.model.common + +import kotlinx.serialization.Serializable + +/** + * ID模型 + * 用于处理只返回ID的接口响应 + * + * @param id ID值 + * @author Joker.X + */ +@Serializable +data class Id( + /** + * ID值 + */ + val id: Long = 0 +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/common/Ids.kt b/app/src/main/java/com/joker/kit/core/model/common/Ids.kt new file mode 100644 index 0000000..2f84f1b --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/common/Ids.kt @@ -0,0 +1,17 @@ +package com.joker.kit.core.model.common + +import kotlinx.serialization.Serializable + +/** + * ID数组 + * + * @param ids 地址ID数组 + * @author Joker.X + */ +@Serializable +data class Ids( + /** + * 地址ID数组 + */ + val ids: List +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/entity/Auth.kt b/app/src/main/java/com/joker/kit/core/model/entity/Auth.kt new file mode 100644 index 0000000..fadf91e --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/entity/Auth.kt @@ -0,0 +1,79 @@ +package com.joker.kit.core.model.entity + +import kotlinx.serialization.Serializable + +/** + * 认证令牌模型 + * 与后端登录返回的token数据对应 + * + * @param token token + * @param refreshToken 刷新token + * @param expire token过期时间(秒) + * @param refreshExpire 刷新令牌过期时间 + * @param createdAt 令牌创建时间(不来自服务端) + * @author Joker.X + */ +@Serializable +data class Auth( + + /** + * token + */ + val token: String = "", + + /** + * 刷新 token + */ + val refreshToken: String = "", + + /** + * token 过期时间(秒) + */ + val expire: Long = 0, + + /** + * 刷新令牌过期时间 + */ + val refreshExpire: Long = 0, + + /** + * 令牌创建时间(不来自服务端) + */ + val createdAt: Long = System.currentTimeMillis() +) { + /** + * 检查访问令牌是否过期 + * + * @return 是否过期 + * @author Joker.X + */ + fun isExpired(): Boolean { + val currentTime = System.currentTimeMillis() + val expirationTime = createdAt + (expire * 1000) + return currentTime >= expirationTime + } + + /** + * 检查刷新令牌是否过期 + * + * @return 是否过期 + * @author Joker.X + */ + fun isRefreshTokenExpired(): Boolean { + val currentTime = System.currentTimeMillis() + val expirationTime = createdAt + (refreshExpire * 1000) + return currentTime >= expirationTime + } + + /** + * 检查令牌是否需要刷新(过期前15分钟) + * + * @return 是否需要刷新 + * @author Joker.X + */ + fun shouldRefresh(): Boolean { + val currentTime = System.currentTimeMillis() + val refreshTime = createdAt + (expire * 1000) - (15 * 60 * 1000) // 提前15分钟刷新 + return currentTime >= refreshTime && !isRefreshTokenExpired() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/entity/Goods.kt b/app/src/main/java/com/joker/kit/core/model/entity/Goods.kt new file mode 100644 index 0000000..321b247 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/entity/Goods.kt @@ -0,0 +1,102 @@ +package com.joker.kit.core.model.entity + +import kotlinx.serialization.Serializable + +/** + * 商品模型 + * + * @param id ID + * @param typeId 类型ID + * @param title 标题 + * @param subTitle 副标题 + * @param mainPic 主图 + * @param pics 图片 + * @param price 价格 + * @param sold 已售 + * @param contentPics 详情图片 + * @param recommend 推荐 + * @param featured 精选 + * @param status 状态 0-下架 1-上架 + * @param sortNum 排序 + * @param createTime 创建时间 + * @param updateTime 更新时间 + * @author Joker.X + */ +@Serializable +data class Goods( + + /** + * ID + */ + val id: Long = 0, + + /** + * 类型ID + */ + val typeId: Long = 0, + + /** + * 标题 + */ + val title: String = "", + + /** + * 副标题 + */ + val subTitle: String? = null, + + /** + * 主图 + */ + val mainPic: String = "", + + /** + * 图片 + */ + val pics: List? = null, + + /** + * 价格 + */ + val price: Int = 0, + + /** + * 已售 + */ + val sold: Int = 0, + + /** + * 详情图片 + */ + val contentPics: List? = null, + + /** + * 推荐 + */ + val recommend: Boolean = false, + + /** + * 精选 + */ + val featured: Boolean = false, + + /** + * 状态 0-下架 1-上架 + */ + val status: Int = 0, + + /** + * 排序 + */ + val sortNum: Int = 0, + + /** + * 创建时间 + */ + val createTime: String? = null, + + /** + * 更新时间 + */ + val updateTime: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/entity/User.kt b/app/src/main/java/com/joker/kit/core/model/entity/User.kt new file mode 100644 index 0000000..60b434a --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/entity/User.kt @@ -0,0 +1,78 @@ +package com.joker.kit.core.model.entity + +import kotlinx.serialization.Serializable + +/** + * 用户信息模型 + * + * @param id ID + * @param unionid 登录唯一ID + * @param avatarUrl 头像 + * @param nickName 昵称 + * @param phone 手机号 + * @param gender 性别 0-未知 1-男 2-女 + * @param status 状态 0-禁用 1-正常 2-已注销 + * @param loginType 登录方式 0-小程序 1-公众号 2-H5 + * @param password 密码 + * @param createTime 创建时间 + * @param updateTime 更新时间 + * @author Joker.X + */ +@Serializable +data class User( + + /** + * ID + */ + val id: Long = 0, + + /** + * 登录唯一ID + */ + val unionid: String = "", + + /** + * 头像 + */ + val avatarUrl: String? = null, + + /** + * 昵称 + */ + val nickName: String? = null, + + /** + * 手机号 + */ + val phone: String? = null, + + /** + * 性别 0-未知 1-男 2-女 + */ + val gender: Int = 0, + + /** + * 状态 0-禁用 1-正常 2-已注销 + */ + val status: Int = 1, + + /** + * 登录方式 0-小程序 1-公众号 2-H5 + */ + val loginType: String = "0", + + /** + * 密码 + */ + val password: String? = null, + + /** + * 创建时间 + */ + val createTime: String? = null, + + /** + * 更新时间 + */ + val updateTime: String? = null +) diff --git a/app/src/main/java/com/joker/kit/core/model/network/NetworkPageData.kt b/app/src/main/java/com/joker/kit/core/model/network/NetworkPageData.kt new file mode 100644 index 0000000..25686ca --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/network/NetworkPageData.kt @@ -0,0 +1,24 @@ +package com.joker.kit.core.model.network + +import kotlinx.serialization.Serializable + +/** + * 网络响应分页模型 + * + * @param T 数据类型 + * @param list 列表 + * @param pagination 分页数据 + * @author Joker.X + */ +@Serializable +data class NetworkPageData( + /** + * 列表 + */ + var list: List? = null, + + /** + * 分页数据 + */ + var pagination: NetworkPageMeta? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/network/NetworkPageMeta.kt b/app/src/main/java/com/joker/kit/core/model/network/NetworkPageMeta.kt new file mode 100644 index 0000000..3c1b89a --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/network/NetworkPageMeta.kt @@ -0,0 +1,29 @@ +package com.joker.kit.core.model.network + +import kotlinx.serialization.Serializable + +/** + * 分页模型 + * + * @param total 总条数 + * @param size 每页显示条数 + * @param page 当前页码 + * @author Joker.X + */ +@Serializable +data class NetworkPageMeta( + /** + * 总条数 + */ + val total: Int? = null, + + /** + * 每页显示条数 + */ + val size: Int? = null, + + /** + * 当前页码 + */ + val page: Int? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/network/NetworkResponse.kt b/app/src/main/java/com/joker/kit/core/model/network/NetworkResponse.kt new file mode 100644 index 0000000..bd51928 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/network/NetworkResponse.kt @@ -0,0 +1,44 @@ +package com.joker.kit.core.model.network + +import kotlinx.serialization.Serializable + +/** + * 解析网络响应 + * + * @param T 数据类型 + * @param data 真实数据 + * @param code 状态码 等于1000表示成功 + * @param message 出错的提示信息 + * @author Joker.X + */ +@Serializable +data class NetworkResponse( + /** + * 真实数据 + * 类型是泛型 + */ + val data: T? = null, + + /** + * 状态码 + * 等于0表示成功 + */ + val code: Int = 1000, + + /** + * 出错的提示信息 + * 发生了错误不一定有 + */ + val message: String? = null, + + + ) { + /** + * 是否成功 + * + * @return 是否成功 + * @author Joker.X + */ + val isSucceeded: Boolean + get() = code == 1000 +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/model/request/GoodsSearchRequest.kt b/app/src/main/java/com/joker/kit/core/model/request/GoodsSearchRequest.kt new file mode 100644 index 0000000..25729fe --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/model/request/GoodsSearchRequest.kt @@ -0,0 +1,23 @@ +package com.joker.kit.core.model.request + +import kotlinx.serialization.Serializable + +/** + * 商品搜索分页请求模型 + * + * @param page 页码 + * @param size 每页大小 + * @author Joker.X + */ +@Serializable +data class GoodsSearchRequest( + /** + * 页码 + */ + val page: Int = 1, + + /** + * 每页大小 + */ + val size: Int = 20, +) \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/base/BaseNetworkDataSource.kt b/app/src/main/java/com/joker/kit/core/network/base/BaseNetworkDataSource.kt new file mode 100644 index 0000000..38cc1f5 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/base/BaseNetworkDataSource.kt @@ -0,0 +1,22 @@ +package com.joker.kit.core.network.base + +import retrofit2.Retrofit + +/** + * 网络数据源基类 + * 提供所有网络数据源实现的通用功能 + * + * @author Joker.X + */ +abstract class BaseNetworkDataSource { + /** + * 创建API服务实例的辅助方法 + * + * @param T API服务接口类型 + * @return API服务实例 + * @author Joker.X + */ + protected inline fun Retrofit.createService(): T { + return this.create(T::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSource.kt b/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSource.kt new file mode 100644 index 0000000..a0b12bb --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSource.kt @@ -0,0 +1,21 @@ +package com.joker.kit.core.network.datasource.auth + +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.model.network.NetworkResponse + + +/** + * 认证相关数据源接口 + * + * @author Joker.X + */ +interface AuthNetworkDataSource { + /** + * 密码登录 + * + * @param params 密码登录请求参数 + * @return 认证信息响应 + * @author Joker.X + */ + suspend fun loginByPassword(params: Map): NetworkResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSourceImpl.kt b/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSourceImpl.kt new file mode 100644 index 0000000..192c111 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/auth/AuthNetworkDataSourceImpl.kt @@ -0,0 +1,31 @@ +package com.joker.kit.core.network.datasource.auth + +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.network.base.BaseNetworkDataSource +import com.joker.kit.core.network.service.AuthService +import javax.inject.Inject + +/** + * 认证相关数据源实现类 + * 负责处理所有与用户认证相关的网络请求 + * + * @param authService 认证服务接口,用于发起实际的网络请求 + * @author Joker.X + */ +class AuthNetworkDataSourceImpl @Inject constructor( + private val authService: AuthService +) : BaseNetworkDataSource(), AuthNetworkDataSource { + + /** + * 账号密码登录 + * + * @param params 请求参数,包含账号和密码 + * @return 登录结果响应数据 + * @author Joker.X + */ + override suspend fun loginByPassword(params: Map): NetworkResponse { + return authService.loginByPassword(params) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSource.kt b/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSource.kt new file mode 100644 index 0000000..be6cd34 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSource.kt @@ -0,0 +1,32 @@ +package com.joker.kit.core.network.datasource.goods + +import com.joker.kit.core.model.entity.Goods +import com.joker.kit.core.model.network.NetworkPageData +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.model.request.GoodsSearchRequest + +/** + * 商品相关数据源接口 + * + * @author Joker.X + */ +interface GoodsNetworkDataSource { + /** + * 分页查询商品 + * + * @param params 商品搜索请求参数 + * @return 商品分页数据响应 + * @author Joker.X + */ + suspend fun getGoodsPage(params: GoodsSearchRequest): NetworkResponse> + + /** + * 获取商品信息 + * + * @param id 商品ID + * @return 商品信息响应 + * @author Joker.X + */ + suspend fun getGoodsInfo(id: String): NetworkResponse + +} diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSourceImpl.kt b/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSourceImpl.kt new file mode 100644 index 0000000..e63f8bb --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/goods/GoodsNetworkDataSourceImpl.kt @@ -0,0 +1,43 @@ +package com.joker.kit.core.network.datasource.goods + +import com.joker.kit.core.model.entity.Goods +import com.joker.kit.core.model.network.NetworkPageData +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.model.request.GoodsSearchRequest +import com.joker.kit.core.network.base.BaseNetworkDataSource +import com.joker.kit.core.network.service.GoodsService +import javax.inject.Inject + +/** + * 商品相关数据源实现类 + * 负责处理所有与商品相关的网络请求 + * + * @param goodsService 商品服务接口,用于发起实际的网络请求 + * @author Joker.X + */ +class GoodsNetworkDataSourceImpl @Inject constructor( + private val goodsService: GoodsService +) : BaseNetworkDataSource(), GoodsNetworkDataSource { + + /** + * 分页查询商品 + * + * @param params 请求参数,包含分页和筛选信息 + * @return 商品分页列表响应数据 + * @author Joker.X + */ + override suspend fun getGoodsPage(params: GoodsSearchRequest): NetworkResponse> { + return goodsService.getGoodsPage(params) + } + + /** + * 获取商品详情 + * + * @param id 商品ID + * @return 商品详情响应数据 + * @author Joker.X + */ + override suspend fun getGoodsInfo(id: String): NetworkResponse { + return goodsService.getGoodsInfo(id) + } +} diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSource.kt b/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSource.kt new file mode 100644 index 0000000..244d493 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSource.kt @@ -0,0 +1,20 @@ +package com.joker.kit.core.network.datasource.userinfo + +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.model.network.NetworkResponse + +/** + * 用户信息相关数据源接口 + * + * @author Joker.X + */ +interface UserInfoNetworkDataSource { + + /** + * 获取用户个人信息 + * + * @return 用户信息响应 + * @author Joker.X + */ + suspend fun getPersonInfo(): NetworkResponse +} diff --git a/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSourceImpl.kt b/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSourceImpl.kt new file mode 100644 index 0000000..6159224 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/datasource/userinfo/UserInfoNetworkDataSourceImpl.kt @@ -0,0 +1,29 @@ +package com.joker.kit.core.network.datasource.userinfo + +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.network.base.BaseNetworkDataSource +import com.joker.kit.core.network.service.UserInfoService +import javax.inject.Inject + +/** + * 用户信息相关数据源实现类 + * 负责处理所有与用户信息相关的网络请求 + * + * @param userInfoService 用户信息服务接口,用于发起实际的网络请求 + * @author Joker.X + */ +class UserInfoNetworkDataSourceImpl @Inject constructor( + private val userInfoService: UserInfoService +) : BaseNetworkDataSource(), UserInfoNetworkDataSource { + + /** + * 获取用户个人信息 + * + * @return 用户个人信息响应数据 + * @author Joker.X + */ + override suspend fun getPersonInfo(): NetworkResponse { + return userInfoService.getPersonInfo() + } +} diff --git a/app/src/main/java/com/joker/kit/core/network/di/DataSourceModule.kt b/app/src/main/java/com/joker/kit/core/network/di/DataSourceModule.kt new file mode 100644 index 0000000..dc739de --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/di/DataSourceModule.kt @@ -0,0 +1,66 @@ +package com.joker.kit.core.network.di + +import com.joker.kit.core.network.datasource.auth.AuthNetworkDataSource +import com.joker.kit.core.network.datasource.auth.AuthNetworkDataSourceImpl +import com.joker.kit.core.network.datasource.goods.GoodsNetworkDataSource +import com.joker.kit.core.network.datasource.goods.GoodsNetworkDataSourceImpl +import com.joker.kit.core.network.datasource.userinfo.UserInfoNetworkDataSource +import com.joker.kit.core.network.datasource.userinfo.UserInfoNetworkDataSourceImpl +import com.joker.kit.core.network.service.AuthService +import com.joker.kit.core.network.service.GoodsService +import com.joker.kit.core.network.service.UserInfoService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * 数据源模块,提供所有网络数据源的依赖注入 + * 为Hilt提供各种网络数据源的实例 + * + * @author Joker.X + */ +@Module +@InstallIn(SingletonComponent::class) +object DataSourceModule { + + /** + * 提供认证相关网络数据源 + * + * @param authService 认证服务接口 + * @return 认证网络数据源实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideAuthNetworkDataSource(authService: AuthService): AuthNetworkDataSource { + return AuthNetworkDataSourceImpl(authService) + } + + /** + * 提供商品相关网络数据源 + * + * @param goodsService 商品服务接口 + * @return 商品网络数据源实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideGoodsNetworkDataSource(goodsService: GoodsService): GoodsNetworkDataSource { + return GoodsNetworkDataSourceImpl(goodsService) + } + + /** + * 提供用户信息相关网络数据源 + * + * @param userInfoService 用户信息服务接口 + * @return 用户信息网络数据源实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideUserInfoNetworkDataSource(userInfoService: UserInfoService): UserInfoNetworkDataSource { + return UserInfoNetworkDataSourceImpl(userInfoService) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/di/NetworkModule.kt b/app/src/main/java/com/joker/kit/core/network/di/NetworkModule.kt new file mode 100644 index 0000000..6fa73e4 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/di/NetworkModule.kt @@ -0,0 +1,118 @@ +package com.joker.kit.core.network.di + +import android.content.Context +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.joker.kit.core.network.interceptor.AuthInterceptor +import com.joker.kit.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +/** + * 网络模块 + * 负责提供网络相关的依赖注入 + * + * @author Joker.X + */ +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + private const val BASE_URL = BuildConfig.BASE_URL + + /** + * 提供JSON序列化配置 + * + * @return JSON序列化实例 + * @author Joker.X + */ + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + + /** + * 提供OkHttpClient实例 + * + * @param authInterceptor 认证拦截器 + * @param loggingInterceptor 日志拦截器 + * @param context 应用上下文 + * @return OkHttpClient实例 + * @author Joker.X + */ + @Provides + @Singleton + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + loggingInterceptor: HttpLoggingInterceptor, + @ApplicationContext context: Context + ): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) // 连接超时时间 + .writeTimeout(10, TimeUnit.SECONDS) // 写超时时间 + .readTimeout(10, TimeUnit.SECONDS) // 读超时时间 + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(ChuckerInterceptor.Builder(context).build()) + } + } + // 请求失败重试 + .retryOnConnectionFailure(true) + .build() + } + + /** + * 提供Retrofit实例 + * + * @param okHttpClient OkHttp客户端 + * @param json JSON序列化实例 + * @return Retrofit实例 + * @author Joker.X + */ + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + val contentType = "application/json".toMediaType() + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + } + + /** + * 提供日志拦截器 + * + * @return 日志拦截器实例 + * @author Joker.X + */ + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + } +} diff --git a/app/src/main/java/com/joker/kit/core/network/di/ServiceModule.kt b/app/src/main/java/com/joker/kit/core/network/di/ServiceModule.kt new file mode 100644 index 0000000..7a5588e --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/di/ServiceModule.kt @@ -0,0 +1,61 @@ +package com.joker.kit.core.network.di + +import com.joker.kit.core.network.service.AuthService +import com.joker.kit.core.network.service.GoodsService +import com.joker.kit.core.network.service.UserInfoService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * 服务模块,提供所有网络服务接口的依赖注入 + * 为Hilt提供各种网络服务接口的实例 + * + * @author Joker.X + */ +@Module +@InstallIn(SingletonComponent::class) +object ServiceModule { + + /** + * 提供认证相关服务接口 + * + * @param retrofit Retrofit实例 + * @return 认证服务接口实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideAuthService(retrofit: Retrofit): AuthService { + return retrofit.create(AuthService::class.java) + } + + /** + * 提供商品相关服务接口 + * + * @param retrofit Retrofit实例 + * @return 商品服务接口实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideGoodsService(retrofit: Retrofit): GoodsService { + return retrofit.create(GoodsService::class.java) + } + + /** + * 提供用户信息相关服务接口 + * + * @param retrofit Retrofit实例 + * @return 用户信息服务接口实现 + * @author Joker.X + */ + @Provides + @Singleton + fun provideUserInfoService(retrofit: Retrofit): UserInfoService { + return retrofit.create(UserInfoService::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/interceptor/AuthInterceptor.kt b/app/src/main/java/com/joker/kit/core/network/interceptor/AuthInterceptor.kt new file mode 100644 index 0000000..770cfbb --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/interceptor/AuthInterceptor.kt @@ -0,0 +1,48 @@ +package com.joker.kit.core.network.interceptor + +import com.joker.kit.core.datastore.datasource.auth.AuthStoreDataSource +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.text.isNotBlank + +/** + * 认证拦截器 - 添加授权头信息 + * + * @param authStoreDataSource 认证数据存储源 + * @author Joker.X + */ +@Singleton +class AuthInterceptor @Inject constructor( + private val authStoreDataSource: AuthStoreDataSource +) : Interceptor { + + /** + * 拦截请求并添加认证信息 + * + * @param chain 拦截器链 + * @return 响应结果 + * @author Joker.X + */ + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // 从 DataStore 获取 token,使用 runBlocking 调用挂起函数 + val token = runBlocking { + authStoreDataSource.getToken() ?: "" + } + + // 如果有Token,添加到请求头 + val request = if (token.isNotBlank()) { + originalRequest.newBuilder() + .header("Authorization", token) + .build() + } else { + originalRequest + } + + return chain.proceed(request) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/interceptor/LoggingInterceptor.kt b/app/src/main/java/com/joker/kit/core/network/interceptor/LoggingInterceptor.kt new file mode 100644 index 0000000..5f84cd6 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/interceptor/LoggingInterceptor.kt @@ -0,0 +1,27 @@ +package com.joker.kit.core.network.interceptor + +import okhttp3.logging.HttpLoggingInterceptor +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 日志拦截器 - 记录网络请求日志 + * + * @author Joker.X + */ +@Singleton +class LoggingInterceptor @Inject constructor() { + + /** + * 初始化日志拦截器 + * + * @return HTTP日志拦截器实例 + * @author Joker.X + */ + @Inject + fun init(): HttpLoggingInterceptor { + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + return loggingInterceptor + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/service/AuthService.kt b/app/src/main/java/com/joker/kit/core/network/service/AuthService.kt new file mode 100644 index 0000000..9304be3 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/service/AuthService.kt @@ -0,0 +1,25 @@ +package com.joker.kit.core.network.service + +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.model.network.NetworkResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * 认证相关接口 + * + * @author Joker.X + */ +interface AuthService { + + /** + * 密码登录 + * + * @param params 密码登录请求参数 + * @return 认证信息响应 + * @author Joker.X + */ + @POST("user/login/password") + suspend fun loginByPassword(@Body params: Map): NetworkResponse + +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/network/service/GoodsService.kt b/app/src/main/java/com/joker/kit/core/network/service/GoodsService.kt new file mode 100644 index 0000000..c777318 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/service/GoodsService.kt @@ -0,0 +1,38 @@ +package com.joker.kit.core.network.service + +import com.joker.kit.core.model.entity.Goods +import com.joker.kit.core.model.network.NetworkPageData +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.model.request.GoodsSearchRequest +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * 商品相关接口 + * + * @author Joker.X + */ +interface GoodsService { + + /** + * 分页查询商品 + * + * @param params 商品搜索请求参数 + * @return 商品分页数据响应 + * @author Joker.X + */ + @POST("goods/info/page") + suspend fun getGoodsPage(@Body params: GoodsSearchRequest): NetworkResponse> + + /** + * 获取商品信息 + * + * @param id 商品ID + * @return 商品信息响应 + * @author Joker.X + */ + @GET("goods/info/info") + suspend fun getGoodsInfo(@Query("id") id: String): NetworkResponse +} diff --git a/app/src/main/java/com/joker/kit/core/network/service/UserInfoService.kt b/app/src/main/java/com/joker/kit/core/network/service/UserInfoService.kt new file mode 100644 index 0000000..2bda532 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/network/service/UserInfoService.kt @@ -0,0 +1,22 @@ +package com.joker.kit.core.network.service + +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.model.network.NetworkResponse +import retrofit2.http.GET + +/** + * 用户信息相关接口 + * + * @author Joker.X + */ +interface UserInfoService { + + /** + * 获取用户个人信息 + * + * @return 用户信息响应 + * @author Joker.X + */ + @GET("user/info/person") + suspend fun getPersonInfo(): NetworkResponse +} diff --git a/app/src/main/java/com/joker/kit/core/result/Result.kt b/app/src/main/java/com/joker/kit/core/result/Result.kt new file mode 100644 index 0000000..215c23d --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/result/Result.kt @@ -0,0 +1,33 @@ +package com.joker.kit.core.result + +/** + * 网络请求结果包装类 + * + * @param T 数据类型 + * @author Joker.X + */ +sealed interface Result { + /** + * 加载中状态 + * + * @author Joker.X + */ + data object Loading : Result + + /** + * 成功状态,包含数据 + * + * @param T 数据类型 + * @param data 成功返回的数据 + * @author Joker.X + */ + data class Success(val data: T) : Result + + /** + * 错误状态,包含异常信息 + * + * @param exception 异常对象 + * @author Joker.X + */ + data class Error(val exception: Throwable) : Result +} diff --git a/app/src/main/java/com/joker/kit/core/result/ResultExt.kt b/app/src/main/java/com/joker/kit/core/result/ResultExt.kt new file mode 100644 index 0000000..fffaf57 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/result/ResultExt.kt @@ -0,0 +1,17 @@ +package com.joker.kit.core.result + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +/** + * 将网络响应Flow转换为Result类型 + * + * @param T 数据类型 + * @return 包含Result的Flow + * @author Joker.X + */ +fun Flow.asResult(): Flow> = map> { Result.Success(it) } + .onStart { emit(Result.Loading) } + .catch { emit(Result.Error(it)) } \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/result/ResultHandler.kt b/app/src/main/java/com/joker/kit/core/result/ResultHandler.kt new file mode 100644 index 0000000..8501733 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/result/ResultHandler.kt @@ -0,0 +1,226 @@ +package com.joker.kit.core.result + +import com.joker.kit.core.model.network.NetworkResponse +import com.joker.kit.core.util.toast.ToastUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import timber.log.Timber +import java.io.IOException +import java.io.PrintWriter +import java.io.StringWriter +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * 网络请求结果处理工具类 + * 用于简化ViewModel中对网络请求结果的处理 + * + * @author Joker.X + */ +object ResultHandler { + + /** + * 处理网络请求结果,自动处理Loading、Success和Error状态 + * + * @param T 数据类型 + * @param scope CoroutineScope,通常是viewModelScope + * @param flow 包含Result的Flow + * @param showToast 是否显示错误Toast,默认为true + * @param onLoading 加载中状态的回调 + * @param onSuccess 成功状态的回调,接收NetworkResponse对象 + * @param onSuccessWithData 成功且有数据状态的回调,接收数据对象 + * @param onError 错误状态的回调,接收错误消息和异常 + * @param onFinally 最终执行的回调,无论成功或失败都会执行 + * @author Joker.X + */ + fun handleResult( + scope: CoroutineScope, + flow: Flow>>, + showToast: Boolean = true, + onLoading: () -> Unit = {}, + onSuccess: (NetworkResponse) -> Unit = {}, + onSuccessWithData: (T) -> Unit = {}, + onError: (String, Throwable?) -> Unit = { _, _ -> }, + onFinally: () -> Unit = {} + ) { + scope.launch { + try { + flow.collectLatest { result -> + when (result) { + is Result.Loading -> onLoading() + is Result.Success -> handleSuccess( + response = result.data, + onSuccess = onSuccess, + onSuccessWithData = onSuccessWithData, + showToast = showToast, + onError = onError + ) + + is Result.Error -> handleError( + errorMsg = result.exception.message ?: "网络请求失败", + throwable = result.exception, + showToast = showToast, + onError = onError + ) + } + } + } catch (e: Exception) { + handleError( + errorMsg = "请求处理异常", + throwable = e, + showToast = showToast, + onError = onError + ) + } finally { + onFinally() + } + } + } + + /** + * 处理网络请求结果的简化版本,直接处理成功的数据 + * 适用于只关心成功数据的场景 + * + * @param T 数据类型 + * @param scope CoroutineScope,通常是viewModelScope + * @param flow 包含Result的Flow + * @param showToast 是否显示错误Toast,默认为true + * @param onLoading 加载中状态的回调 + * @param onData 成功且有数据状态的回调,只有当请求成功且有数据时才会调用 + * @param onError 错误状态的回调,接收错误消息和异常 + * @param onFinally 最终执行的回调,无论成功或失败都会执行 + * @author Joker.X + */ + fun handleResultWithData( + scope: CoroutineScope, + flow: Flow>>, + showToast: Boolean = true, + onLoading: () -> Unit = {}, + onData: (T) -> Unit, + onError: (String, Throwable?) -> Unit = { _, _ -> }, + onFinally: () -> Unit = {} + ) { + handleResult( + scope = scope, + flow = flow, + showToast = showToast, + onLoading = onLoading, + onSuccessWithData = onData, + onError = onError, + onFinally = onFinally + ) + } + + /** + * 获取完整的异常堆栈信息 + * + * @param throwable 异常对象 + * @return 堆栈信息字符串 + * @author Joker.X + */ + private fun getStackTraceString(throwable: Throwable): String { + return StringWriter().apply { + throwable.printStackTrace(PrintWriter(this)) + }.toString() + } + + /** + * 格式化错误日志信息 + * + * @param errorMsg 错误消息 + * @param throwable 异常对象 + * @param additionalInfo 附加信息 + * @return 格式化后的错误日志 + * @author Joker.X + */ + private fun formatErrorLog( + errorMsg: String, + throwable: Throwable?, + additionalInfo: String = "" + ): String = buildString { + appendLine("=== 网络请求错误 ===") + appendLine("错误信息: $errorMsg") + if (additionalInfo.isNotEmpty()) { + appendLine("附加信息: $additionalInfo") + } + throwable?.let { + appendLine("异常类型: ${it.javaClass.name}") + appendLine("异常堆栈:") + appendLine(getStackTraceString(it)) + } + appendLine("==================") + } + + /** + * 获取错误类型描述 + * + * @param throwable 异常对象 + * @return 错误类型描述 + * @author Joker.X + */ + private fun getErrorTypeDescription(throwable: Throwable): String = when (throwable) { + is SerializationException -> buildString { + append("JSON解析错误\n") + append("错误位置: ${throwable.message}") + } + + is SocketTimeoutException -> "网络连接超时" + is UnknownHostException -> "无法解析主机地址" + is IOException -> "网络IO异常" + else -> "未知异常类型" + } + + /** + * 处理错误状态 + * + * @param errorMsg 错误消息 + * @param throwable 异常对象 + * @param showToast 是否显示Toast + * @param onError 错误回调 + * @author Joker.X + */ + private fun handleError( + errorMsg: String, + throwable: Throwable?, + showToast: Boolean, + onError: (String, Throwable?) -> Unit + ) { + val additionalInfo = throwable?.let { getErrorTypeDescription(it) } ?: "" + Timber.e(formatErrorLog(errorMsg, throwable, additionalInfo)) + onError(errorMsg, throwable) + if (showToast) { + ToastUtils.showError(errorMsg) + } + } + + /** + * 处理成功状态 + * + * @param T 数据类型 + * @param response 网络响应对象 + * @param onSuccess 成功回调 + * @param onSuccessWithData 成功且有数据回调 + * @param showToast 是否显示Toast + * @param onError 错误回调 + * @author Joker.X + */ + private fun handleSuccess( + response: NetworkResponse, + onSuccess: (NetworkResponse) -> Unit, + onSuccessWithData: (T) -> Unit, + showToast: Boolean, + onError: (String, Throwable?) -> Unit + ) { + onSuccess(response) + if (response.isSucceeded) { + val data = response.data ?: return + onSuccessWithData(data) + } else { + val errorMsg = response.message ?: "未知错误" + handleError(errorMsg, Exception(errorMsg), showToast, onError) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/state/UserState.kt b/app/src/main/java/com/joker/kit/core/state/UserState.kt new file mode 100644 index 0000000..4575891 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/state/UserState.kt @@ -0,0 +1,185 @@ +package com.joker.kit.core.state + +import com.joker.kit.core.data.repository.AuthStoreRepository +import com.joker.kit.core.data.repository.UserInfoRepository +import com.joker.kit.core.data.repository.UserInfoStoreRepository +import com.joker.kit.core.model.entity.Auth +import com.joker.kit.core.model.entity.User +import com.joker.kit.core.result.ResultHandler +import com.joker.kit.core.result.asResult +import com.joker.kit.core.state.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 用户状态管理类 + * + * 该类负责管理整个应用的全局状态,包括: + * - 用户登录状态 + * - 用户认证信息(token等) + * - 用户个人信息 + * + * 通过StateFlow提供响应式的状态管理,任何组件都可以订阅状态变化 + * + * @param authStoreRepository 认证信息存储仓库 + * @param userInfoStoreRepository 用户信息存储仓库 + * @param userInfoRepository 用户信息网络仓库 + * @param applicationScope 应用级协程作用域 + * @author Joker.X + */ +@Singleton +class UserState @Inject constructor( + private val authStoreRepository: AuthStoreRepository, + private val userInfoStoreRepository: UserInfoStoreRepository, + private val userInfoRepository: UserInfoRepository, + @param:ApplicationScope private val applicationScope: CoroutineScope +) { + // 用户登录状态 + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + // 用户ID + private val _userId = MutableStateFlow(0L) + val userId: StateFlow = _userId.asStateFlow() + + // 用户授权信息 + private val _auth = MutableStateFlow(null) + val auth: StateFlow = _auth.asStateFlow() + + // 用户信息 + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + /** + * 初始化应用状态 + * 由外部调用而不是自动初始化 + * + * @author Joker.X + */ + fun initialize() { + applicationScope.launch { + initializeState() + } + } + + /** + * 从本地存储初始化应用状态 + * + * @author Joker.X + */ + private suspend fun initializeState() { + // 获取认证信息 + val authData = authStoreRepository.getAuth() + val loggedIn = authStoreRepository.isLoggedIn() + _isLoggedIn.value = loggedIn + _auth.value = authData + + // 仅在已登录状态下加载用户信息 + if (loggedIn) { + val user = userInfoStoreRepository.getUserInfo() + _userInfo.value = user + _userId.value = user?.id ?: 0L + } + } + + /** + * 更新用户登录状态 + * + * @param auth 认证信息 + * @param user 用户信息 + * @author Joker.X + */ + suspend fun updateUserState(auth: Auth, user: User) { + // 保存到本地存储 + authStoreRepository.saveAuth(auth) + userInfoStoreRepository.saveUserInfo(user) + + // 更新内存中的状态 + _auth.value = auth + _userInfo.value = user + _userId.value = user.id + _isLoggedIn.value = true + } + + /** + * 更新用户信息 + * + * @param user 新的用户信息 + * @author Joker.X + */ + suspend fun updateUserInfo(user: User) { + // 保存到本地存储 + userInfoStoreRepository.saveUserInfo(user) + + // 更新内存中的状态 + _userInfo.value = user + _userId.value = user.id + } + + /** + * 更新认证信息(如token刷新) + * + * @param auth 新的认证信息 + * @author Joker.X + */ + suspend fun updateAuth(auth: Auth) { + // 保存到本地存储 + authStoreRepository.saveAuth(auth) + + // 更新内存中的状态 + _auth.value = auth + + // 设置登录状态 + _isLoggedIn.value = true + } + + /** + * 用户登出 + * + * @author Joker.X + */ + suspend fun logout() { + // 清除本地存储 + authStoreRepository.clearAuth() + userInfoStoreRepository.clearUserInfo() + + // 重置内存中的状态 + _isLoggedIn.value = false + _auth.value = null + _userInfo.value = null + _userId.value = 0L + } + + /** + * 检查token是否需要刷新 + * + * @return 是否需要刷新token + * @author Joker.X + */ + suspend fun shouldRefreshToken(): Boolean { + return authStoreRepository.shouldRefreshToken() + } + + /** + * 从网络获取最新的用户信息并更新到状态 + * + * @author Joker.X + */ + fun refreshUserInfo() { + if (!_isLoggedIn.value) return + ResultHandler.handleResultWithData( + scope = applicationScope, + flow = userInfoRepository.getPersonInfo().asResult(), + onData = { data -> + applicationScope.launch { + updateUserInfo(data) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/state/di/UserStateModule.kt b/app/src/main/java/com/joker/kit/core/state/di/UserStateModule.kt new file mode 100644 index 0000000..a3424ad --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/state/di/UserStateModule.kt @@ -0,0 +1,37 @@ +package com.joker.kit.core.state.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +/** + * 用户状态模块,提供 UserState 所需的应用级协程作用域 + */ +@Module +@InstallIn(SingletonComponent::class) +object UserStateModule { + + /** + * 提供应用级别的协程作用域 + * SupervisorJob 确保子协程失败不会终止整个作用域 + */ + @ApplicationScope + @Singleton + @Provides + fun providesApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } +} + +/** + * 应用级协程作用域限定符 + */ +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope diff --git a/app/src/main/java/com/joker/kit/core/ui/component/divider/Divider.kt b/app/src/main/java/com/joker/kit/core/ui/component/divider/Divider.kt new file mode 100644 index 0000000..823450a --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/divider/Divider.kt @@ -0,0 +1,19 @@ +package com.joker.kit.core.ui.component.divider + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 水平分割线组件 + * + * @param modifier 修饰符,用于自定义组件样式 + * @param color 分割线颜色,默认使用outline颜色 + */ +@Composable +fun Divider(modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.outline) { + HorizontalDivider(modifier, thickness = 0.5.dp, color = color) +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/empty/Empty.kt b/app/src/main/java/com/joker/kit/core/ui/component/empty/Empty.kt new file mode 100644 index 0000000..a6daa48 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/empty/Empty.kt @@ -0,0 +1,118 @@ +package com.joker.kit.core.ui.component.empty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.joker.kit.R +import com.joker.kit.core.designsystem.component.SpaceVerticalSmall +import com.joker.kit.core.designsystem.component.SpaceVerticalXLarge +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.designsystem.theme.SpacePaddingLarge + +/** + * 页面状态视图 + * + * @param modifier 修饰符 + * @param message 消息文本资源ID + * @param subtitle 副标题文本资源ID + * @param retryButtonText 重试按钮文本资源ID + * @param icon 图标资源ID + * @param onRetryClick 重试点击回调 + * @author Joker.X + */ +@Composable +fun Empty( + modifier: Modifier = Modifier, + message: Int = R.string.empty_error, + subtitle: Int? = null, + retryButtonText: Int = R.string.click_retry, + icon: Int = R.drawable.ic_empty_error, + onRetryClick: (() -> Unit)? = null +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(SpacePaddingLarge) + ) { + Icon( + painter = painterResource(id = icon), + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2F), + contentDescription = null, + ) + + SpaceVerticalXLarge() + + Text( + text = stringResource(id = message), + style = MaterialTheme.typography.titleLarge + ) + + subtitle?.let { + SpaceVerticalSmall() + Text( + text = stringResource(id = it), + style = MaterialTheme.typography.bodyMedium, + ) + } + + // 如果没有传递重试方法,则不显示重试按钮 + if (onRetryClick != null) { + SpaceVerticalXLarge() + OutlinedButton( + onClick = onRetryClick, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.primary), + modifier = Modifier + .padding(horizontal = 50.dp) + .widthIn(200.dp) + ) { + Text( + text = stringResource(id = retryButtonText) + ) + } + } + } +} + +/** + * 页面状态视图浅色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun EmptyPreview() { + AppTheme { + Empty() + } +} + +/** + * 页面状态视图深色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun EmptyPreviewDark() { + AppTheme(darkTheme = true) { + Empty() + } +} diff --git a/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyData.kt b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyData.kt new file mode 100644 index 0000000..3e9be9e --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyData.kt @@ -0,0 +1,46 @@ +package com.joker.kit.core.ui.component.empty + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.joker.kit.R +import com.joker.kit.core.designsystem.theme.AppTheme + +/** + * 暂无数据状态视图 + * + * @param modifier 修饰符 + * @param onRetryClick 重试点击回调 + * @author Joker.X + */ +@Composable +fun EmptyData( + modifier: Modifier = Modifier, + onRetryClick: (() -> Unit)? = null +) { + Empty( + modifier = modifier, + message = R.string.empty_data, + subtitle = R.string.empty_data_subtitle, + icon = R.drawable.ic_empty_data, + retryButtonText = R.string.click_retry, + onRetryClick = onRetryClick + ) +} + +/** + * 暂无数据状态预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun EmptyDataPreview() { + AppTheme { + Empty( + message = R.string.empty_data, + icon = R.drawable.ic_empty_data, + retryButtonText = R.string.click_retry, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyError.kt b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyError.kt new file mode 100644 index 0000000..d401f7d --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyError.kt @@ -0,0 +1,46 @@ +package com.joker.kit.core.ui.component.empty + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.joker.kit.R +import com.joker.kit.core.designsystem.theme.AppTheme + +/** + * 加载失败状态视图 + * + * @param modifier 修饰符 + * @param onRetryClick 重试点击回调 + * @author Joker.X + */ +@Composable +fun EmptyError( + modifier: Modifier = Modifier, + onRetryClick: (() -> Unit)? = null +) { + Empty( + modifier = modifier, + message = R.string.empty_error, + subtitle = R.string.empty_error_subtitle, + icon = R.drawable.ic_empty_error, + retryButtonText = R.string.click_retry, + onRetryClick = onRetryClick + ) +} + +/** + * 加载失败状态预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun EmptyErrorPreview() { + AppTheme { + Empty( + message = R.string.empty_error, + icon = R.drawable.ic_empty_error, + retryButtonText = R.string.click_retry, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyNetwork.kt b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyNetwork.kt new file mode 100644 index 0000000..083ecb2 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/empty/EmptyNetwork.kt @@ -0,0 +1,46 @@ +package com.joker.kit.core.ui.component.empty + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.joker.kit.R +import com.joker.kit.core.designsystem.theme.AppTheme + +/** + * 网络连接失败状态视图 + * + * @param modifier 修饰符 + * @param onRetryClick 重试点击回调 + * @author Joker.X + */ +@Composable +fun EmptyNetwork( + modifier: Modifier = Modifier, + onRetryClick: (() -> Unit)? = null +) { + Empty( + modifier = modifier, + message = R.string.empty_network, + subtitle = R.string.empty_network_subtitle, + icon = R.drawable.ic_empty_network, + retryButtonText = R.string.click_retry, + onRetryClick = onRetryClick + ) +} + +/** + * 网络连接失败状态预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun EmptyNetworkPreview() { + AppTheme { + Empty( + message = R.string.empty_network, + icon = R.drawable.ic_empty_network, + retryButtonText = R.string.click_retry, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/loading/LoadMore.kt b/app/src/main/java/com/joker/kit/core/ui/component/loading/LoadMore.kt new file mode 100644 index 0000000..d7c403e --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/loading/LoadMore.kt @@ -0,0 +1,224 @@ +package com.joker.kit.core.ui.component.loading + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.joker.kit.R +import com.joker.kit.core.base.state.LoadMoreState +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.divider.Divider +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextType + +/** + * 订单列表加载更多组件 + * + * 用于显示列表底部的加载状态,支持以下几种状态: + * 1. 可上拉加载:显示上拉加载更多提示 + * 2. 加载中:显示加载动画和提示文本 + * 3. 加载成功:显示成功提示 + * 4. 加载失败:显示错误提示,支持点击重试 + * 5. 没有更多数据:显示分割线和圆点 + * + * @param modifier 组件修饰符 + * @param state 当前加载状态,默认为可上拉加载状态 + * @param listState LazyList的状态,用于自动滚动到底部,可为空 + * @param onRetry 加载失败时的重试回调,为空时不可点击重试 + * @author Joker.X + */ +@Composable +fun LoadMore( + modifier: Modifier = Modifier, + state: LoadMoreState = LoadMoreState.PullToLoad, + listState: LazyListState? = null, + onRetry: (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + when (state) { + // 可上拉加载更多状态:显示提示文本 + LoadMoreState.PullToLoad -> { + Divider(modifier = Modifier.weight(1f)) + AppText( + text = stringResource(R.string.load_more_pull), + modifier = Modifier.padding(horizontal = 8.dp), + type = TextType.SECONDARY + ) + Divider(modifier = Modifier.weight(1f)) + } + + // 加载中状态:显示加载动画和提示文本 + LoadMoreState.Loading -> { + MiLoadingWeb() // 显示加载动画 + Spacer(modifier = Modifier.width(8.dp)) + AppText(text = stringResource(R.string.load_more_loading)) + // 如果提供了列表状态,自动滚动到底部 + if (listState != null) { + LaunchedEffect(Unit) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount) + } + } + } + + // 加载成功状态:显示加载成功提示 + LoadMoreState.Success -> { + Divider(modifier = Modifier.weight(1f)) + AppText( + text = stringResource(R.string.load_more_success), + modifier = Modifier.padding(horizontal = 8.dp) + ) + Divider(modifier = Modifier.weight(1f)) + } + + // 加载失败状态:显示错误提示和分割线 + LoadMoreState.Error -> { + Divider(modifier = Modifier.weight(1f)) + if (onRetry != null) { + AppText( + text = stringResource(R.string.load_more_error_retry), + modifier = Modifier + .padding(horizontal = 8.dp) + .clip(MaterialTheme.shapes.small) + .clickable { onRetry() } + .padding(vertical = 4.dp), + type = TextType.ERROR + ) + } else { + AppText( + text = stringResource(R.string.load_more_error), + modifier = Modifier.padding(horizontal = 8.dp), + type = TextType.ERROR + ) + } + Divider(modifier = Modifier.weight(1f)) + } + + // 没有更多数据状态:显示分割线和中间的圆点 + LoadMoreState.NoMore -> { + // 左侧分割线 + Divider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f) + ) + // 中间圆点 + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(4.dp) + .background( + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f), + CircleShape + ) + ) + // 右侧分割线 + Divider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f) + ) + } + } + } +} + +/** + * OrderLoadMore 组件预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun OrderLoadMorePreview() { + AppTheme { + Column { + // 可上拉加载更多状态预览 + LoadMore(state = LoadMoreState.PullToLoad) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载中状态预览 + LoadMore(state = LoadMoreState.Loading) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载成功状态预览 + LoadMore(state = LoadMoreState.Success) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载失败状态预览(带重试回调) + LoadMore( + state = LoadMoreState.Error, + onRetry = {} + ) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载失败状态预览(不带重试回调) + LoadMore(state = LoadMoreState.Error) + Spacer(modifier = Modifier.height(8.dp)) + + // 没有更多数据状态预览 + LoadMore(state = LoadMoreState.NoMore) + } + } +} + +/** + * OrderLoadMore 组件深色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun OrderLoadMorePreviewDark() { + AppTheme(darkTheme = true) { + Column { + // 可上拉加载更多状态预览 + LoadMore(state = LoadMoreState.PullToLoad) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载中状态预览 + LoadMore(state = LoadMoreState.Loading) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载成功状态预览 + LoadMore(state = LoadMoreState.Success) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载失败状态预览(带重试回调) + LoadMore( + state = LoadMoreState.Error, + onRetry = {} + ) + Spacer(modifier = Modifier.height(8.dp)) + + // 加载失败状态预览(不带重试回调) + LoadMore(state = LoadMoreState.Error) + Spacer(modifier = Modifier.height(8.dp)) + + // 没有更多数据状态预览 + LoadMore(state = LoadMoreState.NoMore) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/loading/Loading.kt b/app/src/main/java/com/joker/kit/core/ui/component/loading/Loading.kt new file mode 100644 index 0000000..b172b36 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/loading/Loading.kt @@ -0,0 +1,152 @@ +package com.joker.kit.core.ui.component.loading + +import androidx.compose.animation.core.DurationBasedAnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.designsystem.theme.Primary +import kotlin.math.cos +import kotlin.math.sin + +/** + * 小米风格Web加载动画 - 3条竖线交替缩放 + * + * @param color 竖线颜色,默认使用Primary主题色 + */ +@Composable +fun MiLoadingWeb(color: Color = Primary) { + val infiniteTransition = rememberInfiniteTransition(label = "") + val animations = List(3) { index -> + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 400, + delayMillis = index * 100, + easing = LinearEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "MiLoadingWebAlphaAnimation" + ) + val scaleY by infiniteTransition.animateFloat( + initialValue = 0.5f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 400, + delayMillis = index * 100, + easing = LinearEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "MiLoadingWebScaleAnimation" + ) + Pair(alpha, scaleY) + } + + Canvas(modifier = Modifier.size(24.dp)) { + animations.forEachIndexed { index, item -> + val strokeWidth = 4.dp.toPx() + val spacing = (size.width - (3 * strokeWidth)) / 2 + + scale(scaleX = 1f, scaleY = item.second) { + drawLine( + color = color.copy(alpha = item.first), + start = Offset( + x = strokeWidth / 2 + (strokeWidth + spacing) * index, + y = 0f + ), + end = Offset( + x = strokeWidth / 2 + (strokeWidth + spacing) * index, + y = size.height + ), + strokeWidth + ) + } + } + } +} + +/** + * 小米风格移动端加载动画 - 圆形轨道上的圆点旋转 + * + * @param borderColor 圆形轨道边框颜色,默认使用onSurface颜色 + * @param dotColor 旋转圆点的颜色,默认与边框颜色相同 + * @param animationSpec 动画规格配置,默认1200ms线性动画 + */ +@Composable +fun MiLoadingMobile( + borderColor: Color = MaterialTheme.colorScheme.onSurface, + dotColor: Color = borderColor, + animationSpec: DurationBasedAnimationSpec = tween( + durationMillis = 1200, + easing = LinearEasing + ) +) { + val infiniteTransition = rememberInfiniteTransition(label = "") + val angle = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = animationSpec, + repeatMode = RepeatMode.Restart + ), + label = "MiLoadingMobileAnimation" + ) + + Canvas( + modifier = Modifier + .size(28.dp) + .border(2.dp, borderColor, CircleShape) + ) { + val circleRadius = size.minDimension / 2 - 8.dp.toPx() + val dotRadius = 3.dp.toPx() + val center = size.center + val dotX = cos(Math.toRadians(angle.value.toDouble())) * circleRadius + center.x + val dotY = sin(Math.toRadians(angle.value.toDouble())) * circleRadius + center.y + + drawCircle(dotColor, radius = dotRadius, center = Offset(dotX.toFloat(), dotY.toFloat())) + } +} + +/** + * MiLoadingWeb组件预览 + */ +@Preview(showBackground = true) +@Composable +fun MiLoadingWebPreview() { + AppTheme { + MiLoadingWeb() + } +} + +/** + * 小米风格移动端加载动画 + */ +@Preview(showBackground = true) +@Composable +fun MiLoadingMobilePreview() { + AppTheme { + MiLoadingMobile() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/loading/PageLoading.kt b/app/src/main/java/com/joker/kit/core/ui/component/loading/PageLoading.kt new file mode 100644 index 0000000..dbc2702 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/loading/PageLoading.kt @@ -0,0 +1,47 @@ +package com.joker.kit.core.ui.component.loading + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.joker.kit.R +import com.joker.kit.core.designsystem.component.SpaceVerticalSmall +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize + +/** + * 页面加载中 + * + * @param modifier 可选修饰符 + * @author Joker.X + */ +@Composable +fun PageLoading( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + MiLoadingMobile( + borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + SpaceVerticalSmall() + AppText(text = stringResource(R.string.loading), size = TextSize.BODY_MEDIUM) + } +} + +@Preview(showBackground = true) +@Composable +fun PageLoadingPreview() { + AppTheme { + PageLoading() + } +} diff --git a/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkListView.kt b/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkListView.kt new file mode 100644 index 0000000..9e3ad97 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkListView.kt @@ -0,0 +1,85 @@ +package com.joker.kit.core.ui.component.network + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.joker.kit.core.ui.component.empty.EmptyData +import com.joker.kit.core.ui.component.empty.EmptyNetwork +import com.joker.kit.core.ui.component.loading.PageLoading +import com.joker.kit.core.base.state.BaseNetWorkListUiState + +/** + * 基础网络列表视图组件 + * + * 用于处理列表页的四种状态:加载中、错误、空数据和成功 + * + * @param uiState 列表页UI状态 + * @param modifier 修饰符 + * @param padding 内边距 + * @param onRetry 重试回调 + * @param customLoading 自定义加载组件 + * @param customError 自定义错误组件 + * @param customEmpty 自定义空数据组件 + * @param content 成功状态下显示的内容 + * @author Joker.X + */ +@Composable +fun BaseNetWorkListView( + uiState: BaseNetWorkListUiState, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(), + onRetry: () -> Unit = {}, + customLoading: @Composable (() -> Unit)? = null, + customError: @Composable (() -> Unit)? = null, + customEmpty: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .padding(padding) + ) { + AnimatedContent( + targetState = uiState, + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + }, + label = "ListStateAnimation" + ) { state -> + when (state) { + is BaseNetWorkListUiState.Loading -> { + if (customLoading != null) { + customLoading() + } else { + PageLoading() + } + } + + is BaseNetWorkListUiState.Error -> { + if (customError != null) { + customError() + } else { + EmptyNetwork(onRetryClick = onRetry) + } + } + + is BaseNetWorkListUiState.Empty -> { + if (customEmpty != null) { + customEmpty() + } else { + EmptyData(onRetryClick = onRetry) + } + } + + is BaseNetWorkListUiState.Success -> content() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkView.kt b/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkView.kt new file mode 100644 index 0000000..5592930 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/network/BaseNetWorkView.kt @@ -0,0 +1,75 @@ +package com.joker.kit.core.ui.component.network + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.joker.kit.core.ui.component.empty.EmptyNetwork +import com.joker.kit.core.ui.component.loading.PageLoading +import com.joker.kit.core.base.state.BaseNetWorkUiState + +/** + * 基础网络视图组件,用于处理网络请求的三种状态:加载中、错误和成功 + * 简化页面开发,避免重复编写状态处理代码 + * + * @param T 数据类型 + * @param uiState 当前UI状态 + * @param modifier 可选修饰符 + * @param padding 内边距值,通常来自Scaffold + * @param onRetry 错误状态下重试点击回调 + * @param customLoading 自定义加载组件,为null时使用默认组件 + * @param customError 自定义错误组件,为null时使用默认组件 + * @param content 成功状态下显示的内容,接收数据参数 + * @author Joker.X + */ +@Composable +fun BaseNetWorkView( + uiState: BaseNetWorkUiState, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(), + onRetry: () -> Unit = {}, + customLoading: @Composable (() -> Unit)? = null, + customError: @Composable (() -> Unit)? = null, + content: @Composable (data: T) -> Unit +) { + Box( + modifier = modifier + .padding(padding), + ) { + AnimatedContent( + targetState = uiState, + transitionSpec = { + // 定义进入和退出动画 + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + }, + label = "NetworkStateAnimation" + ) { state -> + when (state) { + is BaseNetWorkUiState.Loading -> { + if (customLoading != null) { + customLoading() + } else { + PageLoading() + } + } + + is BaseNetWorkUiState.Error -> { + if (customError != null) { + customError() + } else { + EmptyNetwork(onRetryClick = onRetry) + } + } + + is BaseNetWorkUiState.Success -> content(state.data) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshContent.kt b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshContent.kt new file mode 100644 index 0000000..2669323 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshContent.kt @@ -0,0 +1,215 @@ +package com.joker.kit.core.ui.component.refresh + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.joker.kit.core.base.state.LoadMoreState +import com.joker.kit.core.designsystem.theme.SpaceHorizontalMedium +import com.joker.kit.core.designsystem.theme.SpaceHorizontalXXLarge +import com.joker.kit.core.designsystem.theme.SpacePaddingMedium +import com.joker.kit.core.designsystem.theme.SpaceVerticalMedium +import com.joker.kit.core.ui.component.loading.LoadMore + +/** + * 刷新内容区域组件 + * + * 根据 [isGrid] 参数切换显示列表或网格布局,并支持布局切换动画。 + * + * @param isGrid 是否为网格模式 + * @param listState 列表状态 + * @param gridState 网格状态 + * @param loadMoreState 加载更多状态 + * @param onLoadMore 加载更多回调 + * @param shouldTriggerLoadMore 判断是否应该触发加载更多的函数 + * @param gridContent 网格内容构建器 + * @param content 列表内容构建器 + * @author Joker.X + */ +@Composable +fun RefreshContent( + isGrid: Boolean, + listState: LazyListState?, + gridState: LazyStaggeredGridState?, + loadMoreState: LoadMoreState, + onLoadMore: () -> Unit, + shouldTriggerLoadMore: (lastIndex: Int, totalCount: Int) -> Boolean, + gridContent: LazyStaggeredGridScope.() -> Unit, + content: LazyListScope.() -> Unit +) { + AnimatedContent( + targetState = isGrid, + transitionSpec = { + (fadeIn(animationSpec = tween(300, easing = LinearEasing)) + + scaleIn( + initialScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing) + )) + .togetherWith( + fadeOut(animationSpec = tween(300, easing = LinearEasing)) + + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing) + ) + ) + }, + label = "layout_switch_animation" + ) { targetIsGrid -> + if (targetIsGrid) { + RefreshGridContent( + gridState = gridState, + loadMoreState = loadMoreState, + onLoadMore = onLoadMore, + shouldTriggerLoadMore = shouldTriggerLoadMore, + content = gridContent + ) + } else { + RefreshListContent( + listState = listState, + loadMoreState = loadMoreState, + onLoadMore = onLoadMore, + shouldTriggerLoadMore = shouldTriggerLoadMore, + content = content + ) + } + } +} + +/** + * 列表刷新内容组件 + * + * @param listState 列表状态 + * @param loadMoreState 加载更多状态 + * @param onLoadMore 加载更多回调 + * @param shouldTriggerLoadMore 判断是否应该触发加载更多的函数 + * @param content 列表内容构建器 + * @author Joker.X + */ +@Composable +private fun RefreshListContent( + listState: LazyListState?, + loadMoreState: LoadMoreState, + onLoadMore: () -> Unit, + shouldTriggerLoadMore: (lastIndex: Int, totalCount: Int) -> Boolean, + content: LazyListScope.() -> Unit +) { + val actualListState = listState ?: rememberLazyListState() + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleItem = actualListState.layoutInfo.visibleItemsInfo.lastOrNull() + if (lastVisibleItem != null) { + shouldTriggerLoadMore( + lastVisibleItem.index, + actualListState.layoutInfo.totalItemsCount + ) + } else false + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(SpaceVerticalMedium), + state = actualListState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = SpaceHorizontalMedium) + ) { + item { Spacer(modifier = Modifier) } + content() + item { + LoadMore( + modifier = Modifier.padding(horizontal = SpaceHorizontalXXLarge), + state = loadMoreState, + listState = if (loadMoreState == LoadMoreState.Loading) actualListState else null, + onRetry = onLoadMore + ) + } + } +} + +/** + * 网格刷新内容组件 + * + * @param gridState 网格状态 + * @param loadMoreState 加载更多状态 + * @param onLoadMore 加载更多回调 + * @param shouldTriggerLoadMore 判断是否应该触发加载更多的函数 + * @param content 网格内容构建器 + * @author Joker.X + */ +@Composable +private fun RefreshGridContent( + gridState: LazyStaggeredGridState? = null, + loadMoreState: LoadMoreState, + onLoadMore: () -> Unit, + shouldTriggerLoadMore: (lastIndex: Int, totalCount: Int) -> Boolean, + content: LazyStaggeredGridScope.() -> Unit +) { + val actualGridState = gridState ?: rememberLazyStaggeredGridState() + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleItem = actualGridState.layoutInfo.visibleItemsInfo.lastOrNull() + if (lastVisibleItem != null) { + shouldTriggerLoadMore( + lastVisibleItem.index, + actualGridState.layoutInfo.totalItemsCount + ) + } else false + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(SpacePaddingMedium), + horizontalArrangement = Arrangement.spacedBy(SpacePaddingMedium), + verticalItemSpacing = SpacePaddingMedium, + state = actualGridState + ) { + content() + item(span = StaggeredGridItemSpan.FullLine) { + LoadMore( + modifier = Modifier.padding(horizontal = SpaceHorizontalXXLarge), + state = loadMoreState, + listState = null, + onRetry = onLoadMore + ) + } + } +} diff --git a/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshHeader.kt b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshHeader.kt new file mode 100644 index 0000000..cb2da6a --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshHeader.kt @@ -0,0 +1,116 @@ +package com.joker.kit.core.ui.component.refresh + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.joker.kit.R +import com.joker.kit.core.designsystem.component.SpaceHorizontalSmall +import com.joker.kit.core.ui.component.loading.MiLoadingMobile +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize +import com.joker.kit.core.ui.component.text.TextType +import kotlin.math.cos +import kotlin.math.sin + +/** + * 刷新指示器组件 + * + * 使用 MiLoadingMobile 风格: + * 1. 下拉时:显示圆形轨道和圆点,圆点随下拉距离旋转。 + * 2. 刷新时:显示 MiLoadingMobile 动画(自动旋转)。 + * 3. 添加文本提示:下拉刷新 / 松开刷新 / 刷新中... / 刷新完成 + * + * @param modifier 修饰符 + * @param state 刷新状态 + * @author Joker.X + */ +@Composable +fun RefreshHeader( + modifier: Modifier = Modifier, + state: RefreshState +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val threshold = state.headerHeight + val offset = state.indicatorOffset + val isRefreshing = state.isRefreshing + val isFinishing = state.isFinishing + + // 文本提示 + val text = when { + isRefreshing -> stringResource(id = R.string.refresh_refreshing) + isFinishing -> stringResource(id = R.string.refresh_complete) + offset > threshold -> stringResource(id = R.string.refresh_release) + else -> stringResource(id = R.string.refresh_pull_down) + } + + // 边框颜色 + val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // 图标区域 + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + if (isRefreshing || isFinishing) { + // 刷新中或结束延迟中:显示 MiLoadingMobile 动画 + MiLoadingMobile(borderColor = borderColor) + } else { + // 固定角度,保持在顶部或初始位置 (例如 -90度为顶部,0度为右侧) + // MiLoadingMobile 动画是从 0 度开始的 + val angle = 0f + Canvas( + modifier = Modifier + .fillMaxSize() + .border( + 2.dp, + borderColor, + CircleShape + ) + ) { + val circleRadius = size.minDimension / 2 - 8.dp.toPx() + val dotRadius = 3.dp.toPx() + val center = center + + val dotX = + cos(Math.toRadians(angle.toDouble())) * circleRadius + center.x + val dotY = + sin(Math.toRadians(angle.toDouble())) * circleRadius + center.y + + drawCircle( + borderColor, + radius = dotRadius, + center = Offset(dotX.toFloat(), dotY.toFloat()) + ) + } + } + } + + SpaceHorizontalSmall() + + AppText( + text = text, + type = TextType.SECONDARY, + size = TextSize.BODY_MEDIUM, + ) + } + } +} diff --git a/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshLayout.kt b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshLayout.kt new file mode 100644 index 0000000..a3139f6 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshLayout.kt @@ -0,0 +1,151 @@ +package com.joker.kit.core.ui.component.refresh + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.dp +import com.joker.kit.core.base.state.LoadMoreState +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +/** + * 支持下拉刷新和上拉加载更多的布局组件 + * + * 基于 Compose 自定义 Layout 和 NestedScrollConnection 实现。 + * 替换了官方的 PullToRefreshBox + * + * @param modifier 修饰符 + * @param isGrid 是否为网格模式,默认为 false(列表模式) + * @param listState 列表状态,如果为 null 则创建新(列表模式时使用) + * @param gridState 网格状态,如果为 null 则创建新(网格模式时使用) + * @param isRefreshing 是否正在刷新 + * @param loadMoreState 加载更多状态 + * @param scrollBehavior 顶部导航栏滚动行为,用于实现滑动折叠效果 + * @param onRefresh 刷新回调 + * @param onLoadMore 加载更多回调 + * @param shouldTriggerLoadMore 判断是否应该触发加载更多的函数 + * @param gridContent 网格内容构建器(网格模式时使用) + * @param content 列表内容构建器(列表模式时使用) + * @author Joker.X + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RefreshLayout( + modifier: Modifier = Modifier, + isGrid: Boolean = false, + listState: LazyListState? = null, + gridState: LazyStaggeredGridState? = null, + isRefreshing: Boolean = false, + loadMoreState: LoadMoreState = LoadMoreState.PullToLoad, + scrollBehavior: TopAppBarScrollBehavior? = null, + onRefresh: () -> Unit = {}, + onLoadMore: () -> Unit = {}, + shouldTriggerLoadMore: (lastIndex: Int, totalCount: Int) -> Boolean = { _, _ -> false }, + gridContent: LazyStaggeredGridScope.() -> Unit = {}, + content: LazyListScope.() -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val refreshState = rememberRefreshState(coroutineScope) + + // 同步刷新状态 + LaunchedEffect(isRefreshing) { + if (!isRefreshing && refreshState.isRefreshing) { + // 从刷新状态变为非刷新状态时,如果还有偏移量,说明需要进入结束动画 + // 立即设置 isFinishing 为 true,防止 UI 闪烁(Loading 动画重置) + if (refreshState.indicatorOffset > 0) { + refreshState.isFinishing = true + } + } + refreshState.isRefreshing = isRefreshing + } + + // 监听刷新状态变化 + LaunchedEffect(refreshState.isRefreshing) { + if (refreshState.isRefreshing) { + refreshState.animateIsOver = false + refreshState.isFinishing = false + onRefresh() + } else { + // 刷新结束,如果有偏移量(说明是刚刷新完),则显示完成状态并延迟收起 + if (refreshState.indicatorOffset > 0) { + refreshState.isFinishing = true + delay(800) + refreshState.isFinishing = false + refreshState.animateOffsetTo(0f) + } else { + refreshState.animateOffsetTo(0f) + } + } + } + + // 使用 Layout 实现自定义布局 + Layout( + modifier = modifier + .clipToBounds() + .fillMaxSize() + .nestedScroll(refreshState.connection) + .let { mod -> + if (scrollBehavior != null) { + mod.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + mod + } + }, + content = { + // 内容区域 + RefreshContent( + isGrid = isGrid, + listState = listState, + gridState = gridState, + loadMoreState = loadMoreState, + onLoadMore = onLoadMore, + shouldTriggerLoadMore = shouldTriggerLoadMore, + gridContent = gridContent, + content = content + ) + + // 刷新指示器 + RefreshHeader( + modifier = Modifier + .fillMaxWidth() + .height(52.dp), // 调整高度为标准高度,避免遮挡 + state = refreshState + ) + } + ) { measurables, constraints -> + val contentPlaceable = measurables[0].measure(constraints) + val headerPlaceable = measurables.getOrNull(1)?.measure( + constraints.copy(minHeight = 0, maxHeight = constraints.maxHeight) + ) + + // 设定触发刷新的阈值高度 + // 使用测量的高度作为阈值,确保完全显示 + refreshState.headerHeight = headerPlaceable?.height?.toFloat() ?: 0f + + layout(constraints.maxWidth, constraints.maxHeight) { + // 内容随刷新offset移动 + contentPlaceable.placeRelative(0, refreshState.indicatorOffset.roundToInt()) + + // 刷新指示器 + // 它的位置需要精心计算以保持"拉伸"的视觉中心感 + // 这里我们将它固定在顶部区域,通过 offset 移动 + headerPlaceable?.placeRelative( + 0, + -headerPlaceable.height + refreshState.indicatorOffset.roundToInt() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshState.kt b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshState.kt new file mode 100644 index 0000000..6e0efc8 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/refresh/RefreshState.kt @@ -0,0 +1,242 @@ +package com.joker.kit.core.ui.component.refresh + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationConstants +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * 记住刷新状态 + * + * @param coroutineScope 协程作用域 + * @return [RefreshState] 实例 + * @author Joker.X + */ +@Composable +fun rememberRefreshState( + coroutineScope: CoroutineScope = rememberCoroutineScope() +): RefreshState { + return remember { + RefreshState(coroutineScope) + } +} + +/** + * 刷新状态管理类 + * + * 负责管理下拉刷新的状态、动画和嵌套滚动逻辑。 + * + * @param coroutineScope 协程作用域,用于执行动画和状态更新 + * @author Joker.X + */ +@Stable +class RefreshState(private val coroutineScope: CoroutineScope) { + /** + * 刷新头部的触发高度阈值 + */ + var headerHeight = 0f + + /** + * 是否启用刷新 + */ + var enableRefresh = true + + /** + * 是否正在刷新中 + */ + var isRefreshing by mutableStateOf(false) + + /** + * 是否正在结束刷新(显示"刷新完成"状态) + */ + var isFinishing by mutableStateOf(false) + + /** + * 动画是否结束 + */ + var animateIsOver by mutableStateOf(true) + + /** + * 内部使用的动画状态,用于控制指示器的偏移量 + */ + private val _indicatorOffset = Animatable(0f) + + /** + * 互斥锁,用于防止多个动画或手势冲突 + */ + private val mutatorMutex = MutatorMutex() + + /** + * 当前指示器的偏移量(下拉距离) + */ + val indicatorOffset: Float + get() = _indicatorOffset.value + + /** + * 判断是否处于加载或动画状态 + */ + fun isLoading() = !animateIsOver || isRefreshing + + /** + * 动画滚动到指定偏移量 + * + * @param offset 目标偏移量 + * @param durationMillis 动画时长 + * @author Joker.X + */ + suspend fun animateOffsetTo( + offset: Float, + durationMillis: Int = AnimationConstants.DefaultDurationMillis + ) { + mutatorMutex.mutate { + _indicatorOffset.animateTo(offset, animationSpec = tween(durationMillis)) { + if (this.value == 0f) { + animateIsOver = true + } + } + } + } + + /** + * 立即跳转到指定偏移量 + * + * @param offset 目标偏移量 + * @author Joker.X + */ + suspend fun snapOffsetTo(offset: Float) { + mutatorMutex.mutate(MutatePriority.UserInput) { + _indicatorOffset.snapTo(offset) + } + } + + /** + * 消费滚动距离,更新偏移量 + * + * @param needConsumedY 需要消费的Y轴距离 + * @author Joker.X + */ + private fun consumed(needConsumedY: Float) { + if (needConsumedY == 0f) return + coroutineScope.launch { + snapOffsetTo(indicatorOffset + needConsumedY) + } + } + + /** + * 嵌套滚动连接器,处理下拉手势 + */ + internal val connection = object : NestedScrollConnection { + /** + * 预滚动事件处理 + * + * 在子视图消费滚动事件之前触发。用于处理上滑收起刷新头部的逻辑。 + * + * @param available 剩余可用的滚动偏移量 + * @param source 滚动来源 + * @return 消费掉的滚动偏移量 + * @author Joker.X + */ + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return when { + enableRefresh && available.y < 0 -> { + // 处理上滑(收起) + val canConsumed = (available.y * 0.5f).coerceAtLeast(0 - indicatorOffset) + consumed(canConsumed) + available.copy(x = 0f, y = canConsumed / 0.5f) + } + + else -> Offset.Zero + } + } + + /** + * 后滚动事件处理 + * + * 在子视图消费滚动事件之后触发。用于处理下拉显示刷新头部的逻辑。 + * + * @param consumed 子视图已经消费的滚动偏移量 + * @param available 剩余可用的滚动偏移量 + * @param source 滚动来源 + * @return 消费掉的滚动偏移量 + * @author Joker.X + */ + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return when { + enableRefresh && available.y > 0 -> { + // 处理下拉 + val canConsumed = available.y * 0.5f + consumed(canConsumed) + if (source == NestedScrollSource.SideEffect && indicatorOffset > headerHeight) { + throw CancellationException() + } + available.copy(x = 0f, y = canConsumed / 0.5f) + } + + else -> Offset.Zero + } + } + + /** + * 预惯性滑动事件处理 + * + * 在手指松开准备进行惯性滑动时触发。用于判断是否触发刷新。 + * + * @param available 剩余可用的速度 + * @return 消费掉的速度 + * @author Joker.X + */ + override suspend fun onPreFling(available: Velocity): Velocity { + // 手指松开时,如果超过阈值,触发刷新 + if (indicatorOffset >= headerHeight) { + if (!isLoading()) { + isRefreshing = true + animateOffsetTo(headerHeight) + return available + } + } + return super.onPreFling(available) + } + + /** + * 后惯性滑动事件处理 + * + * 在惯性滑动结束后触发。用于处理未触发刷新时的回弹动画。 + * + * @param consumed 已消费的速度 + * @param available 剩余可用的速度 + * @return 消费掉的速度 + * @author Joker.X + */ + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // 手指松开后的惯性处理 + if (indicatorOffset > 0) { + if (isRefreshing && indicatorOffset > headerHeight) { + animateOffsetTo(headerHeight) + } else if (!isRefreshing) { + animateOffsetTo(0f) + } + return available + } + return super.onPostFling(consumed, available) + } + } +} diff --git a/app/src/main/java/com/joker/kit/core/ui/component/text/Text.kt b/app/src/main/java/com/joker/kit/core/ui/component/text/Text.kt new file mode 100644 index 0000000..19ff8c9 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/text/Text.kt @@ -0,0 +1,453 @@ +package com.joker.kit.core.ui.component.text + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import com.joker.kit.core.designsystem.theme.ColorDanger +import com.joker.kit.core.designsystem.theme.ColorSuccess +import com.joker.kit.core.designsystem.theme.ColorWarning +import com.joker.kit.core.designsystem.theme.Primary + +/** + * 文本类型 + * + * @author Joker.X + */ +enum class TextType { + /** + * 主要文本,用于标题、重要内容 + * 使用Material Theme的onBackground颜色 + */ + PRIMARY, + + /** + * 次要文本,用于正文内容 + * 使用Material Theme的onSurface颜色带透明度 + */ + SECONDARY, + + /** + * 辅助文本,用于辅助说明 + * 使用Material Theme的onSurfaceVariant颜色 + */ + TERTIARY, + + /** + * 白色文本,用于深色背景 + */ + WHITE, + + /** + * 链接文本,可点击 + */ + LINK, + + /** + * 成功文本,通常为绿色 + */ + SUCCESS, + + /** + * 警告文本,通常为黄色 + */ + WARNING, + + /** + * 错误文本,通常为红色 + */ + ERROR +} + +/** + * 文本大小 + * + * @author Joker.X + */ +enum class TextSize { + /** + * 特大:22sp,用于大标题 + */ + DISPLAY_LARGE, + + /** + * 大号:18sp,用于页面标题 + */ + DISPLAY_MEDIUM, + + /** + * 中大:16sp,用于二级标题 + */ + TITLE_LARGE, + + /** + * 中号:14sp,用于分类标题、强调内容 + */ + TITLE_MEDIUM, + + /** + * 正常:14sp,用于正文内容 + */ + BODY_LARGE, + + /** + * 小号:12sp,用于辅助文字、标签 + */ + BODY_MEDIUM, + + /** + * 超小:10sp,用于极小的辅助文字 + */ + BODY_SMALL +} + +/** + * 通用文本组件 + * + * @param text 文本内容 + * @param modifier 修饰符 + * @param type 文本类型,默认为PRIMARY + * @param size 文本大小,默认为BODY_LARGE + * @param color 自定义颜色,会覆盖type的默认颜色 + * @param fontWeight 字体粗细,默认根据size自动设置 + * @param fontStyle 字体样式,默认为FontStyle.Normal + * @param fontFamily 字体家族,默认为null + * @param letterSpacing 字母间距,默认为TextUnit.Unspecified + * @param textDecoration 文本装饰,如下划线等,默认为null + * @param textAlign 文本对齐方式,默认为null + * @param lineHeight 行高,默认为TextUnit.Unspecified + * @param overflow 文本溢出处理方式,默认为TextOverflow.Clip + * @param softWrap 是否自动换行,默认为true + * @param maxLines 最大行数,默认为Int.MAX_VALUE + * @param minLines 最小行数,默认为1 + * @param onTextLayout 文本布局回调,默认为null + * @param onClick 点击回调,设置后文本将变为可点击状态 + * @param style 自定义文本样式,会覆盖其他样式设置 + * @param selectable 是否可选择,默认为false + * @author Joker.X + */ +@Composable +fun AppText( + text: String, + modifier: Modifier = Modifier, + type: TextType = TextType.PRIMARY, + size: TextSize = TextSize.BODY_LARGE, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, + fontStyle: FontStyle? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + onClick: (() -> Unit)? = null, + style: TextStyle? = null, + selectable: Boolean = false +) { + // 根据类型设置颜色 + val textColor = when { + color != Color.Unspecified -> color + type == TextType.PRIMARY -> Color.Unspecified // 使用默认Material颜色 + type == TextType.SECONDARY -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + type == TextType.TERTIARY -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + type == TextType.WHITE -> Color.White + type == TextType.LINK -> Primary + type == TextType.SUCCESS -> ColorSuccess + type == TextType.WARNING -> ColorWarning + type == TextType.ERROR -> ColorDanger + else -> Color.Unspecified + } + + // 根据大小设置文本样式 + val textStyle = style ?: when (size) { + TextSize.DISPLAY_LARGE -> MaterialTheme.typography.displayLarge + TextSize.DISPLAY_MEDIUM -> MaterialTheme.typography.displayMedium + TextSize.TITLE_LARGE -> MaterialTheme.typography.titleLarge + TextSize.TITLE_MEDIUM -> MaterialTheme.typography.titleMedium + TextSize.BODY_LARGE -> MaterialTheme.typography.bodyLarge + TextSize.BODY_MEDIUM -> MaterialTheme.typography.bodyMedium + TextSize.BODY_SMALL -> MaterialTheme.typography.bodySmall + } + + // 设置字体粗细(如果未指定,使用样式默认值) + val finalFontWeight = fontWeight ?: textStyle.fontWeight + + // 创建修改后的样式 + val finalStyle = textStyle.copy( + color = textColor, + fontWeight = finalFontWeight, + fontStyle = fontStyle ?: textStyle.fontStyle, + fontFamily = fontFamily ?: textStyle.fontFamily, + letterSpacing = if (letterSpacing != TextUnit.Unspecified) letterSpacing else textStyle.letterSpacing, + textDecoration = textDecoration ?: textStyle.textDecoration, + textAlign = textAlign ?: textStyle.textAlign, + lineHeight = if (lineHeight != TextUnit.Unspecified) lineHeight else textStyle.lineHeight + ) + + // 处理可点击状态 + val clickableModifier = if (onClick != null) { + modifier.clickable { onClick() } + } else { + modifier + } + + // 处理可选择状态 + if (selectable) { + SelectionContainer { + Text( + text = text, + modifier = clickableModifier, + style = finalStyle, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + color = if (textColor == Color.Unspecified) Color.Unspecified else textColor + ) + } + } else { + Text( + text = text, + modifier = clickableModifier, + style = finalStyle, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + color = if (textColor == Color.Unspecified) Color.Unspecified else textColor + ) + } +} + +/** + * 通用文本组件 - AnnotatedString版本 + * + * @param text 富文本内容 + * @param modifier 修饰符 + * @param type 文本类型,默认为PRIMARY + * @param size 文本大小,默认为BODY_LARGE + * @param color 自定义颜色,会覆盖type的默认颜色 + * @param fontWeight 字体粗细,默认根据size自动设置 + * @param fontStyle 字体样式,默认为FontStyle.Normal + * @param fontFamily 字体家族,默认为null + * @param letterSpacing 字母间距,默认为TextUnit.Unspecified + * @param textDecoration 文本装饰,如下划线等,默认为null + * @param textAlign 文本对齐方式,默认为null + * @param lineHeight 行高,默认为TextUnit.Unspecified + * @param overflow 文本溢出处理方式,默认为TextOverflow.Clip + * @param softWrap 是否自动换行,默认为true + * @param maxLines 最大行数,默认为Int.MAX_VALUE + * @param minLines 最小行数,默认为1 + * @param onTextLayout 文本布局回调,默认为null + * @param onClick 点击回调,设置后文本将变为可点击状态 + * @param style 自定义文本样式,会覆盖其他样式设置 + * @param selectable 是否可选择,默认为false + * @author Joker.X + */ +@Composable +fun AppText( + text: AnnotatedString, + modifier: Modifier = Modifier, + type: TextType = TextType.PRIMARY, + size: TextSize = TextSize.BODY_LARGE, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, + fontStyle: FontStyle? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + onClick: (() -> Unit)? = null, + style: TextStyle? = null, + selectable: Boolean = false +) { + // 根据类型设置颜色 + val textColor = when { + color != Color.Unspecified -> color + type == TextType.PRIMARY -> Color.Unspecified // 使用默认Material颜色 + type == TextType.SECONDARY -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + type == TextType.TERTIARY -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + type == TextType.WHITE -> Color.White + type == TextType.LINK -> Primary + type == TextType.SUCCESS -> ColorSuccess + type == TextType.WARNING -> ColorWarning + type == TextType.ERROR -> ColorDanger + else -> Color.Unspecified + } + + // 根据大小设置文本样式 + val textStyle = style ?: when (size) { + TextSize.DISPLAY_LARGE -> MaterialTheme.typography.displayLarge + TextSize.DISPLAY_MEDIUM -> MaterialTheme.typography.displayMedium + TextSize.TITLE_LARGE -> MaterialTheme.typography.titleLarge + TextSize.TITLE_MEDIUM -> MaterialTheme.typography.titleMedium + TextSize.BODY_LARGE -> MaterialTheme.typography.bodyLarge + TextSize.BODY_MEDIUM -> MaterialTheme.typography.bodyMedium + TextSize.BODY_SMALL -> MaterialTheme.typography.bodySmall + } + + // 设置字体粗细(如果未指定,使用样式默认值) + val finalFontWeight = fontWeight ?: textStyle.fontWeight + + // 创建修改后的样式 + val finalStyle = textStyle.copy( + color = textColor, + fontWeight = finalFontWeight, + fontStyle = fontStyle ?: textStyle.fontStyle, + fontFamily = fontFamily ?: textStyle.fontFamily, + letterSpacing = if (letterSpacing != TextUnit.Unspecified) letterSpacing else textStyle.letterSpacing, + textDecoration = textDecoration ?: textStyle.textDecoration, + textAlign = textAlign ?: textStyle.textAlign, + lineHeight = if (lineHeight != TextUnit.Unspecified) lineHeight else textStyle.lineHeight + ) + + // 处理可点击状态 + val clickableModifier = if (onClick != null) { + modifier.clickable { onClick() } + } else { + modifier + } + + // 处理可选择状态 + if (selectable) { + SelectionContainer { + Text( + text = text, + modifier = clickableModifier, + style = finalStyle, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + color = if (textColor == Color.Unspecified) Color.Unspecified else textColor + ) + } + } else { + Text( + text = text, + modifier = clickableModifier, + style = finalStyle, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + color = if (textColor == Color.Unspecified) Color.Unspecified else textColor + ) + } +} + +/** + * 基础文本组件 - 字符串版本 + * + * 该组件是对 Material3 Text 的轻量封装,主要用于设置ContentColor + * + * @param text 文本内容 + * @param modifier 修饰符 + * @param style 文本样式 + * @param overflow 文本溢出处理方式 + * @param softWrap 是否自动换行 + * @param maxLines 最大行数 + * @param minLines 最小行数 + * @param onTextLayout 文本布局回调 + * @author Joker.X + */ +@Composable +private fun BasicText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {} +) { + val textColor = style.color.takeOrElse { LocalContentColor.current } + + CompositionLocalProvider(LocalContentColor provides textColor) { + Text( + text = text, + modifier = modifier, + style = style, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout + ) + } +} + +/** + * 基础文本组件 - AnnotatedString版本 + * + * 该组件是对 Material3 Text 的轻量封装,主要用于设置ContentColor + * + * @param text 富文本内容 + * @param modifier 修饰符 + * @param style 文本样式 + * @param overflow 文本溢出处理方式 + * @param softWrap 是否自动换行 + * @param maxLines 最大行数 + * @param minLines 最小行数 + * @param onTextLayout 文本布局回调 + * @author Joker.X + */ +@Composable +private fun BasicText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {} +) { + val textColor = style.color.takeOrElse { LocalContentColor.current } + + CompositionLocalProvider(LocalContentColor provides textColor) { + Text( + text = text, + modifier = modifier, + style = style, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/ui/component/title/TitleWithLine.kt b/app/src/main/java/com/joker/kit/core/ui/component/title/TitleWithLine.kt new file mode 100644 index 0000000..d19e5f6 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/ui/component/title/TitleWithLine.kt @@ -0,0 +1,73 @@ +package com.joker.kit.core.ui.component.title + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.joker.kit.core.designsystem.component.SpaceHorizontalSmall +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize + +/** + * 带有垂直装饰线的标题组件 + * + * @param text 标题文本 + * @param textColor 文本颜色 + * @param modifier 修饰符 + * @param lineColor 装饰线颜色 + * @author Joker.X + */ +@Composable +fun TitleWithLine( + text: String, + textColor: Color = MaterialTheme.colorScheme.onSurface, + modifier: Modifier = Modifier, + lineColor: Color = MaterialTheme.colorScheme.primary, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // 垂直装饰线 + Box( + modifier = Modifier + .width(4.dp) + .height(20.dp) + .clip(RoundedCornerShape(2.dp)) + .background(lineColor) + + ) + SpaceHorizontalSmall() + // 标题文本 + AppText( + text = text, + size = TextSize.TITLE_LARGE, + fontWeight = MaterialTheme.typography.titleLarge.fontWeight, + color = textColor + ) + } +} + +/** + * 带装饰线标题组件预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +fun TitleWithLinePreview() { + AppTheme { + TitleWithLine(text = "标题预览") + } +} diff --git a/app/src/main/java/com/joker/kit/core/util/package/PackageUtils.kt b/app/src/main/java/com/joker/kit/core/util/package/PackageUtils.kt new file mode 100644 index 0000000..0eaf734 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/util/package/PackageUtils.kt @@ -0,0 +1,237 @@ +package com.joker.kit.core.util.`package` + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * Android 应用包信息工具类 + * + * 提供获取应用版本、签名、安装状态等功能的便捷方法 + * + * @author Joker.X + */ +object PackageUtils { + + /** + * 获取当前应用的显示名称 + * + * @param context 应用上下文 + * @return 应用名称,获取失败时返回空字符串 + * @author Joker.X + * + * @sample + * ```kotlin + * val appName = PackageUtils.getCurrentAppName(context) + * // 返回: "青商城" + * ``` + */ + fun getCurrentAppName(context: Context): String { + return try { + val packageManager = context.packageManager + val applicationInfo = packageManager.getApplicationInfo(context.packageName, 0) + packageManager.getApplicationLabel(applicationInfo).toString() + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + "" + } + } + + /** + * 获取当前应用的版本名称 + * + * 版本名称通常用于向用户展示,格式如 "1.0.0" + * + * @param context 应用上下文 + * @return 版本名称字符串,获取失败时返回空字符串 + * @author Joker.X + * + * @sample + * ```kotlin + * val versionName = PackageUtils.getCurrentVersionName(context) + * // 返回: "1.2.3" + * ``` + */ + fun getCurrentVersionName(context: Context): String { + return try { + val packageInfo = getPackageInfoSafely(context, context.packageName) + packageInfo?.versionName ?: "" + } catch (e: Exception) { + e.printStackTrace() + "" + } + } + + /** + * 获取当前应用的版本号 + * + * 版本号是用于程序内部比较版本大小的数字标识 + * + * @param context 应用上下文 + * @return 版本号,获取失败时返回 0 + * @author Joker.X + * + * @sample + * ```kotlin + * val versionCode = PackageUtils.getCurrentVersionCode(context) + * // 返回: 123L + * ``` + */ + fun getCurrentVersionCode(context: Context): Long { + return try { + val packageInfo = getPackageInfoSafely(context, context.packageName) + packageInfo?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + it.longVersionCode + } else { + @Suppress("DEPRECATION") + it.versionCode.toLong() + } + } ?: 0L + } catch (e: Exception) { + e.printStackTrace() + 0L + } + } + + /** + * 检查指定应用是否已安装 + * + * @param context 应用上下文 + * @param packageName 目标应用的包名 + * @return true 表示已安装,false 表示未安装 + * @author Joker.X + * + * @sample + * ```kotlin + * val isInstalled = PackageUtils.isAppInstalled(context, "com.tencent.mm") + * // 检查微信是否安装 + * ``` + */ + fun isAppInstalled(context: Context, packageName: String): Boolean { + return try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + /** + * 获取当前应用的 MD5 签名 + * + * @param context 应用上下文 + * @return MD5 签名字符串,格式为小写十六进制,用冒号分隔,获取失败时返回 null + * @author Joker.X + * + * @sample + * ```kotlin + * val md5Signature = PackageUtils.getAppSignatureMD5(context) + * // 返回: "a1:b2:c3:d4:e5:f6:..." + * ``` + */ + fun getAppSignatureMD5(context: Context): String? { + return generateSignatureHash(context, "MD5") + } + + /** + * 获取当前应用的 SHA1 签名 + * + * @param context 应用上下文 + * @return SHA1 签名字符串,格式为小写十六进制,用冒号分隔,获取失败时返回 null + * @author Joker.X + * + * @sample + * ```kotlin + * val sha1Signature = PackageUtils.getAppSignatureSHA1(context) + * // 返回: "12:34:56:78:9a:bc:..." + * ``` + */ + fun getAppSignatureSHA1(context: Context): String? { + return generateSignatureHash(context, "SHA1") + } + + /** + * 安全地获取包信息 + * + * @param context 应用上下文 + * @param packageName 包名 + * @return PackageInfo 对象,获取失败时返回 null + * @author Joker.X + */ + private fun getPackageInfoSafely(context: Context, packageName: String): PackageInfo? { + return try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + null + } + } + + /** + * 生成应用签名的哈希值 + * + * @param context 应用上下文 + * @param algorithm 哈希算法名称(如 "MD5", "SHA1") + * @return 签名哈希字符串,获取失败时返回 null + * @author Joker.X + */ + private fun generateSignatureHash(context: Context, algorithm: String): String? { + return try { + val packageManager = context.packageManager + + // 获取签名信息 + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNATURES + ) + } + + // 获取证书字节数组 + val certificateBytes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.signingInfo?.apkContentsSigners?.get(0)?.toByteArray() + } else { + @Suppress("DEPRECATION") + packageInfo.signatures?.get(0)?.toByteArray() + } + + certificateBytes?.let { bytes -> + val messageDigest = MessageDigest.getInstance(algorithm) + val hashBytes = messageDigest.digest(bytes) + + // 转换为十六进制字符串 + buildString { + hashBytes.forEachIndexed { index, byte -> + val hex = String.format("%02x", byte.toInt() and 0xFF) + append(hex) + if (index < hashBytes.size - 1) { + append(":") + } + } + } + } + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + null + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + null + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/util/permission/PermissionUtils.kt b/app/src/main/java/com/joker/kit/core/util/permission/PermissionUtils.kt new file mode 100644 index 0000000..1ffe71d --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/util/permission/PermissionUtils.kt @@ -0,0 +1,413 @@ +package com.joker.kit.core.util.permission + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import com.hjq.permissions.XXPermissions +import com.hjq.permissions.permission.PermissionLists +import com.hjq.permissions.permission.base.IPermission +import com.joker.kit.core.util.toast.ToastUtils + +/** + * 权限工具类,基于 XXPermissions 框架封装 + * 提供常用权限的快捷申请方法 + * + * @author Joker.X + */ +object PermissionUtils { + + /** + * 处理权限申请结果的公共方法 + * 统一处理权限申请成功/失败的逻辑和用户提示 + * + * @param activity Activity 实例 + * @param grantedList 已授予的权限列表 + * @param deniedList 被拒绝的权限列表 + * @param permissionName 权限名称(用于错误提示) + * @param callback 权限申请结果回调 + * @param permissions 申请的权限(可变参数) + * @author Joker.X + */ + private fun handlePermissionResult( + activity: Activity, + grantedList: MutableList, + deniedList: MutableList, + permissionName: String, + callback: (granted: Boolean) -> Unit, + vararg permissions: IPermission + ) { + val allGranted = deniedList.isEmpty() + if (allGranted) { + // 权限申请成功 + callback(true) + } else { + // 权限申请失败 + if (XXPermissions.isDoNotAskAgainPermissions(activity, deniedList)) { + // 用户勾选了不再询问,引导用户到设置页面 + ToastUtils.showError("${permissionName}被永久拒绝,请手动授予") + XXPermissions.startPermissionActivity(activity, *permissions) + } else { + ToastUtils.showError("${permissionName}获取失败") + } + callback(false) + } + } + + /** + * 申请存储权限(读写外部存储) + * 用法示例:PermissionUtils.requestStoragePermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestStoragePermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getReadExternalStoragePermission()) + .permission(PermissionLists.getWriteExternalStoragePermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "存储权限", + callback, + PermissionLists.getReadExternalStoragePermission(), + PermissionLists.getWriteExternalStoragePermission() + ) + } + } + + + /** + * 申请相机权限 + * 用法示例:PermissionUtils.requestCameraPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestCameraPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getCameraPermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "相机权限", + callback, + PermissionLists.getCameraPermission() + ) + } + } + + + /** + * 申请相册权限(读取媒体文件) + * 用法示例:PermissionUtils.requestGalleryPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestGalleryPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getReadExternalStoragePermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "相册权限", + callback, + PermissionLists.getReadExternalStoragePermission() + ) + } + } + + + /** + * 申请通知权限 + * 用法示例:PermissionUtils.requestNotificationPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestNotificationPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getPostNotificationsPermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "通知权限", + callback, + PermissionLists.getPostNotificationsPermission() + ) + } + } + + + /** + * 申请录音权限 + * 用法示例:PermissionUtils.requestAudioPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestAudioPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getRecordAudioPermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "录音权限", + callback, + PermissionLists.getRecordAudioPermission() + ) + } + } + + + /** + * 申请位置权限 + * 用法示例:PermissionUtils.requestLocationPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestLocationPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getAccessFineLocationPermission()) + .permission(PermissionLists.getAccessCoarseLocationPermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "位置权限", + callback, + PermissionLists.getAccessFineLocationPermission(), + PermissionLists.getAccessCoarseLocationPermission() + ) + } + } + + /** + * 申请相机和相册权限(组合权限) + * 用法示例:PermissionUtils.requestCameraAndGalleryPermission(this) { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestCameraAndGalleryPermission( + context: Context, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + XXPermissions.with(activity) + .permission(PermissionLists.getCameraPermission()) + .permission(PermissionLists.getReadExternalStoragePermission()) + .permission(PermissionLists.getWriteExternalStoragePermission()) + .request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + "相机和相册权限", + callback, + PermissionLists.getCameraPermission(), + PermissionLists.getReadExternalStoragePermission(), + PermissionLists.getWriteExternalStoragePermission() + ) + } + } + + + /** + * 申请自定义权限组合 + * 用法示例:PermissionUtils.requestCustomPermissions(this, arrayOf(PermissionLists.getCameraPermission(), PermissionLists.getRecordAudioPermission()), "自定义权限") { granted -> ... } + * + * @param context Activity 或 Fragment 的上下文 + * @param permissions 权限数组 + * @param permissionName 权限名称(用于错误提示) + * @param callback 权限申请结果回调 + * @author Joker.X + */ + fun requestCustomPermissions( + context: Context, + permissions: Array, + permissionName: String, + callback: (granted: Boolean) -> Unit + ) { + val activity = getActivityFromContext(context) + if (activity == null) { + ToastUtils.showError("无法获取Activity实例,权限申请失败") + callback(false) + return + } + + val permissionBuilder = XXPermissions.with(activity) + permissions.forEach { permission -> + permissionBuilder.permission(permission) + } + permissionBuilder.request { grantedList, deniedList -> + handlePermissionResult( + activity, + grantedList, + deniedList, + permissionName, + callback, + *permissions + ) + } + } + + + /** + * 将 Context 转换为 Activity 实例的私有方法 + * 处理 Context 包装类的情况,递归查找真正的 Activity + * + * @param context 输入的 Context + * @return Activity 实例,如果无法获取则返回 null + * @author Joker.X + */ + private fun getActivityFromContext(context: Context): Activity? { + return when (context) { + is Activity -> context + is ContextWrapper -> { + var baseContext = context.baseContext + while (baseContext is ContextWrapper && baseContext !is Activity) { + baseContext = baseContext.baseContext + } + baseContext as? Activity + } + + else -> null + } + } + + + /** + * 检查单个权限是否已授予 + * 用法示例:val hasCamera = PermissionUtils.hasPermission(this, PermissionLists.getCameraPermission()) + * + * @param context Context + * @param permission 权限对象 + * @return 是否已授予权限 + * @author Joker.X + */ + fun hasPermission(context: Context, permission: IPermission): Boolean { + return XXPermissions.isGrantedPermission(context, permission) + } + + /** + * 检查多个权限是否已授予 + * 用法示例:val hasPermissions = PermissionUtils.hasPermissions(this, arrayOf(PermissionLists.getCameraPermission(), PermissionLists.getRecordAudioPermission())) + * + * @param context Context + * @param permissions 权限数组 + * @return 是否全部权限都已授予 + * @author Joker.X + */ + fun hasPermissions(context: Context, permissions: Array): Boolean { + return XXPermissions.isGrantedPermissions(context, permissions) + } + + /** + * 跳转到应用权限设置页面 + * 用法示例:PermissionUtils.openPermissionSettings(this) + * + * @param context Context + * @author Joker.X + */ + fun openPermissionSettings(context: Context) { + XXPermissions.startPermissionActivity(context) + } + + /** + * 跳转到应用权限设置页面(指定权限) + * 用法示例:PermissionUtils.openPermissionSettings(this, arrayOf(PermissionLists.getCameraPermission())) + * + * @param context Context + * @param permissions 权限数组 + * @author Joker.X + */ + fun openPermissionSettings(context: Context, permissions: Array) { + XXPermissions.startPermissionActivity(context, *permissions) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/util/storage/MMKVUtils.kt b/app/src/main/java/com/joker/kit/core/util/storage/MMKVUtils.kt new file mode 100644 index 0000000..ddc5e97 --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/util/storage/MMKVUtils.kt @@ -0,0 +1,456 @@ +package com.joker.kit.core.util.storage + +import android.app.Application +import android.os.Parcelable +import com.tencent.mmkv.MMKV +import kotlinx.serialization.json.Json +import java.util.Collections + +/** + * 基于腾讯MMKV封装的高性能键值存储工具类 + * 提供统一的接口进行数据存取,支持多种数据类型和多实例模式 + * + * @author Joker.X + */ +object MMKVUtils { + + /** + * MMKV 是否已初始化 + */ + private var isInitialized = false + + /** + * 默认实例,一般情况下使用此实例即可 + */ + private val defaultMMKV by lazy { + checkInitialization() + MMKV.defaultMMKV() + } + + /** + * 存储所有创建的命名实例,避免重复创建 + */ + private val instanceMap = Collections.synchronizedMap(HashMap()) + + /** + * 初始化MMKV,应在Application中调用 + * 用法示例:MMKVUtils.init(application) + * + * @param application Application对象 + * @return MMKV根目录 + * @author Joker.X + */ + fun init(application: Application): String { + val rootDir = MMKV.initialize(application) + isInitialized = true + return rootDir + } + + /** + * 检查MMKV是否已初始化 + * + * @author Joker.X + */ + private fun checkInitialization() { + if (!isInitialized) { + throw IllegalStateException("MMKVUtils未初始化,请先在Application中调用MMKVUtils.init()") + } + } + + /** + * 获取MMKV命名实例 + * 用法示例:val userKV = MMKVUtils.getInstance("user") + * + * @param name 实例名称 + * @param mode 多进程模式,默认单进程 + * @param cryptKey 加密密钥,传null表示不加密 + * @param rootDir 自定义存储目录,传null表示使用默认目录 + * @return MMKV实例 + * @author Joker.X + */ + fun getInstance( + name: String, + mode: Int = MMKV.SINGLE_PROCESS_MODE, + cryptKey: String? = null, + rootDir: String? = null + ): MMKV { + checkInitialization() + + return instanceMap.getOrPut(name) { + if (cryptKey != null) { + MMKV.mmkvWithID(name, mode, cryptKey, rootDir) + } else { + MMKV.mmkvWithID(name, mode, null, rootDir) + } + } + } + + /** + * 获取多进程访问的MMKV实例 + * 用法示例:val sharedKV = MMKVUtils.getMultiProcessInstance("shared") + * + * @param name 实例名称 + * @param cryptKey 加密密钥,传null表示不加密 + * @return 支持多进程访问的MMKV实例 + * @author Joker.X + */ + fun getMultiProcessInstance(name: String, cryptKey: String? = null): MMKV { + return getInstance(name, MMKV.MULTI_PROCESS_MODE, cryptKey) + } + + /** + * 获取加密的MMKV实例 + * 用法示例:val secureKV = MMKVUtils.getEncryptedInstance("secure", "your_secret_key") + * + * @param name 实例名称 + * @param cryptKey 加密密钥 + * @param multiProcess 是否支持多进程访问 + * @return 加密的MMKV实例 + * @author Joker.X + */ + fun getEncryptedInstance(name: String, cryptKey: String, multiProcess: Boolean = false): MMKV { + val mode = if (multiProcess) MMKV.MULTI_PROCESS_MODE else MMKV.SINGLE_PROCESS_MODE + return getInstance(name, mode, cryptKey) + } + + // ====================== 默认实例的读写操作 ====================== + + /** + * 存储Boolean值 + * 用法示例:MMKVUtils.putBoolean("isLogin", true) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putBoolean(key: String, value: Boolean) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Boolean值 + * 用法示例:val isLogin = MMKVUtils.getBoolean("isLogin", false) + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Boolean值,如不存在则返回默认值 + * @author Joker.X + */ + fun getBoolean(key: String, defaultValue: Boolean = false): Boolean { + return defaultMMKV.decodeBool(key, defaultValue) + } + + /** + * 存储Int值 + * 用法示例:MMKVUtils.putInt("userId", 12345) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putInt(key: String, value: Int) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Int值 + * 用法示例:val userId = MMKVUtils.getInt("userId", 0) + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Int值,如不存在则返回默认值 + * @author Joker.X + */ + fun getInt(key: String, defaultValue: Int = 0): Int { + return defaultMMKV.decodeInt(key, defaultValue) + } + + /** + * 存储Long值 + * 用法示例:MMKVUtils.putLong("timestamp", System.currentTimeMillis()) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putLong(key: String, value: Long) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Long值 + * 用法示例:val timestamp = MMKVUtils.getLong("timestamp", 0L) + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Long值,如不存在则返回默认值 + * @author Joker.X + */ + fun getLong(key: String, defaultValue: Long = 0L): Long { + return defaultMMKV.decodeLong(key, defaultValue) + } + + /** + * 存储Float值 + * 用法示例:MMKVUtils.putFloat("price", 99.9f) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putFloat(key: String, value: Float) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Float值 + * 用法示例:val price = MMKVUtils.getFloat("price", 0f) + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Float值,如不存在则返回默认值 + * @author Joker.X + */ + fun getFloat(key: String, defaultValue: Float = 0f): Float { + return defaultMMKV.decodeFloat(key, defaultValue) + } + + /** + * 存储Double值 + * 用法示例:MMKVUtils.putDouble("distance", 12.34) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putDouble(key: String, value: Double) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Double值 + * 用法示例:val distance = MMKVUtils.getDouble("distance", 0.0) + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Double值,如不存在则返回默认值 + * @author Joker.X + */ + fun getDouble(key: String, defaultValue: Double = 0.0): Double { + return defaultMMKV.decodeDouble(key, defaultValue) + } + + /** + * 存储String值 + * 用法示例:MMKVUtils.putString("username", "张三") + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putString(key: String, value: String?) { + defaultMMKV.encode(key, value) + } + + /** + * 获取String值 + * 用法示例:val username = MMKVUtils.getString("username", "") + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的String值,如不存在则返回默认值 + * @author Joker.X + */ + fun getString(key: String, defaultValue: String = ""): String { + return defaultMMKV.decodeString(key, defaultValue) ?: defaultValue + } + + /** + * 存储ByteArray值 + * 用法示例:MMKVUtils.putBytes("data", byteArrayOf(1, 2, 3)) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putBytes(key: String, value: ByteArray?) { + defaultMMKV.encode(key, value) + } + + /** + * 获取ByteArray值 + * 用法示例:val data = MMKVUtils.getBytes("data") + * + * @param key 键 + * @return 存储的ByteArray值,如不存在则返回null + * @author Joker.X + */ + fun getBytes(key: String): ByteArray? { + return defaultMMKV.decodeBytes(key) + } + + /** + * 存储可序列化对象 + * 用法示例:MMKVUtils.putParcelable("user", userInfo) + * + * @param key 键 + * @param value 值,需实现Parcelable接口 + * @author Joker.X + */ + fun putParcelable(key: String, value: T?) { + defaultMMKV.encode(key, value) + } + + /** + * 获取可序列化对象 + * 用法示例:val user = MMKVUtils.getParcelable("user", UserInfo::class.java) + * + * @param key 键 + * @param clazz 对象类型 + * @return 存储的对象,如不存在则返回null + * @author Joker.X + */ + fun getParcelable(key: String, clazz: Class): T? { + return defaultMMKV.decodeParcelable(key, clazz) + } + + /** + * 存储任意可序列化对象(基于kotlinx.serialization) + * 用法示例:MMKVUtils.putObject("cart", cart) + * + * @param key 键 + * @param value 值,需加 @Serializable 注解 + * @author Joker.X + */ + inline fun putObject(key: String, value: T) { + val json = Json.encodeToString(value) + putString(key, json) + } + + /** + * 获取任意可序列化对象(基于kotlinx.serialization) + * 用法示例:val cart = MMKVUtils.getObject("cart") + * + * @param key 键 + * @return 存储的对象,如不存在或解析失败则返回null + * @author Joker.X + */ + inline fun getObject(key: String): T? { + val json = getString(key, "") + return if (json.isNotEmpty()) { + try { + Json.decodeFromString(json) + } catch (e: Exception) { + null + } + } else { + null + } + } + + /** + * 存储Set集合 + * 用法示例:MMKVUtils.putStringSet("tags", setOf("tag1", "tag2")) + * + * @param key 键 + * @param value 值 + * @author Joker.X + */ + fun putStringSet(key: String, value: Set?) { + defaultMMKV.encode(key, value) + } + + /** + * 获取Set集合 + * 用法示例:val tags = MMKVUtils.getStringSet("tags") + * + * @param key 键 + * @param defaultValue 默认值 + * @return 存储的Set,如不存在则返回默认值 + * @author Joker.X + */ + fun getStringSet(key: String, defaultValue: Set = emptySet()): Set { + return defaultMMKV.decodeStringSet(key, defaultValue) ?: defaultValue + } + + /** + * 判断是否包含指定键 + * 用法示例:if (MMKVUtils.containsKey("username")) { ... } + * + * @param key 键 + * @return 是否包含该键 + * @author Joker.X + */ + fun containsKey(key: String): Boolean { + return defaultMMKV.containsKey(key) + } + + /** + * 移除指定键值对 + * 用法示例:MMKVUtils.remove("username") + * + * @param key 键 + * @author Joker.X + */ + fun remove(key: String) { + defaultMMKV.removeValueForKey(key) + } + + /** + * 移除包含指定前缀的所有键值对 + * 用法示例:MMKVUtils.removeValuesForKeys("user_") + * + * @param keyPrefix 键前缀 + * @author Joker.X + */ + fun removeValuesForKeys(keyPrefix: String) { + val keys = defaultMMKV.allKeys() + if (keys != null) { + val keysToRemove = keys.filter { it.startsWith(keyPrefix) }.toTypedArray() + defaultMMKV.removeValuesForKeys(keysToRemove) + } + } + + /** + * 清除所有数据 + * 用法示例:MMKVUtils.clearAll() + * + * @author Joker.X + */ + fun clearAll() { + defaultMMKV.clearAll() + } + + /** + * 获取所有键名 + * 用法示例:val allKeys = MMKVUtils.getAllKeys() + * + * @return 所有键的集合,如果没有则返回空集合 + * @author Joker.X + */ + fun getAllKeys(): Set { + return defaultMMKV.allKeys()?.toSet() ?: emptySet() + } + + /** + * 获取MMKV实例大小(字节) + * 用法示例:val size = MMKVUtils.totalSize() + * + * @return MMKV实例占用的大小(字节) + * @author Joker.X + */ + fun totalSize(): Long { + return defaultMMKV.totalSize() + } + + /** + * 获取MMKV实例中的条目数量 + * 用法示例:val count = MMKVUtils.count() + * + * @return MMKV实例中的条目数量 + * @author Joker.X + */ + fun count(): Long { + return defaultMMKV.count() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/core/util/toast/ToastUtils.kt b/app/src/main/java/com/joker/kit/core/util/toast/ToastUtils.kt new file mode 100644 index 0000000..4bbb24d --- /dev/null +++ b/app/src/main/java/com/joker/kit/core/util/toast/ToastUtils.kt @@ -0,0 +1,305 @@ +package com.joker.kit.core.util.toast + +import android.app.Application +import android.content.Context +import androidx.annotation.StringRes +import com.hjq.toast.ToastParams +import com.hjq.toast.Toaster +import com.hjq.toast.style.BlackToastStyle +import com.hjq.toast.style.CustomToastStyle +import com.hjq.toast.style.WhiteToastStyle +import com.joker.kit.R + +/** + * Toast 工具类,基于 Toaster 框架封装 + * 提供基本的 Toast 显示和特定样式(成功、失败、警告) + * + * @author Joker.X + */ +object ToastUtils { + + /** + * 是否为深色主题 + */ + private var isDarkMode = false + + /** + * 初始化 Toast,应在 Application 中调用 + * 用法示例:ToastUtils.init(application, isDarkTheme) + * + * @param application Application 对象 + * @param isDarkTheme 是否为深色主题,用于选择默认样式 + * @author Joker.X + */ + fun init(application: Application, isDarkTheme: Boolean = false) { + // 保存当前主题模式 + isDarkMode = isDarkTheme + + // 根据主题选择默认样式 + val style = if (isDarkTheme) WhiteToastStyle() else BlackToastStyle() + Toaster.init(application, style) + } + + /** + * 设置为黑色样式 + * 用法示例:ToastUtils.setBlackStyle() + * + * @author Joker.X + */ + fun setBlackStyle() { + isDarkMode = false + Toaster.setStyle(BlackToastStyle()) + } + + /** + * 设置为白色样式 + * 用法示例:ToastUtils.setWhiteStyle() + * + * @author Joker.X + */ + fun setWhiteStyle() { + isDarkMode = true + Toaster.setStyle(WhiteToastStyle()) + } + + /** + * 显示普通 Toast + * 用法示例:ToastUtils.show("这是普通提示") + * + * @param text 文本内容 + * @author Joker.X + */ + fun show(text: CharSequence) { + Toaster.show(text) + } + + /** + * 显示普通 Toast(资源ID) + * 用法示例:ToastUtils.show(R.string.message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun show(@StringRes resId: Int) { + Toaster.show(resId) + } + + /** + * 显示成功样式的 Toast + * 用法示例:ToastUtils.showSuccess("操作成功") + * + * @param text 文本内容 + * @author Joker.X + */ + fun showSuccess(text: CharSequence) { + val params = ToastParams() + params.text = text + params.style = CustomToastStyle(R.layout.toast_success) + Toaster.show(params) + } + + /** + * 显示成功样式的 Toast(资源ID) + * 用法示例:ToastUtils.showSuccess(context, R.string.success_message) + * + * @param context 上下文 + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showSuccess(context: Context, @StringRes resId: Int) { + val text = context.getString(resId) + showSuccess(text) + } + + /** + * 显示成功样式的 Toast(资源ID),不需要传递上下文 + * 注意:此方法仅适用于Toast已经初始化之后使用 + * 用法示例:ToastUtils.showSuccess(R.string.success_message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showSuccess(@StringRes resId: Int) { + // 保存当前样式 + val currentStyle = if (isDarkMode) WhiteToastStyle() else BlackToastStyle() + + // 设置成功样式 + Toaster.setStyle(CustomToastStyle(R.layout.toast_success)) + Toaster.show(resId) + + // 恢复默认样式 + Toaster.setStyle(currentStyle) + } + + /** + * 显示失败样式的 Toast + * 用法示例:ToastUtils.showError("操作失败") + * + * @param text 文本内容 + * @author Joker.X + */ + fun showError(text: CharSequence) { + val params = ToastParams() + params.text = text + params.style = CustomToastStyle(R.layout.toast_error) + Toaster.show(params) + } + + /** + * 显示失败样式的 Toast(资源ID) + * 用法示例:ToastUtils.showError(context, R.string.error_message) + * + * @param context 上下文 + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showError(context: Context, @StringRes resId: Int) { + val text = context.getString(resId) + showError(text) + } + + /** + * 显示失败样式的 Toast(资源ID),不需要传递上下文 + * 注意:此方法仅适用于Toast已经初始化之后使用 + * 用法示例:ToastUtils.showError(R.string.error_message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showError(@StringRes resId: Int) { + // 保存当前样式 + val currentStyle = if (isDarkMode) WhiteToastStyle() else BlackToastStyle() + + // 设置错误样式 + Toaster.setStyle(CustomToastStyle(R.layout.toast_error)) + Toaster.show(resId) + + // 恢复默认样式 + Toaster.setStyle(currentStyle) + } + + /** + * 显示警告样式的 Toast + * 用法示例:ToastUtils.showWarning("请注意") + * + * @param text 文本内容 + * @author Joker.X + */ + fun showWarning(text: CharSequence) { + val params = ToastParams() + params.text = text + params.style = CustomToastStyle(R.layout.toast_warn) + Toaster.show(params) + } + + /** + * 显示警告样式的 Toast(资源ID) + * 用法示例:ToastUtils.showWarning(context, R.string.warning_message) + * + * @param context 上下文 + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showWarning(context: Context, @StringRes resId: Int) { + val text = context.getString(resId) + showWarning(text) + } + + /** + * 显示警告样式的 Toast(资源ID),不需要传递上下文 + * 注意:此方法仅适用于Toast已经初始化之后使用 + * 用法示例:ToastUtils.showWarning(R.string.warning_message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showWarning(@StringRes resId: Int) { + // 保存当前样式 + val currentStyle = if (isDarkMode) WhiteToastStyle() else BlackToastStyle() + + // 设置警告样式 + Toaster.setStyle(CustomToastStyle(R.layout.toast_warn)) + Toaster.show(resId) + + // 恢复默认样式 + Toaster.setStyle(currentStyle) + } + + /** + * 显示短时间Toast + * 用法示例:ToastUtils.showShort("这是短时间提示") + * + * @param text 文本内容 + * @author Joker.X + */ + fun showShort(text: CharSequence) { + Toaster.showShort(text) + } + + /** + * 显示短时间Toast(资源ID) + * 用法示例:ToastUtils.showShort(R.string.short_message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showShort(@StringRes resId: Int) { + Toaster.showShort(resId) + } + + /** + * 显示长时间Toast + * 用法示例:ToastUtils.showLong("这是长时间提示") + * + * @param text 文本内容 + * @author Joker.X + */ + fun showLong(text: CharSequence) { + Toaster.showLong(text) + } + + /** + * 显示长时间Toast(资源ID) + * 用法示例:ToastUtils.showLong(R.string.long_message) + * + * @param resId 字符串资源ID + * @author Joker.X + */ + fun showLong(@StringRes resId: Int) { + Toaster.showLong(resId) + } + + /** + * 延迟显示Toast + * 用法示例:ToastUtils.delayedShow("这是延迟显示的提示", 2000) + * + * @param text 文本内容 + * @param delayMillis 延迟时间,单位毫秒 + * @author Joker.X + */ + fun delayedShow(text: CharSequence, delayMillis: Long) { + Toaster.delayedShow(text, delayMillis) + } + + /** + * 延迟显示Toast(资源ID) + * 用法示例:ToastUtils.delayedShow(R.string.delayed_message, 2000) + * + * @param resId 字符串资源ID + * @param delayMillis 延迟时间,单位毫秒 + * @author Joker.X + */ + fun delayedShow(@StringRes resId: Int, delayMillis: Long) { + Toaster.delayedShow(resId, delayMillis) + } + + /** + * 取消Toast显示 + * 用法示例:ToastUtils.cancel() + * + * @author Joker.X + */ + fun cancel() { + Toaster.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthGraph.kt b/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthGraph.kt new file mode 100644 index 0000000..648aaf5 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthGraph.kt @@ -0,0 +1,21 @@ +package com.joker.kit.feature.auth.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController + +/** + * 认证模块导航图 + * + * @param navController 导航控制器 + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.authGraph( + navController: NavHostController, + sharedTransitionScope: SharedTransitionScope +) { + loginScreen(sharedTransitionScope) +} diff --git a/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthNavigation.kt b/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthNavigation.kt new file mode 100644 index 0000000..83e27ff --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/auth/navigation/AuthNavigation.kt @@ -0,0 +1,21 @@ +package com.joker.kit.feature.auth.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.joker.kit.feature.auth.view.LoginRoute +import com.joker.kit.navigation.routes.AuthRoutes + +/** + * 注册登录页路由 + * + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.loginScreen(sharedTransitionScope: SharedTransitionScope) { + composable { + LoginRoute() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/auth/view/LoginScreen.kt b/app/src/main/java/com/joker/kit/feature/auth/view/LoginScreen.kt new file mode 100644 index 0000000..843c641 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/auth/view/LoginScreen.kt @@ -0,0 +1,85 @@ +package com.joker.kit.feature.auth.view + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize +import com.joker.kit.feature.auth.viewmodel.LoginViewModel + +/** + * 登录页面路由入口 + * + * @param viewModel 登录页 ViewModel + * @author Joker.X + */ +@Composable +internal fun LoginRoute( + viewModel: LoginViewModel = hiltViewModel() +) { + LoginScreen() +} + +/** + * 登录页面 + * + * @param onBackClick 返回按钮回调 + * @author Joker.X + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LoginScreen( + onBackClick: () -> Unit = {}, +) { + Scaffold { innerPadding -> + LoginContentView( + modifier = Modifier.padding(innerPadding) + ) + } +} + +/** + * 登录页面内容 + * + * @param modifier 修饰符 + * @author Joker.X + */ +@Composable +private fun LoginContentView(modifier: Modifier = Modifier) { + AppText( + text = "登录页面", + size = TextSize.TITLE_MEDIUM, + modifier = modifier + ) +} + +/** + * 登录页浅色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreview() { + AppTheme { + LoginScreen() + } +} + +/** + * 登录页深色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreviewDark() { + AppTheme(darkTheme = true) { + LoginScreen() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/auth/viewmodel/LoginViewModel.kt b/app/src/main/java/com/joker/kit/feature/auth/viewmodel/LoginViewModel.kt new file mode 100644 index 0000000..5399f1e --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/auth/viewmodel/LoginViewModel.kt @@ -0,0 +1,23 @@ +package com.joker.kit.feature.auth.viewmodel + +import com.joker.kit.core.base.viewmodel.BaseViewModel +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * 登录页 ViewModel + * + * @param navigator 导航管理器 + * @param userState 全局用户状态 + * @author Joker.X + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + navigator: AppNavigator, + userState: UserState +) : BaseViewModel( + navigator = navigator, + userState = userState +) diff --git a/app/src/main/java/com/joker/kit/feature/main/navigation/MainGraph.kt b/app/src/main/java/com/joker/kit/feature/main/navigation/MainGraph.kt new file mode 100644 index 0000000..81a449f --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/main/navigation/MainGraph.kt @@ -0,0 +1,22 @@ +package com.joker.kit.feature.main.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController + +/** + * 主模块导航图 + * + * @param navController 导航控制器 + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.mainGraph( + navController: NavHostController, + sharedTransitionScope: SharedTransitionScope +) { + // 只调用页面级导航函数,不包含其他逻辑 + mainScreen(sharedTransitionScope) +} diff --git a/app/src/main/java/com/joker/kit/feature/main/navigation/MainNavigation.kt b/app/src/main/java/com/joker/kit/feature/main/navigation/MainNavigation.kt new file mode 100644 index 0000000..baaa0b4 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/main/navigation/MainNavigation.kt @@ -0,0 +1,21 @@ +package com.joker.kit.feature.main.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.joker.kit.feature.main.view.MainRoute +import com.joker.kit.navigation.routes.MainRoutes + +/** + * 注册主页面路由 + * + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.mainScreen(sharedTransitionScope: SharedTransitionScope) { + composable { + MainRoute() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/main/view/MainScreen.kt b/app/src/main/java/com/joker/kit/feature/main/view/MainScreen.kt new file mode 100644 index 0000000..6f93750 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/main/view/MainScreen.kt @@ -0,0 +1,85 @@ +package com.joker.kit.feature.main.view + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize +import com.joker.kit.feature.main.viewmodel.MainViewModel + +/** + * 主页面路由 + * + * @param viewModel 主页面 ViewModel + * @author Joker.X + */ +@Composable +internal fun MainRoute( + viewModel: MainViewModel = hiltViewModel() +) { + MainScreen() +} + +/** + * 主页面 + * + * @param onBackClick 返回按钮回调 + * @author Joker.X + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MainScreen( + onBackClick: () -> Unit = {}, +) { + Scaffold { innerPadding -> + MainContentView( + modifier = Modifier.padding(innerPadding) + ) + } +} + +/** + * 主页面内容视图 + * + * @param modifier 修饰符 + * @author Joker.X + */ +@Composable +private fun MainContentView(modifier: Modifier = Modifier) { + AppText( + text = "主页面", + size = TextSize.TITLE_MEDIUM, + modifier = modifier + ) +} + +/** + * 主页面界面浅色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +internal fun MainScreenPreview() { + AppTheme { + MainScreen() + } +} + +/** + * 主页面界面深色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +internal fun MainScreenPreviewDark() { + AppTheme(darkTheme = true) { + MainScreen() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/main/viewmodel/MainViewModel.kt b/app/src/main/java/com/joker/kit/feature/main/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..e0e377c --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/main/viewmodel/MainViewModel.kt @@ -0,0 +1,23 @@ +package com.joker.kit.feature.main.viewmodel + +import com.joker.kit.core.base.viewmodel.BaseViewModel +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * 主界面 ViewModel + * + * @param navigator 导航控制器 + * @param userState 全局用户状态 + * @author Joker.X + */ +@HiltViewModel +class MainViewModel @Inject constructor( + navigator: AppNavigator, + userState: UserState +) : BaseViewModel( + navigator = navigator, + userState = userState +) {} diff --git a/app/src/main/java/com/joker/kit/feature/user/navigation/UserGraph.kt b/app/src/main/java/com/joker/kit/feature/user/navigation/UserGraph.kt new file mode 100644 index 0000000..e539884 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/user/navigation/UserGraph.kt @@ -0,0 +1,21 @@ +package com.joker.kit.feature.user.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController + +/** + * 用户模块导航图 + * + * @param navController 导航控制器 + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.userGraph( + navController: NavHostController, + sharedTransitionScope: SharedTransitionScope +) { + userInfoScreen(sharedTransitionScope) +} diff --git a/app/src/main/java/com/joker/kit/feature/user/navigation/UserNavigation.kt b/app/src/main/java/com/joker/kit/feature/user/navigation/UserNavigation.kt new file mode 100644 index 0000000..d54031d --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/user/navigation/UserNavigation.kt @@ -0,0 +1,21 @@ +package com.joker.kit.feature.user.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.joker.kit.feature.user.view.UserInfoRoute +import com.joker.kit.navigation.routes.UserRoutes + +/** + * 注册用户信息页路由 + * + * @param sharedTransitionScope 共享转场作用域 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun NavGraphBuilder.userInfoScreen(sharedTransitionScope: SharedTransitionScope) { + composable { + UserInfoRoute() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/user/view/UserInfoScreen.kt b/app/src/main/java/com/joker/kit/feature/user/view/UserInfoScreen.kt new file mode 100644 index 0000000..9f1b425 --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/user/view/UserInfoScreen.kt @@ -0,0 +1,85 @@ +package com.joker.kit.feature.user.view + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.joker.kit.core.designsystem.theme.AppTheme +import com.joker.kit.core.ui.component.text.AppText +import com.joker.kit.core.ui.component.text.TextSize +import com.joker.kit.feature.user.viewmodel.UserInfoViewModel + +/** + * 用户信息页面路由入口 + * + * @param viewModel 用户信息页 ViewModel + * @author Joker.X + */ +@Composable +internal fun UserInfoRoute( + viewModel: UserInfoViewModel = hiltViewModel() +) { + UserInfoScreen() +} + +/** + * 用户信息页面 + * + * @param onBackClick 返回按钮回调 + * @author Joker.X + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UserInfoScreen( + onBackClick: () -> Unit = {}, +) { + Scaffold { innerPadding -> + UserInfoContentView( + modifier = Modifier.padding(innerPadding) + ) + } +} + +/** + * 用户信息内容 + * + * @param modifier 修饰符 + * @author Joker.X + */ +@Composable +private fun UserInfoContentView(modifier: Modifier = Modifier) { + AppText( + text = "用户信息页", + size = TextSize.TITLE_MEDIUM, + modifier = modifier + ) +} + +/** + * 用户信息页浅色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun UserInfoScreenPreview() { + AppTheme { + UserInfoScreen() + } +} + +/** + * 用户信息页深色主题预览 + * + * @author Joker.X + */ +@Preview(showBackground = true) +@Composable +private fun UserInfoScreenPreviewDark() { + AppTheme(darkTheme = true) { + UserInfoScreen() + } +} diff --git a/app/src/main/java/com/joker/kit/feature/user/viewmodel/UserInfoViewModel.kt b/app/src/main/java/com/joker/kit/feature/user/viewmodel/UserInfoViewModel.kt new file mode 100644 index 0000000..5273ddf --- /dev/null +++ b/app/src/main/java/com/joker/kit/feature/user/viewmodel/UserInfoViewModel.kt @@ -0,0 +1,23 @@ +package com.joker.kit.feature.user.viewmodel + +import com.joker.kit.core.base.viewmodel.BaseViewModel +import com.joker.kit.core.state.UserState +import com.joker.kit.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * 用户信息页 ViewModel + * + * @param navigator 导航管理器 + * @param userState 全局用户状态 + * @author Joker.X + */ +@HiltViewModel +class UserInfoViewModel @Inject constructor( + navigator: AppNavigator, + userState: UserState +) : BaseViewModel( + navigator = navigator, + userState = userState +) diff --git a/app/src/main/java/com/joker/kit/navigation/AppNavHost.kt b/app/src/main/java/com/joker/kit/navigation/AppNavHost.kt new file mode 100644 index 0000000..80398ac --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/AppNavHost.kt @@ -0,0 +1,80 @@ +package com.joker.kit.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import com.joker.kit.feature.auth.navigation.authGraph +import com.joker.kit.feature.main.navigation.mainGraph +import com.joker.kit.feature.user.navigation.userGraph +import com.joker.kit.navigation.routes.MainRoutes +import kotlinx.coroutines.flow.collectLatest + +/** + * 应用导航宿主 + * 配置整个应用的导航图和动画 + * + * @param navigator 导航管理器 + * @param modifier 修饰符 + * @author Joker.X + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AppNavHost( + navigator: AppNavigator, + modifier: Modifier = Modifier +) { + val navController = rememberNavController() + + // 监听导航事件 + LaunchedEffect(navController) { + navigator.navigationEvents.collectLatest { event -> + navController.handleNavigationEvent(event) + } + } + + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = MainRoutes.Main, + modifier = modifier, + // 页面进入动画 + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300) + ) + }, + // 页面退出动画 + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300) + ) + }, + // 返回时页面进入动画 + popEnterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300) + ) + }, + // 返回时页面退出动画 + popExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300) + ) + } + ) { + mainGraph(navController, this@SharedTransitionLayout) + authGraph(navController, this@SharedTransitionLayout) + userGraph(navController, this@SharedTransitionLayout) + } + } +} diff --git a/app/src/main/java/com/joker/kit/navigation/AppNavigator.kt b/app/src/main/java/com/joker/kit/navigation/AppNavigator.kt new file mode 100644 index 0000000..ac80b75 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/AppNavigator.kt @@ -0,0 +1,176 @@ +package com.joker.kit.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 导航管理器 + * + * 负责处理应用内所有的导航请求,采用事件驱动模式: + * 1. ViewModel 通过 AppNavigator 发送导航事件 + * 2. AppNavHost 监听事件并通过 NavController 执行导航 + * 3. 实现了 ViewModel 与 NavController 的解耦 + * + * 优势: + * - ViewModel 无需持有 NavController 引用 + * - 支持类型安全的路由导航 + * - 便于单元测试 + * - 统一管理导航逻辑 + * + * 使用示例: + * ```kotlin + * // 在 ViewModel 中 + * class MyViewModel @Inject constructor( + * private val navigator: AppNavigator + * ) : ViewModel() { + * fun navigateToDetail() { + * viewModelScope.launch { + * navigator.navigateTo(GoodsRoutes.Detail(id = 123)) + * } + * } + * } + * ``` + * + * @author Joker.X + */ +@Singleton +class AppNavigator @Inject constructor() { + private val _navigationEvents = MutableSharedFlow() + val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + + /** + * 导航到指定路由 + * + * @param route 类型安全的路由对象(必须是 @Serializable) + * @param navOptions 导航选项(可选) + * + * 使用示例: + * ```kotlin + * // 简单导航 + * navigateTo(MainRoutes.Home) + * + * // 带参数导航 + * navigateTo(GoodsRoutes.Detail(goodsId = 123)) + * + * // 带 NavOptions + * navigateTo(UserRoutes.Profile, navOptions) + * ``` + * + * @author Joker.X + */ + suspend fun navigateTo(route: Any, navOptions: NavOptions? = null) { + _navigationEvents.emit(NavigationEvent.NavigateTo(route, navOptions)) + } + + /** + * 返回上一页(简单返回,无结果) + * + * 使用示例: + * ```kotlin + * navigateBack() + * ``` + * + * @author Joker.X + */ + suspend fun navigateBack() { + _navigationEvents.emit(NavigationEvent.NavigateUp) + } + + /** + * 返回上一页并携带类型安全的结果(使用 NavigationResultKey) + * + * 这是 V3.2 版本的最终方案,实现了端到端的类型安全 + * + * @param key 类型安全的结果 Key + * @param result 要传递的结果对象 + * + * 使用示例: + * ```kotlin + * // 1. 定义返回结果数据类型 + * @Serializable + * data class Address(val id: Long, val fullAddress: String) + * + * // 2. 定义 ResultKey + * object SelectAddressResultKey : NavigationResultKey
+ * + * // 3. 在发送方使用 + * popBackStackWithResult(SelectAddressResultKey, address) + * ``` + * + * 在接收方使用: + * ```kotlin + * navController.collectResult(SelectAddressResultKey) { address -> + * // address 是强类型的 Address 对象,绝对类型安全 + * viewModel.updateAddress(address) + * } + * ``` + * + * @author Joker.X + */ + suspend fun popBackStackWithResult(key: NavigationResultKey, result: T) { + _navigationEvents.emit(NavigationEvent.PopBackStackWithResult(key, result)) + } + + /** + * 返回到指定路由 + * + * @param route 目标路由对象(必须是 @Serializable) + * @param inclusive 是否包含目标路由本身(true: 目标也会被弹出,false: 保留目标) + * + * 使用示例: + * ```kotlin + * // 返回到主页并保留主页 + * navigateBackTo(MainRoutes.Main, inclusive = false) + * + * // 返回到登录页并移除登录页(重新加载登录页) + * navigateBackTo(AuthRoutes.Login, inclusive = true) + * ``` + * + * @author Joker.X + */ + suspend fun navigateBackTo(route: Any, inclusive: Boolean = false) { + _navigationEvents.emit(NavigationEvent.NavigateBackTo(route, inclusive)) + } +} + +/** + * 处理导航事件的 NavController 扩展函数 + * + * 将导航事件转换为实际的导航操作 + * + * @param event 导航事件 + * @author Joker.X + */ +fun NavController.handleNavigationEvent(event: NavigationEvent) { + when (event) { + is NavigationEvent.NavigateTo -> { + // 使用类型安全的 navigate 方法 + this.navigate(event.route, event.navOptions) + } + + is NavigationEvent.NavigateUp -> { + // 简单返回,不携带结果 + this.popBackStack() + } + + is NavigationEvent.PopBackStackWithResult<*> -> { + // 使用 NavigationResultKey 的类型安全结果回传 + // 通过 key.serialize 将结果转换为 SavedStateHandle 支持的类型 + @Suppress("UNCHECKED_CAST") + val key = event.key as NavigationResultKey + val rawValue = key.serialize(event.result) + previousBackStackEntry?.savedStateHandle?.set(key.key, rawValue) + this.popBackStack() + } + + is NavigationEvent.NavigateBackTo -> { + // 弹出回退栈到指定路由 + this.popBackStack(event.route, event.inclusive) + } + } +} diff --git a/app/src/main/java/com/joker/kit/navigation/NavigationEvent.kt b/app/src/main/java/com/joker/kit/navigation/NavigationEvent.kt new file mode 100644 index 0000000..5e82e87 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/NavigationEvent.kt @@ -0,0 +1,54 @@ +package com.joker.kit.navigation + +import androidx.navigation.NavOptions + +/** + * 导航事件 + * 定义所有可能的导航操作类型 + * + * @author Joker.X + */ +sealed class NavigationEvent { + /** + * 导航到指定路由 + * + * @param route 类型安全的路由对象(必须是 @Serializable) + * @param navOptions 导航选项 + * @author Joker.X + */ + data class NavigateTo( + val route: Any, + val navOptions: NavOptions? = null + ) : NavigationEvent() + + /** + * 返回上一页(简单返回,无结果) + * + * @author Joker.X + */ + data object NavigateUp : NavigationEvent() + + /** + * 返回上一页并携带类型安全的结果(使用 NavigationResultKey) + * + * @param key 类型安全的结果 Key + * @param result 返回结果 + * @author Joker.X + */ + data class PopBackStackWithResult( + val key: NavigationResultKey, + val result: T + ) : NavigationEvent() + + /** + * 返回到指定路由 + * + * @param route 类型安全的路由对象(必须是 @Serializable) + * @param inclusive 是否包含目标路由本身 + * @author Joker.X + */ + data class NavigateBackTo( + val route: Any, + val inclusive: Boolean = false + ) : NavigationEvent() +} diff --git a/app/src/main/java/com/joker/kit/navigation/NavigationResultKey.kt b/app/src/main/java/com/joker/kit/navigation/NavigationResultKey.kt new file mode 100644 index 0000000..b2c8d61 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/NavigationResultKey.kt @@ -0,0 +1,51 @@ +package com.joker.kit.navigation + +/** + * 导航返回结果的类型安全 Key。 + * + * 将一个唯一的字符串 key 与返回结果的数据类型 [T] 在编译期绑定 + * + * 使用示例(定义 Key): + * ```kotlin + * object SelectAddressResultKey : NavigationResultKey
+ * ``` + * + * 使用示例(发送结果): + * ```kotlin + * popBackStackWithResult(SelectAddressResultKey, address) + * ``` + * + * 使用示例(接收结果): + * ```kotlin + * navController.collectResult(SelectAddressResultKey) { address -> + * viewModel.onAddressSelected(address) + * } + * ``` + * + * @param T 返回结果的数据类型 + * @author Joker.X + */ +interface NavigationResultKey { + /** + * 底层用于 SavedStateHandle 存储的字符串 key。 + * + * 默认实现使用 Key 对象自身的完全限定类名,保证全局唯一且无需手写字符串。 + */ + val key: String + get() = this::class.java.name + + /** + * 将结果对象序列化为 SavedStateHandle 可接受的底层类型。 + * + * 默认实现为透传(即直接存储原始对象),适用于 Boolean、Int、String 等基础类型。 + * 复杂类型可以在具体的 Key 中重写此方法,例如序列化为 JSON 字符串。 + */ + fun serialize(value: T): Any = value as Any + + /** + * 从 SavedStateHandle 中还原结果对象。 + * + * 默认实现为简单强转,复杂类型的 Key 需要重写以配合 [serialize]。 + */ + fun deserialize(raw: Any): T = raw as T +} diff --git a/app/src/main/java/com/joker/kit/navigation/RefreshResultKey.kt b/app/src/main/java/com/joker/kit/navigation/RefreshResultKey.kt new file mode 100644 index 0000000..9531608 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/RefreshResultKey.kt @@ -0,0 +1,23 @@ +package com.joker.kit.navigation + +/** + * 通用的页面刷新结果 Key。 + * + * 语义等价于以前的 "refresh" 布尔标记: + * - true 表示上一个页面需要刷新数据 + * - false 或 null 表示不刷新 + * + * 示例: + * ```kotlin + * // 子页面:操作成功后返回并通知上一个页面刷新 + * popBackStackWithResult(RefreshResultKey, true) + * + * // 上一个页面(ViewModel): + * fun observeRefresh(backStackEntry: NavBackStackEntry?) { + * observeRefreshState(backStackEntry, RefreshResultKey) + * } + * ``` + * + * @author Joker.X + */ +object RefreshResultKey : NavigationResultKey diff --git a/app/src/main/java/com/joker/kit/navigation/RouteInterceptor.kt b/app/src/main/java/com/joker/kit/navigation/RouteInterceptor.kt new file mode 100644 index 0000000..20bc9cd --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/RouteInterceptor.kt @@ -0,0 +1,74 @@ +package com.joker.kit.navigation + +import com.joker.kit.navigation.routes.AuthRoutes +import com.joker.kit.navigation.routes.UserRoutes +import kotlin.reflect.KClass + +/** + * 路由拦截器(类型安全版本) + * + * 负责管理需要登录的页面配置和路由拦截逻辑 + * 使用类型安全的方式处理路由拦截 + * + * @author Joker.X + */ +class RouteInterceptor { + + /** + * 需要登录的路由类型集合 + * 在这里配置所有需要登录才能访问的页面类型 + */ + private val loginRequiredRouteTypes: MutableSet> = mutableSetOf( + UserRoutes.Info::class + ) + + /** + * 检查指定路由对象是否需要登录 + * + * @param route 要检查的路由对象(类型安全) + * @return true表示需要登录,false表示不需要登录 + * @author Joker.X + */ + fun requiresLogin(route: Any): Boolean { + val routeClass = route::class + return loginRequiredRouteTypes.contains(routeClass) + } + + /** + * 获取登录页面路由对象 + * + * @return 登录页面的路由对象 + * @author Joker.X + */ + fun getLoginRoute(): Any = AuthRoutes.Login + + /** + * 添加需要登录的路由类型 + * + * @param routeClass 需要登录的路由类型 + * @author Joker.X + */ + fun addLoginRequiredRoute(routeClass: KClass<*>) { + loginRequiredRouteTypes.add(routeClass) + } + + /** + * 移除需要登录的路由类型 + * + * @param routeClass 不再需要登录的路由类型 + * @author Joker.X + */ + fun removeLoginRequiredRoute(routeClass: KClass<*>) { + loginRequiredRouteTypes.remove(routeClass) + } + + /** + * 获取所有需要登录的路由类型 + * + * @return 需要登录的路由类型集合 + * @author Joker.X + */ + fun getLoginRequiredRoutes(): Set> { + return loginRequiredRouteTypes.toSet() + } +} diff --git a/app/src/main/java/com/joker/kit/navigation/routes/AuthRoutes.kt b/app/src/main/java/com/joker/kit/navigation/routes/AuthRoutes.kt new file mode 100644 index 0000000..bbd4698 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/routes/AuthRoutes.kt @@ -0,0 +1,16 @@ +package com.joker.kit.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * 用户认证相关路由 + * 这里只提供一个示例登录路由 + */ +object AuthRoutes { + + /** + * 登录页 + */ + @Serializable + data object Login +} diff --git a/app/src/main/java/com/joker/kit/navigation/routes/MainRoutes.kt b/app/src/main/java/com/joker/kit/navigation/routes/MainRoutes.kt new file mode 100644 index 0000000..8b55143 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/routes/MainRoutes.kt @@ -0,0 +1,20 @@ +package com.joker.kit.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * 主模块路由 + * + * @author Joker.X + */ +object MainRoutes { + /** + * 主框架路由 + * + * 应用的主框架,包含底部导航栏 + * + * @author Joker.X + */ + @Serializable + data object Main +} diff --git a/app/src/main/java/com/joker/kit/navigation/routes/UserRoutes.kt b/app/src/main/java/com/joker/kit/navigation/routes/UserRoutes.kt new file mode 100644 index 0000000..fb552a8 --- /dev/null +++ b/app/src/main/java/com/joker/kit/navigation/routes/UserRoutes.kt @@ -0,0 +1,14 @@ +package com.joker.kit.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * 用户相关路由 + */ +object UserRoutes { + /** + * 用户信息页 + */ + @Serializable + data object Info +} diff --git a/app/src/main/res/drawable/ic_empty_data.xml b/app/src/main/res/drawable/ic_empty_data.xml new file mode 100644 index 0000000..b684010 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_data.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_empty_error.xml b/app/src/main/res/drawable/ic_empty_error.xml new file mode 100644 index 0000000..b0c731d --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_error.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_empty_network.xml b/app/src/main/res/drawable/ic_empty_network.xml new file mode 100644 index 0000000..0fe4c49 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_network.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml new file mode 100644 index 0000000..ae4a653 --- /dev/null +++ b/app/src/main/res/drawable/ic_error.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_success.xml b/app/src/main/res/drawable/ic_success.xml new file mode 100644 index 0000000..51b6fdf --- /dev/null +++ b/app/src/main/res/drawable/ic_success.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..113ec3b --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable/toast_error_bg.xml b/app/src/main/res/drawable/toast_error_bg.xml new file mode 100644 index 0000000..4189d2e --- /dev/null +++ b/app/src/main/res/drawable/toast_error_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toast_success_bg.xml b/app/src/main/res/drawable/toast_success_bg.xml new file mode 100644 index 0000000..1019d65 --- /dev/null +++ b/app/src/main/res/drawable/toast_success_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toast_warn_bg.xml b/app/src/main/res/drawable/toast_warn_bg.xml new file mode 100644 index 0000000..9e8ba8e --- /dev/null +++ b/app/src/main/res/drawable/toast_warn_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toast_error.xml b/app/src/main/res/layout/toast_error.xml new file mode 100644 index 0000000..7d02a9f --- /dev/null +++ b/app/src/main/res/layout/toast_error.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toast_success.xml b/app/src/main/res/layout/toast_success.xml new file mode 100644 index 0000000..63c7b56 --- /dev/null +++ b/app/src/main/res/layout/toast_success.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toast_warn.xml b/app/src/main/res/layout/toast_warn.xml new file mode 100644 index 0000000..76f3b54 --- /dev/null +++ b/app/src/main/res/layout/toast_warn.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..da6868a --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,27 @@ + + AndroidProject-Compose + + + Go Shopping + No Data + No content available + Load Failed + Something went wrong + Network Error + Please check your connection + Retry + + + Loading… + Pull to Load More + Loading… + Load Success + Load Failed + Load Failed, Click to Retry + No More Data + Pull to Refresh + Release to Refresh + Refreshing… + Refresh Complete + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d77654..d7186ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,26 @@ AndroidProject-Compose + + + 去逛逛 + 暂无数据 + 暂时没有相关内容 + 加载失败 + 页面加载出现了问题 + 网络连接失败 + 请检查网络连接后重试 + 重新加载 + + + 加载中… + 上拉加载更多 + 正在加载… + 加载成功 + 加载失败 + 加载失败,点击重试 + 没有更多了 + 下拉刷新 + 松开刷新 + 刷新中… + 刷新完成 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e9c950..0bcd0d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,18 @@ leakcanaryAndroid = "2.14" # 说明: SQLite 的抽象层,提供流畅的数据库访问 room = "2.8.4" +# Toast 相关版本 +# Toaster 吐司框架: https://github.com/getActivity/Toaster +toaster = "13.2" + +# 权限 +# XXPermissions 权限框架: https://github.com/getActivity/XXPermissions +xxpermissions = "26.5" + +# 数据存储 +# 腾讯 MMKV 高性能存储: https://github.com/Tencent/MMKV +mmkv = "2.2.4" + [libraries] # AndroidX 基础组件 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -142,6 +154,15 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } +# Toast +toaster = { group = "com.github.getActivity", name = "Toaster", version.ref = "toaster" } + +# 数据存储相关库 +mmkv = { group = "com.tencent", name = "mmkv", version.ref = "mmkv" } + +# 权限相关库 +xxpermissions = { module = "com.github.getActivity:XXPermissions", version.ref = "xxpermissions" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e83032..66b2448 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,8 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + // JitPack 远程仓库:https://jitpack.io + maven { url = uri("https://jitpack.io") } } }