From 44c182426950d4fce48d61fb50b2df2bbcdbefc3 Mon Sep 17 00:00:00 2001 From: amos wong Date: Thu, 1 Jan 2026 01:04:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E6=96=87=E5=9B=BE=E7=89=87=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B9=9D=E5=AE=AB=E6=A0=BC&=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=B9=B6=E5=8F=91=E4=B8=8A=E4=BC=A0=E5=B9=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 6 +- .../app/data/api/ProgressRequestBody.kt | 40 ++++ .../com/memory/app/ui/components/MediaGrid.kt | 21 ++- .../memory/app/ui/navigation/Navigation.kt | 17 +- .../memory/app/ui/screen/CreatePostScreen.kt | 149 +++++++++------ .../memory/app/ui/screen/EditPostScreen.kt | 171 +++++++++++------- .../memory/app/ui/screen/PostDetailScreen.kt | 19 +- .../memory/app/ui/viewmodel/HomeViewModel.kt | 125 ++++++++++--- release.sh | 56 +++--- 9 files changed, 400 insertions(+), 204 deletions(-) create mode 100644 android/app/src/main/java/com/memory/app/data/api/ProgressRequestBody.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5840d57..4f08057 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/com/memory/app/data/api/ProgressRequestBody.kt b/android/app/src/main/java/com/memory/app/data/api/ProgressRequestBody.kt new file mode 100644 index 0000000..993a659 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/data/api/ProgressRequestBody.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt index ec1b0b7..5149249 100644 --- a/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt +++ b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt @@ -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)) } } } diff --git a/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt index df9dc16..7a2745a 100644 --- a/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt @@ -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 ) } } diff --git a/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt index 18fabe0..86e128b 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt @@ -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, Int, String?) -> Unit, // content, images, visibility, musicUrl - isLoading: Boolean + isLoading: Boolean, + uploadProgress: Map = 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 + ) + } } } } diff --git a/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt index 93eed78..33cdcae 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt @@ -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, List, Int, String?) -> Unit, // content, existingImages, newImages, visibility, musicUrl - isLoading: Boolean + isLoading: Boolean, + uploadProgress: Map = 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 + ) + } } } } diff --git a/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt index 645ea5c..d37e66a 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt index 0208072..d6e2509 100644 --- a/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt @@ -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>(emptyList()) @@ -30,9 +33,16 @@ class HomeViewModel : ViewModel() { private val _postSuccess = MutableStateFlow(false) val postSuccess: StateFlow = _postSuccess + private val _editSuccess = MutableStateFlow(false) + val editSuccess: StateFlow = _editSuccess + private val _currentUser = MutableStateFlow(null) val currentUser: StateFlow = _currentUser + // 上传进度: Map<图片索引, 进度(0.0~1.0)> + private val _uploadProgress = MutableStateFlow>(emptyMap()) + val uploadProgress: StateFlow> = _uploadProgress + private var currentPage = 1 private var hasMore = true @@ -137,14 +147,17 @@ class HomeViewModel : ViewModel() { fun updatePost(postId: Long, content: String, mediaIds: List? = 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, newImages: List, visibility: Int? = null, musicUrl: String? = null) { viewModelScope.launch { _isPosting.value = true + _uploadProgress.value = emptyMap() try { - // 上传新图片 - val newImageUrls = mutableListOf() - 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() - 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 + } } diff --git a/release.sh b/release.sh index 1da14f2..921386d 100755 --- a/release.sh +++ b/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