Compare commits

..

No commits in common. "0f5bfe6aecaf917d7c9710358ba1e41dc1242926" and "a5041db38412eadcab9edbdc622af4d3ba8ec84b" have entirely different histories.

23 changed files with 121 additions and 2454 deletions

View File

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-08T15:39:42.580350198Z">
<DropdownSelection timestamp="2025-10-06T14:55:34.827294Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -3,11 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:name=".TangyuanApplication"

View File

@ -21,8 +21,6 @@ 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.AboutScreen
import com.qingshuige.tangyuan.ui.screens.CreatePostScreen
import com.qingshuige.tangyuan.ui.screens.DesignSystemScreen
import com.qingshuige.tangyuan.ui.screens.PostDetailScreen
import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen
import com.qingshuige.tangyuan.ui.screens.TalkScreen
@ -31,10 +29,6 @@ import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
import com.qingshuige.tangyuan.ui.screens.UserScreen
import com.qingshuige.tangyuan.viewmodel.UserViewModel
// 自定义带回弹效果的easing - 快速流畅
private val QuickSpringEasing = CubicBezierEasing(0.34f, 1.3f, 0.64f, 1.0f)
private val QuickEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun App() {
@ -58,10 +52,8 @@ fun App() {
navController.navigate(Screen.UserDetail.createRoute(authorId))
},
onAboutClick = { navController.navigate(Screen.About.route) },
onCreatePostClick = { navController.navigate(Screen.CreatePost.route) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable,
onDesignSystemClick = { navController.navigate(Screen.DesignSystem.route) }
animatedContentScope = this@composable
)
}
@ -71,8 +63,8 @@ fun App() {
slideInVertically(
initialOffsetY = { it },
animationSpec = tween(
durationMillis = 350,
easing = QuickSpringEasing
durationMillis = 800,
easing = FastOutSlowInEasing
)
)
},
@ -80,8 +72,8 @@ fun App() {
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(
durationMillis = 250,
easing = QuickEasing
durationMillis = 600,
easing = FastOutSlowInEasing
)
)
},
@ -89,8 +81,8 @@ fun App() {
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(
durationMillis = 250,
easing = QuickEasing
durationMillis = 600,
easing = FastOutSlowInEasing
)
)
}
@ -98,44 +90,12 @@ fun App() {
LoginScreen(navController = navController)
}
// 帖子详情页 - 使用淡入淡出避免与共享元素冲突
// 帖子详情页
composable(
route = Screen.PostDetail.route,
arguments = listOf(
navArgument("postId") { type = NavType.IntType }
),
enterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popEnterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popExitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
}
) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
@ -158,45 +118,13 @@ fun App() {
)
}
// 图片详情页 - 使用淡入淡出避免与共享元素冲突
// 图片详情页
composable(
route = Screen.ImageDetail.route,
arguments = listOf(
navArgument("postId") { type = NavType.IntType },
navArgument("imageIndex") { type = NavType.IntType }
),
enterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popEnterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popExitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
}
) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0
@ -221,44 +149,12 @@ fun App() {
)
}
// 用户详情页 - 使用淡入淡出避免与共享元素冲突
// 用户详情页
composable(
route = Screen.UserDetail.route,
arguments = listOf(
navArgument("userId") { type = NavType.IntType }
),
enterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popEnterTransition = {
fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popExitTransition = {
fadeOut(
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
}
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: 0
@ -268,14 +164,6 @@ fun App() {
onPostClick = { postId ->
navController.navigate(Screen.PostDetail.createRoute(postId))
},
onImageClick = { postId, imageIndex ->
navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex)) {
popUpTo(Screen.PostDetail.createRoute(postId)) {
inclusive = true
}
launchSingleTop = true
}
},
onFollowClick = {
// TODO: 实现关注功能
},
@ -284,144 +172,11 @@ fun App() {
)
}
composable(
route = Screen.About.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(
durationMillis = 300,
easing = QuickSpringEasing
)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(
durationMillis = 250,
easing = QuickEasing
)
)
}
) {
composable(Screen.About.route) {
AboutScreen(
onBackClick = { navController.popBackStack() }
)
}
composable(
route = Screen.DesignSystem.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(
durationMillis = 300,
easing = QuickSpringEasing
)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(
durationMillis = 250,
easing = QuickEasing
)
)
}
) {
DesignSystemScreen(
onBackClick = { navController.popBackStack() }
)
}
composable(
route = Screen.CreatePost.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(
durationMillis = 300,
easing = QuickSpringEasing
)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it / 3 },
animationSpec = tween(
durationMillis = 300,
easing = QuickEasing
)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(
durationMillis = 250,
easing = QuickEasing
)
)
}
) {
CreatePostScreen(
onBackClick = {
navController.popBackStack()
},
onPostSuccess = {
// 发帖成功后返回首页
navController.popBackStack("main", false)
}
)
}
}
}
}
@ -434,8 +189,6 @@ fun MainFlow(
onImageClick: (Int, Int) -> Unit = { _, _ -> },
onAuthorClick: (Int) -> Unit = {},
onAboutClick: () -> Unit,
onDesignSystemClick: () -> Unit,
onCreatePostClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
userViewModel: UserViewModel = hiltViewModel()
@ -483,7 +236,7 @@ fun MainFlow(
pageLevel = PageLevel.PRIMARY,
onAvatarClick = onAvatarClick,
onAnnouncementClick = {/* 公告点击事件 */ },
onPostClick = onCreatePostClick
onPostClick = {/* 发表点击事件 */ }
)
},
bottomBar = {
@ -501,63 +254,7 @@ fun MainFlow(
NavHost(
navController = mainNavController,
startDestination = Screen.Talk.route,
modifier = Modifier.padding(innerPadding),
enterTransition = {
slideInHorizontally(
initialOffsetX = { it / 2 },
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
) + fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it / 2 },
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
) + fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it / 2 },
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
) + fadeIn(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it / 2 },
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
) + fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = QuickEasing
)
)
}
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.Talk.route) {
TalkScreen(
@ -581,8 +278,7 @@ fun MainFlow(
onSettings = {
// TODO: 导航到设置页面
},
onAbout = onAboutClick,
onDesignSystem = onDesignSystemClick,
onAbout = onAboutClick
)
}
}

View File

