Compare commits
No commits in common. "0f5bfe6aecaf917d7c9710358ba1e41dc1242926" and "a5041db38412eadcab9edbdc622af4d3ba8ec84b" have entirely different histories.
0f5bfe6aec
...
a5041db384
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -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-08T15:39:42.580350198Z">
|
<DropdownSelection timestamp="2025-10-06T14:55:34.827294Z">
|
||||||
<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
13
.idea/deviceManager.xml
generated
@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceTable">
|
|
||||||
<option name="columnSorters">
|
|
||||||
<list>
|
|
||||||
<ColumnSorterState>
|
|
||||||
<option name="column" value="Name" />
|
|
||||||
<option name="order" value="ASCENDING" />
|
|
||||||
</ColumnSorterState>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -3,11 +3,6 @@
|
|||||||
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"
|
||||||
|
|||||||
@ -21,8 +21,6 @@ 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
|
||||||
@ -31,10 +29,6 @@ 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() {
|
||||||
@ -58,10 +52,8 @@ 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) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,8 +63,8 @@ fun App() {
|
|||||||
slideInVertically(
|
slideInVertically(
|
||||||
initialOffsetY = { it },
|
initialOffsetY = { it },
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 350,
|
durationMillis = 800,
|
||||||
easing = QuickSpringEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -80,8 +72,8 @@ fun App() {
|
|||||||
slideOutVertically(
|
slideOutVertically(
|
||||||
targetOffsetY = { it },
|
targetOffsetY = { it },
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 250,
|
durationMillis = 600,
|
||||||
easing = QuickEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -89,8 +81,8 @@ fun App() {
|
|||||||
slideOutVertically(
|
slideOutVertically(
|
||||||
targetOffsetY = { it },
|
targetOffsetY = { it },
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 250,
|
durationMillis = 600,
|
||||||
easing = QuickEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -98,44 +90,12 @@ 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
|
||||||
|
|
||||||
@ -158,45 +118,13 @@ 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
|
||||||
@ -221,44 +149,12 @@ 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
|
||||||
|
|
||||||
@ -268,14 +164,6 @@ 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: 实现关注功能
|
||||||
},
|
},
|
||||||
@ -284,144 +172,11 @@ fun App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(Screen.About.route) {
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -434,8 +189,6 @@ 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()
|
||||||
@ -483,7 +236,7 @@ fun MainFlow(
|
|||||||
pageLevel = PageLevel.PRIMARY,
|
pageLevel = PageLevel.PRIMARY,
|
||||||
onAvatarClick = onAvatarClick,
|
onAvatarClick = onAvatarClick,
|
||||||
onAnnouncementClick = {/* 公告点击事件 */ },
|
onAnnouncementClick = {/* 公告点击事件 */ },
|
||||||
onPostClick = onCreatePostClick
|
onPostClick = {/* 发表点击事件 */ }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@ -501,63 +254,7 @@ 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(
|
||||||
@ -581,8 +278,7 @@ fun MainFlow(
|
|||||||
onSettings = {
|
onSettings = {
|
||||||
// TODO: 导航到设置页面
|
// TODO: 导航到设置页面
|
||||||
},
|
},
|
||||||
onAbout = onAboutClick,
|
onAbout = onAboutClick
|
||||||
onDesignSystem = onDesignSystemClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,10 +91,7 @@ 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -3,15 +3,8 @@ package com.qingshuige.tangyuan.model
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
data class CreateCommentDto(
|
data class CreateCommentDto(
|
||||||
@Deprecated(
|
val commentDateTime: Date? = null,
|
||||||
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,
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.model
|
|
||||||
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
data class CreatePostDto(
|
|
||||||
val textContent: String,
|
|
||||||
val categoryId: Int,
|
|
||||||
val sectionId: Int, // 0 或 1
|
|
||||||
val isVisible: Boolean = true,
|
|
||||||
val imageUUIDs: List<String> = emptyList()
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun toCreatPostMetadataDto(userId: Int): CreatPostMetadataDto {
|
|
||||||
return CreatPostMetadataDto(
|
|
||||||
isVisible = isVisible,
|
|
||||||
postDateTime = Date(),
|
|
||||||
sectionId = sectionId,
|
|
||||||
categoryId = categoryId,
|
|
||||||
userId = userId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toPostBody(postId: Int): PostBody {
|
|
||||||
return PostBody(
|
|
||||||
postId = postId,
|
|
||||||
textContent = textContent,
|
|
||||||
image1UUID = imageUUIDs.getOrNull(0),
|
|
||||||
image2UUID = imageUUIDs.getOrNull(1),
|
|
||||||
image3UUID = imageUUIDs.getOrNull(2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CreatePostState(
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val content: String = "",
|
|
||||||
val selectedCategoryId: Int? = null,
|
|
||||||
val selectedSectionId: Int = 0, // 默认分区0
|
|
||||||
val selectedImageUris: List<String> = emptyList(),
|
|
||||||
val uploadedImageUUIDs: List<String> = emptyList(),
|
|
||||||
val categories: List<Category> = emptyList(),
|
|
||||||
val isLoadingCategories: Boolean = false,
|
|
||||||
val isUploading: Boolean = false,
|
|
||||||
val uploadProgress: Map<String, Float> = emptyMap(),
|
|
||||||
val error: String? = null,
|
|
||||||
val success: Boolean = false
|
|
||||||
) {
|
|
||||||
|
|
||||||
val canPost: Boolean
|
|
||||||
get() = content.isNotBlank() &&
|
|
||||||
selectedCategoryId != null &&
|
|
||||||
!isLoading &&
|
|
||||||
!isUploading &&
|
|
||||||
uploadedImageUUIDs.size == selectedImageUris.size
|
|
||||||
|
|
||||||
val remainingImageSlots: Int
|
|
||||||
get() = maxOf(0, 3 - selectedImageUris.size)
|
|
||||||
|
|
||||||
val hasImages: Boolean
|
|
||||||
get() = selectedImageUris.isNotEmpty()
|
|
||||||
|
|
||||||
val isContentValid: Boolean
|
|
||||||
get() = content.isNotBlank() && content.length <= 2000
|
|
||||||
|
|
||||||
val contentCharCount: Int
|
|
||||||
get() = content.length
|
|
||||||
}
|
|
||||||
@ -7,10 +7,6 @@ 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.repository
|
|
||||||
|
|
||||||
import com.qingshuige.tangyuan.api.ApiInterface
|
|
||||||
import com.qingshuige.tangyuan.model.Category
|
|
||||||
import com.qingshuige.tangyuan.model.CreatePostDto
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import retrofit2.awaitResponse
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class CreatePostRepository @Inject constructor(
|
|
||||||
private val apiInterface: ApiInterface
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有分类
|
|
||||||
*/
|
|
||||||
fun getAllCategories(): Flow<List<Category>> = flow {
|
|
||||||
try {
|
|
||||||
val response = apiInterface.getAllCategories().awaitResponse()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.body()?.let { emit(it) }
|
|
||||||
?: emit(emptyList())
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to get categories: ${response.message()}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw Exception("Network error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建新帖子
|
|
||||||
* 1. 先创建PostMetadata获取postId
|
|
||||||
* 2. 再创建PostBody
|
|
||||||
*/
|
|
||||||
suspend fun createPost(createPostDto: CreatePostDto, userId: Int): Result<Int> {
|
|
||||||
return try {
|
|
||||||
// 1. 创建PostMetadata
|
|
||||||
val metadataDto = createPostDto.toCreatPostMetadataDto(userId)
|
|
||||||
val metadataResponse = apiInterface.postPostMetadata(metadataDto).awaitResponse()
|
|
||||||
|
|
||||||
if (!metadataResponse.isSuccessful) {
|
|
||||||
return Result.failure(Exception("Failed to create post metadata: ${metadataResponse.message()}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val postId = metadataResponse.body()?.get("postId")
|
|
||||||
?: return Result.failure(Exception("No post ID returned"))
|
|
||||||
|
|
||||||
// 2. 创建PostBody
|
|
||||||
val postBody = createPostDto.toPostBody(postId)
|
|
||||||
val bodyResponse = apiInterface.postPostBody(postBody).awaitResponse()
|
|
||||||
|
|
||||||
if (!bodyResponse.isSuccessful) {
|
|
||||||
return Result.failure(Exception("Failed to create post body: ${bodyResponse.message()}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
Result.success(postId)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(Exception("Network error: ${e.message}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,13 +17,11 @@ 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
|
||||||
@ -32,8 +30,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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 评论项组件
|
* 评论项组件
|
||||||
@ -122,7 +120,6 @@ private fun CommentMainContent(
|
|||||||
lineHeight = 20.sp
|
lineHeight = 20.sp
|
||||||
),
|
),
|
||||||
fontFamily = LiteraryFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -302,7 +299,7 @@ private fun CommentActions(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CommentActionButton(
|
private fun CommentActionButton(
|
||||||
icon: ImageVector,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
count: Int = 0,
|
count: Int = 0,
|
||||||
text: String = "",
|
text: String = "",
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
@ -331,7 +328,7 @@ private fun CommentActionButton(
|
|||||||
Text(
|
Text(
|
||||||
text = count.toString(),
|
text = count.toString(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
color = color,
|
color = color,
|
||||||
fontSize = 11.sp
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
@ -342,7 +339,7 @@ private fun CommentActionButton(
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
color = color,
|
color = color,
|
||||||
fontSize = 11.sp
|
fontSize = 11.sp
|
||||||
)
|
)
|
||||||
@ -449,7 +446,6 @@ private fun ReplyItem(
|
|||||||
lineHeight = 18.sp
|
lineHeight = 18.sp
|
||||||
),
|
),
|
||||||
fontFamily = LiteraryFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -539,7 +535,7 @@ fun CommentInputBar(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// 输入框
|
// 输入框
|
||||||
@ -549,12 +545,9 @@ fun CommentInputBar(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" + ": " + {replyToComment.content.take(20) + if (replyToComment.content.length > 20) "..." else ""}()
|
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" else "说点什么...",
|
||||||
else "说点什么...",
|
|
||||||
fontFamily = LiteraryFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
@ -609,25 +602,6 @@ fun CommentInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun CommentInputBarPreview() {
|
|
||||||
CommentInputBar(
|
|
||||||
isCreating = false,
|
|
||||||
replyToComment = CommentCard(
|
|
||||||
commentId = 1,
|
|
||||||
postId = 1,
|
|
||||||
content = "这是一个回复评论的示例。",
|
|
||||||
commentDateTime = Date(),
|
|
||||||
authorId = 1,
|
|
||||||
authorName = "示例用户",
|
|
||||||
authorAvatar = "avatar1"
|
|
||||||
),
|
|
||||||
onSendComment = {},
|
|
||||||
onCancelReply = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回复指示器
|
* 回复指示器
|
||||||
*/
|
*/
|
||||||
@ -658,7 +632,7 @@ private fun ReplyIndicator(
|
|||||||
Text(
|
Text(
|
||||||
text = "回复 ${comment.authorName}",
|
text = "回复 ${comment.authorName}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -322,7 +322,7 @@ private fun PostCardContent(postCard: PostCard) {
|
|||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
),
|
),
|
||||||
fontFamily = LiteraryFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 6,
|
maxLines = 6,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
|
|||||||
@ -1,645 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.ui.screens
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.qingshuige.tangyuan.model.Category
|
|
||||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
|
||||||
import com.qingshuige.tangyuan.viewmodel.CreatePostViewModel
|
|
||||||
|
|
||||||
// 新增:用于管理 BottomSheet 状态的枚举
|
|
||||||
private enum class BottomSheetType { NONE, SECTION, CATEGORY }
|
|
||||||
|
|
||||||
// 新增:用于表示分区的简单数据类
|
|
||||||
private data class Section(val id: Int, val name: String)
|
|
||||||
|
|
||||||
private val sections = listOf(Section(0, "聊一聊"), Section(1, "侃一侃"))
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CreatePostScreen(
|
|
||||||
onBackClick: () -> Unit = {},
|
|
||||||
onPostSuccess: () -> Unit = {},
|
|
||||||
viewModel: CreatePostViewModel = hiltViewModel()
|
|
||||||
) {
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
// 修改:使用枚举来管理活动的 BottomSheet
|
|
||||||
var activeSheet by remember { mutableStateOf(BottomSheetType.NONE) }
|
|
||||||
|
|
||||||
// 图片选择器 - 使用 PickVisualMedia 更可靠
|
|
||||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.PickVisualMedia()
|
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let {
|
|
||||||
// 获取持久化权限
|
|
||||||
try {
|
|
||||||
context.contentResolver.takePersistableUriPermission(
|
|
||||||
it,
|
|
||||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 某些 URI 可能不支持持久化权限,继续处理
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.addImageAndUpload(context, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听发布成功
|
|
||||||
LaunchedEffect(uiState.success) {
|
|
||||||
if (uiState.success) {
|
|
||||||
onPostSuccess()
|
|
||||||
viewModel.resetState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示错误提示
|
|
||||||
uiState.error?.let {
|
|
||||||
LaunchedEffect(it) {
|
|
||||||
kotlinx.coroutines.delay(3000)
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增/修改:处理 BottomSheet 的显示逻辑
|
|
||||||
when (activeSheet) {
|
|
||||||
BottomSheetType.SECTION -> {
|
|
||||||
SelectionBottomSheet(
|
|
||||||
title = "选择分区",
|
|
||||||
items = sections,
|
|
||||||
selectedItem = sections.find { it.id == uiState.selectedSectionId },
|
|
||||||
onItemSelected = { section ->
|
|
||||||
viewModel.selectSection(section.id)
|
|
||||||
activeSheet = BottomSheetType.NONE
|
|
||||||
},
|
|
||||||
onDismiss = { activeSheet = BottomSheetType.NONE }
|
|
||||||
) { section, isSelected ->
|
|
||||||
SelectionListItem(
|
|
||||||
text = section.name,
|
|
||||||
isSelected = isSelected
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BottomSheetType.CATEGORY -> {
|
|
||||||
SelectionBottomSheet(
|
|
||||||
title = "选择分类",
|
|
||||||
items = uiState.categories,
|
|
||||||
selectedItem = uiState.categories.find { it.categoryId == uiState.selectedCategoryId },
|
|
||||||
isLoading = uiState.isLoadingCategories,
|
|
||||||
onItemSelected = { category ->
|
|
||||||
viewModel.selectCategory(category.categoryId!!)
|
|
||||||
activeSheet = BottomSheetType.NONE
|
|
||||||
},
|
|
||||||
onDismiss = { activeSheet = BottomSheetType.NONE }
|
|
||||||
) { category, isSelected ->
|
|
||||||
SelectionListItem(
|
|
||||||
text = category.baseName ?: "未知分类",
|
|
||||||
description = category.baseDescription,
|
|
||||||
isSelected = isSelected
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BottomSheetType.NONE -> {
|
|
||||||
// 不显示任何 BottomSheet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "发布动态",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "返回"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.createPost() },
|
|
||||||
enabled = uiState.canPost,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("发布")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.background)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
// 内容输入
|
|
||||||
ContentInput(
|
|
||||||
content = uiState.content,
|
|
||||||
onContentChange = { viewModel.updateContent(it) },
|
|
||||||
charCount = uiState.contentCharCount,
|
|
||||||
isValid = uiState.isContentValid
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// 修改:使用新的选择器样式
|
|
||||||
SelectionField(
|
|
||||||
label = "选择分区",
|
|
||||||
selectedValueText = sections.find { it.id == uiState.selectedSectionId }?.name
|
|
||||||
?: "请选择分区",
|
|
||||||
onClick = { activeSheet = BottomSheetType.SECTION }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 修改:使用新的选择器样式
|
|
||||||
SelectionField(
|
|
||||||
label = "选择分类",
|
|
||||||
selectedValueText = uiState.categories.find { it.categoryId == uiState.selectedCategoryId }?.baseName
|
|
||||||
?: "请选择分类",
|
|
||||||
isLoading = uiState.isLoadingCategories,
|
|
||||||
onClick = { activeSheet = BottomSheetType.CATEGORY }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片选择
|
|
||||||
ImageSelector(
|
|
||||||
selectedImages = uiState.selectedImageUris,
|
|
||||||
uploadProgress = uiState.uploadProgress,
|
|
||||||
remainingSlots = uiState.remainingImageSlots,
|
|
||||||
onAddImage = {
|
|
||||||
imagePickerLauncher.launch(
|
|
||||||
androidx.activity.result.PickVisualMediaRequest(
|
|
||||||
ActivityResultContracts.PickVisualMedia.ImageOnly
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onRemoveImage = { viewModel.removeImageAt(it) }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误提示 (保持不变)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = uiState.error != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
tonalElevation = 4.dp
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = uiState.error ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:统一的选择器按钮样式
|
|
||||||
@Composable
|
|
||||||
private fun RowScope.SelectionField(
|
|
||||||
label: String,
|
|
||||||
selectedValueText: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
isLoading: Boolean = false
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
border = BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isLoading) "加载中..." else selectedValueText,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (selectedValueText.startsWith("请选择"))
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = "展开选择",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 新增:通用的半屏选择器组件
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun <T> SelectionBottomSheet(
|
|
||||||
title: String,
|
|
||||||
items: List<T>,
|
|
||||||
selectedItem: T?,
|
|
||||||
onItemSelected: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
isLoading: Boolean = false,
|
|
||||||
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(200.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else if (items.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(200.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("没有可选项", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.navigationBarsPadding()) {
|
|
||||||
items(items) { item ->
|
|
||||||
Box(modifier = Modifier.clickable { onItemSelected(item) }) {
|
|
||||||
itemContent(item, item == selectedItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:选择列表中的条目样式
|
|
||||||
@Composable
|
|
||||||
fun SelectionListItem(
|
|
||||||
text: String,
|
|
||||||
description: String? = null,
|
|
||||||
isSelected: Boolean
|
|
||||||
) {
|
|
||||||
val backgroundColor = if (isSelected)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
|
||||||
else
|
|
||||||
Color.Transparent
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
if (!description.isNullOrBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
fontFamily = LiteraryFontFamily,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = "已选择",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentInput(
|
|
||||||
content: String,
|
|
||||||
onContentChange: (String) -> Unit,
|
|
||||||
charCount: Int,
|
|
||||||
isValid: Boolean
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// Text(
|
|
||||||
// text = "内容",
|
|
||||||
// style = MaterialTheme.typography.titleSmall,
|
|
||||||
// fontWeight = FontWeight.SemiBold,
|
|
||||||
// color = MaterialTheme.colorScheme.onSurface
|
|
||||||
// )
|
|
||||||
// Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
border = BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = if (isValid)
|
|
||||||
MaterialTheme.colorScheme.outline
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
TextField(
|
|
||||||
value = content,
|
|
||||||
onValueChange = onContentChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 200.dp),
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = "分享你的想法...",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textStyle = MaterialTheme.typography.bodyLarge,
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Color.Transparent,
|
|
||||||
unfocusedContainerColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
maxLines = 15
|
|
||||||
)
|
|
||||||
|
|
||||||
// 字数统计
|
|
||||||
Text(
|
|
||||||
text = "$charCount / 2000",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = if (charCount > 2000)
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.End)
|
|
||||||
.padding(12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ImageSelector(
|
|
||||||
selectedImages: List<String>,
|
|
||||||
uploadProgress: Map<String, Float>,
|
|
||||||
remainingSlots: Int,
|
|
||||||
onAddImage: () -> Unit,
|
|
||||||
onRemoveImage: (Int) -> Unit
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "图片 (最多3张)",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${selectedImages.size} / 3",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// 显示已选择的图片
|
|
||||||
selectedImages.forEachIndexed { index, imageUri ->
|
|
||||||
ImagePreview(
|
|
||||||
imageUri = imageUri,
|
|
||||||
uploadProgress = uploadProgress[imageUri],
|
|
||||||
onRemove = { onRemoveImage(index) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加图片按钮
|
|
||||||
if (remainingSlots > 0) {
|
|
||||||
AddImageButton(onClick = onAddImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ImagePreview(
|
|
||||||
imageUri: String,
|
|
||||||
uploadProgress: Float?,
|
|
||||||
onRemove: () -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(100.dp)
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUri,
|
|
||||||
contentDescription = "选择的图片",
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
|
|
||||||
// 上传进度
|
|
||||||
if (uploadProgress != null && uploadProgress < 1f) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = { uploadProgress },
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除按钮
|
|
||||||
Surface(
|
|
||||||
onClick = onRemove,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(4.dp)
|
|
||||||
.size(24.dp),
|
|
||||||
shape = CircleShape,
|
|
||||||
color = Color.Black.copy(alpha = 0.6f)
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = "删除",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddImageButton(
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.size(100.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
border = BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Image,
|
|
||||||
contentDescription = "添加图片",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "添加",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,591 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.ui.screens
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInParent
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.qingshuige.tangyuan.ui.theme.*
|
|
||||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设计系统预览页面
|
|
||||||
*
|
|
||||||
* 用于集中展示和测试 `TangyuanTheme` 中的颜色、排版和形状,
|
|
||||||
* 方便设计师和开发者快速查阅和验证 UI 组件。
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DesignSystemScreen(
|
|
||||||
onBackClick: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Tangyuan Design System",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "返回",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = "其他",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.background)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
DesignTitleWithGuides()
|
|
||||||
|
|
||||||
// 颜色系统
|
|
||||||
DesignSection(title = "颜色系统 (Colors)") {
|
|
||||||
ColorSystemPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 排版系统
|
|
||||||
DesignSection(title = "排版系统 (Typography)") {
|
|
||||||
TypographySystemPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 形状系统
|
|
||||||
DesignSection(title = "形状系统 (Shapes)") {
|
|
||||||
ShapeSystemPreview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DesignTitleWithGuides() {
|
|
||||||
// 状态变量,用于存储测量到的标题和副标题的布局坐标
|
|
||||||
var titleCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
|
||||||
var subtitleCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
|
||||||
|
|
||||||
// 动画状态:lineProgress 控制线条划过,alpha 控制淡出
|
|
||||||
val lineProgress = remember { Animatable(0f) }
|
|
||||||
val alpha = remember { Animatable(1f) }
|
|
||||||
|
|
||||||
// 使用 LaunchedEffect 启动一次性动画
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 启动一个协程来执行动画序列
|
|
||||||
launch {
|
|
||||||
// 1. 线条划入动画
|
|
||||||
lineProgress.animateTo(1f, animationSpec = tween(durationMillis = 600))
|
|
||||||
// 2. 短暂保持可见
|
|
||||||
delay(200)
|
|
||||||
// 3. 线条淡出动画
|
|
||||||
alpha.animateTo(0f, animationSpec = tween(durationMillis = 400))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义参考线的颜色
|
|
||||||
val guideColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(bottom = 24.dp)
|
|
||||||
) {
|
|
||||||
// Canvas 用于在文本背后绘制引导线
|
|
||||||
Canvas(modifier = Modifier.matchParentSize()) {
|
|
||||||
val titleLayout = titleCoords
|
|
||||||
val subtitleLayout = subtitleCoords
|
|
||||||
|
|
||||||
// 确保坐标已经被测量到才开始绘制
|
|
||||||
if (titleLayout != null && subtitleLayout != null) {
|
|
||||||
val animatedAlphaColor = guideColor.copy(alpha = alpha.value)
|
|
||||||
|
|
||||||
// 计算整个标题区域的边界
|
|
||||||
val left = 0f
|
|
||||||
val top = 0f
|
|
||||||
val right = titleLayout.size.width.toFloat()
|
|
||||||
val bottom = subtitleLayout.positionInParent().y + subtitleLayout.size.height
|
|
||||||
|
|
||||||
// 动画进度
|
|
||||||
val progress = lineProgress.value
|
|
||||||
|
|
||||||
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 15f), 0f)
|
|
||||||
|
|
||||||
// 绘制四条动态参考线
|
|
||||||
// 1. 从左到右的上边线
|
|
||||||
drawLine(animatedAlphaColor, start = Offset(left, top), end = Offset(right * progress, top), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
|
|
||||||
// 2. 从左到右的下边线
|
|
||||||
drawLine(animatedAlphaColor, start = Offset(left, bottom), end = Offset(right * progress, bottom), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
|
|
||||||
// 3. 从上到下的左边线
|
|
||||||
drawLine(animatedAlphaColor, start = Offset(left, top), end = Offset(left, bottom * progress), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
|
|
||||||
// 4. 从上到下的右边线
|
|
||||||
drawLine(animatedAlphaColor, start = Offset(right, top), end = Offset(right, bottom * progress), strokeWidth = 1.dp.toPx(), pathEffect = dashEffect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实际的标题和副标题文本
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "糖原社区设计系统",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
|
||||||
titleCoords = coordinates
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "现代简洁 · 文化雅致",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontFamily = LiteraryFontFamily, // 保留文学字体
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
|
||||||
subtitleCoords = coordinates
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设计系统的分区组件,包含标题和内容
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DesignSection(title: String, content: @Composable () -> Unit) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 颜色系统预览
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ColorSystemPreview() {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
Text("主题色 (Light / Dark)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
|
||||||
) {
|
|
||||||
ColorRoleItem("Primary", TangyuanColors.PrimaryLight, TangyuanColors.PrimaryDark)
|
|
||||||
ColorRoleItem("Secondary", TangyuanColors.SecondaryLight, TangyuanColors.SecondaryDark)
|
|
||||||
ColorRoleItem("Tertiary", TangyuanColors.TertiaryLight, TangyuanColors.TertiaryDark)
|
|
||||||
ColorRoleItem("Accent", TangyuanColors.AccentLight, TangyuanColors.AccentDark)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text("功能色 (Success / Warning / Error)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
|
||||||
) {
|
|
||||||
ColorFunctionItem("Success", TangyuanColors.SuccessLight, TangyuanColors.SuccessDark)
|
|
||||||
ColorFunctionItem("Warning", TangyuanColors.WarningLight, TangyuanColors.WarningDark)
|
|
||||||
ColorFunctionItem("Error", TangyuanColors.ErrorLight, TangyuanColors.ErrorDark)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text("界面基础色 (Background / Surface)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
SurfaceColorItem(
|
|
||||||
"Background",
|
|
||||||
TangyuanColors.BackgroundLight,
|
|
||||||
TangyuanColors.BackgroundDark,
|
|
||||||
TangyuanColors.OnBackgroundLight,
|
|
||||||
TangyuanColors.OnBackgroundDark
|
|
||||||
)
|
|
||||||
SurfaceColorItem(
|
|
||||||
"Surface",
|
|
||||||
TangyuanColors.SurfaceLight,
|
|
||||||
TangyuanColors.SurfaceDark,
|
|
||||||
TangyuanColors.OnSurfaceLight,
|
|
||||||
TangyuanColors.OnSurfaceDark
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColorRoleItem(name: String, lightColor: Color, darkColor: Color) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Row {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(50.dp)
|
|
||||||
.background(lightColor)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(50.dp)
|
|
||||||
.background(darkColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(name, style = MaterialTheme.typography.labelMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RowScope.ColorFunctionItem(name: String, lightColor: Color, darkColor: Color) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(40.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
brush = Brush.horizontalGradient(listOf(lightColor, darkColor)),
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RowScope.SurfaceColorItem(
|
|
||||||
name: String,
|
|
||||||
lightBg: Color,
|
|
||||||
darkBg: Color,
|
|
||||||
lightContent: Color,
|
|
||||||
darkContent: Color
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(60.dp)
|
|
||||||
.background(lightBg),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Text", color = lightContent, style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(60.dp)
|
|
||||||
.background(darkBg),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Text", color = darkContent, style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(name, style = MaterialTheme.typography.labelMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 排版系统预览
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun TypographySystemPreview() {
|
|
||||||
val exampleText = "糖原社区 Tangyuan 2025"
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
// M3 Type Scale
|
|
||||||
TypographyItem("Headline Medium", exampleText, MaterialTheme.typography.headlineMedium)
|
|
||||||
TypographyItem("Title Large", exampleText, MaterialTheme.typography.titleLarge)
|
|
||||||
TypographyItem("Body Large", exampleText, MaterialTheme.typography.bodyLarge)
|
|
||||||
TypographyItem("Label Large", exampleText, MaterialTheme.typography.labelLarge)
|
|
||||||
|
|
||||||
// Font Weight Section
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("字重 (Font Weights)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
FontWeightShowcase()
|
|
||||||
|
|
||||||
// Chinese & Mixed Typography Section
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("中英混排处理", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text("自动盘古之白 (Pangu Spacing)", style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
text = "在Tangyuan中使用Jetpack Compose构建UI。",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "在Tangyuan中使用Jetpack Compose构建UI。".withPanguSpacing(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special Purpose Fonts
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("特殊用途字体", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
// Literary Font
|
|
||||||
TypographyItem(
|
|
||||||
name = "文学字体 (Literary)",
|
|
||||||
exampleText = "人生若只如初见",
|
|
||||||
style = TextStyle(
|
|
||||||
fontFamily = LiteraryFontFamily,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.8.sp
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
|
|
||||||
// Other extended styles
|
|
||||||
TypographyItem(
|
|
||||||
"数字字体 (Number Large)",
|
|
||||||
"1,234,567",
|
|
||||||
TangyuanTypography.numberLarge,
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
TypographyItem(
|
|
||||||
"代码字体 (Code)",
|
|
||||||
"val name = \"Tangyuan\"",
|
|
||||||
TangyuanTypography.code,
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TypographyItem(
|
|
||||||
name: String,
|
|
||||||
exampleText: String,
|
|
||||||
style: TextStyle,
|
|
||||||
color: Color = MaterialTheme.colorScheme.onSurface
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(bottom = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = exampleText,
|
|
||||||
style = style.copy(color = color),
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Font: ${getFontFamilyName(style.fontFamily)} | Size: ${style.fontSize.value.toInt()}sp | Weight: ${style.fontWeight?.weight}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FontWeightShowcase() {
|
|
||||||
val weights = listOf(
|
|
||||||
FontWeight.Normal to "Normal (400)",
|
|
||||||
FontWeight.Medium to "Medium (500)",
|
|
||||||
FontWeight.SemiBold to "SemiBold (600)",
|
|
||||||
FontWeight.Bold to "Bold (700)"
|
|
||||||
)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
weights.forEach { (weight, name) ->
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier.width(120.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "线粒体 XianlitiCN",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = weight)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFontFamilyName(fontFamily: FontFamily?): String {
|
|
||||||
return when (fontFamily) {
|
|
||||||
TangyuanGeneralFontFamily -> "General (混排)"
|
|
||||||
EnglishFontFamily -> "Quicksand"
|
|
||||||
ChineseFontFamily -> "Noto Sans SC"
|
|
||||||
LiteraryFontFamily -> "Noto Serif SC (文学)"
|
|
||||||
FontFamily.Monospace -> "Monospace"
|
|
||||||
else -> "Default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 形状系统预览
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ShapeSystemPreview() {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
Text("Material Shapes", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
ShapeItem("Extra Small (4dp)", MaterialTheme.shapes.extraSmall)
|
|
||||||
ShapeItem("Small (8dp)", MaterialTheme.shapes.small)
|
|
||||||
ShapeItem("Medium (12dp)", MaterialTheme.shapes.medium)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
ShapeItem("Large (16dp)", MaterialTheme.shapes.large, Modifier.size(80.dp, 60.dp))
|
|
||||||
ShapeItem(
|
|
||||||
"Extra Large (28dp)",
|
|
||||||
MaterialTheme.shapes.extraLarge,
|
|
||||||
Modifier.size(80.dp, 60.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("扩展形状 (TangyuanShapes)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
ShapeItem("Circle", TangyuanShapes.Circle)
|
|
||||||
ShapeItem("Top Rounded", TangyuanShapes.TopRounded)
|
|
||||||
ShapeItem("Cultural Card", TangyuanShapes.CulturalCard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ShapeItem(name: String, shape: Shape, modifier: Modifier = Modifier.size(72.dp)) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer, shape)
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.primary, shape)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================================
|
|
||||||
// 预览
|
|
||||||
// ====================================
|
|
||||||
@Preview(showBackground = true, name = "Design System - Light Theme")
|
|
||||||
@Composable
|
|
||||||
fun DesignSystemScreenLightPreview() {
|
|
||||||
TangyuanTheme(darkTheme = false) {
|
|
||||||
Surface {
|
|
||||||
DesignSystemScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true, name = "Design System - Dark Theme")
|
|
||||||
@Composable
|
|
||||||
fun DesignSystemScreenDarkPreview() {
|
|
||||||
TangyuanTheme(darkTheme = true) {
|
|
||||||
Surface {
|
|
||||||
DesignSystemScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,16 +23,12 @@ 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
|
||||||
@ -42,10 +38,11 @@ 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 kotlinx.coroutines.CoroutineScope
|
import kotlin.math.max
|
||||||
import kotlinx.coroutines.launch
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片详情页面 - 以图片为主的展示界面
|
* 图片详情页面 - 以图片为主的展示界面
|
||||||
@ -63,15 +60,8 @@ 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)
|
||||||
}
|
}
|
||||||
@ -98,14 +88,8 @@ 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(
|
||||||
@ -124,8 +108,6 @@ fun ImageDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 底部内容区域(模糊遮罩)
|
// 底部内容区域(模糊遮罩)
|
||||||
BottomContentOverlay(
|
BottomContentOverlay(
|
||||||
@ -136,20 +118,14 @@ 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(
|
||||||
@ -184,7 +160,7 @@ private fun BackgroundBlurredImage(
|
|||||||
alpha = 0.3f
|
alpha = 0.3f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渐变遮罩
|
// 渐变遮罩
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -209,8 +185,7 @@ 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 = {
|
||||||
@ -231,22 +206,15 @@ 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(
|
||||||
@ -256,13 +224,9 @@ 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",
|
||||||
@ -270,14 +234,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(
|
||||||
@ -286,45 +250,23 @@ 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
|
modifier = Modifier.fillMaxSize(),
|
||||||
.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(
|
||||||
@ -333,34 +275,43 @@ private fun ZoomableImage(
|
|||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.let { mod ->
|
||||||
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) {
|
||||||
Modifier.sharedElement(
|
mod.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 Modifier
|
} else mod
|
||||||
|
}
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offset.x,
|
||||||
|
translationY = offset.y
|
||||||
)
|
)
|
||||||
|
.transformable(state = transformableState)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onDoubleTap = {
|
||||||
|
scale = if (scale > 1f) 1f else 2f
|
||||||
|
offset = Offset.Zero
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部内容遮罩
|
* 底部内容遮罩
|
||||||
*/
|
*/
|
||||||
@ -375,7 +326,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()
|
||||||
@ -407,13 +358,17 @@ 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(
|
||||||
@ -424,7 +379,10 @@ 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,
|
||||||
@ -443,6 +401,7 @@ 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,
|
||||||
@ -450,7 +409,10 @@ 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
|
||||||
@ -505,7 +467,9 @@ 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(),
|
||||||
@ -525,6 +489,7 @@ 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(),
|
||||||
@ -592,6 +557,7 @@ 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,
|
||||||
@ -599,6 +565,7 @@ 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,
|
||||||
@ -606,6 +573,7 @@ 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(
|
||||||
|
|||||||
@ -137,9 +137,8 @@ private fun PostDetailTopBar(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "帖子详情",
|
text = "详情",
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -310,7 +309,6 @@ private fun PostDetailCard(
|
|||||||
lineHeight = 28.sp
|
lineHeight = 28.sp
|
||||||
),
|
),
|
||||||
fontFamily = LiteraryFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -532,7 +530,7 @@ private fun CommentSectionHeader(commentCount: Int, isLoading: Boolean = false)
|
|||||||
Text(
|
Text(
|
||||||
text = "评论 ($commentCount)",
|
text = "评论 ($commentCount)",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = LiteraryFontFamily,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
|
|||||||
@ -44,7 +44,6 @@ 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,
|
||||||
@ -154,7 +153,7 @@ fun UserDetailScreen(
|
|||||||
onBookmarkClick = { /* TODO: 实现收藏 */ },
|
onBookmarkClick = { /* TODO: 实现收藏 */ },
|
||||||
onMoreClick = { /* TODO: 实现更多操作 */ },
|
onMoreClick = { /* TODO: 实现更多操作 */ },
|
||||||
onImageClick = { postId, imageIndex ->
|
onImageClick = { postId, imageIndex ->
|
||||||
onImageClick(postId, imageIndex)
|
// TODO: 实现图片点击
|
||||||
},
|
},
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedContentScope = animatedContentScope,
|
animatedContentScope = animatedContentScope,
|
||||||
@ -190,14 +189,14 @@ private fun UserDetailTopBar(
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
Text(
|
Text(
|
||||||
text = "用户详情",
|
text = "用户详情",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
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.titleMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontFamily = TangyuanGeneralFontFamily,
|
fontFamily = TangyuanGeneralFontFamily,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.ui.screens
|
|
||||||
|
|
||||||
@ -26,7 +26,6 @@ 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
|
||||||
@ -77,7 +76,6 @@ 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()
|
||||||
@ -106,18 +104,11 @@ 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -405,7 +396,6 @@ 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
|
||||||
) {
|
) {
|
||||||
@ -448,76 +438,6 @@ 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 = "关于",
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.utils
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.drawable.BitmapDrawable
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
object ImageSaveUtils {
|
|
||||||
|
|
||||||
suspend fun saveImageToGallery(
|
|
||||||
context: Context,
|
|
||||||
imageUrl: String,
|
|
||||||
fileName: String? = null
|
|
||||||
): Result<String> = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val imageLoader = ImageLoader(context)
|
|
||||||
val request = ImageRequest.Builder(context)
|
|
||||||
.data(imageUrl)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val drawable = imageLoader.execute(request).drawable
|
|
||||||
val bitmap = (drawable as? BitmapDrawable)?.bitmap
|
|
||||||
?: return@withContext Result.failure(Exception("无法获取图片"))
|
|
||||||
|
|
||||||
val displayName = fileName ?: "Tangyuan_${System.currentTimeMillis()}.jpg"
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
saveImageToMediaStore(context, bitmap, displayName)
|
|
||||||
} else {
|
|
||||||
saveImageToExternalStorage(bitmap, displayName)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveImageToMediaStore(
|
|
||||||
context: Context,
|
|
||||||
bitmap: Bitmap,
|
|
||||||
displayName: String
|
|
||||||
): Result<String> {
|
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Tangyuan")
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = context.contentResolver.insert(
|
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
|
||||||
contentValues
|
|
||||||
) ?: return Result.failure(Exception("无法创建文件"))
|
|
||||||
|
|
||||||
return try {
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
|
||||||
}
|
|
||||||
Result.success("图片已保存到相册")
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveImageToExternalStorage(
|
|
||||||
bitmap: Bitmap,
|
|
||||||
displayName: String
|
|
||||||
): Result<String> {
|
|
||||||
val picturesDir = File(
|
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
|
||||||
"Tangyuan"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!picturesDir.exists()) {
|
|
||||||
picturesDir.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
val imageFile = File(picturesDir, displayName)
|
|
||||||
|
|
||||||
return try {
|
|
||||||
FileOutputStream(imageFile).use { outputStream ->
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
|
||||||
}
|
|
||||||
Result.success("图片已保存到 ${imageFile.absolutePath}")
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
package com.qingshuige.tangyuan.viewmodel
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.qingshuige.tangyuan.model.Category
|
|
||||||
import com.qingshuige.tangyuan.model.CreatePostDto
|
|
||||||
import com.qingshuige.tangyuan.model.CreatePostState
|
|
||||||
import com.qingshuige.tangyuan.network.TokenManager
|
|
||||||
import com.qingshuige.tangyuan.repository.CreatePostRepository
|
|
||||||
import com.qingshuige.tangyuan.repository.MediaRepository
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class CreatePostViewModel @Inject constructor(
|
|
||||||
private val createPostRepository: CreatePostRepository,
|
|
||||||
private val mediaRepository: MediaRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val tokenManager = TokenManager()
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CreatePostState())
|
|
||||||
val uiState: StateFlow<CreatePostState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载所有分类
|
|
||||||
*/
|
|
||||||
private fun loadCategories() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoadingCategories = true, error = null)
|
|
||||||
createPostRepository.getAllCategories()
|
|
||||||
.catch { e ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoadingCategories = false,
|
|
||||||
error = "加载分类失败: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.collect { categories ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoadingCategories = false,
|
|
||||||
categories = categories,
|
|
||||||
selectedCategoryId = categories.firstOrNull()?.categoryId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新内容
|
|
||||||
*/
|
|
||||||
fun updateContent(content: String) {
|
|
||||||
_uiState.value = _uiState.value.copy(content = content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择分类
|
|
||||||
*/
|
|
||||||
fun selectCategory(categoryId: Int) {
|
|
||||||
_uiState.value = _uiState.value.copy(selectedCategoryId = categoryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择分区 (0: 聊一聊, 1: 侃一侃)
|
|
||||||
*/
|
|
||||||
fun selectSection(sectionId: Int) {
|
|
||||||
_uiState.value = _uiState.value.copy(selectedSectionId = sectionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加图片 URI
|
|
||||||
*/
|
|
||||||
fun addImageUri(uri: String) {
|
|
||||||
Log.d("CreatePostViewModel", "Adding image URI: $uri")
|
|
||||||
val currentImages = _uiState.value.selectedImageUris
|
|
||||||
if (currentImages.size < 3) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
selectedImageUris = currentImages + uri
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Log.d("CreatePostViewModel", "Updated image URIs: ${_uiState.value.selectedImageUris}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除图片
|
|
||||||
*/
|
|
||||||
fun removeImageAt(index: Int) {
|
|
||||||
val currentImages = _uiState.value.selectedImageUris.toMutableList()
|
|
||||||
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
|
|
||||||
|
|
||||||
if (index in currentImages.indices) {
|
|
||||||
currentImages.removeAt(index)
|
|
||||||
if (index in currentUUIDs.indices) {
|
|
||||||
currentUUIDs.removeAt(index)
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
selectedImageUris = currentImages,
|
|
||||||
uploadedImageUUIDs = currentUUIDs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addImageAndUpload(context: Context, uri: Uri) {
|
|
||||||
val uriString = uri.toString()
|
|
||||||
val currentImages = _uiState.value.selectedImageUris
|
|
||||||
|
|
||||||
if (currentImages.size < 3) {
|
|
||||||
// 1. 更新 URI 列表
|
|
||||||
val updatedImages = currentImages + uriString
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
selectedImageUris = updatedImages
|
|
||||||
)
|
|
||||||
Log.d("CreatePostViewModel", "Updated image URIs: $updatedImages")
|
|
||||||
|
|
||||||
// 2. 使用刚刚更新的列表来获取正确的索引
|
|
||||||
val newIndex = updatedImages.size - 1
|
|
||||||
|
|
||||||
// 3. 使用正确的索引来调用上传逻辑
|
|
||||||
uploadImage(context, uri, newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传单张图片,不要直接调用这个方法,所有状态让ViewModel管理
|
|
||||||
*/
|
|
||||||
private fun uploadImage(context: Context, uri: Uri, index: Int) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isUploading = true,
|
|
||||||
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 0.5f)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 将 Uri 转换为 File
|
|
||||||
val file = uriToFile(context, uri)
|
|
||||||
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
|
|
||||||
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
|
||||||
|
|
||||||
// 上传图片
|
|
||||||
mediaRepository.uploadImage(body)
|
|
||||||
.catch { e ->
|
|
||||||
Log.e("CreatePostViewModel", "Image upload error: ${e}")
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isUploading = false,
|
|
||||||
error = "图片上传失败: ${e.message}",
|
|
||||||
uploadProgress = _uiState.value.uploadProgress - uri.toString()
|
|
||||||
)
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
.collect { result ->
|
|
||||||
// API 返回的是 Map<String, String>,其中包含图片的 UUID
|
|
||||||
val imageUUID = result["guid"]
|
|
||||||
|
|
||||||
Log.d("CreatePostViewModel", "Image uploaded with UUID: $imageUUID")
|
|
||||||
Log.d(
|
|
||||||
"CreatePostViewModel",
|
|
||||||
"Current uploaded UUIDs: ${_uiState.value.uploadedImageUUIDs}"
|
|
||||||
)
|
|
||||||
Log.d(
|
|
||||||
"CreatePostViewModel",
|
|
||||||
"Current selected URIs: ${_uiState.value.selectedImageUris}"
|
|
||||||
)
|
|
||||||
Log.d("CreatePostViewModel", "Index: $index")
|
|
||||||
|
|
||||||
if (imageUUID != null) {
|
|
||||||
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
|
|
||||||
|
|
||||||
// 确保列表长度足够
|
|
||||||
while (currentUUIDs.size <= index) {
|
|
||||||
currentUUIDs.add("")
|
|
||||||
}
|
|
||||||
currentUUIDs[index] = imageUUID
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
uploadedImageUUIDs = currentUUIDs,
|
|
||||||
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否所有图片都上传完成
|
|
||||||
val allUploaded = _uiState.value.selectedImageUris.size ==
|
|
||||||
_uiState.value.uploadedImageUUIDs.size
|
|
||||||
if (allUploaded) {
|
|
||||||
_uiState.value = _uiState.value.copy(isUploading = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("CreatePostViewModel", "Image upload error: ${e}")
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isUploading = false,
|
|
||||||
error = "图片处理失败: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传所有图片
|
|
||||||
*/
|
|
||||||
fun uploadAllImages(context: Context) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.value.selectedImageUris.forEachIndexed { index, uriString ->
|
|
||||||
if (index >= _uiState.value.uploadedImageUUIDs.size ||
|
|
||||||
_uiState.value.uploadedImageUUIDs[index].isEmpty()
|
|
||||||
) {
|
|
||||||
uploadImage(context, Uri.parse(uriString), index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发布帖子
|
|
||||||
*/
|
|
||||||
fun createPost() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val state = _uiState.value
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
if (state.content.isBlank()) {
|
|
||||||
_uiState.value = state.copy(error = "请输入内容")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.selectedCategoryId == null) {
|
|
||||||
_uiState.value = state.copy(error = "请选择分类")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查图片是否都已上传
|
|
||||||
if (state.selectedImageUris.isNotEmpty() &&
|
|
||||||
state.selectedImageUris.size != state.uploadedImageUUIDs.size
|
|
||||||
) {
|
|
||||||
_uiState.value = state.copy(error = "图片正在上传中,请稍候")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val userId = tokenManager.getUserIdFromToken()
|
|
||||||
if (userId == null) {
|
|
||||||
_uiState.value = state.copy(error = "请先登录")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.value = state.copy(isLoading = true, error = null)
|
|
||||||
|
|
||||||
val createPostDto = CreatePostDto(
|
|
||||||
textContent = state.content,
|
|
||||||
categoryId = state.selectedCategoryId,
|
|
||||||
sectionId = state.selectedSectionId,
|
|
||||||
isVisible = true,
|
|
||||||
imageUUIDs = state.uploadedImageUUIDs
|
|
||||||
)
|
|
||||||
|
|
||||||
createPostRepository.createPost(createPostDto, userId)
|
|
||||||
.onSuccess { postId ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
success = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onFailure { e ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "发布失败: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置状态
|
|
||||||
*/
|
|
||||||
fun resetState() {
|
|
||||||
_uiState.value = CreatePostState(categories = _uiState.value.categories)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除错误
|
|
||||||
*/
|
|
||||||
fun clearError() {
|
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 Uri 转换为 File
|
|
||||||
*/
|
|
||||||
private fun uriToFile(context: Context, uri: Uri): File {
|
|
||||||
val contentResolver = context.contentResolver
|
|
||||||
val file = File(context.cacheDir, "upload_${System.currentTimeMillis()}.jpg")
|
|
||||||
|
|
||||||
try {
|
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
FileOutputStream(file).use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: throw IllegalStateException("无法打开输入流")
|
|
||||||
|
|
||||||
if (!file.exists() || file.length() == 0L) {
|
|
||||||
throw IllegalStateException("文件创建失败或为空")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("CreatePostViewModel", "Uri to File conversion failed", e)
|
|
||||||
file.delete()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +1,25 @@
|
|||||||
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()
|
||||||
|
|
||||||
@ -37,9 +29,9 @@ class PostDetailViewModel @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* 加载帖子详情和评论 - 分离加载,先加载帖子再加载评论
|
* 加载帖子详情和评论 - 分离加载,先加载帖子再加载评论
|
||||||
*/
|
*/
|
||||||
fun loadPostDetail(postId: Int) {
|
fun loadPostDetail(postId: Int, userId: Int = 0) {
|
||||||
currentPostId = postId
|
currentPostId = postId
|
||||||
currentUserId = tokenManager.getUserIdFromToken() ?: 0
|
currentUserId = userId
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
@ -61,7 +53,7 @@ class PostDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 然后异步加载评论
|
// 然后异步加载评论
|
||||||
loadComments(postId, currentUserId)
|
loadComments(postId, userId)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
@ -137,10 +129,8 @@ 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) 0L else parentCommentId.toLong(),
|
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong()
|
||||||
userId = currentUserId.toLong()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
postDetailRepository.createComment(createCommentDto)
|
postDetailRepository.createComment(createCommentDto)
|
||||||
@ -294,36 +284,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -161,26 +161,13 @@ 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 {
|
||||||
var postId: Int? = null
|
// TODO: Call repository createPost method
|
||||||
|
// val postId = postRepository.createPostMetadata(metadata)
|
||||||
postRepository.createPostMetadata(metadata)
|
// postRepository.createPostBody(body.copy(postId = postId))
|
||||||
.catch { e -> throw e }
|
// _postUiState.value = _postUiState.value.copy(
|
||||||
.collect { id ->
|
// isCreating = false,
|
||||||
postId = id
|
// createSuccess = true
|
||||||
|
// )
|
||||||
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,
|
||||||
|
|||||||
@ -150,7 +150,6 @@ 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(
|
||||||
@ -163,13 +162,8 @@ class UserViewModel @Inject constructor(
|
|||||||
isLoggedIn = true,
|
isLoggedIn = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 确保token保存后再获取用户信息
|
// 登录成功后获取用户信息
|
||||||
if (token != null) {
|
getCurrentUserFromToken()
|
||||||
println("DEBUG: 手动登录后开始获取用户信息")
|
|
||||||
getCurrentUserFromToken()
|
|
||||||
} else {
|
|
||||||
println("DEBUG: 手动登录失败,未获取到token")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user