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:
grtsinry43 2025-10-06 00:29:51 +08:00
parent 6d1c03ec85
commit 6a1bc7ad97
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
43 changed files with 5652 additions and 245 deletions

View File

@ -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
View File

@ -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
View 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>

View File

@ -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)

View 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
}

View File

@ -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 = "我的") }
}
}
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,123 @@
package com.qingshuige.tangyuan.model
import java.util.Date
/**
* 评论卡片展示数据模型
* 聚合了CommentUser信息的完整展示数据
*/
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
)
}

View File

@ -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
)

View File

@ -0,0 +1,138 @@
package com.qingshuige.tangyuan.model
import java.util.Date
/**
* 文章卡片展示数据模型
* 聚合了PostMetadataUserCategoryPostBody的关键信息
*/
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()
)
}

View File

@ -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"
}
}

View File

@ -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()}")
}
}
}

View File

@ -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()}")
}
}
}

View File

@ -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()}")
}
}
}

View File

@ -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()}")
}
}
}

View File

@ -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)
}
}
}

View File

@ -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()}")
}
}
}

View File

@ -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()}")
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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)
)
// ====================================

View File

@ -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)
}
}
}

View File

@ -0,0 +1,10 @@
package com.qingshuige.tangyuan.utils
import dev.darkokoa.pangu.Pangu
/**
* 对字符串应用盘古之白格式化
*/
fun String.withPanguSpacing(): String {
return Pangu.spacingText(this)
}

View 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
}
}

View File

@ -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
)
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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}"
)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
}

View File

@ -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" }