This commit is contained in:
Eric85710 2026-06-28 16:08:01 +08:00
parent 6f8f42b51a
commit 5ae72801d1
3 changed files with 230 additions and 52 deletions

View file

@ -4,7 +4,9 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.datastore.core.use
import com.example.login_v3.data.api.TecnologiaApi 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.ChatRoom
import com.example.login_v3.data.api.api_class.Message 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.MessageEntity
import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.io.use
import kotlin.use
@Singleton @Singleton
class ChatRoomsRepository @Inject constructor( 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 //upload file
suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> { suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> {
@ -452,4 +429,69 @@ class ChatRoomsRepository @Inject constructor(
Result.failure(e) 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 執行緒
} }

View file

@ -80,8 +80,11 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.layout.statusBars // 如果要使用 statusBars 也需要這個 import androidx.compose.foundation.layout.statusBars // 如果要使用 statusBars 也需要這個
import androidx.compose.foundation.shape.CircleShape 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.Build
import androidx.compose.material.icons.filled.Call 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.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow 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.MessageAttachmentBar
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot 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.UI.Detail.Message_Component.VideoLightbox
import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus
//bottom bar state //bottom bar state
@ -952,7 +956,11 @@ fun MessageRow(
} }
} }
//file message
isFile -> { isFile -> {
// 1. 🎯 從 ViewModel 取得當前訊息的下載狀態
val downloadStatus = viewModel.getDownloadStatus(message.id)
Box( Box(
modifier = Modifier modifier = Modifier
.widthIn(max = 260.dp) .widthIn(max = 260.dp)
@ -965,7 +973,15 @@ fun MessageRow(
) )
.combinedClickable( .combinedClickable(
onLongClick = { onReplyClick(message) }, onLongClick = { onReplyClick(message) },
onClick = { onRowClick(message) } // 🎯 點擊時可以在此觸發下載或開啟檔案 onClick = {
// 點擊整個氣泡:如果是已完成就直接開啟,否則可維持你原本的行為
if (downloadStatus is DownloadStatus.Completed) {
onRowClick(message)
} else {
// 也可以在這裡同樣觸發下載
viewModel.downloadFile(message, displayFileName)
}
}
) )
.background(color = finalBubbleColor) .background(color = finalBubbleColor)
.padding(horizontal = 12.dp, vertical = 10.dp) .padding(horizontal = 12.dp, vertical = 10.dp)
@ -975,30 +991,89 @@ fun MessageRow(
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId) RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
} }
// 檔案的 UI 佈局 (左邊圖示,右邊文字) // 檔案的 UI 佈局 (左邊按鈕,右邊文字)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// 根據副檔名決定圖示,這裡示範用內建圖示,你也可以換成自己的 zip 圖片 // 檔案下載按鈕 / 進度顯示
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .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 contentAlignment = Alignment.Center
) { ) {
Icon( // 3. 🎯 根據密封介面 (Sealed Interface) 狀態切換 Icon 與進度條
imageVector = if (displayFileName.endsWith(".zip") || displayFileName.endsWith(".rar")) { when (downloadStatus) {
androidx.compose.material.icons.Icons.Default.Build // 或者是常規的 Folder / Img is DownloadStatus.NotStarted -> {
} else { Icon(
androidx.compose.material.icons.Icons.Default.Info imageVector = androidx.compose.material.icons.Icons.Default.ArrowDownward,
}, contentDescription = "下載檔案",
contentDescription = "檔案", tint = Color.Gray,
tint = Color.Gray, modifier = Modifier.size(24.dp)
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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = displayFileName, text = displayFileName,
@ -1007,7 +1082,15 @@ fun MessageRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( 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) style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
) )
} }

View file

@ -4,6 +4,7 @@ package com.example.login_v3.home.Message.ViewModel.Detail
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -73,6 +74,14 @@ data class AudioPlaybackState(
val duration: Long = 0L // 總時長 (毫秒) 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 @HiltViewModel
class ChatViewModel @Inject constructor( class ChatViewModel @Inject constructor(
@ -381,15 +390,59 @@ class ChatViewModel @Inject constructor(
progressLogJob = null progressLogJob = null
} }
// =====================================================================
// 🌟 生命週期釋放:ViewModel 銷毀時,必須徹底關閉 Player
// =====================================================================
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
stopProgressTracker() stopProgressTracker()
exoPlayer?.release() exoPlayer?.release()
exoPlayer = null 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 resetDeleteMessageState() { _deleteMessageState.value = DeleteMessageState.Idle }
fun resetSendMessageState() { _sendMessageState.value = SendMessageState.Idle } fun resetSendMessageState() { _sendMessageState.value = SendMessageState.Idle }