feat: Implement core features with MVVM and Hilt
This commit introduces a comprehensive set of features, establishing the core functionality of the application using an MVVM architecture with Hilt for dependency injection.
**Key Changes:**
* **UI & Navigation:**
* Implemented navigation between the main feed, post details, and login screens using Jetpack Navigation Compose.
* Added `TalkScreen` for displaying a feed of posts and `PostDetailScreen` for viewing individual posts and their comments.
* Created a `LoginScreen` with input fields and authentication logic.
* Introduced `PostCardItem` and `CommentItem` Composables for a consistent and reusable UI.
* Added shared element transitions for a smoother user experience when navigating to post details.
* **Architecture & State Management:**
* Integrated Hilt for dependency injection across ViewModels and Repositories.
* Created ViewModels (`TalkViewModel`, `PostDetailViewModel`, `UserViewModel`, `CommentViewModel`, etc.) to manage UI state and business logic.
* Implemented Repository pattern for abstracting data sources from the backend API.
* Defined UI state data classes to ensure a predictable and observable state flow.
* **Data & Models:**
* Introduced data models for `PostCard` and `CommentCard` to aggregate and display complex data structures.
* Added `PostDetailRepository` to orchestrate fetching of post and comment data concurrently.
* Refined DTOs, such as `CreateCommentDto`, for API interactions.
* **Dependencies & Tooling:**
* Added Hilt, Navigation Compose, and Lifecycle ViewModel dependencies.
* Included the `pangu-jvm` library for improved text formatting with spacing between Chinese and English characters.
This commit is contained in:
parent
6d1c03ec85
commit
6a1bc7ad97
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-10-05T02:12:09.761378Z">
|
||||
<DropdownSelection timestamp="2025-10-05T14:23:49.203872Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
||||
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -2,6 +2,8 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.hilt.android)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
}
|
||||
|
||||
android {
|
||||
@ -57,6 +59,7 @@ dependencies {
|
||||
implementation(libs.androidx.material.icons.core)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Network dependencies
|
||||
implementation(libs.retrofit)
|
||||
@ -67,6 +70,19 @@ dependencies {
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// Hilt dependencies
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.ui.graphics)
|
||||
implementation(libs.androidx.animation.core)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// ViewModel and Lifecycle
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
|
||||
implementation(libs.pangu.jvm)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
37
app/release/output-metadata.json
Normal file
37
app/release/output-metadata.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.qingshuige.tangyuan",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
@ -1,23 +1,121 @@
|
||||
package com.qingshuige.tangyuan
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.qingshuige.tangyuan.navigation.Screen
|
||||
import com.qingshuige.tangyuan.ui.components.PageLevel
|
||||
import com.qingshuige.tangyuan.ui.components.TangyuanBottomAppBar
|
||||
import com.qingshuige.tangyuan.ui.components.TangyuanTopBar
|
||||
import com.qingshuige.tangyuan.ui.screens.PostDetailScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.TalkScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.LoginScreen
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun App() {
|
||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Talk) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
SharedTransitionLayout {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "main"
|
||||
) {
|
||||
composable("main") {
|
||||
MainFlow(
|
||||
onLoginClick = { navController.navigate(Screen.Login.route) },
|
||||
onPostClick = { postId ->
|
||||
navController.navigate(Screen.PostDetail.createRoute(postId))
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Login.route,
|
||||
enterTransition = {
|
||||
slideInVertically(
|
||||
initialOffsetY = { it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 800,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 600,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 600,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
LoginScreen(navController = navController)
|
||||
}
|
||||
|
||||
// 帖子详情页 - 只有共享元素动画,无页面切换动画
|
||||
composable(
|
||||
route = Screen.PostDetail.route,
|
||||
arguments = listOf(navArgument("postId") { type = NavType.IntType })
|
||||
) { backStackEntry ->
|
||||
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
|
||||
PostDetailScreen(
|
||||
postId = postId,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onAuthorClick = { authorId ->
|
||||
// TODO: 导航到用户详情页
|
||||
},
|
||||
onImageClick = { imageUuid ->
|
||||
// TODO: 导航到图片查看页
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun MainFlow(
|
||||
onLoginClick: () -> Unit,
|
||||
onPostClick: (Int) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
val mainNavController = rememberNavController()
|
||||
val navBackStackEntry by mainNavController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
val bottomBarScreens = listOf(Screen.Talk, Screen.Topic, Screen.Message, Screen.User)
|
||||
val currentScreen = bottomBarScreens.find { it.route == currentDestination?.route } ?: Screen.Talk
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@ -25,20 +123,41 @@ fun App() {
|
||||
currentScreen = currentScreen,
|
||||
avatarUrl = "https://dogeoss.grtsinry43.com/img/author.jpeg",
|
||||
pageLevel = PageLevel.PRIMARY,
|
||||
onAvatarClick = {/* 头像点击事件 */ },
|
||||
onAnnouncementClick = {/* 公告点击事件 */ },
|
||||
onPostClick = {/* 发表点击事件 */ }
|
||||
onAvatarClick = onLoginClick,
|
||||
onAnnouncementClick = { /* 公告点击事件 */ },
|
||||
onPostClick = { /* 发表点击事件 */ }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
TangyuanBottomAppBar(currentScreen) { selectedScreen ->
|
||||
currentScreen = selectedScreen
|
||||
mainNavController.navigate(selectedScreen.route) {
|
||||
popUpTo(mainNavController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Text(
|
||||
text = "Android",
|
||||
NavHost(
|
||||
navController = mainNavController,
|
||||
startDestination = Screen.Talk.route,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
) {
|
||||
composable(Screen.Talk.route) {
|
||||
TalkScreen(
|
||||
onPostClick = onPostClick,
|
||||
onAuthorClick = { authorId ->
|
||||
// TODO: 导航到用户详情页
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
composable(Screen.Topic.route) { Text(text = "侃一侃") }
|
||||
composable(Screen.Message.route) { Text(text = "消息") }
|
||||
composable(Screen.User.route) { Text(text = "我的") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,26 +4,14 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.qingshuige.tangyuan.navigation.Screen
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanTheme
|
||||
import com.qingshuige.tangyuan.utils.PrefsManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
PrefsManager.init(this)
|
||||
setContent {
|
||||
TangyuanTheme {
|
||||
App()
|
||||
|
||||
@ -4,31 +4,33 @@ import android.app.Application
|
||||
import com.qingshuige.tangyuan.network.NetworkClient
|
||||
import com.qingshuige.tangyuan.network.TokenManager
|
||||
import com.qingshuige.tangyuan.utils.PrefsManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
|
||||
@HiltAndroidApp
|
||||
class TangyuanApplication : Application() {
|
||||
|
||||
val bizDomain = "https://ty.qingshuige.ink/"
|
||||
companion object {
|
||||
lateinit var instance: TangyuanApplication
|
||||
private set
|
||||
}
|
||||
|
||||
|
||||
// 全局实例
|
||||
lateinit var tokenManager: TokenManager
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
lateinit var retrofit: Retrofit
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
|
||||
// 初始化 PrefsManager
|
||||
PrefsManager.init(this)
|
||||
|
||||
|
||||
// 初始化 TokenManager
|
||||
tokenManager = TokenManager(this)
|
||||
|
||||
|
||||
// 初始化网络客户端
|
||||
okHttpClient = NetworkClient.createOkHttpClient(this)
|
||||
retrofit = NetworkClient.createRetrofit(okHttpClient)
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.qingshuige.tangyuan.di
|
||||
|
||||
import android.content.Context
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.network.NetworkClient
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
|
||||
return NetworkClient.createOkHttpClient(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return NetworkClient.createRetrofit(okHttpClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiInterface(retrofit: Retrofit): ApiInterface {
|
||||
return retrofit.create(ApiInterface::class.java)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package com.qingshuige.tangyuan.di
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.repository.*
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object RepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserRepository(apiInterface: ApiInterface): UserRepository {
|
||||
return UserRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePostRepository(apiInterface: ApiInterface): PostRepository {
|
||||
return PostRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommentRepository(apiInterface: ApiInterface): CommentRepository {
|
||||
return CommentRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCategoryRepository(apiInterface: ApiInterface): CategoryRepository {
|
||||
return CategoryRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNotificationRepository(apiInterface: ApiInterface): NotificationRepository {
|
||||
return NotificationRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMediaRepository(apiInterface: ApiInterface): MediaRepository {
|
||||
return MediaRepository(apiInterface)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePostDetailRepository(
|
||||
apiInterface: ApiInterface,
|
||||
postRepository: PostRepository,
|
||||
userRepository: UserRepository
|
||||
): PostDetailRepository {
|
||||
return PostDetailRepository(apiInterface, postRepository, userRepository)
|
||||
}
|
||||
}
|
||||
123
app/src/main/java/com/qingshuige/tangyuan/model/CommentCard.kt
Normal file
123
app/src/main/java/com/qingshuige/tangyuan/model/CommentCard.kt
Normal file
@ -0,0 +1,123 @@
|
||||
package com.qingshuige.tangyuan.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* 评论卡片展示数据模型
|
||||
* 聚合了Comment、User信息的完整展示数据
|
||||
*/
|
||||
data class CommentCard(
|
||||
// 评论基本信息
|
||||
val commentId: Int,
|
||||
val parentCommentId: Int = 0,
|
||||
val postId: Int,
|
||||
val content: String,
|
||||
val imageGuid: String? = null,
|
||||
val commentDateTime: Date?,
|
||||
|
||||
// 作者信息
|
||||
val authorId: Int,
|
||||
val authorName: String,
|
||||
val authorAvatar: String,
|
||||
val authorBio: String = "",
|
||||
|
||||
// 互动信息
|
||||
val likeCount: Int = 0,
|
||||
val replyCount: Int = 0,
|
||||
|
||||
// 状态
|
||||
val isLiked: Boolean = false,
|
||||
val canDelete: Boolean = false,
|
||||
val canReply: Boolean = true,
|
||||
|
||||
// 子评论
|
||||
val replies: List<CommentCard> = emptyList(),
|
||||
val hasMoreReplies: Boolean = false
|
||||
) {
|
||||
/**
|
||||
* 是否为回复评论
|
||||
*/
|
||||
val isReply: Boolean
|
||||
get() = parentCommentId != 0
|
||||
|
||||
/**
|
||||
* 是否有图片
|
||||
*/
|
||||
val hasImage: Boolean
|
||||
get() = !imageGuid.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* 获取时间显示文本
|
||||
*/
|
||||
fun getTimeDisplayText(): String {
|
||||
commentDateTime ?: return "未知时间"
|
||||
|
||||
val now = Date()
|
||||
val diffMillis = now.time - commentDateTime.time
|
||||
val diffMinutes = diffMillis / (1000 * 60)
|
||||
val diffHours = diffMinutes / 60
|
||||
val diffDays = diffHours / 24
|
||||
|
||||
return when {
|
||||
diffMinutes < 1 -> "刚刚"
|
||||
diffMinutes < 60 -> "${diffMinutes}分钟前"
|
||||
diffHours < 24 -> "${diffHours}小时前"
|
||||
diffDays < 7 -> "${diffDays}天前"
|
||||
else -> {
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.time = commentDateTime
|
||||
String.format("%02d-%02d",
|
||||
calendar.get(java.util.Calendar.MONTH) + 1,
|
||||
calendar.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情页状态数据模型
|
||||
*/
|
||||
data class PostDetailState(
|
||||
val isLoading: Boolean = false,
|
||||
val postCard: PostCard? = null,
|
||||
val comments: List<CommentCard> = emptyList(),
|
||||
val error: String? = null,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isLoadingMoreComments: Boolean = false,
|
||||
val hasMoreComments: Boolean = true,
|
||||
val currentCommentPage: Int = 0,
|
||||
|
||||
// 评论输入状态
|
||||
val isCreatingComment: Boolean = false,
|
||||
val commentError: String? = null,
|
||||
val replyToComment: CommentCard? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Comment 到 CommentCard 的转换扩展
|
||||
*/
|
||||
fun Comment.toCommentCard(
|
||||
author: User,
|
||||
replies: List<CommentCard> = emptyList(),
|
||||
hasMoreReplies: Boolean = false,
|
||||
currentUserId: Int = 0
|
||||
): CommentCard {
|
||||
return CommentCard(
|
||||
commentId = this.commentId,
|
||||
parentCommentId = this.parentCommentId,
|
||||
postId = this.postId,
|
||||
content = this.content ?: "",
|
||||
imageGuid = this.imageGuid,
|
||||
commentDateTime = this.commentDateTime,
|
||||
|
||||
authorId = author.userId,
|
||||
authorName = author.nickName.ifBlank { "匿名用户" },
|
||||
authorAvatar = author.avatarGuid,
|
||||
authorBio = author.bio,
|
||||
|
||||
replies = replies,
|
||||
hasMoreReplies = hasMoreReplies,
|
||||
canDelete = author.userId == currentUserId
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ data class CreateCommentDto(
|
||||
val commentDateTime: Date? = null,
|
||||
val content: String? = null,
|
||||
val imageGuid: String? = null,
|
||||
val parentCommentId: Long = 0,
|
||||
val parentCommentId: Long? = 0,
|
||||
val postId: Long = 0,
|
||||
val userId: Long = 0
|
||||
)
|
||||
|
||||
138
app/src/main/java/com/qingshuige/tangyuan/model/PostCard.kt
Normal file
138
app/src/main/java/com/qingshuige/tangyuan/model/PostCard.kt
Normal file
@ -0,0 +1,138 @@
|
||||
package com.qingshuige.tangyuan.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* 文章卡片展示数据模型
|
||||
* 聚合了PostMetadata、User、Category、PostBody的关键信息
|
||||
*/
|
||||
data class PostCard(
|
||||
// 文章基本信息
|
||||
val postId: Int,
|
||||
val postDateTime: Date?,
|
||||
val isVisible: Boolean,
|
||||
|
||||
// 作者信息
|
||||
val authorId: Int,
|
||||
val authorName: String,
|
||||
val authorAvatar: String,
|
||||
val authorBio: String = "",
|
||||
|
||||
// 分类信息
|
||||
val categoryId: Int,
|
||||
val categoryName: String,
|
||||
val categoryDescription: String = "",
|
||||
|
||||
// 内容信息
|
||||
val textContent: String,
|
||||
val imageUUIDs: List<String> = emptyList(), // 图片UUID列表
|
||||
val hasImages: Boolean = false,
|
||||
|
||||
// 互动信息(预留)
|
||||
val likeCount: Int = 0,
|
||||
val commentCount: Int = 0,
|
||||
val shareCount: Int = 0,
|
||||
|
||||
// 状态
|
||||
val isLiked: Boolean = false,
|
||||
val isBookmarked: Boolean = false
|
||||
) {
|
||||
|
||||
/**
|
||||
* 获取第一张图片UUID
|
||||
*/
|
||||
val firstImageUUID: String?
|
||||
get() = imageUUIDs.firstOrNull()
|
||||
|
||||
/**
|
||||
* 获取内容预览(去除HTML标签,限制长度)
|
||||
*/
|
||||
val contentPreview: String
|
||||
get() = textContent
|
||||
.replace(Regex("<[^>]*>"), "") // 移除HTML标签
|
||||
.replace(Regex("\\s+"), " ") // 合并空白字符
|
||||
.trim()
|
||||
.take(150) // 限制150字符
|
||||
.let { if (textContent.length > 150) "$it..." else it }
|
||||
|
||||
/**
|
||||
* 判断是否有多张图片
|
||||
*/
|
||||
val hasMultipleImages: Boolean
|
||||
get() = imageUUIDs.size > 1
|
||||
|
||||
/**
|
||||
* 获取时间显示文本
|
||||
*/
|
||||
fun getTimeDisplayText(): String {
|
||||
postDateTime ?: return "未知时间"
|
||||
|
||||
val now = Date()
|
||||
val diffMillis = now.time - postDateTime.time
|
||||
val diffMinutes = diffMillis / (1000 * 60)
|
||||
val diffHours = diffMinutes / 60
|
||||
val diffDays = diffHours / 24
|
||||
|
||||
return when {
|
||||
diffMinutes < 1 -> "刚刚"
|
||||
diffMinutes < 60 -> "${diffMinutes}分钟前"
|
||||
diffHours < 24 -> "${diffHours}小时前"
|
||||
diffDays < 7 -> "${diffDays}天前"
|
||||
else -> {
|
||||
// 格式化为 MM-dd
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.time = postDateTime
|
||||
String.format("%02d-%02d",
|
||||
calendar.get(java.util.Calendar.MONTH) + 1,
|
||||
calendar.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐文章列表状态
|
||||
*/
|
||||
data class RecommendedPostsState(
|
||||
val isLoading: Boolean = false,
|
||||
val posts: List<PostCard> = emptyList(),
|
||||
val error: String? = null,
|
||||
val isRefreshing: Boolean = false,
|
||||
val hasMore: Boolean = true,
|
||||
val currentPage: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* PostMetadata 到 PostCard 的转换扩展
|
||||
*/
|
||||
fun PostMetadata.toPostCard(
|
||||
author: User,
|
||||
category: Category,
|
||||
body: PostBody
|
||||
): PostCard {
|
||||
val images = listOfNotNull(
|
||||
body.image1UUID,
|
||||
body.image2UUID,
|
||||
body.image3UUID
|
||||
).filter { it.isNotBlank() }
|
||||
|
||||
return PostCard(
|
||||
postId = this.postId,
|
||||
postDateTime = this.postDateTime,
|
||||
isVisible = this.isVisible,
|
||||
|
||||
authorId = author.userId,
|
||||
authorName = author.nickName.ifBlank { "匿名用户" },
|
||||
authorAvatar = author.avatarGuid,
|
||||
authorBio = author.bio,
|
||||
|
||||
categoryId = category.categoryId,
|
||||
categoryName = category.baseName ?: "未分类",
|
||||
categoryDescription = category.baseDescription ?: "",
|
||||
|
||||
textContent = body.textContent ?: "",
|
||||
imageUUIDs = images,
|
||||
hasImages = images.isNotEmpty()
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
package com.qingshuige.tangyuan.navigation
|
||||
|
||||
sealed class Screen(val route: String, val title: String) {
|
||||
object Login : Screen("login", "登录")
|
||||
object Talk : Screen("talk", "聊一聊")
|
||||
object Topic : Screen("topic", "侃一侃")
|
||||
object Message : Screen("message", "消息")
|
||||
object User : Screen("settings", "我的")
|
||||
object PostDetail : Screen("post_detail/{postId}", "帖子详情") {
|
||||
fun createRoute(postId: Int) = "post_detail/$postId"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.Category
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CategoryRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun getAllCategories(): Flow<List<Category>> = flow {
|
||||
val response = apiInterface.getAllCategories().awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get categories: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCategoryById(categoryId: Int): Flow<Category> = flow {
|
||||
val response = apiInterface.getCategory(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Category not found")
|
||||
} else {
|
||||
throw Exception("Failed to get category: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPostCountOfCategory(categoryId: Int): Flow<Int> = flow {
|
||||
val response = apiInterface.getPostCountOfCategory(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(0)
|
||||
} else {
|
||||
throw Exception("Failed to get post count: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getWeeklyNewPostCountOfCategory(categoryId: Int): Flow<Int> = flow {
|
||||
val response = apiInterface.getWeeklyNewPostCountOfCategory(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(0)
|
||||
} else {
|
||||
throw Exception("Failed to get weekly new post count: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun get24hNewPostCountByCategoryId(categoryId: Int): Flow<Int> = flow {
|
||||
val response = apiInterface.get24hNewPostCountByCategoryId(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(0)
|
||||
} else {
|
||||
throw Exception("Failed to get 24h new post count: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun get7dNewPostCountByCategoryId(categoryId: Int): Flow<Int> = flow {
|
||||
val response = apiInterface.get7dNewPostCountByCategoryId(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(0)
|
||||
} else {
|
||||
throw Exception("Failed to get 7d new post count: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.Comment
|
||||
import com.qingshuige.tangyuan.model.CreateCommentDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CommentRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun getCommentsForPost(postId: Int): Flow<List<Comment>> = flow {
|
||||
val response = apiInterface.getCommentForPost(postId).awaitResponse()
|
||||
|
||||
// 处理404情况(没有评论时API返回404)
|
||||
if (response.code() == 404) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get comments: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommentById(commentId: Int): Flow<Comment> = flow {
|
||||
val response = apiInterface.getComment(commentId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Comment not found")
|
||||
} else {
|
||||
throw Exception("Failed to get comment: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubComments(parentCommentId: Int): Flow<List<Comment>> = flow {
|
||||
val response = apiInterface.getSubComment(parentCommentId).awaitResponse()
|
||||
|
||||
// 处理404情况(没有子评论时API返回404)
|
||||
if (response.code() == 404) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get sub comments: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createComment(createCommentDto: CreateCommentDto): Flow<Map<String, String>> = flow {
|
||||
val response = apiInterface.postComment(createCommentDto).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Empty response body")
|
||||
} else {
|
||||
throw Exception("Failed to create comment: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteComment(commentId: Int): Flow<Boolean> = flow {
|
||||
val response = apiInterface.deleteComment(commentId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to delete comment: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun searchComments(keyword: String): Flow<List<Comment>> = flow {
|
||||
val response = apiInterface.searchCommentByKeyword(keyword).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Search failed: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MediaRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun uploadImage(file: MultipartBody.Part): Flow<Map<String, String>> = flow {
|
||||
val response = apiInterface.postImage(file).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Empty response body")
|
||||
} else {
|
||||
throw Exception("Failed to upload image: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.NewNotification
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun getAllNotifications(userId: Int): Flow<List<NewNotification>> = flow {
|
||||
val response = apiInterface.getAllNotificationsByUserId(userId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get notifications: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(notificationId: Int): Flow<Boolean> = flow {
|
||||
val response = apiInterface.markNewNotificationAsRead(notificationId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to mark notification as read: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,251 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.Comment
|
||||
import com.qingshuige.tangyuan.model.CommentCard
|
||||
import com.qingshuige.tangyuan.model.CreateCommentDto
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.model.toCommentCard
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PostDetailRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface,
|
||||
private val postRepository: PostRepository,
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* 单独获取帖子详情,不获取评论
|
||||
*/
|
||||
fun getPostCard(postId: Int): Flow<PostCard> = flow {
|
||||
postRepository.getPostCard(postId).collect { postCard ->
|
||||
emit(postCard)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子详情和评论的完整数据
|
||||
*/
|
||||
fun getPostDetailWithComments(
|
||||
postId: Int,
|
||||
currentUserId: Int = 0
|
||||
): Flow<Pair<PostCard, List<CommentCard>>> = flow {
|
||||
try {
|
||||
coroutineScope {
|
||||
// 并行获取帖子详情和评论列表
|
||||
val postDeferred = async {
|
||||
postRepository.getPostCard(postId)
|
||||
}
|
||||
|
||||
val commentsDeferred = async {
|
||||
getCommentCardsForPost(postId, currentUserId)
|
||||
}
|
||||
|
||||
// 等待两个数据都获取完成
|
||||
var postCard: PostCard? = null
|
||||
var commentCards: List<CommentCard> = emptyList()
|
||||
|
||||
postDeferred.await().collect { post ->
|
||||
postCard = post
|
||||
}
|
||||
|
||||
commentsDeferred.await().collect { comments ->
|
||||
commentCards = comments
|
||||
}
|
||||
|
||||
postCard?.let { post ->
|
||||
emit(Pair(post, commentCards))
|
||||
} ?: throw Exception("Failed to load post details")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to get post detail with comments: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取帖子的所有评论卡片数据(包含作者信息)
|
||||
*/
|
||||
fun getCommentCardsForPost(
|
||||
postId: Int,
|
||||
currentUserId: Int = 0
|
||||
): Flow<List<CommentCard>> = flow {
|
||||
try {
|
||||
// 1. 获取主评论列表
|
||||
val commentsResponse = apiInterface.getCommentForPost(postId).awaitResponse()
|
||||
|
||||
// 处理404情况(没有评论时API返回404)
|
||||
if (commentsResponse.code() == 404) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
if (!commentsResponse.isSuccessful) {
|
||||
throw Exception("Failed to get comments: ${commentsResponse.message()}")
|
||||
}
|
||||
|
||||
val comments = commentsResponse.body() ?: emptyList()
|
||||
if (comments.isEmpty()) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
// 2. 过滤出主评论(parentCommentId == 0)
|
||||
val mainComments = comments.filter { it.parentCommentId == 0 }
|
||||
|
||||
// 3. 并行获取所有主评论的作者信息和子评论
|
||||
val commentCards = coroutineScope {
|
||||
mainComments.map { comment ->
|
||||
async {
|
||||
try {
|
||||
// 并行获取作者信息和子评论
|
||||
val authorDeferred = async {
|
||||
val userResponse = apiInterface.getUser(comment.userId).awaitResponse()
|
||||
userResponse.body() ?: User(userId = comment.userId, nickName = "未知用户")
|
||||
}
|
||||
|
||||
val repliesDeferred = async {
|
||||
getReplyCardsForComment(comment.commentId, currentUserId)
|
||||
}
|
||||
|
||||
val author = authorDeferred.await()
|
||||
var replies: List<CommentCard> = emptyList()
|
||||
|
||||
repliesDeferred.await().collect { replyCards ->
|
||||
replies = replyCards
|
||||
}
|
||||
|
||||
comment.toCommentCard(
|
||||
author = author,
|
||||
replies = replies,
|
||||
hasMoreReplies = false, // TODO: 实现分页时处理
|
||||
currentUserId = currentUserId
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// 单个评论失败不影响整体
|
||||
comment.toCommentCard(
|
||||
author = User(userId = comment.userId, nickName = "加载失败"),
|
||||
currentUserId = currentUserId
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
emit(commentCards.sortedByDescending { it.commentDateTime })
|
||||
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to get comment cards: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某个评论的回复列表
|
||||
*/
|
||||
fun getReplyCardsForComment(
|
||||
parentCommentId: Int,
|
||||
currentUserId: Int = 0
|
||||
): Flow<List<CommentCard>> = flow {
|
||||
try {
|
||||
val repliesResponse = apiInterface.getSubComment(parentCommentId).awaitResponse()
|
||||
|
||||
// 处理404情况(没有回复时API返回404)
|
||||
if (repliesResponse.code() == 404) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
if (!repliesResponse.isSuccessful) {
|
||||
throw Exception("Failed to get replies: ${repliesResponse.message()}")
|
||||
}
|
||||
|
||||
val replies = repliesResponse.body() ?: emptyList()
|
||||
if (replies.isEmpty()) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
// 并行获取所有回复的作者信息
|
||||
val replyCards = coroutineScope {
|
||||
replies.map { reply ->
|
||||
async {
|
||||
try {
|
||||
val userResponse = apiInterface.getUser(reply.userId).awaitResponse()
|
||||
val author = userResponse.body() ?: User(userId = reply.userId, nickName = "未知用户")
|
||||
|
||||
reply.toCommentCard(
|
||||
author = author,
|
||||
currentUserId = currentUserId
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
reply.toCommentCard(
|
||||
author = User(userId = reply.userId, nickName = "加载失败"),
|
||||
currentUserId = currentUserId
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
emit(replyCards.sortedBy { it.commentDateTime })
|
||||
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to get reply cards: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新评论
|
||||
*/
|
||||
fun createComment(createCommentDto: CreateCommentDto): Flow<String> = flow {
|
||||
try {
|
||||
val response = apiInterface.postComment(createCommentDto).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.get("message")?.let { message ->
|
||||
emit(message)
|
||||
} ?: emit("评论发布成功")
|
||||
} else {
|
||||
throw Exception("Failed to create comment: ${response.message()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to create comment: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
fun deleteComment(commentId: Int): Flow<Boolean> = flow {
|
||||
try {
|
||||
val response = apiInterface.deleteComment(commentId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to delete comment: ${response.message()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to delete comment: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新帖子详情页数据
|
||||
*/
|
||||
fun refreshPostDetail(
|
||||
postId: Int,
|
||||
currentUserId: Int = 0
|
||||
): Flow<Pair<PostCard, List<CommentCard>>> = flow {
|
||||
// 直接调用getPostDetailWithComments,因为数据都是实时获取的
|
||||
getPostDetailWithComments(postId, currentUserId).collect { result ->
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.Category
|
||||
import com.qingshuige.tangyuan.model.CreatPostMetadataDto
|
||||
import com.qingshuige.tangyuan.model.PostBody
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.model.PostMetadata
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.model.toPostCard
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PostRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun getPostMetadata(postId: Int): Flow<PostMetadata> = flow {
|
||||
val response = apiInterface.getPostMetadata(postId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Post metadata not found")
|
||||
} else {
|
||||
throw Exception("Failed to get post metadata: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPostBody(postId: Int): Flow<PostBody> = flow {
|
||||
val response = apiInterface.getPostBody(postId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Post body not found")
|
||||
} else {
|
||||
throw Exception("Failed to get post body: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserPosts(userId: Int): Flow<List<PostMetadata>> = flow {
|
||||
val response = apiInterface.getMetadatasByUserID(userId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get user posts: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use getPhtPostMetadata instead")
|
||||
fun getRandomPosts(count: Int): Flow<List<PostMetadata>> = flow {
|
||||
val response = apiInterface.getRandomPostMetadata(count).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get random posts: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhtPostMetadata(sectionId: Int, exceptedIds: List<Int>): Flow<List<PostMetadata>> = flow {
|
||||
val response = apiInterface.phtPostMetadata(sectionId, exceptedIds).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get pht post metadata: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐文章卡片 - 聚合完整数据
|
||||
* 这是聊一聊页面的核心方法,会并行获取所有相关数据
|
||||
*/
|
||||
fun getRecommendedPostCards(
|
||||
sectionId: Int,
|
||||
exceptedIds: List<Int> = emptyList()
|
||||
): Flow<List<PostCard>> = flow {
|
||||
try {
|
||||
// 1. 获取推荐文章列表
|
||||
val metadataResponse = apiInterface.phtPostMetadata(sectionId, exceptedIds).awaitResponse()
|
||||
if (!metadataResponse.isSuccessful) {
|
||||
throw Exception("Failed to get recommended posts: ${metadataResponse.message()}")
|
||||
}
|
||||
|
||||
val postMetadataList = metadataResponse.body() ?: emptyList()
|
||||
if (postMetadataList.isEmpty()) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
|
||||
// 2. 并行获取所有相关数据
|
||||
val postCards = coroutineScope {
|
||||
postMetadataList.map { metadata ->
|
||||
async {
|
||||
try {
|
||||
// 并行获取用户、分类、文章内容
|
||||
val userDeferred = async {
|
||||
val userResponse = apiInterface.getUser(metadata.userId).awaitResponse()
|
||||
userResponse.body() ?: User(userId = metadata.userId, nickName = "未知用户")
|
||||
}
|
||||
|
||||
val categoryDeferred = async {
|
||||
val categoryResponse = apiInterface.getCategory(metadata.categoryId).awaitResponse()
|
||||
categoryResponse.body() ?: Category(categoryId = metadata.categoryId, baseName = "未分类")
|
||||
}
|
||||
|
||||
val bodyDeferred = async {
|
||||
val bodyResponse = apiInterface.getPostBody(metadata.postId).awaitResponse()
|
||||
bodyResponse.body() ?: PostBody(postId = metadata.postId, textContent = "内容加载失败")
|
||||
}
|
||||
|
||||
// 等待所有数据获取完成
|
||||
val user = userDeferred.await()
|
||||
val category = categoryDeferred.await()
|
||||
val body = bodyDeferred.await()
|
||||
|
||||
// 转换为PostCard
|
||||
metadata.toPostCard(user, category, body)
|
||||
} catch (e: Exception) {
|
||||
// 单个文章失败不影响整体,创建一个错误状态的卡片
|
||||
PostCard(
|
||||
postId = metadata.postId,
|
||||
postDateTime = metadata.postDateTime,
|
||||
isVisible = metadata.isVisible,
|
||||
authorId = metadata.userId,
|
||||
authorName = "加载失败",
|
||||
authorAvatar = "",
|
||||
categoryId = metadata.categoryId,
|
||||
categoryName = "未知分类",
|
||||
textContent = "内容加载失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
emit(postCards.filter { it.isVisible }) // 只返回可见的文章
|
||||
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to get recommended post cards: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文章的完整卡片数据
|
||||
*/
|
||||
fun getPostCard(postId: Int): Flow<PostCard> = flow {
|
||||
try {
|
||||
coroutineScope {
|
||||
// 并行获取所有数据
|
||||
val metadataDeferred = async {
|
||||
val response = apiInterface.getPostMetadata(postId).awaitResponse()
|
||||
response.body() ?: throw Exception("Post metadata not found")
|
||||
}
|
||||
|
||||
val bodyDeferred = async {
|
||||
val response = apiInterface.getPostBody(postId).awaitResponse()
|
||||
response.body() ?: throw Exception("Post body not found")
|
||||
}
|
||||
|
||||
val metadata = metadataDeferred.await()
|
||||
val body = bodyDeferred.await()
|
||||
|
||||
// 继续并行获取用户和分类信息
|
||||
val userDeferred = async {
|
||||
val response = apiInterface.getUser(metadata.userId).awaitResponse()
|
||||
response.body() ?: User(userId = metadata.userId, nickName = "未知用户")
|
||||
}
|
||||
|
||||
val categoryDeferred = async {
|
||||
val response = apiInterface.getCategory(metadata.categoryId).awaitResponse()
|
||||
response.body() ?: Category(categoryId = metadata.categoryId, baseName = "未分类")
|
||||
}
|
||||
|
||||
val user = userDeferred.await()
|
||||
val category = categoryDeferred.await()
|
||||
|
||||
emit(metadata.toPostCard(user, category, body))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to get post card: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPostsByCategory(categoryId: Int): Flow<List<PostMetadata>> = flow {
|
||||
val response = apiInterface.getAllMetadatasByCategoryId(categoryId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Failed to get posts by category: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createPostMetadata(metadata: CreatPostMetadataDto): Flow<Int> = flow {
|
||||
val response = apiInterface.postPostMetadata(metadata).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.get("postId")?.let { postId ->
|
||||
emit(postId)
|
||||
} ?: throw Exception("No post ID returned")
|
||||
} else {
|
||||
throw Exception("Failed to create post metadata: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createPostBody(body: PostBody): Flow<Boolean> = flow {
|
||||
val response = apiInterface.postPostBody(body).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to create post body: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePost(postId: Int): Flow<Boolean> = flow {
|
||||
val response = apiInterface.deletePost(postId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to delete post: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun searchPosts(keyword: String): Flow<List<PostMetadata>> = flow {
|
||||
val response = apiInterface.searchPostByKeyword(keyword).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Search failed: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getNoticePost(): Flow<PostMetadata> = flow {
|
||||
val response = apiInterface.getNotice().awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("No notice post found")
|
||||
} else {
|
||||
throw Exception("Failed to get notice post: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package com.qingshuige.tangyuan.repository
|
||||
|
||||
import com.qingshuige.tangyuan.api.ApiInterface
|
||||
import com.qingshuige.tangyuan.model.CreateUserDto
|
||||
import com.qingshuige.tangyuan.model.LoginDto
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserRepository @Inject constructor(
|
||||
private val apiInterface: ApiInterface
|
||||
) {
|
||||
|
||||
fun login(loginDto: LoginDto): Flow<Map<String, String>> = flow {
|
||||
val response = apiInterface.login(loginDto).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("Empty response body")
|
||||
} else {
|
||||
throw Exception("Login failed: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun register(createUserDto: CreateUserDto): Flow<Boolean> = flow {
|
||||
val response = apiInterface.postUser(createUserDto).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Registration failed: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserById(userId: Int): Flow<User> = flow {
|
||||
val response = apiInterface.getUser(userId).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: throw Exception("User not found")
|
||||
} else {
|
||||
throw Exception("Failed to get user: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(userId: Int, user: User): Flow<Boolean> = flow {
|
||||
val response = apiInterface.putUser(userId, user).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
emit(true)
|
||||
} else {
|
||||
throw Exception("Failed to update user: ${response.message()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun searchUsers(keyword: String): Flow<List<User>> = flow {
|
||||
val response = apiInterface.searchUserByKeyword(keyword).awaitResponse()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { emit(it) }
|
||||
?: emit(emptyList())
|
||||
} else {
|
||||
throw Exception("Search failed: ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,292 @@
|
||||
package com.qingshuige.tangyuan.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun AuroraBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
showRadialGradient: Boolean = true,
|
||||
darkMode: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "aurora")
|
||||
|
||||
val animationProgress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(20000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "mainProgress"
|
||||
)
|
||||
|
||||
val secondaryProgress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(25000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "secondaryProgress"
|
||||
)
|
||||
|
||||
// 颜色配置
|
||||
val backgroundColor = if (darkMode) Color(0xFF18181B) else Color(0xFFFAFAFA)
|
||||
val auroraColors = if (darkMode) {
|
||||
listOf(
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFFA5B4FC),
|
||||
Color(0xFF93C5FD),
|
||||
Color(0xFFDDD6FE),
|
||||
Color(0xFF60A5FA),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFFA5B4FC),
|
||||
Color(0xFF93C5FD),
|
||||
Color(0xFFDDD6FE),
|
||||
Color(0xFF60A5FA),
|
||||
)
|
||||
}
|
||||
|
||||
val maskColor = if (darkMode) Color(0xFF000000) else Color(0xFFFFFFFF)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 第一层主 Aurora(斜向条纹)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(40.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
// 计算倾斜角度(100度约等于弧度)
|
||||
val angleRad = Math.toRadians(100.0)
|
||||
val stripeWidth = width * 0.1f
|
||||
val totalWidth = width * 3f
|
||||
val offset = -totalWidth * animationProgress
|
||||
|
||||
// 计算倾斜后的绘制范围
|
||||
val extraHeight = width * sin(angleRad).toFloat()
|
||||
|
||||
for (i in 0..40) {
|
||||
val baseX = offset + i * stripeWidth
|
||||
|
||||
// 绘制倾斜的矩形条纹
|
||||
val path = Path().apply {
|
||||
moveTo(baseX, -extraHeight)
|
||||
lineTo(baseX + stripeWidth * 5, -extraHeight)
|
||||
lineTo(
|
||||
baseX + stripeWidth * 5 + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
lineTo(
|
||||
baseX + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
brush = Brush.linearGradient(
|
||||
colors = auroraColors.map { it.copy(alpha = 0.3f) },
|
||||
start = Offset(baseX, 0f),
|
||||
end = Offset(baseX + stripeWidth * 5, 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 第二层 Aurora(反向动画)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(50.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val angleRad = Math.toRadians(100.0)
|
||||
val stripeWidth = width * 0.12f
|
||||
val offset = width * 2.5f * secondaryProgress
|
||||
val extraHeight = width * sin(angleRad).toFloat()
|
||||
|
||||
for (i in 0..35) {
|
||||
val baseX = offset + i * stripeWidth
|
||||
|
||||
val path = Path().apply {
|
||||
moveTo(baseX, -extraHeight)
|
||||
lineTo(baseX + stripeWidth * 4, -extraHeight)
|
||||
lineTo(
|
||||
baseX + stripeWidth * 4 + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
lineTo(
|
||||
baseX + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
brush = Brush.linearGradient(
|
||||
colors = auroraColors.reversed().map { it.copy(alpha = 0.25f) },
|
||||
start = Offset(baseX, 0f),
|
||||
end = Offset(baseX + stripeWidth * 4, 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 白色/黑色条纹遮罩层
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(30.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val angleRad = Math.toRadians(100.0)
|
||||
val offset = -width * 2 * animationProgress * 0.6f
|
||||
val extraHeight = width * sin(angleRad).toFloat()
|
||||
|
||||
for (i in 0..50) {
|
||||
val baseX = offset + i * (width * 0.07f)
|
||||
val phase = (i + animationProgress * 10) % 3
|
||||
val alpha = when {
|
||||
phase < 1f -> 0.15f
|
||||
phase < 2f -> 0.08f
|
||||
else -> 0.12f
|
||||
}
|
||||
|
||||
val path = Path().apply {
|
||||
moveTo(baseX, -extraHeight)
|
||||
lineTo(baseX + width * 0.05f, -extraHeight)
|
||||
lineTo(
|
||||
baseX + width * 0.05f + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
lineTo(
|
||||
baseX + height * cos(angleRad).toFloat().coerceAtMost(0f),
|
||||
height + extraHeight
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = maskColor,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 柔和叠加层
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0.2f),
|
||||
Color.Transparent,
|
||||
backgroundColor.copy(alpha = 0.15f),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// 径向遮罩层
|
||||
if (showRadialGradient) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
backgroundColor.copy(alpha = 0.3f),
|
||||
backgroundColor.copy(alpha = 0.7f),
|
||||
),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * 0.9f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 内容层
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AuroraBackgroundExample() {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
|
||||
AuroraBackground(
|
||||
showRadialGradient = true,
|
||||
darkMode = darkMode
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = "Background\nlights are\ncool\nyou know.",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
color = if (darkMode) Color.White else Color(0xFF18181B),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
androidx.compose.material3.Text(
|
||||
text = "And this, is chemical burn.",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = if (darkMode) Color(0xFFA1A1AA) else Color(0xFF71717A)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
androidx.compose.material3.Button(
|
||||
onClick = { },
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF7C3AED)
|
||||
),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 12.dp)
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
"Debug now",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,601 @@
|
||||
package com.qingshuige.tangyuan.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.model.CommentCard
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
||||
|
||||
/**
|
||||
* 评论项组件
|
||||
*/
|
||||
@Composable
|
||||
fun CommentItem(
|
||||
comment: CommentCard,
|
||||
onReplyToComment: (CommentCard) -> Unit = {},
|
||||
onDeleteComment: (Int) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 评论主体
|
||||
CommentMainContent(
|
||||
comment = comment,
|
||||
onReplyToComment = onReplyToComment,
|
||||
onDeleteComment = onDeleteComment
|
||||
)
|
||||
|
||||
// 回复列表
|
||||
if (comment.replies.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CommentReplies(
|
||||
replies = comment.replies,
|
||||
onReplyToComment = onReplyToComment,
|
||||
onDeleteComment = onDeleteComment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论主体内容
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentMainContent(
|
||||
comment: CommentCard,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit
|
||||
) {
|
||||
Column {
|
||||
// 评论头部 - 用户信息
|
||||
CommentHeader(comment = comment)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 评论内容
|
||||
Text(
|
||||
text = comment.content.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// 评论图片
|
||||
if (comment.hasImage) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CommentImage(imageGuid = comment.imageGuid!!)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 评论操作栏
|
||||
CommentActions(
|
||||
comment = comment,
|
||||
onReplyToComment = onReplyToComment,
|
||||
onDeleteComment = onDeleteComment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论头部 - 用户信息
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentHeader(comment: CommentCard) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 用户头像
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${comment.authorAvatar}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "${comment.authorName}的头像",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 用户信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = comment.authorName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// 时间
|
||||
Text(
|
||||
text = comment.getTimeDisplayText(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论图片
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentImage(imageGuid: String) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/$imageGuid.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "评论图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论操作栏
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentActions(
|
||||
comment: CommentCard,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧操作
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 点赞按钮
|
||||
CommentActionButton(
|
||||
icon = if (comment.isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
count = comment.likeCount,
|
||||
isActive = comment.isLiked,
|
||||
activeColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { /* TODO: 实现点赞 */ }
|
||||
)
|
||||
|
||||
// 回复按钮
|
||||
CommentActionButton(
|
||||
icon = Icons.Outlined.Reply,
|
||||
text = "回复",
|
||||
isActive = false,
|
||||
onClick = { onReplyToComment(comment) }
|
||||
)
|
||||
}
|
||||
|
||||
// 右侧操作
|
||||
if (comment.canDelete) {
|
||||
IconButton(
|
||||
onClick = { onDeleteComment(comment.commentId) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = "删除评论",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论操作按钮
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
count: Int = 0,
|
||||
text: String = "",
|
||||
isActive: Boolean,
|
||||
activeColor: Color = MaterialTheme.colorScheme.primary,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isActive) activeColor else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMedium),
|
||||
label = "comment_action_color"
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { onClick() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
if (count > 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = color,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = color,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论回复列表
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentReplies(
|
||||
replies: List<CommentCard>,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
replies.forEach { reply ->
|
||||
ReplyItem(
|
||||
reply = reply,
|
||||
onReplyToComment = onReplyToComment,
|
||||
onDeleteComment = onDeleteComment
|
||||
)
|
||||
|
||||
if (reply != replies.last()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复项组件
|
||||
*/
|
||||
@Composable
|
||||
private fun ReplyItem(
|
||||
reply: CommentCard,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit
|
||||
) {
|
||||
Column {
|
||||
// 回复头部
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${reply.authorAvatar}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "${reply.authorName}的头像",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
Text(
|
||||
text = reply.authorName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = reply.getTimeDisplayText(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 回复内容
|
||||
Text(
|
||||
text = reply.content.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// 回复图片
|
||||
if (reply.hasImage) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
CommentImage(imageGuid = reply.imageGuid!!)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// 回复操作
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CommentActionButton(
|
||||
icon = if (reply.isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
count = reply.likeCount,
|
||||
isActive = reply.isLiked,
|
||||
activeColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { /* TODO: 实现点赞 */ }
|
||||
)
|
||||
|
||||
CommentActionButton(
|
||||
icon = Icons.Outlined.Reply,
|
||||
text = "回复",
|
||||
isActive = false,
|
||||
onClick = { onReplyToComment(reply) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (reply.canDelete) {
|
||||
IconButton(
|
||||
onClick = { onDeleteComment(reply.commentId) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = "删除回复",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论输入栏
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommentInputBar(
|
||||
isCreating: Boolean = false,
|
||||
replyToComment: CommentCard? = null,
|
||||
onSendComment: (String) -> Unit = {},
|
||||
onCancelReply: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var commentText by remember { mutableStateOf("") }
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column {
|
||||
// 回复提示栏
|
||||
AnimatedVisibility(
|
||||
visible = replyToComment != null,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut()
|
||||
) {
|
||||
replyToComment?.let { comment ->
|
||||
ReplyIndicator(
|
||||
comment = comment,
|
||||
onCancel = onCancelReply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 输入栏
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 输入框
|
||||
OutlinedTextField(
|
||||
value = commentText,
|
||||
onValueChange = { commentText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" else "说点什么...",
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
if (commentText.isNotBlank() && !isCreating) {
|
||||
onSendComment(commentText)
|
||||
commentText = ""
|
||||
}
|
||||
}
|
||||
),
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// 发送按钮
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (commentText.isNotBlank() && !isCreating) {
|
||||
onSendComment(commentText)
|
||||
commentText = ""
|
||||
}
|
||||
},
|
||||
enabled = commentText.isNotBlank() && !isCreating,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
if (isCreating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
contentDescription = "发送",
|
||||
tint = if (commentText.isNotBlank())
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复指示器
|
||||
*/
|
||||
@Composable
|
||||
private fun ReplyIndicator(
|
||||
comment: CommentCard,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Reply,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = "回复 ${comment.authorName}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "取消回复",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,501 @@
|
||||
package com.qingshuige.tangyuan.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
||||
|
||||
/**
|
||||
* Shimmer加载动画效果
|
||||
*/
|
||||
fun Modifier.shimmerEffect(): Modifier = composed {
|
||||
var size by remember { mutableStateOf(0f) }
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val startOffsetX by transition.animateFloat(
|
||||
initialValue = -2 * size,
|
||||
targetValue = 2 * size,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmer_offset"
|
||||
)
|
||||
|
||||
background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB0B0B0),
|
||||
Color(0xFFF0F0F0),
|
||||
Color(0xFFB0B0B0),
|
||||
),
|
||||
start = Offset(startOffsetX, 0f),
|
||||
end = Offset(startOffsetX + size, size)
|
||||
)
|
||||
).onGloballyPositioned {
|
||||
size = it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带有加载动画的AsyncImage组件
|
||||
*/
|
||||
@Composable
|
||||
fun ShimmerAsyncImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = if (onClick != null) {
|
||||
modifier.clickable { onClick() }
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
) {
|
||||
if (painter.state is AsyncImagePainter.State.Loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.shimmerEffect()
|
||||
)
|
||||
}
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = contentScale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章卡片组件
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun PostCardItem(
|
||||
postCard: PostCard,
|
||||
onPostClick: (Int) -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
onLikeClick: (Int) -> Unit = {},
|
||||
onCommentClick: (Int) -> Unit = {},
|
||||
onShareClick: (Int) -> Unit = {},
|
||||
onBookmarkClick: (Int) -> Unit = {},
|
||||
onMoreClick: (Int) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable { onPostClick(postCard.postId) }
|
||||
.let { mod ->
|
||||
if (sharedTransitionScope != null && animatedContentScope != null) {
|
||||
with(sharedTransitionScope) {
|
||||
mod.sharedElement(
|
||||
rememberSharedContentState(key = "post_card_${postCard.postId}"),
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = { _, _ ->
|
||||
tween(durationMillis = 500)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else mod
|
||||
},
|
||||
shape = TangyuanShapes.CulturalCard,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 2.dp,
|
||||
pressedElevation = 4.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 作者信息栏
|
||||
PostCardHeader(
|
||||
postCard = postCard,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onMoreClick = onMoreClick
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 文章内容
|
||||
PostCardContent(postCard = postCard)
|
||||
|
||||
// 图片展示
|
||||
if (postCard.hasImages) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PostCardImages(
|
||||
imageUUIDs = postCard.imageUUIDs,
|
||||
onImageClick = { /* TODO: 查看大图 */ }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 分类标签
|
||||
PostCardCategory(postCard = postCard)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 交互按钮栏
|
||||
PostCardActions(
|
||||
postCard = postCard,
|
||||
onLikeClick = onLikeClick,
|
||||
onCommentClick = onCommentClick,
|
||||
onShareClick = onShareClick,
|
||||
onBookmarkClick = onBookmarkClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章卡片头部 - 作者信息
|
||||
*/
|
||||
@Composable
|
||||
private fun PostCardHeader(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onMoreClick: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 作者头像
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${postCard.authorAvatar}.jpg",
|
||||
contentDescription = "${postCard.authorName}的头像",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onAuthorClick(postCard.authorId) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 作者信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = postCard.authorName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = postCard.getTimeDisplayText(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
// 更多操作按钮
|
||||
IconButton(
|
||||
onClick = { onMoreClick(postCard.postId) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "更多操作",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章内容
|
||||
*/
|
||||
@Composable
|
||||
private fun PostCardContent(postCard: PostCard) {
|
||||
if (postCard.contentPreview.isNotBlank()) {
|
||||
Text(
|
||||
text = postCard.contentPreview.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片展示
|
||||
*/
|
||||
@Composable
|
||||
private fun PostCardImages(
|
||||
imageUUIDs: List<String>,
|
||||
onImageClick: (String) -> Unit
|
||||
) {
|
||||
when (imageUUIDs.size) {
|
||||
1 -> {
|
||||
// 单张图片
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[0]}.jpg",
|
||||
contentDescription = "文章图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(imageUUIDs[0]) }
|
||||
)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
// 两张图片
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
imageUUIDs.forEach { uuid ->
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg",
|
||||
contentDescription = "文章图片",
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(uuid) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3 -> {
|
||||
// 三张图片
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
imageUUIDs.forEach { uuid ->
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg",
|
||||
contentDescription = "文章图片",
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(100.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(uuid) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// 多张图片的处理逻辑
|
||||
// 可以显示前几张,其余用 +N 的形式展示
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类标签
|
||||
*/
|
||||
@Composable
|
||||
private fun PostCardCategory(postCard: PostCard) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
|
||||
modifier = Modifier.wrapContentWidth()
|
||||
) {
|
||||
Text(
|
||||
text = postCard.categoryName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互按钮栏
|
||||
*/
|
||||
@Composable
|
||||
private fun PostCardActions(
|
||||
postCard: PostCard,
|
||||
onLikeClick: (Int) -> Unit,
|
||||
onCommentClick: (Int) -> Unit,
|
||||
onShareClick: (Int) -> Unit,
|
||||
onBookmarkClick: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧按钮组
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 点赞按钮
|
||||
PostActionButton(
|
||||
icon = if (postCard.isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
count = postCard.likeCount,
|
||||
isActive = postCard.isLiked,
|
||||
activeColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { onLikeClick(postCard.postId) }
|
||||
)
|
||||
|
||||
// 评论按钮
|
||||
PostActionButton(
|
||||
icon = Icons.Outlined.ChatBubbleOutline,
|
||||
count = postCard.commentCount,
|
||||
isActive = false,
|
||||
onClick = { onCommentClick(postCard.postId) }
|
||||
)
|
||||
|
||||
// 分享按钮
|
||||
PostActionButton(
|
||||
icon = Icons.Outlined.Share,
|
||||
count = postCard.shareCount,
|
||||
isActive = false,
|
||||
onClick = { onShareClick(postCard.postId) }
|
||||
)
|
||||
}
|
||||
|
||||
// 收藏按钮
|
||||
IconButton(
|
||||
onClick = { onBookmarkClick(postCard.postId) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
val bookmarkColor by animateColorAsState(
|
||||
targetValue = if (postCard.isBookmarked)
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMedium),
|
||||
label = "bookmark_color"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (postCard.isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = if (postCard.isBookmarked) "取消收藏" else "收藏",
|
||||
tint = bookmarkColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互按钮组件
|
||||
*/
|
||||
@Composable
|
||||
private fun PostActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
count: Int,
|
||||
isActive: Boolean,
|
||||
activeColor: Color = MaterialTheme.colorScheme.primary,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isActive) activeColor else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMedium),
|
||||
label = "action_color"
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { onClick() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
if (count > 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = when {
|
||||
count < 1000 -> count.toString()
|
||||
count < 10000 -> "${count / 1000}.${(count % 1000) / 100}k"
|
||||
else -> "${count / 10000}.${(count % 10000) / 1000}w"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = color,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,275 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
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.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.qingshuige.tangyuan.R
|
||||
import com.qingshuige.tangyuan.model.LoginDto
|
||||
import com.qingshuige.tangyuan.ui.components.AuroraBackground
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanTypography
|
||||
import com.qingshuige.tangyuan.viewmodel.UserViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
userViewModel: UserViewModel = hiltViewModel()
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val loginState by userViewModel.loginState.collectAsState()
|
||||
|
||||
LaunchedEffect(loginState) {
|
||||
if (loginState.isLoggedIn) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
AuroraBackground {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 品牌标题区域
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 48.dp)
|
||||
) {
|
||||
// 装饰性的圆形背景
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "糖原社区Logo",
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "糖原社区",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "假装这里有一句 slogan",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing = 2.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 登录卡片
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = TangyuanShapes.CulturalCard,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
// 用户名输入框
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = {
|
||||
Text(
|
||||
"手机号",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
isError = loginState.error != null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// 密码输入框
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = {
|
||||
Text(
|
||||
"密码",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.tertiary,
|
||||
cursorColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = loginState.error != null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// 错误提示
|
||||
loginState.error?.let { error ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 登录按钮
|
||||
Button(
|
||||
onClick = {
|
||||
userViewModel.login(
|
||||
LoginDto(
|
||||
phoneNumber = username,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
enabled = !loginState.isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary
|
||||
),
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
if (loginState.isLoading) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"登录中...",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"进入社区",
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = 16.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// 底部装饰文案
|
||||
Text(
|
||||
text = "欢迎来到糖原社区,看看你的嵴",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,637 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.model.CommentCard
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.ui.components.CommentItem
|
||||
import com.qingshuige.tangyuan.ui.components.CommentInputBar
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
||||
import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel
|
||||
|
||||
/**
|
||||
* 帖子详情页
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun PostDetailScreen(
|
||||
postId: Int,
|
||||
onBackClick: () -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
viewModel: PostDetailViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// 加载帖子详情
|
||||
LaunchedEffect(postId) {
|
||||
viewModel.loadPostDetail(postId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
PostDetailTopBar(
|
||||
onBackClick = onBackClick,
|
||||
isLoading = state.isLoading
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (state.postCard != null) {
|
||||
CommentInputBar(
|
||||
isCreating = state.isCreatingComment,
|
||||
replyToComment = state.replyToComment,
|
||||
onSendComment = { content ->
|
||||
val parentId = state.replyToComment?.commentId ?: 0
|
||||
viewModel.createComment(content, parentId)
|
||||
},
|
||||
onCancelReply = {
|
||||
viewModel.setReplyToComment(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = viewModel::refreshPostDetail,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 始终显示内容,确保共享元素有目标
|
||||
PostDetailContent(
|
||||
postCard = state.postCard,
|
||||
comments = state.comments,
|
||||
listState = listState,
|
||||
isLoadingComments = state.isLoading && state.postCard != null,
|
||||
isError = state.error != null && state.postCard == null,
|
||||
errorMessage = state.error,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onImageClick = onImageClick,
|
||||
onReplyToComment = viewModel::setReplyToComment,
|
||||
onDeleteComment = viewModel::deleteComment,
|
||||
onRetry = {
|
||||
viewModel.clearError()
|
||||
viewModel.loadPostDetail(postId)
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
state.commentError?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
// 显示错误提示,可以用SnackBar
|
||||
viewModel.clearCommentError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部导航栏
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PostDetailTopBar(
|
||||
onBackClick: () -> Unit,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "详情",
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(end = 16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情内容
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun PostDetailContent(
|
||||
postCard: PostCard?,
|
||||
comments: List<CommentCard>,
|
||||
listState: LazyListState,
|
||||
isLoadingComments: Boolean,
|
||||
isError: Boolean,
|
||||
errorMessage: String?,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onImageClick: (String) -> Unit,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
// 如果是错误状态,显示错误页面
|
||||
if (isError) {
|
||||
ErrorContent(
|
||||
message = errorMessage ?: "未知错误",
|
||||
onRetry = onRetry
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 80.dp)
|
||||
) {
|
||||
// 帖子详情卡片 - 如果有数据就显示
|
||||
postCard?.let { card ->
|
||||
item {
|
||||
PostDetailCard(
|
||||
postCard = card,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onImageClick = onImageClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 评论区标题
|
||||
item {
|
||||
CommentSectionHeader(commentCount = comments.size, isLoading = isLoadingComments)
|
||||
}
|
||||
|
||||
// 评论加载状态
|
||||
if (isLoadingComments && comments.isEmpty()) {
|
||||
item {
|
||||
CommentsLoadingContent()
|
||||
}
|
||||
} else {
|
||||
// 评论列表
|
||||
items(
|
||||
items = comments,
|
||||
key = { it.commentId }
|
||||
) { comment ->
|
||||
CommentItem(
|
||||
comment = comment,
|
||||
onReplyToComment = onReplyToComment,
|
||||
onDeleteComment = onDeleteComment
|
||||
)
|
||||
}
|
||||
|
||||
// 空评论提示
|
||||
if (comments.isEmpty() && !isLoadingComments) {
|
||||
item {
|
||||
EmptyCommentsContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情卡片
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun PostDetailCard(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onImageClick: (String) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.let { mod ->
|
||||
if (sharedTransitionScope != null && animatedContentScope != null) {
|
||||
with(sharedTransitionScope) {
|
||||
mod.sharedElement(
|
||||
rememberSharedContentState(key = "post_card_${postCard.postId}"),
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = { _, _ ->
|
||||
tween(durationMillis = 500)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else mod
|
||||
},
|
||||
shape = TangyuanShapes.CulturalCard,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
) {
|
||||
// 作者信息
|
||||
PostDetailHeader(
|
||||
postCard = postCard,
|
||||
onAuthorClick = onAuthorClick
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 文章内容
|
||||
Text(
|
||||
text = postCard.textContent.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// 图片展示
|
||||
if (postCard.hasImages) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PostDetailImages(
|
||||
imageUUIDs = postCard.imageUUIDs,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 分类和时间
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||
) {
|
||||
Text(
|
||||
text = postCard.categoryName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = postCard.getTimeDisplayText(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情头部
|
||||
*/
|
||||
@Composable
|
||||
private fun PostDetailHeader(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { onAuthorClick(postCard.authorId) }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${postCard.authorAvatar}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "${postCard.authorName}的头像",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = postCard.authorName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (postCard.authorBio.isNotBlank()) {
|
||||
Text(
|
||||
text = postCard.authorBio.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情图片
|
||||
*/
|
||||
@Composable
|
||||
private fun PostDetailImages(
|
||||
imageUUIDs: List<String>,
|
||||
onImageClick: (String) -> Unit
|
||||
) {
|
||||
when (imageUUIDs.size) {
|
||||
1 -> {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[0]}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "文章图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onImageClick(imageUUIDs[0]) },
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp)
|
||||
) {
|
||||
items(imageUUIDs) { uuid ->
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/$uuid.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "文章图片",
|
||||
modifier = Modifier
|
||||
.width(200.dp)
|
||||
.height(150.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onImageClick(uuid) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论区标题
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentSectionHeader(commentCount: Int, isLoading: Boolean = false) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "评论 ($commentCount)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论加载内容
|
||||
*/
|
||||
@Composable
|
||||
private fun CommentsLoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "正在加载评论...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空评论内容
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyCommentsContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "还没有评论",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "来发表第一个评论吧",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载内容
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "正在加载详情...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误内容
|
||||
*/
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "加载失败",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "重试",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,398 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.ui.components.PostCardItem
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.viewmodel.TalkViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 聊一聊页面
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun TalkScreen(
|
||||
onPostClick: (Int) -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
viewModel: TalkViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// 监听滚动,实现上拉加载更多
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.collect { layoutInfo ->
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
|
||||
// 当滚动到倒数第3个item时开始加载更多
|
||||
if (totalItems > 0 && lastVisibleItemIndex >= totalItems - 3 &&
|
||||
!uiState.isLoading && uiState.hasMore && uiState.error == null) {
|
||||
viewModel.loadMorePosts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
onRefresh = viewModel::refreshPosts,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
when {
|
||||
// 加载状态
|
||||
uiState.isLoading && uiState.posts.isEmpty() -> {
|
||||
LoadingContent()
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
uiState.error != null && uiState.posts.isEmpty() -> {
|
||||
val errorMessage = uiState.error
|
||||
ErrorContent(
|
||||
message = errorMessage!!,
|
||||
onRetry = {
|
||||
viewModel.clearError()
|
||||
viewModel.loadRecommendedPosts()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 空状态
|
||||
uiState.posts.isEmpty() && !uiState.isLoading -> {
|
||||
EmptyContent(onRefresh = viewModel::refreshPosts)
|
||||
}
|
||||
|
||||
// 正常内容
|
||||
else -> {
|
||||
PostList(
|
||||
posts = uiState.posts,
|
||||
listState = listState,
|
||||
isLoadingMore = uiState.isLoading,
|
||||
hasMore = uiState.hasMore,
|
||||
error = uiState.error,
|
||||
onPostClick = onPostClick,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onLikeClick = viewModel::toggleLike,
|
||||
onCommentClick = { postId -> onPostClick(postId) }, // 点击评论跳转到详情页
|
||||
onShareClick = viewModel::sharePost,
|
||||
onBookmarkClick = viewModel::toggleBookmark,
|
||||
onMoreClick = { postId ->
|
||||
// TODO: 显示更多操作菜单
|
||||
},
|
||||
onErrorDismiss = viewModel::clearError,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章列表
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun PostList(
|
||||
posts: List<PostCard>,
|
||||
listState: LazyListState,
|
||||
isLoadingMore: Boolean,
|
||||
hasMore: Boolean,
|
||||
error: String?,
|
||||
onPostClick: (Int) -> Unit,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onLikeClick: (Int) -> Unit,
|
||||
onCommentClick: (Int) -> Unit,
|
||||
onShareClick: (Int) -> Unit,
|
||||
onBookmarkClick: (Int) -> Unit,
|
||||
onMoreClick: (Int) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 80.dp) // 为底部导航栏留空间
|
||||
) {
|
||||
items(
|
||||
items = posts,
|
||||
key = { it.postId }
|
||||
) { postCard ->
|
||||
PostCardItem(
|
||||
postCard = postCard,
|
||||
onPostClick = onPostClick,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onLikeClick = onLikeClick,
|
||||
onCommentClick = onCommentClick,
|
||||
onShareClick = onShareClick,
|
||||
onBookmarkClick = onBookmarkClick,
|
||||
onMoreClick = onMoreClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
|
||||
// 加载更多指示器
|
||||
if (isLoadingMore && hasMore) {
|
||||
item {
|
||||
LoadingMoreIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// 没有更多数据提示
|
||||
if (!hasMore && posts.isNotEmpty()) {
|
||||
item {
|
||||
NoMoreDataIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
error?.let {
|
||||
item {
|
||||
ErrorSnackbar(
|
||||
message = it,
|
||||
onDismiss = onErrorDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载中内容
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = "正在加载精彩内容...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误内容
|
||||
*/
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "加载失败",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "重试",
|
||||
fontFamily = LiteraryFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空内容
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyContent(onRefresh: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "暂无内容",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "下拉刷新或稍后再试",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
TextButton(onClick = onRefresh) {
|
||||
Text(
|
||||
text = "刷新",
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多指示器
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingMoreIndicator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Text(
|
||||
text = "加载更多...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有更多数据指示器
|
||||
*/
|
||||
@Composable
|
||||
private fun NoMoreDataIndicator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "已经到底了,没有更多内容",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误提示
|
||||
*/
|
||||
@Composable
|
||||
private fun ErrorSnackbar(
|
||||
message: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "知道了",
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,38 +18,23 @@ import com.qingshuige.tangyuan.R
|
||||
|
||||
// 中英文混合字体(通用)
|
||||
val TangyuanGeneralFontFamily = FontFamily(
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Light), // 300
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Normal), // 400
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Medium), // 500
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.SemiBold), // 600
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Bold), // 700
|
||||
Font(R.font.quicksand_variablefont_wght),
|
||||
Font(R.font.notosanssc_variablefont_wght)
|
||||
)
|
||||
|
||||
// 英文字体(Quicksand - 现代圆润)
|
||||
val EnglishFontFamily = FontFamily(
|
||||
Font(R.font.quicksand_variablefont_wght, FontWeight.Light),
|
||||
Font(R.font.quicksand_variablefont_wght, FontWeight.Normal),
|
||||
Font(R.font.quicksand_variablefont_wght, FontWeight.Medium),
|
||||
Font(R.font.quicksand_variablefont_wght, FontWeight.SemiBold),
|
||||
Font(R.font.quicksand_variablefont_wght, FontWeight.Bold)
|
||||
Font(R.font.quicksand_variablefont_wght)
|
||||
)
|
||||
|
||||
// 中文字体(思源黑体 - 清晰易读)
|
||||
val ChineseFontFamily = FontFamily(
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Light),
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Normal),
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Medium),
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.SemiBold),
|
||||
Font(R.font.notosanssc_variablefont_wght, FontWeight.Bold)
|
||||
Font(R.font.notosanssc_variablefont_wght)
|
||||
)
|
||||
|
||||
// 文学专用字体(思源宋体 - 传统韵味)
|
||||
val LiteraryFontFamily = FontFamily(
|
||||
Font(R.font.notoserifsc_variablefont_wght, FontWeight.Light),
|
||||
Font(R.font.notoserifsc_variablefont_wght, FontWeight.Normal),
|
||||
Font(R.font.notoserifsc_variablefont_wght, FontWeight.Medium),
|
||||
Font(R.font.notoserifsc_variablefont_wght, FontWeight.SemiBold),
|
||||
Font(R.font.notoserifsc_variablefont_wght, FontWeight.Bold)
|
||||
Font(R.font.notoserifsc_variablefont_wght)
|
||||
)
|
||||
|
||||
// ====================================
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.qingshuige.tangyuan.utils
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class UiState<T>(
|
||||
val isLoading: Boolean = false,
|
||||
val data: T? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
fun <T> ViewModel.collectFlow(
|
||||
flow: Flow<T>,
|
||||
uiState: MutableStateFlow<UiState<T>>,
|
||||
onLoading: () -> Unit = { uiState.value = uiState.value.copy(isLoading = true, error = null) },
|
||||
onSuccess: (T) -> Unit = { data -> uiState.value = UiState(data = data) },
|
||||
onError: (String) -> Unit = { error -> uiState.value = UiState(error = error) }
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
onLoading()
|
||||
flow.catch { e ->
|
||||
onError(e.message ?: "Unknown error occurred")
|
||||
}.collect { data ->
|
||||
onSuccess(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> ViewModel.collectFlowList(
|
||||
flow: Flow<List<T>>,
|
||||
uiState: MutableStateFlow<UiState<List<T>>>,
|
||||
onLoading: () -> Unit = { uiState.value = uiState.value.copy(isLoading = true, error = null) },
|
||||
onSuccess: (List<T>) -> Unit = { data -> uiState.value = UiState(data = data) },
|
||||
onError: (String) -> Unit = { error -> uiState.value = UiState(error = error) }
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
onLoading()
|
||||
flow.catch { e ->
|
||||
onError(e.message ?: "Unknown error occurred")
|
||||
}.collect { data ->
|
||||
onSuccess(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/com/qingshuige/tangyuan/utils/Pangu.kt
Normal file
10
app/src/main/java/com/qingshuige/tangyuan/utils/Pangu.kt
Normal file
@ -0,0 +1,10 @@
|
||||
package com.qingshuige.tangyuan.utils
|
||||
|
||||
import dev.darkokoa.pangu.Pangu
|
||||
|
||||
/**
|
||||
* 对字符串应用盘古之白格式化
|
||||
*/
|
||||
fun String.withPanguSpacing(): String {
|
||||
return Pangu.spacingText(this)
|
||||
}
|
||||
39
app/src/main/java/com/qingshuige/tangyuan/utils/Resource.kt
Normal file
39
app/src/main/java/com/qingshuige/tangyuan/utils/Resource.kt
Normal file
@ -0,0 +1,39 @@
|
||||
package com.qingshuige.tangyuan.utils
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
sealed class Resource<T> {
|
||||
data class Success<T>(val data: T) : Resource<T>()
|
||||
data class Error<T>(val message: String) : Resource<T>()
|
||||
data class Loading<T>(val isLoading: Boolean = true) : Resource<T>()
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.asResource(): Flow<Resource<T>> {
|
||||
return this
|
||||
.map<T, Resource<T>> { Resource.Success(it) }
|
||||
.catch { emit(Resource.Error(it.message ?: "Unknown error")) }
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.handleResource(
|
||||
onLoading: () -> Unit = {},
|
||||
onSuccess: (T) -> Unit = {},
|
||||
onError: (String) -> Unit = {}
|
||||
): Flow<Resource<T>> {
|
||||
return this.asResource()
|
||||
.map { resource ->
|
||||
when (resource) {
|
||||
is Resource.Loading -> {
|
||||
onLoading()
|
||||
}
|
||||
is Resource.Success -> {
|
||||
onSuccess(resource.data)
|
||||
}
|
||||
is Resource.Error -> {
|
||||
onError(resource.message)
|
||||
}
|
||||
}
|
||||
resource
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.Category
|
||||
import com.qingshuige.tangyuan.repository.CategoryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CategoryStats(
|
||||
val postCount: Int = 0,
|
||||
val weeklyNewCount: Int = 0,
|
||||
val dailyNewCount: Int = 0,
|
||||
val sevenDayNewCount: Int = 0
|
||||
)
|
||||
|
||||
data class CategoryUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val categories: List<Category> = emptyList(),
|
||||
val currentCategory: Category? = null,
|
||||
val categoryStats: CategoryStats? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CategoryViewModel @Inject constructor(
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _categoryUiState = MutableStateFlow(CategoryUiState())
|
||||
val categoryUiState: StateFlow<CategoryUiState> = _categoryUiState.asStateFlow()
|
||||
|
||||
private val _categoryStatsMap = MutableStateFlow<Map<Int, CategoryStats>>(emptyMap())
|
||||
val categoryStatsMap: StateFlow<Map<Int, CategoryStats>> = _categoryStatsMap.asStateFlow()
|
||||
|
||||
fun getAllCategories() {
|
||||
viewModelScope.launch {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(isLoading = true, error = null)
|
||||
categoryRepository.getAllCategories()
|
||||
.catch { e ->
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { categories ->
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
isLoading = false,
|
||||
categories = categories
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCategoryById(categoryId: Int) {
|
||||
viewModelScope.launch {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(isLoading = true, error = null)
|
||||
categoryRepository.getCategoryById(categoryId)
|
||||
.catch { e ->
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { category ->
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
isLoading = false,
|
||||
currentCategory = category
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCategoryStats(categoryId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
var postCount = 0
|
||||
var weeklyNewCount = 0
|
||||
var dailyNewCount = 0
|
||||
var sevenDayNewCount = 0
|
||||
|
||||
categoryRepository.getPostCountOfCategory(categoryId)
|
||||
.catch { throw it }
|
||||
.collect { count -> postCount = count }
|
||||
|
||||
categoryRepository.getWeeklyNewPostCountOfCategory(categoryId)
|
||||
.catch { throw it }
|
||||
.collect { count -> weeklyNewCount = count }
|
||||
|
||||
categoryRepository.get24hNewPostCountByCategoryId(categoryId)
|
||||
.catch { throw it }
|
||||
.collect { count -> dailyNewCount = count }
|
||||
|
||||
categoryRepository.get7dNewPostCountByCategoryId(categoryId)
|
||||
.catch { throw it }
|
||||
.collect { count -> sevenDayNewCount = count }
|
||||
|
||||
val stats = CategoryStats(
|
||||
postCount = postCount,
|
||||
weeklyNewCount = weeklyNewCount,
|
||||
dailyNewCount = dailyNewCount,
|
||||
sevenDayNewCount = sevenDayNewCount
|
||||
)
|
||||
|
||||
_categoryUiState.value = _categoryUiState.value.copy(categoryStats = stats)
|
||||
|
||||
// Also update the stats map
|
||||
val updatedStatsMap = _categoryStatsMap.value.toMutableMap()
|
||||
updatedStatsMap[categoryId] = stats
|
||||
_categoryStatsMap.value = updatedStatsMap
|
||||
} catch (e: Exception) {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAllCategoriesWithStats() {
|
||||
viewModelScope.launch {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository to get all categories
|
||||
// val categories = categoryRepository.getAllCategories()
|
||||
// _categoryUiState.value = _categoryUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// categories = categories
|
||||
// )
|
||||
|
||||
// Load stats for each category
|
||||
// categories.forEach { category ->
|
||||
// getCategoryStats(category.categoryId)
|
||||
// }
|
||||
} catch (e: Exception) {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatsForCategory(categoryId: Int): CategoryStats? {
|
||||
return _categoryStatsMap.value[categoryId]
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun clearCurrentCategory() {
|
||||
_categoryUiState.value = _categoryUiState.value.copy(
|
||||
currentCategory = null,
|
||||
categoryStats = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.Comment
|
||||
import com.qingshuige.tangyuan.model.CreateCommentDto
|
||||
import com.qingshuige.tangyuan.repository.CommentRepository
|
||||
import com.qingshuige.tangyuan.utils.collectFlow
|
||||
import com.qingshuige.tangyuan.utils.collectFlowList
|
||||
import com.qingshuige.tangyuan.utils.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CommentUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val comments: List<Comment> = emptyList(),
|
||||
val subComments: Map<Int, List<Comment>> = emptyMap(),
|
||||
val error: String? = null,
|
||||
val isCreating: Boolean = false,
|
||||
val createSuccess: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CommentViewModel @Inject constructor(
|
||||
private val commentRepository: CommentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _commentUiState = MutableStateFlow(CommentUiState())
|
||||
val commentUiState: StateFlow<CommentUiState> = _commentUiState.asStateFlow()
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<Comment>>(emptyList())
|
||||
val searchResults: StateFlow<List<Comment>> = _searchResults.asStateFlow()
|
||||
|
||||
fun getCommentsForPost(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
_commentUiState.value = _commentUiState.value.copy(isLoading = true, error = null)
|
||||
commentRepository.getCommentsForPost(postId)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { comments ->
|
||||
_commentUiState.value = _commentUiState.value.copy(
|
||||
isLoading = false,
|
||||
comments = comments
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubComments(parentCommentId: Int) {
|
||||
viewModelScope.launch {
|
||||
commentRepository.getSubComments(parentCommentId)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(error = e.message)
|
||||
}
|
||||
.collect { subComments ->
|
||||
val currentSubComments = _commentUiState.value.subComments.toMutableMap()
|
||||
currentSubComments[parentCommentId] = subComments
|
||||
_commentUiState.value = _commentUiState.value.copy(
|
||||
subComments = currentSubComments
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommentById(commentId: Int) {
|
||||
viewModelScope.launch {
|
||||
commentRepository.getCommentById(commentId)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(error = e.message)
|
||||
}
|
||||
.collect { comment ->
|
||||
// Handle single comment result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createComment(createCommentDto: CreateCommentDto) {
|
||||
viewModelScope.launch {
|
||||
_commentUiState.value = _commentUiState.value.copy(isCreating = true, error = null)
|
||||
commentRepository.createComment(createCommentDto)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(
|
||||
isCreating = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { result ->
|
||||
_commentUiState.value = _commentUiState.value.copy(
|
||||
isCreating = false,
|
||||
createSuccess = true
|
||||
)
|
||||
// Refresh comments for the post
|
||||
getCommentsForPost(createCommentDto.postId.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteComment(commentId: Int) {
|
||||
viewModelScope.launch {
|
||||
commentRepository.deleteComment(commentId)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(error = e.message)
|
||||
}
|
||||
.collect { success ->
|
||||
if (success) {
|
||||
// Remove from current comments list
|
||||
val updatedComments = _commentUiState.value.comments.filter {
|
||||
it.commentId != commentId
|
||||
}
|
||||
_commentUiState.value = _commentUiState.value.copy(comments = updatedComments)
|
||||
|
||||
// Also remove from sub-comments if exists
|
||||
val updatedSubComments = _commentUiState.value.subComments.mapValues { entry ->
|
||||
entry.value.filter { it.commentId != commentId }
|
||||
}
|
||||
_commentUiState.value = _commentUiState.value.copy(subComments = updatedSubComments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchComments(keyword: String) {
|
||||
viewModelScope.launch {
|
||||
commentRepository.searchComments(keyword)
|
||||
.catch { e ->
|
||||
_commentUiState.value = _commentUiState.value.copy(error = e.message)
|
||||
}
|
||||
.collect { comments ->
|
||||
_searchResults.value = comments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_commentUiState.value = _commentUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun clearCreateSuccess() {
|
||||
_commentUiState.value = _commentUiState.value.copy(createSuccess = false)
|
||||
}
|
||||
|
||||
fun clearComments() {
|
||||
_commentUiState.value = CommentUiState()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.repository.MediaRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import okhttp3.MultipartBody
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class UploadResult(
|
||||
val success: Boolean = false,
|
||||
val imageUrl: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class MediaUiState(
|
||||
val isUploading: Boolean = false,
|
||||
val uploadResult: UploadResult? = null,
|
||||
val uploadHistory: List<String> = emptyList(),
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MediaViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _mediaUiState = MutableStateFlow(MediaUiState())
|
||||
val mediaUiState: StateFlow<MediaUiState> = _mediaUiState.asStateFlow()
|
||||
|
||||
fun uploadImage(file: MultipartBody.Part) {
|
||||
viewModelScope.launch {
|
||||
_mediaUiState.value = _mediaUiState.value.copy(isUploading = true, error = null)
|
||||
mediaRepository.uploadImage(file)
|
||||
.catch { e ->
|
||||
val uploadResult = UploadResult(
|
||||
success = false,
|
||||
error = e.message
|
||||
)
|
||||
_mediaUiState.value = _mediaUiState.value.copy(
|
||||
isUploading = false,
|
||||
uploadResult = uploadResult,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { result ->
|
||||
val uploadResult = UploadResult(
|
||||
success = true,
|
||||
imageUrl = result["url"] // Assuming API returns URL in response
|
||||
)
|
||||
|
||||
// Add to upload history
|
||||
val updatedHistory = _mediaUiState.value.uploadHistory + listOf(uploadResult.imageUrl!!)
|
||||
|
||||
_mediaUiState.value = _mediaUiState.value.copy(
|
||||
isUploading = false,
|
||||
uploadResult = uploadResult,
|
||||
uploadHistory = updatedHistory
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearUploadResult() {
|
||||
_mediaUiState.value = _mediaUiState.value.copy(uploadResult = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_mediaUiState.value = _mediaUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun getUploadHistory(): List<String> {
|
||||
return _mediaUiState.value.uploadHistory
|
||||
}
|
||||
|
||||
fun clearUploadHistory() {
|
||||
_mediaUiState.value = _mediaUiState.value.copy(uploadHistory = emptyList())
|
||||
}
|
||||
|
||||
fun removeFromHistory(imageUrl: String) {
|
||||
val updatedHistory = _mediaUiState.value.uploadHistory.filter { it != imageUrl }
|
||||
_mediaUiState.value = _mediaUiState.value.copy(uploadHistory = updatedHistory)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.NewNotification
|
||||
import com.qingshuige.tangyuan.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val notifications: List<NewNotification> = emptyList(),
|
||||
val unreadCount: Int = 0,
|
||||
val error: String? = null,
|
||||
val isMarkingAsRead: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationViewModel @Inject constructor(
|
||||
private val notificationRepository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _notificationUiState = MutableStateFlow(NotificationUiState())
|
||||
val notificationUiState: StateFlow<NotificationUiState> = _notificationUiState.asStateFlow()
|
||||
|
||||
fun getAllNotifications(userId: Int) {
|
||||
viewModelScope.launch {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository getAllNotifications method
|
||||
// val notifications = notificationRepository.getAllNotifications(userId)
|
||||
// val unreadCount = notifications.count { !it.isRead }
|
||||
// _notificationUiState.value = _notificationUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// notifications = notifications,
|
||||
// unreadCount = unreadCount
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(notificationId: Int) {
|
||||
viewModelScope.launch {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(isMarkingAsRead = true)
|
||||
try {
|
||||
// TODO: Call repository markAsRead method
|
||||
// notificationRepository.markAsRead(notificationId)
|
||||
|
||||
// Update local state
|
||||
val updatedNotifications = _notificationUiState.value.notifications.map { notification ->
|
||||
if (notification.notificationId == notificationId) {
|
||||
notification.copy(isRead = true)
|
||||
} else {
|
||||
notification
|
||||
}
|
||||
}
|
||||
|
||||
val newUnreadCount = updatedNotifications.count { !it.isRead }
|
||||
|
||||
_notificationUiState.value = _notificationUiState.value.copy(
|
||||
isMarkingAsRead = false,
|
||||
notifications = updatedNotifications,
|
||||
unreadCount = newUnreadCount
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(
|
||||
isMarkingAsRead = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllAsRead(userId: Int) {
|
||||
viewModelScope.launch {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(isMarkingAsRead = true)
|
||||
try {
|
||||
// Mark all unread notifications as read
|
||||
val unreadNotifications = _notificationUiState.value.notifications.filter { !it.isRead }
|
||||
|
||||
// TODO: Call repository for each unread notification or batch operation
|
||||
// unreadNotifications.forEach { notification ->
|
||||
// notificationRepository.markAsRead(notification.notificationId)
|
||||
// }
|
||||
|
||||
// Update local state
|
||||
val updatedNotifications = _notificationUiState.value.notifications.map { notification ->
|
||||
notification.copy(isRead = true)
|
||||
}
|
||||
|
||||
_notificationUiState.value = _notificationUiState.value.copy(
|
||||
isMarkingAsRead = false,
|
||||
notifications = updatedNotifications,
|
||||
unreadCount = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(
|
||||
isMarkingAsRead = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnreadCount(): Int {
|
||||
return _notificationUiState.value.unreadCount
|
||||
}
|
||||
|
||||
fun getUnreadNotifications(): List<NewNotification> {
|
||||
return _notificationUiState.value.notifications.filter { !it.isRead }
|
||||
}
|
||||
|
||||
fun getReadNotifications(): List<NewNotification> {
|
||||
return _notificationUiState.value.notifications.filter { it.isRead }
|
||||
}
|
||||
|
||||
fun refreshNotifications(userId: Int) {
|
||||
getAllNotifications(userId)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_notificationUiState.value = _notificationUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun clearNotifications() {
|
||||
_notificationUiState.value = NotificationUiState()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,287 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.CommentCard
|
||||
import com.qingshuige.tangyuan.model.CreateCommentDto
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.model.PostDetailState
|
||||
import com.qingshuige.tangyuan.repository.PostDetailRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PostDetailViewModel @Inject constructor(
|
||||
private val postDetailRepository: PostDetailRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(PostDetailState())
|
||||
val state: StateFlow<PostDetailState> = _state.asStateFlow()
|
||||
|
||||
private var currentPostId: Int = 0
|
||||
private var currentUserId: Int = 0
|
||||
|
||||
/**
|
||||
* 加载帖子详情和评论 - 分离加载,先加载帖子再加载评论
|
||||
*/
|
||||
fun loadPostDetail(postId: Int, userId: Int = 0) {
|
||||
currentPostId = postId
|
||||
currentUserId = userId
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
|
||||
try {
|
||||
// 先加载帖子详情,立即更新UI
|
||||
postDetailRepository.getPostCard(postId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "加载帖子失败"
|
||||
)
|
||||
}
|
||||
.collect { postCard ->
|
||||
// 立即更新帖子数据,确保共享元素有目标
|
||||
_state.value = _state.value.copy(
|
||||
postCard = postCard,
|
||||
isLoading = false
|
||||
)
|
||||
|
||||
// 然后异步加载评论
|
||||
loadComments(postId, userId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "加载失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载评论数据
|
||||
*/
|
||||
private fun loadComments(postId: Int, userId: Int = 0) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true) // 这里的loading只影响评论区
|
||||
|
||||
postDetailRepository.getCommentCardsForPost(postId, userId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "加载评论失败"
|
||||
)
|
||||
}
|
||||
.collect { commentCards ->
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
comments = commentCards,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新帖子详情
|
||||
*/
|
||||
fun refreshPostDetail() {
|
||||
if (currentPostId == 0) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isRefreshing = true)
|
||||
|
||||
postDetailRepository.refreshPostDetail(currentPostId, currentUserId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
isRefreshing = false,
|
||||
error = e.message ?: "刷新失败"
|
||||
)
|
||||
}
|
||||
.collect { (postCard, commentCards) ->
|
||||
_state.value = _state.value.copy(
|
||||
isRefreshing = false,
|
||||
postCard = postCard,
|
||||
comments = commentCards,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布新评论
|
||||
*/
|
||||
fun createComment(content: String, parentCommentId: Int = 0) {
|
||||
if (currentPostId == 0 || content.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isCreatingComment = true,
|
||||
commentError = null
|
||||
)
|
||||
|
||||
val createCommentDto = CreateCommentDto(
|
||||
postId = currentPostId.toLong(),
|
||||
content = content,
|
||||
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong()
|
||||
)
|
||||
|
||||
postDetailRepository.createComment(createCommentDto)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
isCreatingComment = false,
|
||||
commentError = e.message ?: "评论发布失败"
|
||||
)
|
||||
}
|
||||
.collect { message ->
|
||||
_state.value = _state.value.copy(
|
||||
isCreatingComment = false,
|
||||
replyToComment = null,
|
||||
commentError = null
|
||||
)
|
||||
// 评论发布成功后,刷新评论列表
|
||||
refreshComments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
fun deleteComment(commentId: Int) {
|
||||
viewModelScope.launch {
|
||||
postDetailRepository.deleteComment(commentId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message ?: "删除评论失败"
|
||||
)
|
||||
}
|
||||
.collect { success ->
|
||||
if (success) {
|
||||
// 删除成功后,从本地列表中移除该评论
|
||||
val updatedComments = _state.value.comments.filter { comment ->
|
||||
comment.commentId != commentId &&
|
||||
comment.replies.none { it.commentId == commentId }
|
||||
}.map { comment ->
|
||||
comment.copy(
|
||||
replies = comment.replies.filter { it.commentId != commentId }
|
||||
)
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(comments = updatedComments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置回复目标评论
|
||||
*/
|
||||
fun setReplyToComment(commentCard: CommentCard?) {
|
||||
_state.value = _state.value.copy(replyToComment = commentCard)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新评论列表
|
||||
*/
|
||||
private fun refreshComments() {
|
||||
if (currentPostId == 0) return
|
||||
|
||||
viewModelScope.launch {
|
||||
postDetailRepository.getCommentCardsForPost(currentPostId, currentUserId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message ?: "刷新评论失败"
|
||||
)
|
||||
}
|
||||
.collect { commentCards ->
|
||||
_state.value = _state.value.copy(comments = commentCards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多评论
|
||||
*/
|
||||
fun loadMoreComments() {
|
||||
// TODO: 实现分页加载评论
|
||||
// 当前实现一次加载所有评论,后续可以根据需要实现分页
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开/收起评论回复
|
||||
*/
|
||||
fun toggleReplies(commentId: Int) {
|
||||
viewModelScope.launch {
|
||||
val updatedComments = _state.value.comments.map { comment ->
|
||||
if (comment.commentId == commentId) {
|
||||
if (comment.replies.isEmpty() && comment.replyCount > 0) {
|
||||
// 加载回复
|
||||
loadRepliesForComment(commentId)
|
||||
comment
|
||||
} else {
|
||||
// 切换显示状态(这里简单处理,实际可能需要添加展开状态字段)
|
||||
comment
|
||||
}
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(comments = updatedComments)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载特定评论的回复
|
||||
*/
|
||||
private fun loadRepliesForComment(commentId: Int) {
|
||||
viewModelScope.launch {
|
||||
postDetailRepository.getReplyCardsForComment(commentId, currentUserId)
|
||||
.catch { e ->
|
||||
_state.value = _state.value.copy(
|
||||
error = e.message ?: "加载回复失败"
|
||||
)
|
||||
}
|
||||
.collect { replyCards ->
|
||||
val updatedComments = _state.value.comments.map { comment ->
|
||||
if (comment.commentId == commentId) {
|
||||
comment.copy(replies = replyCards)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(comments = updatedComments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误状态
|
||||
*/
|
||||
fun clearError() {
|
||||
_state.value = _state.value.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除评论错误状态
|
||||
*/
|
||||
fun clearCommentError() {
|
||||
_state.value = _state.value.copy(commentError = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
fun resetState() {
|
||||
_state.value = PostDetailState()
|
||||
currentPostId = 0
|
||||
currentUserId = 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.Category
|
||||
import com.qingshuige.tangyuan.model.CreatPostMetadataDto
|
||||
import com.qingshuige.tangyuan.model.PostBody
|
||||
import com.qingshuige.tangyuan.model.PostMetadata
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.repository.PostRepository
|
||||
import com.qingshuige.tangyuan.repository.UserRepository
|
||||
import com.qingshuige.tangyuan.repository.CategoryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PostDetail(
|
||||
val metadata: PostMetadata,
|
||||
val body: PostBody,
|
||||
val author: User? = null,
|
||||
val category: Category? = null
|
||||
)
|
||||
|
||||
data class PostUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val posts: List<PostMetadata> = emptyList(),
|
||||
val currentPost: PostDetail? = null,
|
||||
val error: String? = null,
|
||||
val isCreating: Boolean = false,
|
||||
val createSuccess: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PostViewModel @Inject constructor(
|
||||
private val postRepository: PostRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _postUiState = MutableStateFlow(PostUiState())
|
||||
val postUiState: StateFlow<PostUiState> = _postUiState.asStateFlow()
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<PostMetadata>>(emptyList())
|
||||
val searchResults: StateFlow<List<PostMetadata>> = _searchResults.asStateFlow()
|
||||
|
||||
private val _noticePost = MutableStateFlow<PostMetadata?>(null)
|
||||
val noticePost: StateFlow<PostMetadata?> = _noticePost.asStateFlow()
|
||||
|
||||
fun getPostDetail(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
var metadata: PostMetadata? = null
|
||||
var body: PostBody? = null
|
||||
var author: User? = null
|
||||
var category: Category? = null
|
||||
|
||||
postRepository.getPostMetadata(postId)
|
||||
.catch { e -> throw e }
|
||||
.collect { postMetadata ->
|
||||
metadata = postMetadata
|
||||
|
||||
// Get post body
|
||||
postRepository.getPostBody(postId)
|
||||
.catch { e -> throw e }
|
||||
.collect { postBody ->
|
||||
body = postBody
|
||||
}
|
||||
|
||||
// Get author
|
||||
userRepository.getUserById(postMetadata.userId)
|
||||
.catch { e -> throw e }
|
||||
.collect { user ->
|
||||
author = user
|
||||
}
|
||||
|
||||
// Get category
|
||||
categoryRepository.getCategoryById(postMetadata.categoryId)
|
||||
.catch { e -> throw e }
|
||||
.collect { cat ->
|
||||
category = cat
|
||||
}
|
||||
|
||||
val postDetail = PostDetail(metadata!!, body!!, author, category)
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
currentPost = postDetail
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserPosts(userId: Int) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository getUserPosts method
|
||||
// val posts = postRepository.getUserPosts(userId)
|
||||
// _postUiState.value = _postUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// posts = posts
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRandomPosts(count: Int) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository getRandomPosts method
|
||||
// val posts = postRepository.getRandomPosts(count)
|
||||
// _postUiState.value = _postUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// posts = posts
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPostsByCategory(categoryId: Int) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository getPostsByCategory method
|
||||
// val posts = postRepository.getPostsByCategory(categoryId)
|
||||
// _postUiState.value = _postUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// posts = posts
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createPost(metadata: CreatPostMetadataDto, body: PostBody) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isCreating = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository createPost method
|
||||
// val postId = postRepository.createPostMetadata(metadata)
|
||||
// postRepository.createPostBody(body.copy(postId = postId))
|
||||
// _postUiState.value = _postUiState.value.copy(
|
||||
// isCreating = false,
|
||||
// createSuccess = true
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isCreating = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePost(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: Call repository deletePost method
|
||||
// postRepository.deletePost(postId)
|
||||
// Remove from current posts list
|
||||
val updatedPosts = _postUiState.value.posts.filter { it.postId != postId }
|
||||
_postUiState.value = _postUiState.value.copy(posts = updatedPosts)
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchPosts(keyword: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: Call repository searchPosts method
|
||||
// val posts = postRepository.searchPosts(keyword)
|
||||
// _searchResults.value = posts
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNoticePost() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: Call repository getNoticePost method
|
||||
// val notice = postRepository.getNoticePost()
|
||||
// _noticePost.value = notice
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhtPostMetadata(sectionId: Int, exceptedIds: List<Int>) {
|
||||
viewModelScope.launch {
|
||||
_postUiState.value = _postUiState.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
// TODO: Call repository getPhtPostMetadata method
|
||||
// val posts = postRepository.getPhtPostMetadata(sectionId, exceptedIds)
|
||||
// _postUiState.value = _postUiState.value.copy(
|
||||
// isLoading = false,
|
||||
// posts = posts
|
||||
// )
|
||||
} catch (e: Exception) {
|
||||
_postUiState.value = _postUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_postUiState.value = _postUiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun clearCreateSuccess() {
|
||||
_postUiState.value = _postUiState.value.copy(createSuccess = false)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,234 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.model.RecommendedPostsState
|
||||
import com.qingshuige.tangyuan.repository.PostRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TalkViewModel @Inject constructor(
|
||||
private val postRepository: PostRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RecommendedPostsState())
|
||||
val uiState: StateFlow<RecommendedPostsState> = _uiState.asStateFlow()
|
||||
|
||||
// 已经获取过的文章ID列表,用于避免重复
|
||||
private val _loadedPostIds = mutableSetOf<Int>()
|
||||
|
||||
// 默认分区ID(聊一聊)
|
||||
private val defaultSectionId = 1
|
||||
|
||||
init {
|
||||
loadRecommendedPosts()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载推荐文章
|
||||
*/
|
||||
fun loadRecommendedPosts(isRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = true, error = null)
|
||||
_loadedPostIds.clear()
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
}
|
||||
|
||||
postRepository.getRecommendedPostCards(
|
||||
sectionId = defaultSectionId,
|
||||
exceptedIds = if (isRefresh) emptyList() else _loadedPostIds.toList()
|
||||
)
|
||||
.catch { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
error = e.message ?: "加载失败"
|
||||
)
|
||||
}
|
||||
.collect { newPosts ->
|
||||
// 更新已加载的文章ID
|
||||
_loadedPostIds.addAll(newPosts.map { it.postId })
|
||||
|
||||
val currentPosts = if (isRefresh) {
|
||||
newPosts
|
||||
} else {
|
||||
_uiState.value.posts + newPosts
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
posts = currentPosts,
|
||||
hasMore = newPosts.isNotEmpty(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
error = e.message ?: "加载失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新文章列表
|
||||
*/
|
||||
fun refreshPosts() {
|
||||
loadRecommendedPosts(isRefresh = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多文章
|
||||
*/
|
||||
fun loadMorePosts() {
|
||||
if (_uiState.value.isLoading || !_uiState.value.hasMore) return
|
||||
loadRecommendedPosts(isRefresh = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误状态
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞文章
|
||||
*/
|
||||
fun toggleLike(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val currentPosts = _uiState.value.posts.toMutableList()
|
||||
val postIndex = currentPosts.indexOfFirst { it.postId == postId }
|
||||
|
||||
if (postIndex != -1) {
|
||||
val post = currentPosts[postIndex]
|
||||
val updatedPost = post.copy(
|
||||
isLiked = !post.isLiked,
|
||||
likeCount = if (post.isLiked) post.likeCount - 1 else post.likeCount + 1
|
||||
)
|
||||
currentPosts[postIndex] = updatedPost
|
||||
|
||||
_uiState.value = _uiState.value.copy(posts = currentPosts)
|
||||
|
||||
// TODO: 调用API更新点赞状态
|
||||
// likeRepository.toggleLike(postId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果API调用失败,回滚UI状态
|
||||
// 这里可以添加错误处理逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏文章
|
||||
*/
|
||||
fun toggleBookmark(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val currentPosts = _uiState.value.posts.toMutableList()
|
||||
val postIndex = currentPosts.indexOfFirst { it.postId == postId }
|
||||
|
||||
if (postIndex != -1) {
|
||||
val post = currentPosts[postIndex]
|
||||
val updatedPost = post.copy(isBookmarked = !post.isBookmarked)
|
||||
currentPosts[postIndex] = updatedPost
|
||||
|
||||
_uiState.value = _uiState.value.copy(posts = currentPosts)
|
||||
|
||||
// TODO: 调用API更新收藏状态
|
||||
// bookmarkRepository.toggleBookmark(postId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果API调用失败,回滚UI状态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文章详情(用于点击跳转)
|
||||
*/
|
||||
fun getPostDetail(postId: Int): PostCard? {
|
||||
return _uiState.value.posts.find { it.postId == postId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享文章
|
||||
*/
|
||||
fun sharePost(postId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val currentPosts = _uiState.value.posts.toMutableList()
|
||||
val postIndex = currentPosts.indexOfFirst { it.postId == postId }
|
||||
|
||||
if (postIndex != -1) {
|
||||
val post = currentPosts[postIndex]
|
||||
val updatedPost = post.copy(shareCount = post.shareCount + 1)
|
||||
currentPosts[postIndex] = updatedPost
|
||||
|
||||
_uiState.value = _uiState.value.copy(posts = currentPosts)
|
||||
|
||||
// TODO: 调用分享相关的逻辑
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 举报文章
|
||||
*/
|
||||
fun reportPost(postId: Int, reason: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: 调用举报API
|
||||
// reportRepository.reportPost(postId, reason)
|
||||
|
||||
// 暂时从列表中移除被举报的文章
|
||||
val currentPosts = _uiState.value.posts.filter { it.postId != postId }
|
||||
_uiState.value = _uiState.value.copy(posts = currentPosts)
|
||||
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "举报失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注/取消关注作者
|
||||
*/
|
||||
fun toggleFollowAuthor(authorId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: 调用关注API
|
||||
// followRepository.toggleFollow(authorId)
|
||||
|
||||
// 更新UI中该作者的所有文章状态
|
||||
// 这里可以添加相关逻辑
|
||||
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "操作失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
package com.qingshuige.tangyuan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.qingshuige.tangyuan.model.CreateUserDto
|
||||
import com.qingshuige.tangyuan.model.LoginDto
|
||||
import com.qingshuige.tangyuan.model.User
|
||||
import com.qingshuige.tangyuan.repository.UserRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginState(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val user: User? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class UserUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val users: List<User> = emptyList(),
|
||||
val currentUser: User? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class UserViewModel @Inject constructor(
|
||||
private val userRepository: UserRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _loginState = MutableStateFlow(LoginState())
|
||||
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
|
||||
|
||||
private val _userUiState = MutableStateFlow(UserUiState())
|
||||
val userUiState: StateFlow<UserUiState> = _userUiState.asStateFlow()
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<User>>(emptyList())
|
||||
val searchResults: StateFlow<List<User>> = _searchResults.asStateFlow()
|
||||
|
||||
fun login(loginDto: LoginDto) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = _loginState.value.copy(isLoading = true, error = null)
|
||||
userRepository.login(loginDto)
|
||||
.catch { e ->
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { result ->
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(createUserDto: CreateUserDto) {
|
||||
viewModelScope.launch {
|
||||
_userUiState.value = _userUiState.value.copy(isLoading = true, error = null)
|
||||
userRepository.register(createUserDto)
|
||||
.catch { e ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { success ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserById(userId: Int) {
|
||||
viewModelScope.launch {
|
||||
_userUiState.value = _userUiState.value.copy(isLoading = true, error = null)
|
||||
userRepository.getUserById(userId)
|
||||
.catch { e ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { user ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false,
|
||||
currentUser = user
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(userId: Int, user: User) {
|
||||
viewModelScope.launch {
|
||||
_userUiState.value = _userUiState.value.copy(isLoading = true, error = null)
|
||||
userRepository.updateUser(userId, user)
|
||||
.catch { e ->
|
||||
_userUiState.value = _userUiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
.collect { success ->
|
||||
if (success) {
|
||||
getUserById(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchUsers(keyword: String) {
|
||||
viewModelScope.launch {
|
||||
userRepository.searchUsers(keyword)
|
||||
.catch { e ->
|
||||
_userUiState.value = _userUiState.value.copy(error = e.message)
|
||||
}
|
||||
.collect { users ->
|
||||
_searchResults.value = users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
_loginState.value = LoginState()
|
||||
_userUiState.value = UserUiState()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_loginState.value = _loginState.value.copy(error = null)
|
||||
_userUiState.value = _userUiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
@ -1,170 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:pathData="M0,0h108v108h-108z"
|
||||
android:fillColor="#273c75"/>
|
||||
</vector>
|
||||
|
||||
@ -1,30 +1,27 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
<path
|
||||
android:pathData="M32.74,49.57l9.27,15.36l17.94,-0.35l8.66,-15.71l-9.27,-15.35l-17.94,0.35l-8.66,15.7z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="3"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#f5f6fa"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M42.44,54.67l9.27,15.35l17.93,-0.35l8.66,-15.7l-9.27,-15.36l-17.93,0.35l-8.66,15.71z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="3"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#f5f6fa"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M32.94,60.4l9.27,15.36l17.93,-0.35l8.66,-15.71l-9.27,-15.35l-17.93,0.35l-8.66,15.7z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="3"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#f5f6fa"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
||||
|
||||
@ -3,4 +3,6 @@ plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.hilt.android) apply false
|
||||
alias(libs.plugins.kotlin.kapt) apply false
|
||||
}
|
||||
@ -10,11 +10,17 @@ lifecycleRuntimeKtx = "2.9.4"
|
||||
activityCompose = "1.11.0"
|
||||
composeBom = "2025.09.01"
|
||||
materialIconsCore = "1.7.8"
|
||||
panguJvm = "0.2.0"
|
||||
retrofit = "3.0.0"
|
||||
okhttp = "4.12.0"
|
||||
gson = "2.13.2"
|
||||
securityCrypto = "1.1.0"
|
||||
datastore = "1.1.7"
|
||||
hilt = "2.57.2"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
navigationCompose = "2.9.5"
|
||||
uiGraphics = "1.9.2"
|
||||
animationCore = "1.9.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@ -34,6 +40,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
pangu-jvm = { module = "io.github.darkokoa:pangu-jvm", version.ref = "panguJvm" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
@ -42,8 +50,20 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
# Hilt dependencies
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||
|
||||
# ViewModel dependencies
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" }
|
||||
androidx-animation-core = { group = "androidx.compose.animation", name = "animation-core", version.ref = "animationCore" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user