Compare commits

..

4 Commits

Author SHA1 Message Date
0f5bfe6aec
feat: Implement "Create Post" screen with image upload
This commit introduces a new "Create Post" screen, enabling users to compose and publish posts with text and up to three images. The implementation includes a dedicated ViewModel, robust state management, and an asynchronous image upload flow.

**Key Changes:**

*   **feat(Create Post Screen):**
    *   Added `CreatePostScreen.kt`, a new Composable screen for creating posts.
    *   The UI includes a text input field, selectors for "Section" and "Category," and an image picker/preview area.
    *   Implemented `SelectionBottomSheet` as a reusable component for picking sections and categories from a modal bottom sheet.
    *   The "Publish" button is dynamically enabled based on content validation and image upload status.
    *   Error messages are displayed to the user via a snackbar-like component.

*   **feat(CreatePostViewModel):**
    *   Created a new `CreatePostViewModel` to manage the logic and state (`CreatePostState`) of the creation process.
    *   Fetches post categories on initialization.
    *   Handles image selection and manages an asynchronous upload queue. Each selected image is immediately uploaded to the backend via `MediaRepository`.
    *   The ViewModel tracks the upload progress for each image and updates the UI accordingly.
    *   The `createPost` function validates all inputs, compiles the post data (including uploaded image UUIDs), and sends it to the repository.

*   **feat(Navigation):**
    *   Added a `CreatePost` route to the navigation graph (`Screen.kt`).
    *   Integrated the new screen into `App.kt` with custom horizontal slide transitions.
    *   The publish button on the main screen's top bar now navigates to the `CreatePostScreen`.
    *   Upon successful post creation, the user is navigated back to the main screen.

*   **refactor(PostDetailViewModel):**
    *   Adjusted the `createComment` logic to correctly handle `parentCommentId`, sending `0L` instead of `null` when there is no parent comment.
2025-10-09 00:53:20 +08:00
a528b623b9
feat: Implement Design System screen and enhance UI/UX
This commit introduces a new "Design System" screen for developers and designers, refines navigation animations for a smoother experience, and improves typography consistency across various UI components.

**Key Changes:**

*   **feat(Design System):**
    *   Added a new `DesignSystemScreen.kt` to visually showcase the app's color palette, typography styles, and shape system.
    *   The screen features animated guides and detailed specifications for each design token (e.g., font size, weight, color roles).
    *   A new "About Tangyuan Design System" menu item has been added to the `UserScreen` (for both logged-in and logged-out states), navigating to this new screen.

*   **refactor(Navigation):**
    *   Replaced default navigation transitions with custom animations using `CubicBezierEasing` for a more fluid and responsive feel (e.g., quick spring and fade effects).
    *   Detail screens (`PostDetail`, `UserDetail`, `ImageDetail`) now use a `fadeIn`/`fadeOut` transition to prevent conflicts with shared element animations.
    *   Side-panel screens (`About`, `DesignSystem`) now use a horizontal slide transition.
    *   Navigation between main tabs (`Talk`, `Message`, `User`) is now animated with a horizontal slide and fade.

*   **refactor(Typography & UI):**
    *   Standardized the font weight for titles and important text to `SemiBold` across `PostCardItem`, `PostDetailScreen`, and `CommentComponents` for better visual hierarchy.
    *   Updated the `PostDetailScreen` top bar title to "帖子详情" for clarity.
    *   Replaced the literary font with the general-purpose font in some UI elements like comment interaction labels for improved readability.
    *   Enabled image click navigation from the `UserDetailScreen`'s post feed.

*   **feat(Create Post):**
    *   Introduced `CreatePostDto.kt` and `CreatePostRepository.kt` to support post creation.
    *   The repository now handles fetching categories and creating posts through a two-step process: creating post metadata and then the post body.
    *   Added `CreatePostState` to manage the UI state for the post creation screen, including validation for content length and image limits.
2025-10-08 17:16:33 +08:00
c5ec5b1a0b
refactor: Integrate user auth for post interactions and enable post creation
This commit enhances the post detail and creation flows by integrating user authentication and activating the post creation functionality.

**Key Changes:**