@ -91,10 +91,7 @@ data class PostDetailState(
// 评论输入状态
val isCreatingComment: Boolean = false,
val commentError: String? = null,
val replyToComment: CommentCard? = null,
// 图片保存状态
val saveMessage: String? = null
val replyToComment: CommentCard? = null
)
/**

View File

@ -3,15 +3,8 @@ package com.qingshuige.tangyuan.model
import java.util.Date
data class CreateCommentDto(
@Deprecated(
message = "后台自动生成,无需传递",
replaceWith = ReplaceWith("null")
) val commentDateTime: Date? = null,
val commentDateTime: Date? = null,
val content: String? = null,
@Deprecated(
message = "字段已废弃,无需传递",
replaceWith = ReplaceWith("null")
)
val imageGuid: String? = null,
val parentCommentId: Long? = 0,
val postId: Long = 0,

View File

@ -1,67 +0,0 @@
package com.qingshuige.tangyuan.model
import java.util.Date
data class CreatePostDto(
val textContent: String,
val categoryId: Int,
val sectionId: Int, // 0 或 1
val isVisible: Boolean = true,
val imageUUIDs: List<String> = emptyList()
) {
fun toCreatPostMetadataDto(userId: Int): CreatPostMetadataDto {
return CreatPostMetadataDto(
isVisible = isVisible,
postDateTime = Date(),
sectionId = sectionId,
categoryId = categoryId,
userId = userId
)
}
fun toPostBody(postId: Int): PostBody {
return PostBody(
postId = postId,
textContent = textContent,
image1UUID = imageUUIDs.getOrNull(0),
image2UUID = imageUUIDs.getOrNull(1),
image3UUID = imageUUIDs.getOrNull(2)
)
}
}
data class CreatePostState(
val isLoading: Boolean = false,
val content: String = "",
val selectedCategoryId: Int? = null,
val selectedSectionId: Int = 0, // 默认分区0
val selectedImageUris: List<String> = emptyList(),
val uploadedImageUUIDs: List<String> = emptyList(),
val categories: List<Category> = emptyList(),
val isLoadingCategories: Boolean = false,
val isUploading: Boolean = false,
val uploadProgress: Map<String, Float> = emptyMap(),
val error: String? = null,
val success: Boolean = false
) {
val canPost: Boolean
get() = content.isNotBlank() &&
selectedCategoryId != null &&
!isLoading &&
!isUploading &&
uploadedImageUUIDs.size == selectedImageUris.size
val remainingImageSlots: Int
get() = maxOf(0, 3 - selectedImageUris.size)
val hasImages: Boolean
get() = selectedImageUris.isNotEmpty()
val isContentValid: Boolean
get() = content.isNotBlank() && content.length <= 2000
val contentCharCount: Int
get() = content.length
}

View File

@ -7,10 +7,6 @@ sealed class Screen(val route: String, val title: String) {
object Message : Screen("message", "消息")
object User : Screen("settings", "我的")
object About : Screen("about", "关于")
object CreatePost : Screen("create_post", "发帖")
object DesignSystem : Screen("design_system", "设计系统")
object PostDetail : Screen("post_detail/{postId}", "帖子详情") {
fun createRoute(postId: Int) = "post_detail/$postId"
}

View File

@ -1,66 +0,0 @@
package com.qingshuige.tangyuan.repository
import com.qingshuige.tangyuan.api.ApiInterface
import com.qingshuige.tangyuan.model.Category
import com.qingshuige.tangyuan.model.CreatePostDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.awaitResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CreatePostRepository @Inject constructor(
private val apiInterface: ApiInterface
) {
/**
* 获取所有分类
*/
fun getAllCategories(): Flow<List<Category>> = flow {
try {
val response = apiInterface.getAllCategories().awaitResponse()
if (response.isSuccessful) {
response.body()?.let { emit(it) }
?: emit(emptyList())
} else {
throw Exception("Failed to get categories: ${response.message()}")
}
} catch (e: Exception) {
throw Exception("Network error: ${e.message}")
}
}
/**
* 创建新帖子
* 1. 先创建PostMetadata获取postId
* 2. 再创建PostBody
*/
suspend fun createPost(createPostDto: CreatePostDto, userId: Int): Result<Int> {
return try {
// 1. 创建PostMetadata
val metadataDto = createPostDto.toCreatPostMetadataDto(userId)
val metadataResponse = apiInterface.postPostMetadata(metadataDto).awaitResponse()
if (!metadataResponse.isSuccessful) {
return Result.failure(Exception("Failed to create post metadata: ${metadataResponse.message()}"))
}
val postId = metadataResponse.body()?.get("postId")
?: return Result.failure(Exception("No post ID returned"))
// 2. 创建PostBody
val postBody = createPostDto.toPostBody(postId)
val bodyResponse = apiInterface.postPostBody(postBody).awaitResponse()
if (!bodyResponse.isSuccessful) {
return Result.failure(Exception("Failed to create post body: ${bodyResponse.message()}"))
}
Result.success(postId)
} catch (e: Exception) {
Result.failure(Exception("Network error: ${e.message}"))
}
}
}

View File

