dm-170
This commit is contained in:
parent
6f8f42b51a
commit
5ae72801d1
3 changed files with 230 additions and 52 deletions
|
|
@ -4,7 +4,9 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.datastore.core.use
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.api.api_class.ChatRoom
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
|
|
@ -16,15 +18,24 @@ import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao
|
|||
import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.io.use
|
||||
import kotlin.use
|
||||
|
||||
@Singleton
|
||||
class ChatRoomsRepository @Inject constructor(
|
||||
|
|
@ -215,40 +226,6 @@ class ChatRoomsRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
//send message
|
||||
suspend fun sendMessage(
|
||||
roomId: String,
|
||||
content: String,
|
||||
replyToId: String? = null
|
||||
): Result<Message> {
|
||||
return try {
|
||||
// 打包 Request Body
|
||||
val requestBody = SendMessageRequest(
|
||||
content = content,
|
||||
replyToId = replyToId
|
||||
)
|
||||
|
||||
// 呼叫 API
|
||||
val response = api.sendMessage(roomId, requestBody)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("發送成功但回應身體為空 (Empty response body)"))
|
||||
}
|
||||
} else {
|
||||
// 抓取後端傳回的錯誤訊息
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("發送訊息失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 捕捉網路斷線或解析 JSON 失敗等狀況
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//upload file
|
||||
suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> {
|
||||
|
|
@ -452,4 +429,69 @@ class ChatRoomsRepository @Inject constructor(
|
|||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//download file
|
||||
private val httpClient = OkHttpClient()
|
||||
fun downloadFileFromUrlFlow(fileUrl: String, fileName: String): Flow<Result<Float>> = flow {
|
||||
try {
|
||||
// 1. 建立 OkHttp Request,直接 GET 該網址
|
||||
val request = Request.Builder()
|
||||
.url(fileUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
emit(Result.failure(Exception("下載失敗,HTTP 錯誤碼: ${response.code}")))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
emit(Result.failure(Exception("下載失敗,回應身體為空")))
|
||||
return@flow
|
||||
}
|
||||
|
||||
// 2. 定義手機本機儲存路徑
|
||||
val targetDirectory = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
?: context.cacheDir
|
||||
val targetFile = File(targetDirectory, fileName)
|
||||
|
||||
// 3. 讀取網路串流並寫入檔案
|
||||
val totalBytes = body.contentLength()
|
||||
var bytesCopied = 0L
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
FileOutputStream(targetFile).use { outputStream ->
|
||||
var bytesRead = inputStream.read(buffer)
|
||||
|
||||
while (bytesRead != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
bytesCopied += bytesRead
|
||||
|
||||
// 4. 計算並發送進度
|
||||
if (totalBytes > 0) {
|
||||
val progress = bytesCopied.toFloat() / totalBytes.toFloat()
|
||||
emit(Result.success(progress))
|
||||
} else {
|
||||
// 如果後端沒給 Content-Length,傳 -1f 走無限轉圈模式
|
||||
emit(Result.success(-1f))
|
||||
}
|
||||
|
||||
bytesRead = inputStream.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 下載成功,發送 100%
|
||||
emit(Result.success(1.0f))
|
||||
|
||||
} catch (e: Exception) {
|
||||
emit(Result.failure(e))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO) // 確保硬碟與網路 I/O 不卡 UI 執行緒
|
||||
}
|
||||
|
|
@ -80,8 +80,11 @@ import androidx.compose.foundation.layout.sizeIn
|
|||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.layout.statusBars // 如果要使用 statusBars 也需要這個
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
|
@ -98,6 +101,7 @@ import com.example.login_v3.home.Message.UI.Detail.Message_Component.ImageLightb
|
|||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageAttachmentBar
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.VideoLightbox
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus
|
||||
|
||||
|
||||
//bottom bar state
|
||||
|
|
@ -952,7 +956,11 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
//file message
|
||||
isFile -> {
|
||||
// 1. 🎯 從 ViewModel 取得當前訊息的下載狀態
|
||||
val downloadStatus = viewModel.getDownloadStatus(message.id)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 260.dp)
|
||||
|
|
@ -965,7 +973,15 @@ fun MessageRow(
|
|||
)
|
||||
.combinedClickable(
|
||||
onLongClick = { onReplyClick(message) },
|
||||
onClick = { onRowClick(message) } // 🎯 點擊時可以在此觸發下載或開啟檔案
|
||||
onClick = {
|
||||
// 點擊整個氣泡:如果是已完成就直接開啟,否則可維持你原本的行為
|
||||
if (downloadStatus is DownloadStatus.Completed) {
|
||||
onRowClick(message)
|
||||
} else {
|
||||
// 也可以在這裡同樣觸發下載
|
||||
viewModel.downloadFile(message, displayFileName)
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(color = finalBubbleColor)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
|
|
@ -975,30 +991,89 @@ fun MessageRow(
|
|||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
// 檔案的 UI 佈局 (左邊圖示,右邊文字)
|
||||
// 檔案的 UI 佈局 (左邊按鈕,右邊文字)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// 根據副檔名決定圖示,這裡示範用內建圖示,你也可以換成自己的 zip 圖片
|
||||
// 檔案下載按鈕 / 進度顯示
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(Color.White.copy(alpha = 0.6f), CircleShape),
|
||||
.background(Color.White.copy(alpha = 0.6f), CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
// 2. 🎯 點擊按鈕觸發下載或開啟邏輯
|
||||
when (downloadStatus) {
|
||||
is DownloadStatus.NotStarted, is DownloadStatus.Error -> {
|
||||
viewModel.downloadFile(message, displayFileName)
|
||||
}
|
||||
is DownloadStatus.Completed -> {
|
||||
onRowClick(message) // 觸發下載完成後的開啟檔案
|
||||
}
|
||||
is DownloadStatus.Downloading -> {
|
||||
// 下載中可以留空,或者未來實作取消下載
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 3. 🎯 根據密封介面 (Sealed Interface) 狀態切換 Icon 與進度條
|
||||
when (downloadStatus) {
|
||||
is DownloadStatus.NotStarted -> {
|
||||
Icon(
|
||||
imageVector = if (displayFileName.endsWith(".zip") || displayFileName.endsWith(".rar")) {
|
||||
androidx.compose.material.icons.Icons.Default.Build // 或者是常規的 Folder / Img
|
||||
} else {
|
||||
androidx.compose.material.icons.Icons.Default.Info
|
||||
},
|
||||
contentDescription = "檔案",
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.ArrowDownward,
|
||||
contentDescription = "下載檔案",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
is DownloadStatus.Downloading -> {
|
||||
// 💡 這裡的 progress 是從 downloadStatus.progress 拿出來的
|
||||
val progress = downloadStatus.progress
|
||||
|
||||
if (progress >= 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
// 如果後端沒給 Content-Length (progress = -1f),改跑無限轉圈
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Close,
|
||||
contentDescription = "下載中",
|
||||
tint = Color.Gray.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
is DownloadStatus.Completed -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Check,
|
||||
contentDescription = "下載完成",
|
||||
tint = Color.Green,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
is DownloadStatus.Error -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning, // 或者是 Refresh 圖示
|
||||
contentDescription = "下載失敗,點擊重試",
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右側檔名與資訊
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayFileName,
|
||||
|
|
@ -1007,7 +1082,15 @@ fun MessageRow(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "檔案", // 如果後端有提供大小(例如 12.5 MB),放這裡最適合
|
||||
text = when (downloadStatus) {
|
||||
is DownloadStatus.Downloading -> {
|
||||
val progress = downloadStatus.progress
|
||||
if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..."
|
||||
}
|
||||
is DownloadStatus.Completed -> "已下載"
|
||||
is DownloadStatus.Error -> "下載失敗,點擊重試"
|
||||
else -> "檔案"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package com.example.login_v3.home.Message.ViewModel.Detail
|
|||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.MediaItem
|
||||
|
|
@ -73,6 +74,14 @@ data class AudioPlaybackState(
|
|||
val duration: Long = 0L // 總時長 (毫秒)
|
||||
)
|
||||
|
||||
// 🎯 檔案下載狀態管理
|
||||
sealed interface DownloadStatus {
|
||||
object NotStarted : DownloadStatus
|
||||
data class Downloading(val progress: Float) : DownloadStatus // 儲存 0.0 ~ 1.0f 的進度
|
||||
object Completed : DownloadStatus
|
||||
data class Error(val message: String) : DownloadStatus
|
||||
}
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
|
|
@ -381,15 +390,59 @@ class ChatViewModel @Inject constructor(
|
|||
progressLogJob = null
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 🌟 生命週期釋放:ViewModel 銷毀時,必須徹底關閉 Player
|
||||
// =====================================================================
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopProgressTracker()
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
// =====================================================================
|
||||
|
||||
|
||||
//file download
|
||||
val fileDownloadStates = mutableStateMapOf<String, DownloadStatus>()
|
||||
|
||||
// 取得特定訊息的下載狀態,若無則預設為 NotStarted
|
||||
fun getDownloadStatus(messageId: String): DownloadStatus {
|
||||
return fileDownloadStates[messageId] ?: DownloadStatus.NotStarted
|
||||
}
|
||||
fun downloadFile(message: Message, displayFileName: String) {
|
||||
val messageId = message.id
|
||||
|
||||
// 🎯 取得後端給的直接 URL。請根據你實際的 Message 資料結構調整欄位名稱
|
||||
val fileUrl = message.content // 假設你把 URL 存放在 content 裡,或是 message.fileUrl
|
||||
|
||||
if (fileUrl.isNullOrBlank()) {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Error("檔案下載連結無效")
|
||||
return
|
||||
}
|
||||
|
||||
if (fileDownloadStates[messageId] is DownloadStatus.Downloading) return
|
||||
|
||||
viewModelScope.launch {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Downloading(0f)
|
||||
|
||||
// 呼叫剛剛改好的 URL 版 Flow
|
||||
repository.downloadFileFromUrlFlow(fileUrl, displayFileName)
|
||||
.collect { progressResult ->
|
||||
progressResult.onSuccess { progress ->
|
||||
if (progress >= 1.0f) {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Completed
|
||||
} else {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Downloading(progress)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.e("ChatViewModel", "下載失敗: ${error.message}")
|
||||
fileDownloadStates[messageId] = DownloadStatus.Error(error.message ?: "下載失敗")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (選填)如果想要清除特定訊息的狀態(例如錯誤後想重試)
|
||||
fun resetDownloadStatus(messageId: String) {
|
||||
fileDownloadStates.remove(messageId)
|
||||
}
|
||||
|
||||
fun resetDeleteMessageState() { _deleteMessageState.value = DeleteMessageState.Idle }
|
||||
fun resetSendMessageState() { _sendMessageState.value = SendMessageState.Idle }
|
||||
|
|
|
|||
Loading…
Reference in a new issue