*   **refactor(PostDetailViewModel):**
    *   The `loadPostDetail` function no longer requires `userId` as a parameter. It now retrieves the current user's ID directly from the JWT using `TokenManager`.
    *   When creating a new comment (`CreateCommentDto`), the current `commentDateTime` (using `java.util.Date`) is now included.

*   **feat(PostViewModel):**
    *   The previously stubbed `createPost` function has been fully implemented.
    *   It now makes sequential repository calls: first to `createPostMetadata` to get a new `postId`, and then to `createPostBody` with that ID.
    *   The UI state is updated to reflect success or failure throughout the creation process.
2025-10-08 00:33:29 +08:00
4325757404
feat: Implement "Save to Gallery" and enhance image/comment interactions
This commit introduces the ability for users to save images to their device's gallery and includes several enhancements to the image detail screen and comment section.

**Key Changes:**

*   **feat(Image Saving):**
    *   **ImageSaveUtils:** Added a new `ImageSaveUtils.kt` utility to handle saving images to the device gallery. It supports both modern (Android Q+) and legacy storage APIs.
    *   **Save Button:** Implemented a "Save" icon button in the `ImageDetailScreen` top bar, allowing users to download the currently viewed image.
    *   **Feedback:** The app now displays a `Snackbar` message (e.g., "Image saved to gallery") upon successful or failed save operations.

*   **feat(Image Detail Screen):**
    *   **Improved Zoom/Pan:** Reworked the zoom and pan logic in `ZoomableImage` for a smoother experience. The `HorizontalPager` is now disabled when an image is zoomed in to prevent accidental swiping.
    *   **Double-Tap to Zoom:** Added double-tap-to-zoom functionality on the `ImageDetailScreen`.

*   **refactor(Comments):**
    *   **Improved Reply UI:** The comment input bar now shows a preview of the comment being replied to (e.g., "Replying to User: This is the comment...").
    *   **Deprecated DTO Fields:** Marked `commentDateTime` and `imageGuid` in `CreateCommentDto` as deprecated, as they are now handled by the backend.

*   **refactor(Permissions):**
    *   Added `WRITE_EXTERNAL_STORAGE`, `READ_EXTERNAL_STORAGE`, and `READ_MEDIA_IMAGES` permissions to `AndroidManifest.xml` to support the new image saving feature across different Android versions.

*   **fix(Login):**
    *   Ensured that the user's profile information is fetched immediately after the login token is successfully saved, fixing a potential race condition.
2025-10-08 00:13:38 +08:00
23 changed files with 2454 additions and 121 deletions

View File

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

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?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,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:name=".TangyuanApplication" android:name=".TangyuanApplication"

View File