@ -17,13 +17,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@ -32,8 +30,8 @@ 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
import java.util.Date
/**
* 评论项组件
@ -122,7 +120,6 @@ private fun CommentMainContent(
lineHeight = 20.sp
),
fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
@ -302,7 +299,7 @@ private fun CommentActions(
*/
@Composable
private fun CommentActionButton(
icon: ImageVector,
icon: androidx.compose.ui.graphics.vector.ImageVector,
count: Int = 0,
text: String = "",
isActive: Boolean,
@ -331,7 +328,7 @@ private fun CommentActionButton(
Text(
text = count.toString(),
style = MaterialTheme.typography.labelSmall,
fontFamily = TangyuanGeneralFontFamily,
fontFamily = LiteraryFontFamily,
color = color,
fontSize = 11.sp
)
@ -342,7 +339,7 @@ private fun CommentActionButton(
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
fontFamily = TangyuanGeneralFontFamily,
fontFamily = LiteraryFontFamily,
color = color,
fontSize = 11.sp
)
@ -449,7 +446,6 @@ private fun ReplyItem(
lineHeight = 18.sp
),
fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
@ -539,7 +535,7 @@ fun CommentInputBar(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 输入框
@ -549,12 +545,9 @@ fun CommentInputBar(
modifier = Modifier.weight(1f),
placeholder = {
Text(
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" + ": " + {replyToComment.content.take(20) + if (replyToComment.content.length > 20) "..." else ""}()
else "说点什么...",
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" else "说点什么...",
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = RoundedCornerShape(20.dp),
@ -609,25 +602,6 @@ fun CommentInputBar(
}
}
@Preview
@Composable
private fun CommentInputBarPreview() {
CommentInputBar(
isCreating = false,
replyToComment = CommentCard(
commentId = 1,
postId = 1,
content = "这是一个回复评论的示例。",
commentDateTime = Date(),
authorId = 1,
authorName = "示例用户",
authorAvatar = "avatar1"
),
onSendComment = {},
onCancelReply = {}
)
}
/**
* 回复指示器
*/
@ -658,7 +632,7 @@ private fun ReplyIndicator(
Text(
text = "回复 ${comment.authorName}",
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f)
)

View File

@ -322,7 +322,7 @@ private fun PostCardContent(postCard: PostCard) {
lineHeight = 22.sp
),
fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 6,
overflow = TextOverflow.Ellipsis

View File

@ -1,645 +0,0 @@
package com.qingshuige.tangyuan.ui.screens
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.qingshuige.tangyuan.model.Category
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
import com.qingshuige.tangyuan.viewmodel.CreatePostViewModel
// 新增:用于管理 BottomSheet 状态的枚举
private enum class BottomSheetType { NONE, SECTION, CATEGORY }
// 新增:用于表示分区的简单数据类
private data class Section(val id: Int, val name: String)
private val sections = listOf(Section(0, "聊一聊"), Section(1, "侃一侃"))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePostScreen(
onBackClick: () -> Unit = {},
onPostSuccess: () -> Unit = {},
viewModel: CreatePostViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val scrollState = rememberScrollState()
// 修改:使用枚举来管理活动的 BottomSheet
var activeSheet by remember { mutableStateOf(BottomSheetType.NONE) }
// 图片选择器 - 使用 PickVisualMedia 更可靠
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? ->
uri?.let {
// 获取持久化权限
try {
context.contentResolver.takePersistableUriPermission(
it,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: Exception) {
// 某些 URI 可能不支持持久化权限,继续处理
}
viewModel.addImageAndUpload(context, it)
}
}
// 监听发布成功
LaunchedEffect(uiState.success) {
if (uiState.success) {
onPostSuccess()
viewModel.resetState()
}
}
// 显示错误提示
uiState.error?.let {
LaunchedEffect(it) {
kotlinx.coroutines.delay(3000)
viewModel.clearError()
}
}
// 新增/修改:处理 BottomSheet 的显示逻辑
when (activeSheet) {
BottomSheetType.SECTION -> {
SelectionBottomSheet(
title = "选择分区",
items = sections,
selectedItem = sections.find { it.id == uiState.selectedSectionId },
onItemSelected = { section ->
viewModel.selectSection(section.id)
activeSheet = BottomSheetType.NONE
},
onDismiss = { activeSheet = BottomSheetType.NONE }
) { section, isSelected ->
SelectionListItem(
text = section.name,
isSelected = isSelected
)
}
}
BottomSheetType.CATEGORY -> {
SelectionBottomSheet(
title = "选择分类",
items = uiState.categories,
selectedItem = uiState.categories.find { it.categoryId == uiState.selectedCategoryId },
isLoading = uiState.isLoadingCategories,
onItemSelected = { category ->
viewModel.selectCategory(category.categoryId!!)
activeSheet = BottomSheetType.NONE
},
onDismiss = { activeSheet = BottomSheetType.NONE }
) { category, isSelected ->
SelectionListItem(
text = category.baseName ?: "未知分类",
description = category.baseDescription,
isSelected = isSelected
)
}
}
BottomSheetType.NONE -> {
// 不显示任何 BottomSheet
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "发布动态",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回"
)
}
},
actions = {
Button(
onClick = { viewModel.createPost() },
enabled = uiState.canPost,
modifier = Modifier.padding(end = 8.dp),
shape = MaterialTheme.shapes.medium
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("发布")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 内容输入
ContentInput(
content = uiState.content,
onContentChange = { viewModel.updateContent(it) },
charCount = uiState.contentCharCount,
isValid = uiState.isContentValid
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// 修改:使用新的选择器样式
SelectionField(
label = "选择分区",
selectedValueText = sections.find { it.id == uiState.selectedSectionId }?.name
?: "请选择分区",
onClick = { activeSheet = BottomSheetType.SECTION }
)
// 修改:使用新的选择器样式
SelectionField(
label = "选择分类",
selectedValueText = uiState.categories.find { it.categoryId == uiState.selectedCategoryId }?.baseName
?: "请选择分类",
isLoading = uiState.isLoadingCategories,
onClick = { activeSheet = BottomSheetType.CATEGORY }
)
}
// 图片选择
ImageSelector(
selectedImages = uiState.selectedImageUris,
uploadProgress = uiState.uploadProgress,
remainingSlots = uiState.remainingImageSlots,
onAddImage = {
imagePickerLauncher.launch(
androidx.activity.result.PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
},
onRemoveImage = { viewModel.removeImageAt(it) }
)
Spacer(modifier = Modifier.height(32.dp))
}
// 错误提示 (保持不变)
AnimatedVisibility(
visible = uiState.error != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
tonalElevation = 4.dp
) {
Text(
text = uiState.error ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
// 新增:统一的选择器按钮样式
@Composable
private fun RowScope.SelectionField(
label: String,
selectedValueText: String,
onClick: () -> Unit,
isLoading: Boolean = false
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline
)
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (isLoading) "加载中..." else selectedValueText,
style = MaterialTheme.typography.bodyLarge,
color = if (selectedValueText.startsWith("请选择"))
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.onSurface
)
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "展开选择",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// 新增:通用的半屏选择器组件
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> SelectionBottomSheet(
title: String,
items: List<T>,
selectedItem: T?,
onItemSelected: (T) -> Unit,
onDismiss: () -> Unit,
isLoading: Boolean = false,
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp)
)
HorizontalDivider()
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text("没有可选项", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyColumn(modifier = Modifier.navigationBarsPadding()) {
items(items) { item ->
Box(modifier = Modifier.clickable { onItemSelected(item) }) {
itemContent(item, item == selectedItem)
}
}
}
}
}
}
}
// 新增:选择列表中的条目样式
@Composable
fun SelectionListItem(
text: String,
description: String? = null,
isSelected: Boolean
) {
val backgroundColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
else
Color.Transparent
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
if (!description.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
fontFamily = LiteraryFontFamily,
style = MaterialTheme.typography.bodySmall,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "已选择",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
@Composable
private fun ContentInput(
content: String,
onContentChange: (String) -> Unit,
charCount: Int,
isValid: Boolean
) {
Column {
// Text(
// text = "内容",
// style = MaterialTheme.typography.titleSmall,
// fontWeight = FontWeight.SemiBold,
// color = MaterialTheme.colorScheme.onSurface
// )
// Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(
width = 1.dp,
color = if (isValid)
MaterialTheme.colorScheme.outline
else
MaterialTheme.colorScheme.error
)
) {
Column {
TextField(
value = content,
onValueChange = onContentChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 200.dp),
placeholder = {
Text(
text = "分享你的想法...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
textStyle = MaterialTheme.typography.bodyLarge,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
maxLines = 15
)
// 字数统计
Text(
text = "$charCount / 2000",
style = MaterialTheme.typography.labelSmall,
color = if (charCount > 2000)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.End)
.padding(12.dp)
)
}
}
}
}
@Composable
private fun ImageSelector(
selectedImages: List<String>,
uploadProgress: Map<String, Float>,
remainingSlots: Int,
onAddImage: () -> Unit,
onRemoveImage: (Int) -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "图片 (最多3张)",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "${selectedImages.size} / 3",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 显示已选择的图片
selectedImages.forEachIndexed { index, imageUri ->
ImagePreview(
imageUri = imageUri,
uploadProgress = uploadProgress[imageUri],
onRemove = { onRemoveImage(index) }
)
}
// 添加图片按钮
if (remainingSlots > 0) {
AddImageButton(onClick = onAddImage)
}
}
}
}
@Composable
private fun ImagePreview(
imageUri: String,
uploadProgress: Float?,
onRemove: () -> Unit
) {
Box(
modifier = Modifier
.size(100.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
AsyncImage(
model = imageUri,
contentDescription = "选择的图片",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// 上传进度
if (uploadProgress != null && uploadProgress < 1f) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = { uploadProgress },
modifier = Modifier.size(32.dp),
color = Color.White,
strokeWidth = 3.dp
)
}
}
// 删除按钮
Surface(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(24.dp),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.6f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "删除",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}
@Composable
private fun AddImageButton(
onClick: () -> Unit
) {
Surface(
onClick = onClick,
modifier = Modifier.size(100.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline
)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "添加图片",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "添加",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@ -1,591 +0,0 @@
package com.qingshuige.tangyuan.ui.screens
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.qingshuige.tangyuan.ui.theme.*
import com.qingshuige.tangyuan.utils.withPanguSpacing
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* 设计系统预览页面
*
* 用于集中展示和测试 `TangyuanTheme` 中的颜色排版和形状
* 方便设计师和开发者快速查阅和验证 UI 组件
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DesignSystemScreen(
onBackClick: () -> Unit = {}
) {
val scrollState = rememberScrollState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Tangyuan Design System",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回",
tint = MaterialTheme.colorScheme.onSurface
)
}
},
actions = {
IconButton(onClick = {}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "其他",
tint = MaterialTheme.colorScheme.onSurface
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(scrollState)
.padding(16.dp)
) {
DesignTitleWithGuides()
// 颜色系统
DesignSection(title = "颜色系统 (Colors)") {
ColorSystemPreview()
}
// 排版系统
DesignSection(title = "排版系统 (Typography)") {
TypographySystemPreview()
}
// 形状系统
DesignSection(title = "形状系统 (Shapes)") {
ShapeSystemPreview()
}
}
}
}
}
@Composable
fun DesignTitleWithGuides() {
// 状态变量,用于存储测量到的标题和副标题的布局坐标
var titleCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var subtitleCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
// 动画状态lineProgress 控制线条划过alpha 控制淡出
val lineProgress = remember { Animatable(0f) }
val alpha = remember { Animatable(1f) }
// 使用 LaunchedEffect 启动一次性动画
LaunchedEffect(Unit) {
// 启动一个协程来执行动画序列
launch {
// 1. 线条划入动画
lineProgress.animateTo(1f, animationSpec = tween(durationMillis = 600))
// 2. 短暂保持可见
delay(200)
// 3. 线条淡出动画
alpha.animateTo(0f, animationSpec = tween(durationMillis = 400))
}
}
// 定义参考线的颜色
val guideColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
Box(
modifier = Modifier.padding(bottom = 24.dp)
) {
// Canvas 用于在文本背后绘制引导线
Canvas(modifier = Modifier.matchParentSize()) {
val titleLayout = titleCoords
val subtitleLayout = subtitleCoords
// 确保坐标已经被测量到才开始绘制
if (titleLayout != null && subtitleLayout != null) {
val animatedAlphaColor = guideColor.copy(alpha = alpha.value)
// 计算整个标题区域的边界
val left = 0f
val top = 0f
val right = titleLayout.size.width.toFloat()
val bottom = subtitleLayout.positionInParent().y + subtitleLayout.size.height
// 动画进度
val progress = lineProgress.value
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 15f), 0f)
// 绘制四条动态参考线
// 1. 从左到右的上边线
drawLine(animatedAlphaColor, start = Offset(left, top), end = Offset(right * progress, top), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
// 2. 从左到右的下边线
drawLine(animatedAlphaColor, start = Offset(left, bottom), end = Offset(right * progress, bottom), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
// 3. 从上到下的左边线
drawLine(animatedAlphaColor, start = Offset(left, top), end = Offset(left, bottom * progress), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
// 4. 从上到下的右边线
drawLine(animatedAlphaColor, start = Offset(right, top), end = Offset(right, bottom * progress), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
}
}
// 实际的标题和副标题文本
Column {
Text(
text = "糖原社区设计系统",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.onGloballyPositioned { coordinates ->
titleCoords = coordinates
}
)
Text(
text = "现代简洁 · 文化雅致",
style = MaterialTheme.typography.titleMedium,
fontFamily = LiteraryFontFamily, // 保留文学字体
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.onGloballyPositioned { coordinates ->
subtitleCoords = coordinates
}
)
}
}
}
/**
* 设计系统的分区组件包含标题和内容
*/
@Composable
private fun DesignSection(title: String, content: @Composable () -> Unit) {
Column(modifier = Modifier.padding(vertical = 16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 12.dp)
)
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
content()
}
}
}
}
/**
* 颜色系统预览
*/
@Composable
private fun ColorSystemPreview() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("主题色 (Light / Dark)", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
ColorRoleItem("Primary", TangyuanColors.PrimaryLight, TangyuanColors.PrimaryDark)
ColorRoleItem("Secondary", TangyuanColors.SecondaryLight, TangyuanColors.SecondaryDark)
ColorRoleItem("Tertiary", TangyuanColors.TertiaryLight, TangyuanColors.TertiaryDark)
ColorRoleItem("Accent", TangyuanColors.AccentLight, TangyuanColors.AccentDark)
}
Spacer(Modifier.height(8.dp))
Text("功能色 (Success / Warning / Error)", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
ColorFunctionItem("Success", TangyuanColors.SuccessLight, TangyuanColors.SuccessDark)
ColorFunctionItem("Warning", TangyuanColors.WarningLight, TangyuanColors.WarningDark)
ColorFunctionItem("Error", TangyuanColors.ErrorLight, TangyuanColors.ErrorDark)
}
Spacer(Modifier.height(8.dp))
Text("界面基础色 (Background / Surface)", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SurfaceColorItem(
"Background",
TangyuanColors.BackgroundLight,
TangyuanColors.BackgroundDark,
TangyuanColors.OnBackgroundLight,
TangyuanColors.OnBackgroundDark
)
SurfaceColorItem(
"Surface",
TangyuanColors.SurfaceLight,
TangyuanColors.SurfaceDark,
TangyuanColors.OnSurfaceLight,
TangyuanColors.OnSurfaceDark
)
}
}
}
@Composable
private fun ColorRoleItem(name: String, lightColor: Color, darkColor: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row {
Box(
modifier = Modifier
.size(50.dp)
.background(lightColor)
)
Box(
modifier = Modifier
.size(50.dp)
.background(darkColor)
)
}
Text(name, style = MaterialTheme.typography.labelMedium)
}
}
@Composable
private fun RowScope.ColorFunctionItem(name: String, lightColor: Color, darkColor: Color) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.background(
brush = Brush.horizontalGradient(listOf(lightColor, darkColor)),
shape = MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
)
Text(
name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
}
}
@Composable
private fun RowScope.SurfaceColorItem(
name: String,
lightBg: Color,
darkBg: Color,
lightContent: Color,
darkContent: Color
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row {
Box(
modifier = Modifier
.size(60.dp)
.background(lightBg),
contentAlignment = Alignment.Center
) {
Text("Text", color = lightContent, style = MaterialTheme.typography.labelSmall)
}
Box(
modifier = Modifier
.size(60.dp)
.background(darkBg),
contentAlignment = Alignment.Center
) {
Text("Text", color = darkContent, style = MaterialTheme.typography.labelSmall)
}
}
Text(name, style = MaterialTheme.typography.labelMedium)
}
}
/**
* 排版系统预览
*/
@Composable
private fun TypographySystemPreview() {
val exampleText = "糖原社区 Tangyuan 2025"
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
// M3 Type Scale
TypographyItem("Headline Medium", exampleText, MaterialTheme.typography.headlineMedium)
TypographyItem("Title Large", exampleText, MaterialTheme.typography.titleLarge)
TypographyItem("Body Large", exampleText, MaterialTheme.typography.bodyLarge)
TypographyItem("Label Large", exampleText, MaterialTheme.typography.labelLarge)
// Font Weight Section
Spacer(Modifier.height(16.dp))
Text("字重 (Font Weights)", style = MaterialTheme.typography.titleMedium)
FontWeightShowcase()
// Chinese & Mixed Typography Section
Spacer(Modifier.height(16.dp))
Text("中英混排处理", style = MaterialTheme.typography.titleMedium)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("自动盘古之白 (Pangu Spacing)", style = MaterialTheme.typography.titleSmall)
Text(
text = "在Tangyuan中使用Jetpack Compose构建UI。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "在Tangyuan中使用Jetpack Compose构建UI。".withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// Special Purpose Fonts
Spacer(Modifier.height(16.dp))
Text("特殊用途字体", style = MaterialTheme.typography.titleMedium)
// Literary Font
TypographyItem(
name = "文学字体 (Literary)",
exampleText = "人生若只如初见",
style = TextStyle(
fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 28.sp,
letterSpacing = 0.8.sp
),
color = MaterialTheme.colorScheme.tertiary
)
// Other extended styles
TypographyItem(
"数字字体 (Number Large)",
"1,234,567",
TangyuanTypography.numberLarge,
MaterialTheme.colorScheme.primary
)
TypographyItem(
"代码字体 (Code)",
"val name = \"Tangyuan\"",
TangyuanTypography.code,
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun TypographyItem(
name: String,
exampleText: String,
style: TextStyle,
color: Color = MaterialTheme.colorScheme.onSurface
) {
Column(modifier = Modifier.padding(bottom = 8.dp)) {
Text(
text = name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = exampleText,
style = style.copy(color = color),
maxLines = 1
)
Text(
text = "Font: ${getFontFamilyName(style.fontFamily)} | Size: ${style.fontSize.value.toInt()}sp | Weight: ${style.fontWeight?.weight}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun FontWeightShowcase() {
val weights = listOf(
FontWeight.Normal to "Normal (400)",
FontWeight.Medium to "Medium (500)",
FontWeight.SemiBold to "SemiBold (600)",
FontWeight.Bold to "Bold (700)"
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
weights.forEach { (weight, name) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = name,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.width(120.dp)
)
Text(
text = "线粒体 XianlitiCN",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = weight)
)
}
}
}
}
private fun getFontFamilyName(fontFamily: FontFamily?): String {
return when (fontFamily) {
TangyuanGeneralFontFamily -> "General (混排)"
EnglishFontFamily -> "Quicksand"
ChineseFontFamily -> "Noto Sans SC"
LiteraryFontFamily -> "Noto Serif SC (文学)"
FontFamily.Monospace -> "Monospace"
else -> "Default"
}
}
/**
* 形状系统预览
*/
@Composable
private fun ShapeSystemPreview() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Material Shapes", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
ShapeItem("Extra Small (4dp)", MaterialTheme.shapes.extraSmall)
ShapeItem("Small (8dp)", MaterialTheme.shapes.small)
ShapeItem("Medium (12dp)", MaterialTheme.shapes.medium)
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
ShapeItem("Large (16dp)", MaterialTheme.shapes.large, Modifier.size(80.dp, 60.dp))
ShapeItem(
"Extra Large (28dp)",
MaterialTheme.shapes.extraLarge,
Modifier.size(80.dp, 60.dp)
)
}
Spacer(Modifier.height(16.dp))
Text("扩展形状 (TangyuanShapes)", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
ShapeItem("Circle", TangyuanShapes.Circle)
ShapeItem("Top Rounded", TangyuanShapes.TopRounded)
ShapeItem("Cultural Card", TangyuanShapes.CulturalCard)
}
}
}
@Composable
private fun ShapeItem(name: String, shape: Shape, modifier: Modifier = Modifier.size(72.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.primaryContainer, shape)
.border(1.dp, MaterialTheme.colorScheme.primary, shape)
)
Text(
text = name,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp)
)
}
}
// ====================================
// 预览
// ====================================
@Preview(showBackground = true, name = "Design System - Light Theme")
@Composable
fun DesignSystemScreenLightPreview() {
TangyuanTheme(darkTheme = false) {
Surface {
DesignSystemScreen()
}
}
}
@Preview(showBackground = true, name = "Design System - Dark Theme")
@Composable
fun DesignSystemScreenDarkPreview() {
TangyuanTheme(darkTheme = true) {
Surface {
DesignSystemScreen()
}
}
}

