feat: Implement "Create Post" screen with image upload
This commit introduces a new "Create Post" screen, enabling users to compose and publish posts with text and up to three images. The implementation includes a dedicated ViewModel, robust state management, and an asynchronous image upload flow.
**Key Changes:**
* **feat(Create Post Screen):**
* Added `CreatePostScreen.kt`, a new Composable screen for creating posts.
* The UI includes a text input field, selectors for "Section" and "Category," and an image picker/preview area.
* Implemented `SelectionBottomSheet` as a reusable component for picking sections and categories from a modal bottom sheet.
* The "Publish" button is dynamically enabled based on content validation and image upload status.
* Error messages are displayed to the user via a snackbar-like component.
* **feat(CreatePostViewModel):**
* Created a new `CreatePostViewModel` to manage the logic and state (`CreatePostState`) of the creation process.
* Fetches post categories on initialization.
* Handles image selection and manages an asynchronous upload queue. Each selected image is immediately uploaded to the backend via `MediaRepository`.
* The ViewModel tracks the upload progress for each image and updates the UI accordingly.
* The `createPost` function validates all inputs, compiles the post data (including uploaded image UUIDs), and sends it to the repository.
* **feat(Navigation):**
* Added a `CreatePost` route to the navigation graph (`Screen.kt`).
* Integrated the new screen into `App.kt` with custom horizontal slide transitions.
* The publish button on the main screen's top bar now navigates to the `CreatePostScreen`.
* Upon successful post creation, the user is navigated back to the main screen.
* **refactor(PostDetailViewModel):**
* Adjusted the `createComment` logic to correctly handle `parentCommentId`, sending `0L` instead of `null` when there is no parent comment.
This commit is contained in:
parent
a528b623b9
commit
0f5bfe6aec
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-08T05:47:59.421656019Z">
|
<DropdownSelection timestamp="2025-10-08T15:39:42.580350198Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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.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
|
||||||
@ -57,6 +58,7 @@ 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) }
|
onDesignSystemClick = { navController.navigate(Screen.DesignSystem.route) }
|
||||||
@ -369,6 +371,57 @@ fun App() {
|
|||||||
onBackClick = { navController.popBackStack() }
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -382,6 +435,7 @@ fun MainFlow(
|
|||||||
onAuthorClick: (Int) -> Unit = {},
|
onAuthorClick: (Int) -> Unit = {},
|
||||||
onAboutClick: () -> Unit,
|
onAboutClick: () -> Unit,
|
||||||
onDesignSystemClick: () -> Unit,
|
onDesignSystemClick: () -> Unit,
|
||||||
|
onCreatePostClick: () -> Unit,
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedContentScope: AnimatedContentScope? = null,
|
animatedContentScope: AnimatedContentScope? = null,
|
||||||
userViewModel: UserViewModel = hiltViewModel()
|
userViewModel: UserViewModel = hiltViewModel()
|
||||||
@ -429,7 +483,7 @@ fun MainFlow(
|
|||||||
pageLevel = PageLevel.PRIMARY,
|
pageLevel = PageLevel.PRIMARY,
|
||||||
onAvatarClick = onAvatarClick,
|
onAvatarClick = onAvatarClick,
|
||||||
onAnnouncementClick = {/* 公告点击事件 */ },
|
onAnnouncementClick = {/* 公告点击事件 */ },
|
||||||
onPostClick = {/* 发表点击事件 */ }
|
onPostClick = onCreatePostClick
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ sealed class Screen(val route: String, val title: String) {
|
|||||||
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 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"
|
||||||
|
|||||||
@ -0,0 +1,645 @@
|
|||||||
|
package com.qingshuige.tangyuan.ui.screens
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.qingshuige.tangyuan.model.Category
|
||||||
|
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||||
|
import com.qingshuige.tangyuan.viewmodel.CreatePostViewModel
|
||||||
|
|
||||||
|
// 新增:用于管理 BottomSheet 状态的枚举
|
||||||
|
private enum class BottomSheetType { NONE, SECTION, CATEGORY }
|
||||||
|
|
||||||
|
// 新增:用于表示分区的简单数据类
|
||||||
|
private data class Section(val id: Int, val name: String)
|
||||||
|
|
||||||
|
private val sections = listOf(Section(0, "聊一聊"), Section(1, "侃一侃"))
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreatePostScreen(
|
||||||
|
onBackClick: () -> Unit = {},
|
||||||
|
onPostSuccess: () -> Unit = {},
|
||||||
|
viewModel: CreatePostViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
// 修改:使用枚举来管理活动的 BottomSheet
|
||||||
|
var activeSheet by remember { mutableStateOf(BottomSheetType.NONE) }
|
||||||
|
|
||||||
|
// 图片选择器 - 使用 PickVisualMedia 更可靠
|
||||||
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.PickVisualMedia()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
// 获取持久化权限
|
||||||
|
try {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
it,
|
||||||
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 某些 URI 可能不支持持久化权限,继续处理
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.addImageAndUpload(context, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听发布成功
|
||||||
|
LaunchedEffect(uiState.success) {
|
||||||
|
if (uiState.success) {
|
||||||
|
onPostSuccess()
|
||||||
|
viewModel.resetState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误提示
|
||||||
|
uiState.error?.let {
|
||||||
|
LaunchedEffect(it) {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增/修改:处理 BottomSheet 的显示逻辑
|
||||||
|
when (activeSheet) {
|
||||||
|
BottomSheetType.SECTION -> {
|
||||||
|
SelectionBottomSheet(
|
||||||
|
title = "选择分区",
|
||||||
|
items = sections,
|
||||||
|
selectedItem = sections.find { it.id == uiState.selectedSectionId },
|
||||||
|
onItemSelected = { section ->
|
||||||
|
viewModel.selectSection(section.id)
|
||||||
|
activeSheet = BottomSheetType.NONE
|
||||||
|
},
|
||||||
|
onDismiss = { activeSheet = BottomSheetType.NONE }
|
||||||
|
) { section, isSelected ->
|
||||||
|
SelectionListItem(
|
||||||
|
text = section.name,
|
||||||
|
isSelected = isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BottomSheetType.CATEGORY -> {
|
||||||
|
SelectionBottomSheet(
|
||||||
|
title = "选择分类",
|
||||||
|
items = uiState.categories,
|
||||||
|
selectedItem = uiState.categories.find { it.categoryId == uiState.selectedCategoryId },
|
||||||
|
isLoading = uiState.isLoadingCategories,
|
||||||
|
onItemSelected = { category ->
|
||||||
|
viewModel.selectCategory(category.categoryId!!)
|
||||||
|
activeSheet = BottomSheetType.NONE
|
||||||
|
},
|
||||||
|
onDismiss = { activeSheet = BottomSheetType.NONE }
|
||||||
|
) { category, isSelected ->
|
||||||
|
SelectionListItem(
|
||||||
|
text = category.baseName ?: "未知分类",
|
||||||
|
description = category.baseDescription,
|
||||||
|
isSelected = isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BottomSheetType.NONE -> {
|
||||||
|
// 不显示任何 BottomSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "发布动态",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "返回"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.createPost() },
|
||||||
|
enabled = uiState.canPost,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("发布")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// 内容输入
|
||||||
|
ContentInput(
|
||||||
|
content = uiState.content,
|
||||||
|
onContentChange = { viewModel.updateContent(it) },
|
||||||
|
charCount = uiState.contentCharCount,
|
||||||
|
isValid = uiState.isContentValid
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 修改:使用新的选择器样式
|
||||||
|
SelectionField(
|
||||||
|
label = "选择分区",
|
||||||
|
selectedValueText = sections.find { it.id == uiState.selectedSectionId }?.name
|
||||||
|
?: "请选择分区",
|
||||||
|
onClick = { activeSheet = BottomSheetType.SECTION }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 修改:使用新的选择器样式
|
||||||
|
SelectionField(
|
||||||
|
label = "选择分类",
|
||||||
|
selectedValueText = uiState.categories.find { it.categoryId == uiState.selectedCategoryId }?.baseName
|
||||||
|
?: "请选择分类",
|
||||||
|
isLoading = uiState.isLoadingCategories,
|
||||||
|
onClick = { activeSheet = BottomSheetType.CATEGORY }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片选择
|
||||||
|
ImageSelector(
|
||||||
|
selectedImages = uiState.selectedImageUris,
|
||||||
|
uploadProgress = uiState.uploadProgress,
|
||||||
|
remainingSlots = uiState.remainingImageSlots,
|
||||||
|
onAddImage = {
|
||||||
|
imagePickerLauncher.launch(
|
||||||
|
androidx.activity.result.PickVisualMediaRequest(
|
||||||
|
ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onRemoveImage = { viewModel.removeImageAt(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误提示 (保持不变)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = uiState.error != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
tonalElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.error ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:统一的选择器按钮样式
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SelectionField(
|
||||||
|
label: String,
|
||||||
|
selectedValueText: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
isLoading: Boolean = false
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isLoading) "加载中..." else selectedValueText,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (selectedValueText.startsWith("请选择"))
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = "展开选择",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 新增:通用的半屏选择器组件
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectionBottomSheet(
|
||||||
|
title: String,
|
||||||
|
items: List<T>,
|
||||||
|
selectedItem: T?,
|
||||||
|
onItemSelected: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (items.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("没有可选项", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.navigationBarsPadding()) {
|
||||||
|
items(items) { item ->
|
||||||
|
Box(modifier = Modifier.clickable { onItemSelected(item) }) {
|
||||||
|
itemContent(item, item == selectedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:选择列表中的条目样式
|
||||||
|
@Composable
|
||||||
|
fun SelectionListItem(
|
||||||
|
text: String,
|
||||||
|
description: String? = null,
|
||||||
|
isSelected: Boolean
|
||||||
|
) {
|
||||||
|
val backgroundColor = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (!description.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
fontFamily = LiteraryFontFamily,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "已选择",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentInput(
|
||||||
|
content: String,
|
||||||
|
onContentChange: (String) -> Unit,
|
||||||
|
charCount: Int,
|
||||||
|
isValid: Boolean
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Text(
|
||||||
|
// text = "内容",
|
||||||
|
// style = MaterialTheme.typography.titleSmall,
|
||||||
|
// fontWeight = FontWeight.SemiBold,
|
||||||
|
// color = MaterialTheme.colorScheme.onSurface
|
||||||
|
// )
|
||||||
|
// Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isValid)
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
TextField(
|
||||||
|
value = content,
|
||||||
|
onValueChange = onContentChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 200.dp),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "分享你的想法...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
maxLines = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// 字数统计
|
||||||
|
Text(
|
||||||
|
text = "$charCount / 2000",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (charCount > 2000)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageSelector(
|
||||||
|
selectedImages: List<String>,
|
||||||
|
uploadProgress: Map<String, Float>,
|
||||||
|
remainingSlots: Int,
|
||||||
|
onAddImage: () -> Unit,
|
||||||
|
onRemoveImage: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "图片 (最多3张)",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${selectedImages.size} / 3",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// 显示已选择的图片
|
||||||
|
selectedImages.forEachIndexed { index, imageUri ->
|
||||||
|
ImagePreview(
|
||||||
|
imageUri = imageUri,
|
||||||
|
uploadProgress = uploadProgress[imageUri],
|
||||||
|
onRemove = { onRemoveImage(index) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加图片按钮
|
||||||
|
if (remainingSlots > 0) {
|
||||||
|
AddImageButton(onClick = onAddImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImagePreview(
|
||||||
|
imageUri: String,
|
||||||
|
uploadProgress: Float?,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUri,
|
||||||
|
contentDescription = "选择的图片",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
// 上传进度
|
||||||
|
if (uploadProgress != null && uploadProgress < 1f) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { uploadProgress },
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除按钮
|
||||||
|
Surface(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color.Black.copy(alpha = 0.6f)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "删除",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddImageButton(
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(100.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Image,
|
||||||
|
contentDescription = "添加图片",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "添加",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
package com.qingshuige.tangyuan.ui.screens
|
||||||
|
|
||||||
@ -0,0 +1,327 @@
|
|||||||
|
package com.qingshuige.tangyuan.viewmodel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.qingshuige.tangyuan.model.Category
|
||||||
|
import com.qingshuige.tangyuan.model.CreatePostDto
|
||||||
|
import com.qingshuige.tangyuan.model.CreatePostState
|
||||||
|
import com.qingshuige.tangyuan.network.TokenManager
|
||||||
|
import com.qingshuige.tangyuan.repository.CreatePostRepository
|
||||||
|
import com.qingshuige.tangyuan.repository.MediaRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CreatePostViewModel @Inject constructor(
|
||||||
|
private val createPostRepository: CreatePostRepository,
|
||||||
|
private val mediaRepository: MediaRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val tokenManager = TokenManager()
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(CreatePostState())
|
||||||
|
val uiState: StateFlow<CreatePostState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载所有分类
|
||||||
|
*/
|
||||||
|
private fun loadCategories() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoadingCategories = true, error = null)
|
||||||
|
createPostRepository.getAllCategories()
|
||||||
|
.catch { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoadingCategories = false,
|
||||||
|
error = "加载分类失败: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.collect { categories ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoadingCategories = false,
|
||||||
|
categories = categories,
|
||||||
|
selectedCategoryId = categories.firstOrNull()?.categoryId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新内容
|
||||||
|
*/
|
||||||
|
fun updateContent(content: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择分类
|
||||||
|
*/
|
||||||
|
fun selectCategory(categoryId: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedCategoryId = categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择分区 (0: 聊一聊, 1: 侃一侃)
|
||||||
|
*/
|
||||||
|
fun selectSection(sectionId: Int) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedSectionId = sectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加图片 URI
|
||||||
|
*/
|
||||||
|
fun addImageUri(uri: String) {
|
||||||
|
Log.d("CreatePostViewModel", "Adding image URI: $uri")
|
||||||
|
val currentImages = _uiState.value.selectedImageUris
|
||||||
|
if (currentImages.size < 3) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
selectedImageUris = currentImages + uri
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d("CreatePostViewModel", "Updated image URIs: ${_uiState.value.selectedImageUris}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除图片
|
||||||
|
*/
|
||||||
|
fun removeImageAt(index: Int) {
|
||||||
|
val currentImages = _uiState.value.selectedImageUris.toMutableList()
|
||||||
|
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
|
||||||
|
|
||||||
|
if (index in currentImages.indices) {
|
||||||
|
currentImages.removeAt(index)
|
||||||
|
if (index in currentUUIDs.indices) {
|
||||||
|
currentUUIDs.removeAt(index)
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
selectedImageUris = currentImages,
|
||||||
|
uploadedImageUUIDs = currentUUIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addImageAndUpload(context: Context, uri: Uri) {
|
||||||
|
val uriString = uri.toString()
|
||||||
|
val currentImages = _uiState.value.selectedImageUris
|
||||||
|
|
||||||
|
if (currentImages.size < 3) {
|
||||||
|
// 1. 更新 URI 列表
|
||||||
|
val updatedImages = currentImages + uriString
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
selectedImageUris = updatedImages
|
||||||
|
)
|
||||||
|
Log.d("CreatePostViewModel", "Updated image URIs: $updatedImages")
|
||||||
|
|
||||||
|
// 2. 使用刚刚更新的列表来获取正确的索引
|
||||||
|
val newIndex = updatedImages.size - 1
|
||||||
|
|
||||||
|
// 3. 使用正确的索引来调用上传逻辑
|
||||||
|
uploadImage(context, uri, newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单张图片,不要直接调用这个方法,所有状态让ViewModel管理
|
||||||
|
*/
|
||||||
|
private fun uploadImage(context: Context, uri: Uri, index: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isUploading = true,
|
||||||
|
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 将 Uri 转换为 File
|
||||||
|
val file = uriToFile(context, uri)
|
||||||
|
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
|
||||||
|
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
mediaRepository.uploadImage(body)
|
||||||
|
.catch { e ->
|
||||||
|
Log.e("CreatePostViewModel", "Image upload error: ${e}")
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isUploading = false,
|
||||||
|
error = "图片上传失败: ${e.message}",
|
||||||
|
uploadProgress = _uiState.value.uploadProgress - uri.toString()
|
||||||
|
)
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
.collect { result ->
|
||||||
|
// API 返回的是 Map<String, String>,其中包含图片的 UUID
|
||||||
|
val imageUUID = result["guid"]
|
||||||
|
|
||||||
|
Log.d("CreatePostViewModel", "Image uploaded with UUID: $imageUUID")
|
||||||
|
Log.d(
|
||||||
|
"CreatePostViewModel",
|
||||||
|
"Current uploaded UUIDs: ${_uiState.value.uploadedImageUUIDs}"
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
"CreatePostViewModel",
|
||||||
|
"Current selected URIs: ${_uiState.value.selectedImageUris}"
|
||||||
|
)
|
||||||
|
Log.d("CreatePostViewModel", "Index: $index")
|
||||||
|
|
||||||
|
if (imageUUID != null) {
|
||||||
|
val currentUUIDs = _uiState.value.uploadedImageUUIDs.toMutableList()
|
||||||
|
|
||||||
|
// 确保列表长度足够
|
||||||
|
while (currentUUIDs.size <= index) {
|
||||||
|
currentUUIDs.add("")
|
||||||
|
}
|
||||||
|
currentUUIDs[index] = imageUUID
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
uploadedImageUUIDs = currentUUIDs,
|
||||||
|
uploadProgress = _uiState.value.uploadProgress + (uri.toString() to 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否所有图片都上传完成
|
||||||
|
val allUploaded = _uiState.value.selectedImageUris.size ==
|
||||||
|
_uiState.value.uploadedImageUUIDs.size
|
||||||
|
if (allUploaded) {
|
||||||
|
_uiState.value = _uiState.value.copy(isUploading = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("CreatePostViewModel", "Image upload error: ${e}")
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isUploading = false,
|
||||||
|
error = "图片处理失败: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传所有图片
|
||||||
|
*/
|
||||||
|
fun uploadAllImages(context: Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value.selectedImageUris.forEachIndexed { index, uriString ->
|
||||||
|
if (index >= _uiState.value.uploadedImageUUIDs.size ||
|
||||||
|
_uiState.value.uploadedImageUUIDs[index].isEmpty()
|
||||||
|
) {
|
||||||
|
uploadImage(context, Uri.parse(uriString), index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布帖子
|
||||||
|
*/
|
||||||
|
fun createPost() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val state = _uiState.value
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if (state.content.isBlank()) {
|
||||||
|
_uiState.value = state.copy(error = "请输入内容")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selectedCategoryId == null) {
|
||||||
|
_uiState.value = state.copy(error = "请选择分类")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查图片是否都已上传
|
||||||
|
if (state.selectedImageUris.isNotEmpty() &&
|
||||||
|
state.selectedImageUris.size != state.uploadedImageUUIDs.size
|
||||||
|
) {
|
||||||
|
_uiState.value = state.copy(error = "图片正在上传中,请稍候")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId = tokenManager.getUserIdFromToken()
|
||||||
|
if (userId == null) {
|
||||||
|
_uiState.value = state.copy(error = "请先登录")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = state.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
|
val createPostDto = CreatePostDto(
|
||||||
|
textContent = state.content,
|
||||||
|
categoryId = state.selectedCategoryId,
|
||||||
|
sectionId = state.selectedSectionId,
|
||||||
|
isVisible = true,
|
||||||
|
imageUUIDs = state.uploadedImageUUIDs
|
||||||
|
)
|
||||||
|
|
||||||
|
createPostRepository.createPost(createPostDto, userId)
|
||||||
|
.onSuccess { postId ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
success = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "发布失败: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置状态
|
||||||
|
*/
|
||||||
|
fun resetState() {
|
||||||
|
_uiState.value = CreatePostState(categories = _uiState.value.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除错误
|
||||||
|
*/
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Uri 转换为 File
|
||||||
|
*/
|
||||||
|
private fun uriToFile(context: Context, uri: Uri): File {
|
||||||
|
val contentResolver = context.contentResolver
|
||||||
|
val file = File(context.cacheDir, "upload_${System.currentTimeMillis()}.jpg")
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: throw IllegalStateException("无法打开输入流")
|
||||||
|
|
||||||
|
if (!file.exists() || file.length() == 0L) {
|
||||||
|
throw IllegalStateException("文件创建失败或为空")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("CreatePostViewModel", "Uri to File conversion failed", e)
|
||||||
|
file.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -139,7 +139,7 @@ class PostDetailViewModel @Inject constructor(
|
|||||||
postId = currentPostId.toLong(),
|
postId = currentPostId.toLong(),
|
||||||
commentDateTime = Date(),
|
commentDateTime = Date(),
|
||||||
content = content,
|
content = content,
|
||||||
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong(),
|
parentCommentId = if (parentCommentId == 0) 0L else parentCommentId.toLong(),
|
||||||
userId = currentUserId.toLong()
|
userId = currentUserId.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user