@ -21,6 +21,8 @@ import com.qingshuige.tangyuan.ui.components.PageLevel
import com.qingshuige.tangyuan.ui.components.TangyuanBottomAppBar import com.qingshuige.tangyuan.ui.components.TangyuanBottomAppBar
import com.qingshuige.tangyuan.ui.components.TangyuanTopBar import com.qingshuige.tangyuan.ui.components.TangyuanTopBar
import com.qingshuige.tangyuan.ui.screens.AboutScreen 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.PostDetailScreen
import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen
import com.qingshuige.tangyuan.ui.screens.TalkScreen import com.qingshuige.tangyuan.ui.screens.TalkScreen
@ -29,6 +31,10 @@ import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
import com.qingshuige.tangyuan.ui.screens.UserScreen import com.qingshuige.tangyuan.ui.screens.UserScreen
import com.qingshuige.tangyuan.viewmodel.UserViewModel 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) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun App() { fun App() {
@ -52,8 +58,10 @@ fun App() {
navController.navigate(Screen.UserDetail.createRoute(authorId)) navController.navigate(Screen.UserDetail.createRoute(authorId))
}, },
onAboutClick = { navController.navigate(Screen.About.route) }, onAboutClick = { navController.navigate(Screen.About.route) },
onCreatePostClick = { navController.navigate(Screen.CreatePost.route) },
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable animatedContentScope = this@composable,
onDesignSystemClick = { navController.navigate(Screen.DesignSystem.route) }
) )
} }
@ -63,8 +71,8 @@ fun App() {
slideInVertically( slideInVertically(
initialOffsetY = { it }, initialOffsetY = { it },
animationSpec = tween( animationSpec = tween(
durationMillis = 800, durationMillis = 350,
easing = FastOutSlowInEasing easing = QuickSpringEasing
) )
) )
}, },
@ -72,8 +80,8 @@ fun App() {
slideOutVertically( slideOutVertically(
targetOffsetY = { it }, targetOffsetY = { it },
animationSpec = tween( animationSpec = tween(
durationMillis = 600, durationMillis = 250,
easing = FastOutSlowInEasing easing = QuickEasing
) )
) )
}, },
@ -81,8 +89,8 @@ fun App() {
slideOutVertically( slideOutVertically(
targetOffsetY = { it }, targetOffsetY = { it },
animationSpec = tween( animationSpec = tween(
durationMillis = 600, durationMillis = 250,
easing = FastOutSlowInEasing easing = QuickEasing
) )
) )
} }
@ -90,12 +98,44 @@ fun App() {
LoginScreen(navController = navController) LoginScreen(navController = navController)
} }
// 帖子详情页 // 帖子详情页 - 使用淡入淡出避免与共享元素冲突
composable( composable(
route = Screen.PostDetail.route, route = Screen.PostDetail.route,
arguments = listOf( arguments = listOf(
navArgument("postId") { type = NavType.IntType } 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 -> ) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0 val postId = backStackEntry.arguments?.getInt("postId") ?: 0
@ -118,13 +158,45 @@ fun App() {
) )
} }
// 图片详情页 // 图片详情页 - 使用淡入淡出避免与共享元素冲突
composable( composable(
route = Screen.ImageDetail.route, route = Screen.ImageDetail.route,
arguments = listOf( arguments = listOf(
navArgument("postId") { type = NavType.IntType }, navArgument("postId") { type = NavType.IntType },
navArgument("imageIndex") { 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 -> ) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0 val postId = backStackEntry.arguments?.getInt("postId") ?: 0
val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0 val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0
@ -149,12 +221,44 @@ fun App() {
) )
} }
// 用户详情页 // 用户详情页 - 使用淡入淡出避免与共享元素冲突
composable( composable(
route = Screen.UserDetail.route, route = Screen.UserDetail.route,
arguments = listOf( arguments = listOf(
navArgument("userId") { type = NavType.IntType } 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 -> ) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: 0 val userId = backStackEntry.arguments?.getInt("userId") ?: 0
@ -164,6 +268,14 @@ fun App() {
onPostClick = { postId -> onPostClick = { postId ->
navController.navigate(Screen.PostDetail.createRoute(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 = { onFollowClick = {
// TODO: 实现关注功能 // TODO: 实现关注功能
}, },
@ -172,11 +284,144 @@ fun App() {
) )
} }
composable(Screen.About.route) { 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
)
)
}
) {
AboutScreen( AboutScreen(
onBackClick = { navController.popBackStack() } 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)
}
)
}
} }
} }
} }
@ -189,6 +434,8 @@ fun MainFlow(
onImageClick: (Int, Int) -> Unit = { _, _ -> }, onImageClick: (Int, Int) -> Unit = { _, _ -> },
onAuthorClick: (Int) -> Unit = {}, onAuthorClick: (Int) -> Unit = {},
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onDesignSystemClick: () -> Unit,
onCreatePostClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null, animatedContentScope: AnimatedContentScope? = null,
userViewModel: UserViewModel = hiltViewModel() userViewModel: UserViewModel = hiltViewModel()
@ -236,7 +483,7 @@ fun MainFlow(
pageLevel = PageLevel.PRIMARY, pageLevel = PageLevel.PRIMARY,
onAvatarClick = onAvatarClick, onAvatarClick = onAvatarClick,
onAnnouncementClick = {/* 公告点击事件 */ }, onAnnouncementClick = {/* 公告点击事件 */ },
onPostClick = {/* 发表点击事件 */ } onPostClick = onCreatePostClick
) )
}, },
bottomBar = { bottomBar = {
@ -254,7 +501,63 @@ fun MainFlow(
NavHost( NavHost(
navController = mainNavController, navController = mainNavController,
startDestination = Screen.Talk.route, startDestination = Screen.Talk.route,
modifier = Modifier.padding(innerPadding) 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
)
)
}
) { ) {
composable(Screen.Talk.route) { composable(Screen.Talk.route) {
TalkScreen( TalkScreen(
@ -278,7 +581,8 @@ fun MainFlow(
onSettings = { onSettings = {
// TODO: 导航到设置页面 // TODO: 导航到设置页面
}, },
onAbout = onAboutClick onAbout = onAboutClick,
onDesignSystem = onDesignSystemClick,
) )
} }
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,66 @@
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,11 +17,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -30,8 +32,8 @@ import com.qingshuige.tangyuan.TangyuanApplication
import com.qingshuige.tangyuan.model.CommentCard import com.qingshuige.tangyuan.model.CommentCard
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
import com.qingshuige.tangyuan.utils.withPanguSpacing import com.qingshuige.tangyuan.utils.withPanguSpacing
import java.util.Date
/** /**
* 评论项组件 * 评论项组件
@ -120,6 +122,7 @@ private fun CommentMainContent(
lineHeight = 20.sp lineHeight = 20.sp
), ),
fontFamily = LiteraryFontFamily, fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@ -299,7 +302,7 @@ private fun CommentActions(
*/ */
@Composable @Composable
private fun CommentActionButton( private fun CommentActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: ImageVector,
count: Int = 0, count: Int = 0,
text: String = "", text: String = "",
isActive: Boolean, isActive: Boolean,
@ -328,7 +331,7 @@ private fun CommentActionButton(
Text( Text(
text = count.toString(), text = count.toString(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = color, color = color,
fontSize = 11.sp fontSize = 11.sp
) )
@ -339,7 +342,7 @@ private fun CommentActionButton(
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = color, color = color,
fontSize = 11.sp fontSize = 11.sp
) )
@ -446,6 +449,7 @@ private fun ReplyItem(
lineHeight = 18.sp lineHeight = 18.sp
), ),
fontFamily = LiteraryFontFamily, fontFamily = LiteraryFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@ -535,7 +539,7 @@ fun CommentInputBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// 输入框 // 输入框
@ -545,9 +549,12 @@ fun CommentInputBar(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { placeholder = {
Text( Text(
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" else "说点什么...", text = if (replyToComment != null) "回复 ${replyToComment.authorName}" + ": " + {replyToComment.content.take(20) + if (replyToComment.content.length > 20) "..." else ""}()
else "说点什么...",
fontFamily = LiteraryFontFamily, fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
) )
}, },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@ -602,6 +609,25 @@ 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 = {}
)
}
/** /**
* 回复指示器 * 回复指示器
*/ */
@ -632,7 +658,7 @@ private fun ReplyIndicator(
Text( Text(
text = "回复 ${comment.authorName}", text = "回复 ${comment.authorName}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )

View File

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

View File

@ -0,0 +1,645 @@
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

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

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Edit
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
@ -76,6 +77,7 @@ fun UserScreen(
onPostManagement: () -> Unit = {}, onPostManagement: () -> Unit = {},
onSettings: () -> Unit = {}, onSettings: () -> Unit = {},
onAbout: () -> Unit = {}, onAbout: () -> Unit = {},
onDesignSystem: () -> Unit = {},
userViewModel: UserViewModel = hiltViewModel() userViewModel: UserViewModel = hiltViewModel()
) { ) {
val loginState by userViewModel.loginState.collectAsState() val loginState by userViewModel.loginState.collectAsState()
@ -104,11 +106,18 @@ fun UserScreen(
MenuSection( MenuSection(
onPostManagement = onPostManagement, onPostManagement = onPostManagement,
onSettings = onSettings, onSettings = onSettings,
onAbout = onAbout onAbout = onAbout,
onDesignSystem = onDesignSystem
) )
} else { } else {
// 未登录状态 // 未登录状态
NotLoggedInContent() NotLoggedInContent()
Spacer(modifier = Modifier.height(24.dp))
MenuSectionNotLogin(
onSettings = onSettings,
onAbout = onAbout,
onDesignSystem = onDesignSystem
)
} }
} }
} }
@ -396,6 +405,7 @@ private fun VerticalDivider() {
@Composable @Composable
private fun MenuSection( private fun MenuSection(
onPostManagement: () -> Unit, onPostManagement: () -> Unit,
onDesignSystem: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onAbout: () -> Unit onAbout: () -> Unit
) { ) {
@ -438,6 +448,76 @@ private fun MenuSection(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) 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( MenuItem(
icon = Icons.Default.Info, icon = Icons.Default.Info,
title = "关于", title = "关于",

View File

@ -0,0 +1,97 @@
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

@ -0,0 +1,327 @@
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,25 +1,33 @@
package com.qingshuige.tangyuan.viewmodel package com.qingshuige.tangyuan.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.qingshuige.tangyuan.model.CommentCard import com.qingshuige.tangyuan.model.CommentCard
import com.qingshuige.tangyuan.model.CreateCommentDto import com.qingshuige.tangyuan.model.CreateCommentDto
import com.qingshuige.tangyuan.model.PostCard import com.qingshuige.tangyuan.model.PostCard
import com.qingshuige.tangyuan.model.PostDetailState import com.qingshuige.tangyuan.model.PostDetailState
import com.qingshuige.tangyuan.network.TokenManager
import com.qingshuige.tangyuan.repository.PostDetailRepository import com.qingshuige.tangyuan.repository.PostDetailRepository
import com.qingshuige.tangyuan.utils.ImageSaveUtils
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PostDetailViewModel @Inject constructor( class PostDetailViewModel @Inject constructor(
private val postDetailRepository: PostDetailRepository private val postDetailRepository: PostDetailRepository,
@ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private val tokenManager = TokenManager()
private val _state = MutableStateFlow(PostDetailState()) private val _state = MutableStateFlow(PostDetailState())
val state: StateFlow<PostDetailState> = _state.asStateFlow() val state: StateFlow<PostDetailState> = _state.asStateFlow()
@ -29,9 +37,9 @@ class PostDetailViewModel @Inject constructor(
/** /**
* 加载帖子详情和评论 - 分离加载先加载帖子再加载评论 * 加载帖子详情和评论 - 分离加载先加载帖子再加载评论
*/ */
fun loadPostDetail(postId: Int, userId: Int = 0) { fun loadPostDetail(postId: Int) {
currentPostId = postId currentPostId = postId
currentUserId = userId currentUserId = tokenManager.getUserIdFromToken() ?: 0
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
@ -53,7 +61,7 @@ class PostDetailViewModel @Inject constructor(
) )
// 然后异步加载评论 // 然后异步加载评论
loadComments(postId, userId) loadComments(postId, currentUserId)
} }
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
@ -129,8 +137,10 @@ class PostDetailViewModel @Inject constructor(
val createCommentDto = CreateCommentDto( val createCommentDto = CreateCommentDto(
postId = currentPostId.toLong(), postId = currentPostId.toLong(),
commentDateTime = Date(),
content = content, content = content,
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong() parentCommentId = if (parentCommentId == 0) 0L else parentCommentId.toLong(),
userId = currentUserId.toLong()
) )
postDetailRepository.createComment(createCommentDto) postDetailRepository.createComment(createCommentDto)
@ -284,4 +294,36 @@ class PostDetailViewModel @Inject constructor(
currentPostId = 0 currentPostId = 0
currentUserId = 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,13 +161,26 @@ class PostViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_postUiState.value = _postUiState.value.copy(isCreating = true, error = null) _postUiState.value = _postUiState.value.copy(isCreating = true, error = null)
try { try {
// TODO: Call repository createPost method var postId: Int? = null
// val postId = postRepository.createPostMetadata(metadata)
// postRepository.createPostBody(body.copy(postId = postId)) postRepository.createPostMetadata(metadata)
// _postUiState.value = _postUiState.value.copy( .catch { e -> throw e }
// isCreating = false, .collect { id ->
// createSuccess = true 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")
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
_postUiState.value = _postUiState.value.copy( _postUiState.value = _postUiState.value.copy(
isCreating = false, isCreating = false,

View File

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