View File

@ -23,16 +23,12 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
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.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
@ -42,10 +38,11 @@ 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
import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
/**
* 图片详情页面 - 以图片为主的展示界面
@ -63,15 +60,8 @@ fun ImageDetailScreen(
animatedContentScope: AnimatedContentScope? = null
) {
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.saveMessage) {
state.saveMessage?.let { message ->
snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short)
viewModel.clearSaveMessage()
}
}
// 加载帖子详情
LaunchedEffect(postId) {
viewModel.loadPostDetail(postId)
}
@ -98,14 +88,8 @@ fun ImageDetailScreen(
ImageDetailTopBar(
onBackClick = onBackClick,
currentIndex = pagerState.currentPage + 1,
totalCount = imageUUIDs.size,
onSaveClick = {
val currentImageUrl =
"${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[pagerState.currentPage]}.jpg"
viewModel.saveCurrentImage(currentImageUrl)
}
totalCount = imageUUIDs.size
)
Spacer(modifier = Modifier.height(16.dp))
// 图片轮播区域
Box(
@ -125,8 +109,6 @@ fun ImageDetailScreen(
}
}
Spacer(modifier = Modifier.height(16.dp))
// 底部内容区域(模糊遮罩)
BottomContentOverlay(
postCard = postCard,
@ -136,12 +118,6 @@ fun ImageDetailScreen(
animatedContentScope = animatedContentScope
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
)
}
}
@ -209,8 +185,7 @@ private fun BackgroundBlurredImage(
private fun ImageDetailTopBar(
onBackClick: () -> Unit,
currentIndex: Int,
totalCount: Int,
onSaveClick: () -> Unit
totalCount: Int
) {
TopAppBar(
title = {
@ -231,22 +206,15 @@ private fun ImageDetailTopBar(
)
}
},
actions = {
// 保存图片按钮
IconButton(onClick = onSaveClick) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = "保存图片",
tint = Color.White
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
/**
* 图片轮播组件
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun ImagePager(
@ -256,13 +224,9 @@ private fun ImagePager(
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) {
// 用于控制Pager是否允许左右滑动
var canScroll by remember { mutableStateOf(true) }
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = canScroll // ✅ 当缩放时禁用Pager滑动
modifier = Modifier.fillMaxSize()
) { page ->
ZoomableImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg",
@ -270,14 +234,14 @@ private fun ImagePager(
imageIndex = page,
contentDescription = "图片 ${page + 1}",
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
onScaleChanged = { newScale ->
canScroll = newScale <= 1.01f // 缩放>1时禁用pager滑动
}
animatedContentScope = animatedContentScope
)
}
}
/**
* 可缩放的图片组件
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun ZoomableImage(
@ -286,45 +250,23 @@ private fun ZoomableImage(
imageIndex: Int,
contentDescription: String,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
onScaleChanged: (Float) -> Unit = {}
animatedContentScope: AnimatedContentScope? = null
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
// ✅ 缩放 & 平移逻辑
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(1f, 5f)
val maxX = (newScale - 1f) * 400f
val maxY = (newScale - 1f) * 400f
val newOffset = Offset(
x = (offset.x + pan.x).coerceIn(-maxX, maxX),
y = (offset.y + pan.y).coerceIn(-maxY, maxY)
val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 5f)
val maxX = (scale - 1) * 300
val maxY = (scale - 1) * 300
offset = Offset(
x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX),
y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY)
)
}
scale = newScale
offset = newOffset
onScaleChanged(newScale)
}
}
// ✅ 双击缩放
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale > 1f) {
scale = 1f
offset = Offset.Zero
} else {
scale = 2f
}
onScaleChanged(scale)
}
)
},
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AsyncImage(
@ -333,34 +275,43 @@ private fun ZoomableImage(
.crossfade(true)
.build(),
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
.then(
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
},
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
renderInOverlayDuringTransition = false
)
}
} else Modifier
} else mod
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
.transformable(state = transformableState)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scale = if (scale > 1f) 1f else 2f
offset = Offset.Zero
}
)
},
contentScale = ContentScale.Fit
)
}
}
/**
* 底部内容遮罩
*/
@ -407,13 +358,17 @@ private fun BottomContentOverlay(
.fillMaxWidth()
.padding(20.dp)
) {
// 作者信息
PostAuthorInfo(
postCard = postCard,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
Spacer(modifier = Modifier.height(16.dp))
// 文章内容
Text(
text = postCard.textContent.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium.copy(
@ -424,7 +379,10 @@ private fun BottomContentOverlay(
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(16.dp))
// 分类和时间
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -443,6 +401,7 @@ private fun BottomContentOverlay(
fontWeight = FontWeight.Medium
)
}
Text(
text = postCard.getTimeDisplayText(),
style = MaterialTheme.typography.bodySmall,
@ -450,7 +409,10 @@ private fun BottomContentOverlay(
color = Color.White.copy(alpha = 0.8f)
)
}
Spacer(modifier = Modifier.height(20.dp))
// 上滑提示 - 放在最下面居中
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@ -505,7 +467,9 @@ private fun PostAuthorInfo(
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = postCard.authorName.withPanguSpacing(),
@ -525,6 +489,7 @@ private fun PostAuthorInfo(
}
} else Modifier
)
if (postCard.authorBio.isNotBlank()) {
Text(
text = postCard.authorBio.withPanguSpacing(),
@ -592,6 +557,7 @@ private fun ErrorContent(
tint = Color.White,
modifier = Modifier.size(48.dp)
)
Text(
text = "加载失败",
style = MaterialTheme.typography.headlineSmall,
@ -599,6 +565,7 @@ private fun ErrorContent(
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
@ -606,6 +573,7 @@ private fun ErrorContent(
color = Color.White.copy(alpha = 0.8f),
textAlign = TextAlign.Center
)
Button(
onClick = onRetry,
colors = ButtonDefaults.buttonColors(

View File

@ -137,9 +137,8 @@ private fun PostDetailTopBar(
TopAppBar(
title = {
Text(
text = "帖子详情",
fontFamily = TangyuanGeneralFontFamily,
style = MaterialTheme.typography.titleMedium,
text = "详情",
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
},
@ -310,7 +309,6 @@ private fun PostDetailCard(
lineHeight = 28.sp
),
fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
@ -532,7 +530,7 @@ private fun CommentSectionHeader(commentCount: Int, isLoading: Boolean = false)
Text(
text = "评论 ($commentCount)",
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)

View File

@ -44,7 +44,6 @@ fun UserDetailScreen(
userId: Int,
onBackClick: () -> Unit,
onPostClick: (Int) -> Unit = {},
onImageClick: (postId: Int, imageIndex: Int) -> Unit = { _, _ -> },
onFollowClick: () -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
@ -154,7 +153,7 @@ fun UserDetailScreen(
onBookmarkClick = { /* TODO: 实现收藏 */ },
onMoreClick = { /* TODO: 实现更多操作 */ },
onImageClick = { postId, imageIndex ->
onImageClick(postId, imageIndex)
// TODO: 实现图片点击
},
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
@ -190,14 +189,14 @@ private fun UserDetailTopBar(
if (isLoading) {
Text(
text = "用户详情",
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = if (userName.isNotBlank()) "用户详情 · $userName" else "用户详情",
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,

View File

@ -1,2 +0,0 @@
package com.qingshuige.tangyuan.ui.screens

View File

@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DesignServices
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowRight
@ -77,7 +76,6 @@ fun UserScreen(
onPostManagement: () -> Unit = {},
onSettings: () -> Unit = {},
onAbout: () -> Unit = {},
onDesignSystem: () -> Unit = {},
userViewModel: UserViewModel = hiltViewModel()
) {
val loginState by userViewModel.loginState.collectAsState()
@ -106,18 +104,11 @@ fun UserScreen(
MenuSection(
onPostManagement = onPostManagement,
onSettings = onSettings,
onAbout = onAbout,
onDesignSystem = onDesignSystem
onAbout = onAbout
)
} else {
// 未登录状态
NotLoggedInContent()
Spacer(modifier = Modifier.height(24.dp))
MenuSectionNotLogin(
onSettings = onSettings,
onAbout = onAbout,
onDesignSystem = onDesignSystem
)
}
}
}
@ -405,7 +396,6 @@ private fun VerticalDivider() {
@Composable
private fun MenuSection(
onPostManagement: () -> Unit,
onDesignSystem: () -> Unit,
onSettings: () -> Unit,
onAbout: () -> Unit
) {
@ -448,76 +438,6 @@ private fun MenuSection(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
)
MenuItem(
icon = Icons.Default.DesignServices,
title = "关于糖原设计系统",
subtitle = "了解 App 的设计系统与排版规范",
onClick = onDesignSystem,
showDivider = false
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
)
MenuItem(
icon = Icons.Default.Info,
title = "关于",
subtitle = "版本信息和帮助",
onClick = onAbout,
showDivider = false
)
}
}
}
@Composable
private fun MenuSectionNotLogin(
onSettings: () -> Unit,
onDesignSystem: () -> Unit,
onAbout: () -> Unit
) {
Text(
text = "功能菜单",
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
Box(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
MenuItem(
icon = Icons.Default.Settings,
title = "设置",
subtitle = "个性化设置和隐私选项",
onClick = onSettings
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
)
MenuItem(
icon = Icons.Default.DesignServices,
title = "关于糖原设计系统",
subtitle = "了解 App 的设计系统与排版规范",
onClick = onDesignSystem,
showDivider = false
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
)
MenuItem(
icon = Icons.Default.Info,
title = "关于",

View File

@ -1,97 +0,0 @@
package com.qingshuige.tangyuan.utils
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object ImageSaveUtils {
suspend fun saveImageToGallery(
context: Context,
imageUrl: String,
fileName: String? = null
): Result<String> = withContext(Dispatchers.IO) {
try {
val imageLoader = ImageLoader(context)
val request = ImageRequest.Builder(context)
.data(imageUrl)
.build()
val drawable = imageLoader.execute(request).drawable
val bitmap = (drawable as? BitmapDrawable)?.bitmap
?: return@withContext Result.failure(Exception("无法获取图片"))
val displayName = fileName ?: "Tangyuan_${System.currentTimeMillis()}.jpg"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveImageToMediaStore(context, bitmap, displayName)
} else {
saveImageToExternalStorage(bitmap, displayName)
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun saveImageToMediaStore(
context: Context,
bitmap: Bitmap,
displayName: String
): Result<String> {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Tangyuan")
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return Result.failure(Exception("无法创建文件"))
return try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
Result.success("图片已保存到相册")
} catch (e: IOException) {
Result.failure(e)
}
}
private fun saveImageToExternalStorage(
bitmap: Bitmap,
displayName: String
): Result<String> {
val picturesDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"Tangyuan"
)
if (!picturesDir.exists()) {
picturesDir.mkdirs()
}
val imageFile = File(picturesDir, displayName)
return try {
FileOutputStream(imageFile).use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
Result.success("图片已保存到 ${imageFile.absolutePath}")
} catch (e: IOException) {
Result.failure(e)
}
}
}

View File

@ -1,327 +0,0 @@
package com.qingshuige.tangyuan.viewmodel
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.qingshuige.tangyuan.model.Category
import com.qingshuige.tangyuan.model.CreatePostDto
import com.qingshuige.tangyuan.model.CreatePostState
import com.qingshuige.tangyuan.network.TokenManager
import com.qingshuige.tangyuan.repository.CreatePostRepository
import com.qingshuige.tangyuan.repository.MediaRepository
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 okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
@HiltViewModel
class CreatePostViewModel @Inject constructor(
private val createPostRepository: CreatePostRepository,
private val mediaRepository: MediaRepository
) : ViewModel() {
private val tokenManager = TokenManager()
private val _uiState = MutableStateFlow(CreatePostState())
val uiState: StateFlow<CreatePostState> = _uiState.asStateFlow()
init {
loadCategories()
}
/**
* 加载所有分类
*/
private fun loadCategories() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoadingCategories = true, error = null)
createPostRepository.getAllCategories()
.catch { e ->
_uiState.value = _uiState.value.copy(
isLoadingCategories = false,
error = "加载分类失败: ${e.message}"
)
}
.collect { categories ->
_uiState.value = _uiState.value.copy(
isLoadingCategories = false,
categories = categories,
selectedCategoryId = categories.firstOrNull()?.categoryId
)
}
}
}
/**
* 更新内容
*/
fun updateContent(content: String) {
_uiState.value = _uiState.value.copy(content = content)
}
/**
* 选择分类
*/
fun selectCategory(categoryId: Int) {
_uiState.value = _uiState.value.copy(selectedCategoryId = categoryId)
}
/**
* 选择分区 (0: 聊一聊, 1: 侃一侃)
*/
fun selectSection(sectionId: Int) {
_uiState.value = _uiState.value.copy(selectedSectionId = sectionId)
}
/**
* 添加图片 URI
*/
fun addImageUri(uri: String) {
Log.d("CreatePostViewModel", "Adding image URI: $uri")
val currentImages = _uiState.value.selectedImageUris
if (currentImages.size < 3) {
_uiState.value = _uiState.value.copy(
selectedImageUris = currentImages + uri
)
}
Log.d("CreatePostViewModel", "Updated image URIs: ${_uiState.value.selectedImageUris}")
}
/**
* 移除图片
*/
fun removeImageAt(index: Int) {
val currentImages = _uiState.value.selectedImageUris.toMutableList()
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
if (index in currentImages.indices) {
currentImages.removeAt(index)
if (index in currentUUIDs.indices) {
currentUUIDs.removeAt(index)
}
_uiState.value = _uiState.value.copy(
selectedImageUris = currentImages,
uploadedImageUUIDs = currentUUIDs
)
}
}
fun addImageAndUpload(context: Context, uri: Uri) {
val uriString = uri.toString()
val currentImages = _uiState.value.selectedImageUris
if (currentImages.size < 3) {
// 1. 更新 URI 列表
val updatedImages = currentImages + uriString
_uiState.value = _uiState.value.copy(
selectedImageUris = updatedImages
)
Log.d("CreatePostViewModel", "Updated image URIs: $updatedImages")
// 2. 使用刚刚更新的列表来获取正确的索引
val newIndex = updatedImages.size - 1
// 3. 使用正确的索引来调用上传逻辑
uploadImage(context, uri, newIndex)
}
}
/**
* 上传单张图片不要直接调用这个方法所有状态让ViewModel管理
*/
private fun uploadImage(context: Context, uri: Uri, index: Int) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(
isUploading = true,
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 0.5f)
)
// 将 Uri 转换为 File
val file = uriToFile(context, uri)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
// 上传图片
mediaRepository.uploadImage(body)
.catch { e ->
Log.e("CreatePostViewModel", "Image upload error: ${e}")
_uiState.value = _uiState.value.copy(
isUploading = false,
error = "图片上传失败: ${e.message}",
uploadProgress = _uiState.value.uploadProgress - uri.toString()
)
file.delete()
}
.collect { result ->
// API 返回的是 Map<String, String>,其中包含图片的 UUID
val imageUUID = result["guid"]
Log.d("CreatePostViewModel", "Image uploaded with UUID: $imageUUID")
Log.d(
"CreatePostViewModel",
"Current uploaded UUIDs: ${_uiState.value.uploadedImageUUIDs}"
)
Log.d(
"CreatePostViewModel",
"Current selected URIs: ${_uiState.value.selectedImageUris}"
)
Log.d("CreatePostViewModel", "Index: $index")
if (imageUUID != null) {
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
// 确保列表长度足够
while (currentUUIDs.size <= index) {
currentUUIDs.add("")
}
currentUUIDs[index] = imageUUID
_uiState.value = _uiState.value.copy(
uploadedImageUUIDs = currentUUIDs,
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 1f)
)
}
// 检查是否所有图片都上传完成
val allUploaded = _uiState.value.selectedImageUris.size ==
_uiState.value.uploadedImageUUIDs.size
if (allUploaded) {
_uiState.value = _uiState.value.copy(isUploading = false)
}
file.delete()
}
} catch (e: Exception) {
Log.e("CreatePostViewModel", "Image upload error: ${e}")
_uiState.value = _uiState.value.copy(
isUploading = false,
error = "图片处理失败: ${e.message}"
)
}
}
}
/**
* 上传所有图片
*/
fun uploadAllImages(context: Context) {
viewModelScope.launch {
_uiState.value.selectedImageUris.forEachIndexed { index, uriString ->
if (index >= _uiState.value.uploadedImageUUIDs.size ||
_uiState.value.uploadedImageUUIDs[index].isEmpty()
) {
uploadImage(context, Uri.parse(uriString), index)
}
}
}
}
/**
* 发布帖子
*/
fun createPost() {
viewModelScope.launch {
val state = _uiState.value
// 验证
if (state.content.isBlank()) {
_uiState.value = state.copy(error = "请输入内容")
return@launch
}
if (state.selectedCategoryId == null) {
_uiState.value = state.copy(error = "请选择分类")
return@launch
}
// 检查图片是否都已上传
if (state.selectedImageUris.isNotEmpty() &&
state.selectedImageUris.size != state.uploadedImageUUIDs.size
) {
_uiState.value = state.copy(error = "图片正在上传中,请稍候")
return@launch
}
val userId = tokenManager.getUserIdFromToken()
if (userId == null) {
_uiState.value = state.copy(error = "请先登录")
return@launch
}
_uiState.value = state.copy(isLoading = true, error = null)
val createPostDto = CreatePostDto(
textContent = state.content,
categoryId = state.selectedCategoryId,
sectionId = state.selectedSectionId,
isVisible = true,
imageUUIDs = state.uploadedImageUUIDs
)
createPostRepository.createPost(createPostDto, userId)
.onSuccess { postId ->
_uiState.value = _uiState.value.copy(
isLoading = false,
success = true
)
}
.onFailure { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "发布失败: ${e.message}"
)
}
}
}
/**
* 重置状态
*/
fun resetState() {
_uiState.value = CreatePostState(categories = _uiState.value.categories)
}
/**
* 清除错误
*/
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
/**
* Uri 转换为 File
*/
private fun uriToFile(context: Context, uri: Uri): File {
val contentResolver = context.contentResolver
val file = File(context.cacheDir, "upload_${System.currentTimeMillis()}.jpg")
try {
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
}
} ?: throw IllegalStateException("无法打开输入流")
if (!file.exists() || file.length() == 0L) {
throw IllegalStateException("文件创建失败或为空")
}
} catch (e: Exception) {
Log.e("CreatePostViewModel", "Uri to File conversion failed", e)
file.delete()
throw e
}
return file
}
}

View File

@ -1,33 +1,25 @@
package com.qingshuige.tangyuan.viewmodel
import android.content.Context
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.network.TokenManager
import com.qingshuige.tangyuan.repository.PostDetailRepository
import com.qingshuige.tangyuan.utils.ImageSaveUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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 java.util.Date
import javax.inject.Inject
@HiltViewModel
class PostDetailViewModel @Inject constructor(
private val postDetailRepository: PostDetailRepository,
@ApplicationContext private val context: Context
private val postDetailRepository: PostDetailRepository
) : ViewModel() {
private val tokenManager = TokenManager()
private val _state = MutableStateFlow(PostDetailState())
val state: StateFlow<PostDetailState> = _state.asStateFlow()
@ -37,9 +29,9 @@ class PostDetailViewModel @Inject constructor(
/**
* 加载帖子详情和评论 - 分离加载先加载帖子再加载评论
*/
fun loadPostDetail(postId: Int) {
fun loadPostDetail(postId: Int, userId: Int = 0) {
currentPostId = postId
currentUserId = tokenManager.getUserIdFromToken() ?: 0
currentUserId = userId
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
@ -61,7 +53,7 @@ class PostDetailViewModel @Inject constructor(
)
// 然后异步加载评论
loadComments(postId, currentUserId)
loadComments(postId, userId)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
@ -137,10 +129,8 @@ class PostDetailViewModel @Inject constructor(
val createCommentDto = CreateCommentDto(
postId = currentPostId.toLong(),
commentDateTime = Date(),
content = content,
parentCommentId = if (parentCommentId == 0) 0L else parentCommentId.toLong(),
userId = currentUserId.toLong()
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong()
)
postDetailRepository.createComment(createCommentDto)
@ -294,36 +284,4 @@ class PostDetailViewModel @Inject constructor(
currentPostId = 0
currentUserId = 0
}
/**
* 保存当前图片到本地
*/
fun saveCurrentImage(imageUrl: String) {
viewModelScope.launch {
try {
val result = ImageSaveUtils.saveImageToGallery(context, imageUrl)
result.onSuccess { message ->
_state.value = _state.value.copy(
error = null,
saveMessage = message
)
}.onFailure { exception ->
_state.value = _state.value.copy(
error = exception.message ?: "保存图片失败"
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
error = e.message ?: "保存图片失败"
)
}
}
}
/**
* 清除保存消息
*/
fun clearSaveMessage() {
_state.value = _state.value.copy(saveMessage = null)
}
}

View File

@ -161,26 +161,13 @@ class PostViewModel @Inject constructor(
viewModelScope.launch {
_postUiState.value = _postUiState.value.copy(isCreating = true, error = null)
try {
var postId: Int? = null
postRepository.createPostMetadata(metadata)
.catch { e -> throw e }
.collect { id ->
postId = id
postRepository.createPostBody(body.copy(postId = id))
.catch { e -> throw e }
.collect { success ->
if (success) {
_postUiState.value = _postUiState.value.copy(
isCreating = false,
createSuccess = true
)
} else {
throw Exception("Failed to create post body")
}
}
}
// 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,

View File

@ -150,7 +150,6 @@ class UserViewModel @Inject constructor(
val token = result["token"]
if (token != null) {
tokenManager.token = token
println("DEBUG: 手动登录成功已保存token: ${token.take(20)}...")
}
// 登录成功,保存账号密码用于自动登录
tokenManager.setPhoneNumberAndPassword(
@ -163,13 +162,8 @@ class UserViewModel @Inject constructor(
isLoggedIn = true,
)
// 确保token保存后再获取用户信息
if (token != null) {
println("DEBUG: 手动登录后开始获取用户信息")
// 登录成功后获取用户信息
getCurrentUserFromToken()
} else {
println("DEBUG: 手动登录失败未获取到token")
}
}
}
}