推文图片支持九宫格&所有图片并发上传并显示上传进度
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.memory.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 34
|
||||
versionName = "1.5.0"
|
||||
versionCode = 41
|
||||
versionName = "1.5.7"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "34")
|
||||
buildConfigField("int", "VERSION_CODE", "41")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -95,32 +95,39 @@ fun MediaGrid(
|
||||
}
|
||||
}
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(shape),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
for (row in 0..1) {
|
||||
for (row in 0 until rows) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(columns.toFloat()),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
for (col in 0..1) {
|
||||
val index = row * 2 + col
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < media.size) {
|
||||
AsyncImage(
|
||||
model = media[index].mediaUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// 占位,保持布局
|
||||
Spacer(modifier = Modifier.weight(1f).aspectRatio(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ fun MainNavigation(
|
||||
val context = LocalContext.current
|
||||
val isPosting by homeViewModel.isPosting.collectAsState()
|
||||
val postSuccess by homeViewModel.postSuccess.collectAsState()
|
||||
val editSuccess by homeViewModel.editSuccess.collectAsState()
|
||||
|
||||
val profileUser by profileViewModel.user.collectAsState()
|
||||
val profilePostCount by profileViewModel.postCount.collectAsState()
|
||||
@@ -70,6 +71,7 @@ fun MainNavigation(
|
||||
val unreadCount by notificationViewModel.unreadCount.collectAsState()
|
||||
val notifications by notificationViewModel.notifications.collectAsState()
|
||||
val notificationLoading by notificationViewModel.isLoading.collectAsState()
|
||||
val uploadProgress by homeViewModel.uploadProgress.collectAsState()
|
||||
|
||||
LaunchedEffect(user) {
|
||||
profileViewModel.setUser(user)
|
||||
@@ -84,6 +86,13 @@ fun MainNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(editSuccess) {
|
||||
if (editSuccess) {
|
||||
editingPost = null
|
||||
homeViewModel.resetEditSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -265,7 +274,8 @@ fun MainNavigation(
|
||||
onPost = { 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)
|
||||
}
|
||||
editingPost = null
|
||||
// 不在这里关闭,等 editSuccess 触发后再关闭
|
||||
},
|
||||
isLoading = isPosting
|
||||
isLoading = isPosting,
|
||||
uploadProgress = uploadProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ package com.memory.app.ui.screen
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.RoundedCornerShape
|
||||
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.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
@@ -42,7 +46,8 @@ fun CreatePostScreen(
|
||||
user: User?,
|
||||
onClose: () -> Unit,
|
||||
onPost: (String, List<Uri>, Int, String?) -> Unit, // content, images, visibility, musicUrl
|
||||
isLoading: Boolean
|
||||
isLoading: Boolean,
|
||||
uploadProgress: Map<Int, Float> = emptyMap()
|
||||
) {
|
||||
// 拦截系统返回手势
|
||||
BackHandler(enabled = true) {
|
||||
@@ -59,10 +64,13 @@ fun CreatePostScreen(
|
||||
var showMusicInput by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 计算还能选多少张
|
||||
val maxSelectable = (9 - selectedImages.size).coerceAtLeast(0)
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
) { uris ->
|
||||
selectedImages = (selectedImages + uris).take(6)
|
||||
selectedImages = (selectedImages + uris).take(9)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -126,12 +134,13 @@ fun CreatePostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Content Area
|
||||
// Content Area - 可滚动
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
@@ -188,36 +197,26 @@ fun CreatePostScreen(
|
||||
)
|
||||
)
|
||||
|
||||
// Selected Images - 超过3张显示为两行
|
||||
// Selected Images - 九宫格布局
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (selectedImages.size <= 3) {
|
||||
// 3张及以下:单行显示
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
selectedImages.forEach { uri ->
|
||||
SelectedImageItem(
|
||||
uri = uri,
|
||||
onRemove = { selectedImages = selectedImages - uri }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 超过3张:两行显示,每行3张
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val columns = if (selectedImages.size <= 3) selectedImages.size else 3
|
||||
val rows = (selectedImages.size + columns - 1) / columns
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
selectedImages.take(3).forEach { uri ->
|
||||
SelectedImageItem(
|
||||
uri = uri,
|
||||
onRemove = { selectedImages = selectedImages - uri }
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
selectedImages.drop(3).forEach { uri ->
|
||||
SelectedImageItem(
|
||||
uri = uri,
|
||||
onRemove = { selectedImages = selectedImages - uri }
|
||||
)
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < selectedImages.size) {
|
||||
val uri = selectedImages[index]
|
||||
val progress = uploadProgress[index]
|
||||
SelectedImageItem(
|
||||
uri = uri,
|
||||
onRemove = if (!isLoading) {{ selectedImages = selectedImages - uri }} else null,
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,14 +298,18 @@ fun CreatePostScreen(
|
||||
) {
|
||||
// Image Button
|
||||
IconButton(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
enabled = selectedImages.size < 6,
|
||||
onClick = {
|
||||
imagePicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
enabled = selectedImages.size < 9,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Image,
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -438,7 +441,8 @@ fun CreatePostScreen(
|
||||
@Composable
|
||||
private fun SelectedImageItem(
|
||||
uri: Uri,
|
||||
onRemove: () -> Unit
|
||||
onRemove: (() -> Unit)?,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
@@ -449,22 +453,65 @@ private fun SelectedImageItem(
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
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
|
||||
.size(90.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { uploadProgress },
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ package com.memory.app.ui.screen
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.RoundedCornerShape
|
||||
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.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
@@ -42,7 +46,8 @@ fun EditPostScreen(
|
||||
post: Post,
|
||||
onClose: () -> Unit,
|
||||
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) {
|
||||
@@ -65,9 +70,9 @@ fun EditPostScreen(
|
||||
val totalImages = existingImages.size + newImages.size
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
) { uris ->
|
||||
val remaining = 6 - totalImages
|
||||
val remaining = 9 - totalImages
|
||||
newImages = (newImages + uris).take(remaining.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
@@ -133,12 +138,13 @@ fun EditPostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Content Area
|
||||
// Content Area - 可滚动
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -187,52 +193,35 @@ fun EditPostScreen(
|
||||
)
|
||||
)
|
||||
|
||||
// Images - 合并已有图片和新图片,超过3张显示为两行
|
||||
val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.map { ImageItem.New(it) }
|
||||
// Images - 合并已有图片和新图片,九宫格布局
|
||||
val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.mapIndexed { idx, uri -> ImageItem.New(uri, idx) }
|
||||
if (allImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (allImages.size <= 3) {
|
||||
// 3张及以下:单行显示
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
allImages.forEach { item ->
|
||||
EditImageItem(
|
||||
item = item,
|
||||
onRemove = {
|
||||
when (item) {
|
||||
is ImageItem.Existing -> existingImages = existingImages - item.url
|
||||
is ImageItem.New -> newImages = newImages - item.uri
|
||||
val columns = if (allImages.size <= 3) allImages.size else 3
|
||||
val rows = (allImages.size + columns - 1) / columns
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < allImages.size) {
|
||||
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)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
enabled = totalImages < 6,
|
||||
onClick = {
|
||||
imagePicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
enabled = totalImages < 9,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Image,
|
||||
contentDescription = "添加图片",
|
||||
tint = if (totalImages < 6) Brand500 else Slate300,
|
||||
tint = if (totalImages < 9) Brand500 else Slate300,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
@@ -426,13 +419,14 @@ fun EditPostScreen(
|
||||
// 图片项类型
|
||||
private sealed class 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
|
||||
private fun EditImageItem(
|
||||
item: ImageItem,
|
||||
onRemove: () -> Unit
|
||||
onRemove: (() -> Unit)?,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
@@ -446,22 +440,65 @@ private fun EditImageItem(
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
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
|
||||
.size(90.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { uploadProgress },
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.memory.app.data.model.Comment
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.ui.components.EmojiPickerDialog
|
||||
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.theme.*
|
||||
import com.memory.app.util.TimeUtils
|
||||
@@ -188,21 +189,13 @@ fun PostDetailScreen(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// Media
|
||||
// Media - 使用 MediaGrid 展示缩略图
|
||||
if (post.media.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
post.media.forEachIndexed { index, media ->
|
||||
AsyncImage(
|
||||
model = media.mediaUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable { showImageViewerIndex = index },
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
MediaGrid(
|
||||
media = post.media,
|
||||
onMediaClick = { index -> showImageViewerIndex = index }
|
||||
)
|
||||
}
|
||||
|
||||
// Music Card
|
||||
|
||||
@@ -10,12 +10,15 @@ import com.memory.app.data.model.CreatePostRequest
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.data.model.UpdatePostRequest
|
||||
import com.memory.app.data.model.User
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import com.memory.app.data.api.ProgressRequestBody
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
private val _posts = MutableStateFlow<List<Post>>(emptyList())
|
||||
@@ -30,9 +33,16 @@ class HomeViewModel : ViewModel() {
|
||||
private val _postSuccess = MutableStateFlow(false)
|
||||
val postSuccess: StateFlow<Boolean> = _postSuccess
|
||||
|
||||
private val _editSuccess = MutableStateFlow(false)
|
||||
val editSuccess: StateFlow<Boolean> = _editSuccess
|
||||
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
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 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) {
|
||||
viewModelScope.launch {
|
||||
_isPosting.value = true
|
||||
try {
|
||||
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, mediaIds, visibility, musicUrl))
|
||||
if (response.isSuccessful) {
|
||||
// 刷新获取最新数据
|
||||
_editSuccess.value = true
|
||||
loadPosts()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
_isPosting.value = true
|
||||
_uploadProgress.value = emptyMap()
|
||||
try {
|
||||
// 上传新图片
|
||||
val newImageUrls = mutableListOf<String>()
|
||||
for (uri in newImages) {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes() ?: continue
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", fileName, requestBody)
|
||||
|
||||
val uploadResponse = ApiClient.api.upload(part)
|
||||
if (uploadResponse.isSuccessful) {
|
||||
uploadResponse.body()?.url?.let { newImageUrls.add(it) }
|
||||
// 并发上传新图片(带进度)
|
||||
val uploadJobs = newImages.mapIndexed { index, uri ->
|
||||
async {
|
||||
try {
|
||||
// 初始化进度
|
||||
_uploadProgress.value = _uploadProgress.value + (index to 0f)
|
||||
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes() ?: return@async null
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
|
||||
// 包装成带进度的 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 response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, allMediaIds, visibility, musicUrl))
|
||||
if (response.isSuccessful) {
|
||||
_editSuccess.value = true
|
||||
loadPosts()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,24 +233,48 @@ class HomeViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
_isPosting.value = true
|
||||
_postSuccess.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
|
||||
try {
|
||||
// 先上传图片
|
||||
val imageUrls = mutableListOf<String>()
|
||||
for (uri in images) {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes() ?: continue
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", fileName, requestBody)
|
||||
|
||||
val uploadResponse = ApiClient.api.upload(part)
|
||||
if (uploadResponse.isSuccessful) {
|
||||
uploadResponse.body()?.url?.let { imageUrls.add(it) }
|
||||
// 并发上传图片
|
||||
val uploadJobs = images.mapIndexed { index, uri ->
|
||||
async {
|
||||
try {
|
||||
// 初始化进度
|
||||
_uploadProgress.value = _uploadProgress.value + (index to 0f)
|
||||
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes() ?: return@async null
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
|
||||
// 包装成带进度的 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(
|
||||
content = content,
|
||||
@@ -229,6 +291,7 @@ class HomeViewModel : ViewModel() {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,4 +299,8 @@ class HomeViewModel : ViewModel() {
|
||||
fun resetPostSuccess() {
|
||||
_postSuccess.value = false
|
||||
}
|
||||
|
||||
fun resetEditSuccess() {
|
||||
_editSuccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
56
release.sh
56
release.sh
@@ -263,38 +263,32 @@ if [ "$BUILD_APK" = true ]; then
|
||||
echo " 版本: v${new_version} (code: ${new_code})"
|
||||
echo " 更新日志: ${update_log}"
|
||||
echo ""
|
||||
read -p "确认发布 APK? (y/n) " confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo "❌ APK 发布已取消"
|
||||
BUILD_APK=false
|
||||
else
|
||||
# 更新版本号
|
||||
|
||||
# 更新版本号
|
||||
echo "📝 更新版本号..."
|
||||
update_version_in_gradle $new_version $new_code
|
||||
|
||||
# 编译
|
||||
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 "📝 更新版本号..."
|
||||
update_version_in_gradle $new_version $new_code
|
||||
|
||||
# 编译
|
||||
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 "🎉 APK 发布完成!"
|
||||
echo " 版本: v${new_version}"
|
||||
echo " 下载: ${download_url}"
|
||||
else
|
||||
echo ""
|
||||
echo "❌ 服务器版本更新失败,APK 未上传"
|
||||
exit 1
|
||||
fi
|
||||
echo "🎉 APK 发布完成!"
|
||||
echo " 版本: v${new_version}"
|
||||
echo " 下载: ${download_url}"
|
||||
else
|
||||
echo ""
|
||||
echo "❌ 服务器版本更新失败,APK 未上传"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user