推文图片支持九宫格&所有图片并发上传并显示上传进度

This commit is contained in:
amos wong
2026-01-01 01:04:52 +08:00
parent d51e907cee
commit 44c1824269
9 changed files with 400 additions and 204 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.memory.app" applicationId = "com.memory.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 34 versionCode = 41
versionName = "1.5.0" versionName = "1.5.7"
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"") buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "34") buildConfigField("int", "VERSION_CODE", "41")
} }
signingConfigs { signingConfigs {

View File

@@ -0,0 +1,40 @@
package com.memory.app.data.api
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.buffer
class ProgressRequestBody(
private val delegate: RequestBody,
private val onProgress: (Float) -> Unit
) : RequestBody() {
override fun contentType(): MediaType? = delegate.contentType()
override fun contentLength(): Long = delegate.contentLength()
override fun writeTo(sink: BufferedSink) {
val total = contentLength()
if (total <= 0) {
delegate.writeTo(sink)
return
}
val progressSink = object : ForwardingSink(sink) {
private var uploaded = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
uploaded += byteCount
onProgress((uploaded.toFloat() / total).coerceIn(0f, 1f))
}
}
val bufferedSink = progressSink.buffer()
delegate.writeTo(bufferedSink)
bufferedSink.flush()
}
}

View File

@@ -95,32 +95,39 @@ fun MediaGrid(
} }
} }
else -> { else -> {
// 4+ images: 2x2 grid // 4+ images: 3x3 grid (九宫格)
val columns = if (media.size == 4) 2 else 3
val rows = (media.size + columns - 1) / columns
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f)
.clip(shape), .clip(shape),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
for (row in 0..1) { for (row in 0 until rows) {
Row( Row(
modifier = Modifier.weight(1f), modifier = Modifier
.fillMaxWidth()
.aspectRatio(columns.toFloat()),
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
for (col in 0..1) { for (col in 0 until columns) {
val index = row * 2 + col val index = row * columns + col
if (index < media.size) { if (index < media.size) {
AsyncImage( AsyncImage(
model = media[index].mediaUrl, model = media[index].mediaUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .aspectRatio(1f)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else {
// 占位,保持布局
Spacer(modifier = Modifier.weight(1f).aspectRatio(1f))
} }
} }
} }

View File

@@ -62,6 +62,7 @@ fun MainNavigation(
val context = LocalContext.current val context = LocalContext.current
val isPosting by homeViewModel.isPosting.collectAsState() val isPosting by homeViewModel.isPosting.collectAsState()
val postSuccess by homeViewModel.postSuccess.collectAsState() val postSuccess by homeViewModel.postSuccess.collectAsState()
val editSuccess by homeViewModel.editSuccess.collectAsState()
val profileUser by profileViewModel.user.collectAsState() val profileUser by profileViewModel.user.collectAsState()
val profilePostCount by profileViewModel.postCount.collectAsState() val profilePostCount by profileViewModel.postCount.collectAsState()
@@ -70,6 +71,7 @@ fun MainNavigation(
val unreadCount by notificationViewModel.unreadCount.collectAsState() val unreadCount by notificationViewModel.unreadCount.collectAsState()
val notifications by notificationViewModel.notifications.collectAsState() val notifications by notificationViewModel.notifications.collectAsState()
val notificationLoading by notificationViewModel.isLoading.collectAsState() val notificationLoading by notificationViewModel.isLoading.collectAsState()
val uploadProgress by homeViewModel.uploadProgress.collectAsState()
LaunchedEffect(user) { LaunchedEffect(user) {
profileViewModel.setUser(user) profileViewModel.setUser(user)
@@ -84,6 +86,13 @@ fun MainNavigation(
} }
} }
LaunchedEffect(editSuccess) {
if (editSuccess) {
editingPost = null
homeViewModel.resetEditSuccess()
}
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -265,7 +274,8 @@ fun MainNavigation(
onPost = { content, images, visibility, musicUrl -> onPost = { content, images, visibility, musicUrl ->
homeViewModel.createPost(context, content, images, visibility, musicUrl) homeViewModel.createPost(context, content, images, visibility, musicUrl)
}, },
isLoading = isPosting isLoading = isPosting,
uploadProgress = uploadProgress
) )
} }
@@ -282,9 +292,10 @@ fun MainNavigation(
// 更新文字、图片、可见性和音乐 // 更新文字、图片、可见性和音乐
homeViewModel.updatePostWithImages(context, post.id, newContent, existingUrls, newImages, visibility, musicUrl) homeViewModel.updatePostWithImages(context, post.id, newContent, existingUrls, newImages, visibility, musicUrl)
} }
editingPost = null // 不在这里关闭,等 editSuccess 触发后再关闭
}, },
isLoading = isPosting isLoading = isPosting,
uploadProgress = uploadProgress
) )
} }
} }

