diff --git a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt index 3f25709..6ec60f1 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt @@ -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 { - 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 { @@ -452,4 +429,69 @@ class ChatRoomsRepository @Inject constructor( Result.failure(e) } } + + + + //download file + private val httpClient = OkHttpClient() + fun downloadFileFromUrlFlow(fileUrl: String, fileName: String): Flow> = 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 執行緒 } \ No newline at end of file diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt index b30e544..137a7b3 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt @@ -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 ) { - 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 = "檔案", - tint = Color.Gray, - modifier = Modifier.size(24.dp) - ) + // 3. 🎯 根據密封介面 (Sealed Interface) 狀態切換 Icon 與進度條 + when (downloadStatus) { + is DownloadStatus.NotStarted -> { + Icon( + 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) ) } diff --git a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt index d786ea0..f78e8ef 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt @@ -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() + + // 取得特定訊息的下載狀態,若無則預設為 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 }