View File

@@ -3,14 +3,18 @@ package com.memory.app.ui.screen
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
@@ -42,7 +46,8 @@ fun CreatePostScreen(
user: User?, user: User?,
onClose: () -> Unit, onClose: () -> Unit,
onPost: (String, List<Uri>, Int, String?) -> Unit, // content, images, visibility, musicUrl onPost: (String, List<Uri>, Int, String?) -> Unit, // content, images, visibility, musicUrl
isLoading: Boolean isLoading: Boolean,
uploadProgress: Map<Int, Float> = emptyMap()
) { ) {
// 拦截系统返回手势 // 拦截系统返回手势
BackHandler(enabled = true) { BackHandler(enabled = true) {
@@ -59,10 +64,13 @@ fun CreatePostScreen(
var showMusicInput by remember { mutableStateOf(false) } var showMusicInput by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 计算还能选多少张
val maxSelectable = (9 - selectedImages.size).coerceAtLeast(0)
val imagePicker = rememberLauncherForActivityResult( val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents() contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
) { uris -> ) { uris ->
selectedImages = (selectedImages + uris).take(6) selectedImages = (selectedImages + uris).take(9)
} }
Column( Column(
@@ -126,12 +134,13 @@ fun CreatePostScreen(
} }
} }
// Content Area // Content Area - 可滚动
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.padding(horizontal = 20.dp, vertical = 8.dp) .padding(horizontal = 20.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState())
) { ) {
// Avatar // Avatar
Box( Box(
@@ -188,36 +197,26 @@ fun CreatePostScreen(
) )
) )
// Selected Images - 超过3张显示为两行 // Selected Images - 九宫格布局
if (selectedImages.isNotEmpty()) { if (selectedImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (selectedImages.size <= 3) { val columns = if (selectedImages.size <= 3) selectedImages.size else 3
// 3张及以下单行显示 val rows = (selectedImages.size + columns - 1) / columns
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
selectedImages.forEach { uri -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectedImageItem( for (row in 0 until rows) {
uri = uri,
onRemove = { selectedImages = selectedImages - uri }
)
}
}
} else {
// 超过3张两行显示每行3张
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
selectedImages.take(3).forEach { uri -> for (col in 0 until columns) {
SelectedImageItem( val index = row * columns + col
uri = uri, if (index < selectedImages.size) {
onRemove = { selectedImages = selectedImages - uri } val uri = selectedImages[index]
) val progress = uploadProgress[index]
} SelectedImageItem(
} uri = uri,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { onRemove = if (!isLoading) {{ selectedImages = selectedImages - uri }} else null,
selectedImages.drop(3).forEach { uri -> uploadProgress = progress
SelectedImageItem( )
uri = uri, }
onRemove = { selectedImages = selectedImages - uri }
)
} }
} }
} }
@@ -299,14 +298,18 @@ fun CreatePostScreen(
) { ) {
// Image Button // Image Button
IconButton( IconButton(
onClick = { imagePicker.launch("image/*") }, onClick = {
enabled = selectedImages.size < 6, imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
enabled = selectedImages.size < 9,
modifier = Modifier.size(40.dp) modifier = Modifier.size(40.dp)
) { ) {
Icon( Icon(
Icons.Outlined.Image, Icons.Outlined.Image,
contentDescription = "添加图片", contentDescription = "添加图片",
tint = if (selectedImages.size < 6) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (selectedImages.size < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -438,7 +441,8 @@ fun CreatePostScreen(
@Composable @Composable
private fun SelectedImageItem( private fun SelectedImageItem(
uri: Uri, uri: Uri,
onRemove: () -> Unit onRemove: (() -> Unit)?,
uploadProgress: Float? = null
) { ) {
Box { Box {
AsyncImage( AsyncImage(
@@ -449,22 +453,65 @@ private fun SelectedImageItem(
.clip(RoundedCornerShape(10.dp)), .clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Box(
modifier = Modifier // 上传进度遮罩
.align(Alignment.TopEnd) if (uploadProgress != null && uploadProgress < 1f) {
.padding(4.dp) Box(
.size(22.dp) modifier = Modifier
.clip(CircleShape) .size(90.dp)
.background(Color.Black.copy(alpha = 0.6f)) .clip(RoundedCornerShape(10.dp))
.clickable { onRemove() }, .background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( CircularProgressIndicator(
Icons.Default.Close, progress = { uploadProgress },
contentDescription = "移除", modifier = Modifier.size(36.dp),
modifier = Modifier.size(12.dp), color = Color.White,
tint = Color.White strokeWidth = 3.dp,
) trackColor = Color.White.copy(alpha = 0.3f)
)
}
}
// 删除按钮(上传中不显示)
if (onRemove != null && uploadProgress == null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.6f))
.clickable { onRemove() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Close,
contentDescription = "移除",
modifier = Modifier.size(12.dp),
tint = Color.White
)
}
}
// 上传完成标记
if (uploadProgress != null && uploadProgress >= 1f) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.clip(CircleShape)
.background(Brand500),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Check,
contentDescription = "完成",
modifier = Modifier.size(14.dp),
tint = Color.White
)
}
} }
} }
} }

View File

@@ -3,14 +3,18 @@ package com.memory.app.ui.screen
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
@@ -42,7 +46,8 @@ fun EditPostScreen(
post: Post, post: Post,
onClose: () -> Unit, onClose: () -> Unit,
onSave: (String, List<String>, List<Uri>, Int, String?) -> Unit, // content, existingImages, newImages, visibility, musicUrl onSave: (String, List<String>, List<Uri>, Int, String?) -> Unit, // content, existingImages, newImages, visibility, musicUrl
isLoading: Boolean isLoading: Boolean,
uploadProgress: Map<Int, Float> = emptyMap()
) { ) {
// 拦截系统返回手势 // 拦截系统返回手势
BackHandler(enabled = true) { BackHandler(enabled = true) {
@@ -65,9 +70,9 @@ fun EditPostScreen(
val totalImages = existingImages.size + newImages.size val totalImages = existingImages.size + newImages.size
val imagePicker = rememberLauncherForActivityResult( val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents() contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
) { uris -> ) { uris ->
val remaining = 6 - totalImages val remaining = 9 - totalImages
newImages = (newImages + uris).take(remaining.coerceAtLeast(0)) newImages = (newImages + uris).take(remaining.coerceAtLeast(0))
} }
@@ -133,12 +138,13 @@ fun EditPostScreen(
} }
} }
// Content Area // Content Area - 可滚动
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.padding(horizontal = 20.dp, vertical = 8.dp) .padding(horizontal = 20.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState())
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -187,52 +193,35 @@ fun EditPostScreen(
) )
) )
// Images - 合并已有图片和新图片,超过3张显示为两行 // Images - 合并已有图片和新图片,九宫格布局
val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.map { ImageItem.New(it) } val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.mapIndexed { idx, uri -> ImageItem.New(uri, idx) }
if (allImages.isNotEmpty()) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (allImages.size <= 3) { val columns = if (allImages.size <= 3) allImages.size else 3
// 3张及以下单行显示 val rows = (allImages.size + columns - 1) / columns
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
allImages.forEach { item -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
EditImageItem( for (row in 0 until rows) {
item = item, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onRemove = { for (col in 0 until columns) {
when (item) { val index = row * columns + col
is ImageItem.Existing -> existingImages = existingImages - item.url if (index < allImages.size) {
is ImageItem.New -> newImages = newImages - item.uri val item = allImages[index]
val progress = when (item) {
is ImageItem.New -> uploadProgress[item.index]
else -> null
} }
EditImageItem(
item = item,
onRemove = if (!isLoading) {{
when (item) {
is ImageItem.Existing -> existingImages = existingImages - item.url
is ImageItem.New -> newImages = newImages - item.uri
}
}} else null,
uploadProgress = progress
)
} }
)
}
}
} else {
// 超过3张两行显示每行3张
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
allImages.take(3).forEach { item ->
EditImageItem(
item = item,
onRemove = {
when (item) {
is ImageItem.Existing -> existingImages = existingImages - item.url
is ImageItem.New -> newImages = newImages - item.uri
}
}
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
allImages.drop(3).forEach { item ->
EditImageItem(
item = item,
onRemove = {
when (item) {
is ImageItem.Existing -> existingImages = existingImages - item.url
is ImageItem.New -> newImages = newImages - item.uri
}
}
)
} }
} }
} }
@@ -309,14 +298,18 @@ fun EditPostScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
IconButton( IconButton(
onClick = { imagePicker.launch("image/*") }, onClick = {
enabled = totalImages < 6, imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
enabled = totalImages < 9,
modifier = Modifier.size(40.dp) modifier = Modifier.size(40.dp)
) { ) {
Icon( Icon(
Icons.Outlined.Image, Icons.Outlined.Image,
contentDescription = "添加图片", contentDescription = "添加图片",
tint = if (totalImages < 6) Brand500 else Slate300, tint = if (totalImages < 9) Brand500 else Slate300,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -426,13 +419,14 @@ fun EditPostScreen(
// 图片项类型 // 图片项类型
private sealed class ImageItem { private sealed class ImageItem {
data class Existing(val url: String) : ImageItem() data class Existing(val url: String) : ImageItem()
data class New(val uri: Uri) : ImageItem() data class New(val uri: Uri, val index: Int) : ImageItem()
} }
@Composable @Composable
private fun EditImageItem( private fun EditImageItem(
item: ImageItem, item: ImageItem,
onRemove: () -> Unit onRemove: (() -> Unit)?,
uploadProgress: Float? = null
) { ) {
Box { Box {
AsyncImage( AsyncImage(
@@ -446,22 +440,65 @@ private fun EditImageItem(
.clip(RoundedCornerShape(10.dp)), .clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Box(
modifier = Modifier // 上传进度遮罩
.align(Alignment.TopEnd) if (uploadProgress != null && uploadProgress < 1f) {
.padding(4.dp) Box(
.size(22.dp) modifier = Modifier
.clip(CircleShape) .size(90.dp)
.background(Color.Black.copy(alpha = 0.6f)) .clip(RoundedCornerShape(10.dp))
.clickable { onRemove() }, .background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( CircularProgressIndicator(
Icons.Default.Close, progress = { uploadProgress },
contentDescription = "移除", modifier = Modifier.size(36.dp),
modifier = Modifier.size(12.dp), color = Color.White,
tint = Color.White strokeWidth = 3.dp,
) trackColor = Color.White.copy(alpha = 0.3f)
)
}
}
// 删除按钮(上传中不显示)
if (onRemove != null && uploadProgress == null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.6f))
.clickable { onRemove() },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Close,
contentDescription = "移除",
modifier = Modifier.size(12.dp),
tint = Color.White
)
}
}
// 上传完成标记
if (uploadProgress != null && uploadProgress >= 1f) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.clip(CircleShape)
.background(Brand500),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Check,
contentDescription = "完成",
modifier = Modifier.size(14.dp),
tint = Color.White
)
}
} }
} }
} }

View File

@@ -28,6 +28,7 @@ import com.memory.app.data.model.Comment
import com.memory.app.data.model.Post import com.memory.app.data.model.Post
import com.memory.app.ui.components.EmojiPickerDialog import com.memory.app.ui.components.EmojiPickerDialog
import com.memory.app.ui.components.FullScreenImageViewer import com.memory.app.ui.components.FullScreenImageViewer
import com.memory.app.ui.components.MediaGrid
import com.memory.app.ui.components.MusicCard import com.memory.app.ui.components.MusicCard
import com.memory.app.ui.theme.* import com.memory.app.ui.theme.*
import com.memory.app.util.TimeUtils import com.memory.app.util.TimeUtils
@@ -188,21 +189,13 @@ fun PostDetailScreen(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
// Media // Media - 使用 MediaGrid 展示缩略图
if (post.media.isNotEmpty()) { if (post.media.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
post.media.forEachIndexed { index, media -> MediaGrid(
AsyncImage( media = post.media,
model = media.mediaUrl, onMediaClick = { index -> showImageViewerIndex = index }
contentDescription = null, )
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable { showImageViewerIndex = index },
contentScale = ContentScale.FillWidth
)
Spacer(modifier = Modifier.height(8.dp))
}
} }
// Music Card // Music Card

View File

@@ -10,12 +10,15 @@ import com.memory.app.data.model.CreatePostRequest
import com.memory.app.data.model.Post import com.memory.app.data.model.Post
import com.memory.app.data.model.UpdatePostRequest import com.memory.app.data.model.UpdatePostRequest
import com.memory.app.data.model.User import com.memory.app.data.model.User
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import com.memory.app.data.api.ProgressRequestBody
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
private val _posts = MutableStateFlow<List<Post>>(emptyList()) private val _posts = MutableStateFlow<List<Post>>(emptyList())
@@ -30,9 +33,16 @@ class HomeViewModel : ViewModel() {
private val _postSuccess = MutableStateFlow(false) private val _postSuccess = MutableStateFlow(false)
val postSuccess: StateFlow<Boolean> = _postSuccess val postSuccess: StateFlow<Boolean> = _postSuccess
private val _editSuccess = MutableStateFlow(false)
val editSuccess: StateFlow<Boolean> = _editSuccess
private val _currentUser = MutableStateFlow<User?>(null) private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser val currentUser: StateFlow<User?> = _currentUser
// 上传进度: Map<图片索引, 进度(0.0~1.0)>
private val _uploadProgress = MutableStateFlow<Map<Int, Float>>(emptyMap())
val uploadProgress: StateFlow<Map<Int, Float>> = _uploadProgress
private var currentPage = 1 private var currentPage = 1
private var hasMore = true private var hasMore = true
@@ -137,14 +147,17 @@ class HomeViewModel : ViewModel() {
fun updatePost(postId: Long, content: String, mediaIds: List<String>? = null, visibility: Int? = null, musicUrl: String? = null) { fun updatePost(postId: Long, content: String, mediaIds: List<String>? = null, visibility: Int? = null, musicUrl: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isPosting.value = true
try { try {
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, mediaIds, visibility, musicUrl)) val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, mediaIds, visibility, musicUrl))
if (response.isSuccessful) { if (response.isSuccessful) {
// 刷新获取最新数据 _editSuccess.value = true
loadPosts() loadPosts()
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally {
_isPosting.value = false
} }
} }
} }
@@ -152,36 +165,61 @@ class HomeViewModel : ViewModel() {
fun updatePostWithImages(context: Context, postId: Long, content: String, existingUrls: List<String>, newImages: List<Uri>, visibility: Int? = null, musicUrl: String? = null) { fun updatePostWithImages(context: Context, postId: Long, content: String, existingUrls: List<String>, newImages: List<Uri>, visibility: Int? = null, musicUrl: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isPosting.value = true _isPosting.value = true
_uploadProgress.value = emptyMap()
try { try {
// 上传新图片 // 并发上传新图片(带进度)
val newImageUrls = mutableListOf<String>() val uploadJobs = newImages.mapIndexed { index, uri ->
for (uri in newImages) { async {
val inputStream = context.contentResolver.openInputStream(uri) try {
val bytes = inputStream?.readBytes() ?: continue // 初始化进度
inputStream.close() _uploadProgress.value = _uploadProgress.value + (index to 0f)
val fileName = "image_${System.currentTimeMillis()}.jpg" val inputStream = context.contentResolver.openInputStream(uri)
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) val bytes = inputStream?.readBytes() ?: return@async null
val part = MultipartBody.Part.createFormData("file", fileName, requestBody) inputStream.close()
val uploadResponse = ApiClient.api.upload(part) val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
if (uploadResponse.isSuccessful) { val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
uploadResponse.body()?.url?.let { newImageUrls.add(it) }
// 包装成带进度的 RequestBody
val progressBody = ProgressRequestBody(requestBody) { progress ->
_uploadProgress.value = _uploadProgress.value + (index to progress)
}
val part = MultipartBody.Part.createFormData("file", fileName, progressBody)
val uploadResponse = ApiClient.api.upload(part)
if (uploadResponse.isSuccessful) {
_uploadProgress.value = _uploadProgress.value + (index to 1f)
index to uploadResponse.body()?.url
} else null
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
} }
// 等待所有上传完成,按原始顺序排列
val newImageUrls = uploadJobs.awaitAll()
.filterNotNull()
.sortedBy { it.first }
.mapNotNull { it.second }
// 合并现有图片和新图片 // 合并现有图片和新图片
val allMediaIds = existingUrls + newImageUrls val allMediaIds = existingUrls + newImageUrls
// 更新帖子 // 更新帖子
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, allMediaIds, visibility, musicUrl)) val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, allMediaIds, visibility, musicUrl))
if (response.isSuccessful) { if (response.isSuccessful) {
_editSuccess.value = true
loadPosts() loadPosts()
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
_isPosting.value = false _isPosting.value = false
_uploadProgress.value = emptyMap()
} }
} }
} }
@@ -195,24 +233,48 @@ class HomeViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
_isPosting.value = true _isPosting.value = true
_postSuccess.value = false _postSuccess.value = false
_uploadProgress.value = emptyMap()
try { try {
// 上传图片 // 并发上传图片
val imageUrls = mutableListOf<String>() val uploadJobs = images.mapIndexed { index, uri ->
for (uri in images) { async {
val inputStream = context.contentResolver.openInputStream(uri) try {
val bytes = inputStream?.readBytes() ?: continue // 初始化进度
inputStream.close() _uploadProgress.value = _uploadProgress.value + (index to 0f)
val fileName = "image_${System.currentTimeMillis()}.jpg" val inputStream = context.contentResolver.openInputStream(uri)
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) val bytes = inputStream?.readBytes() ?: return@async null
val part = MultipartBody.Part.createFormData("file", fileName, requestBody) inputStream.close()
val uploadResponse = ApiClient.api.upload(part) val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
if (uploadResponse.isSuccessful) { val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
uploadResponse.body()?.url?.let { imageUrls.add(it) }
// 包装成带进度的 RequestBody
val progressBody = ProgressRequestBody(requestBody) { progress ->
_uploadProgress.value = _uploadProgress.value + (index to progress)
}
val part = MultipartBody.Part.createFormData("file", fileName, progressBody)
val uploadResponse = ApiClient.api.upload(part)
if (uploadResponse.isSuccessful) {
_uploadProgress.value = _uploadProgress.value + (index to 1f)
index to uploadResponse.body()?.url
} else null
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
} }
// 等待所有上传完成,按原始顺序排列
val imageUrls = uploadJobs.awaitAll()
.filterNotNull()
.sortedBy { it.first }
.mapNotNull { it.second }
// 创建帖子 // 创建帖子
val request = CreatePostRequest( val request = CreatePostRequest(
content = content, content = content,
@@ -229,6 +291,7 @@ class HomeViewModel : ViewModel() {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
_isPosting.value = false _isPosting.value = false
_uploadProgress.value = emptyMap()
} }
} }
} }
@@ -236,4 +299,8 @@ class HomeViewModel : ViewModel() {
fun resetPostSuccess() { fun resetPostSuccess() {
_postSuccess.value = false _postSuccess.value = false
} }
fun resetEditSuccess() {
_editSuccess.value = false
}
} }

View File

@@ -263,38 +263,32 @@ if [ "$BUILD_APK" = true ]; then
echo " 版本: v${new_version} (code: ${new_code})" echo " 版本: v${new_version} (code: ${new_code})"
echo " 更新日志: ${update_log}" echo " 更新日志: ${update_log}"
echo "" echo ""
read -p "确认发布 APK? (y/n) " confirm
if [ "$confirm" != "y" ]; then # 更新版本号
echo "❌ APK 发布已取消" echo "📝 更新版本号..."
BUILD_APK=false update_version_in_gradle $new_version $new_code
else
# 更新版本号 # 编译
build_apk
# 先更新服务器版本信息,成功后再上传 APK
download_url="${R2_PUBLIC_URL}/releases/memory-${new_version}.apk"
if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then
# 上传 APK
upload_to_r2 $new_version
echo "✅ 上传完成: $download_url"
# 清理旧版本
cleanup_old_apks
echo "" echo ""
echo "📝 更新版本号..." echo "🎉 APK 发布完成!"
update_version_in_gradle $new_version $new_code echo " 版本: v${new_version}"
echo " 下载: ${download_url}"
# 编译 else
build_apk echo ""
echo "❌ 服务器版本更新失败APK 未上传"
# 先更新服务器版本信息,成功后再上传 APK exit 1
download_url="${R2_PUBLIC_URL}/releases/memory-${new_version}.apk"
if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then
# 上传 APK
upload_to_r2 $new_version
echo "✅ 上传完成: $download_url"
# 清理旧版本
cleanup_old_apks
echo ""
echo "🎉 APK 发布完成!"
echo " 版本: v${new_version}"
echo " 下载: ${download_url}"
else
echo ""
echo "❌ 服务器版本更新失败APK 未上传"
exit 1
fi
fi fi
fi fi