Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ private fun ReceiverDetailRouteContent(
val detailViewModel: ReceiverDetailViewModel = hiltViewModel(backStackEntry)
val detailState by detailViewModel.uiState.collectAsStateWithLifecycle()

val isCurrentDestination = navController.currentBackStackEntry == backStackEntry
LaunchedEffect(isCurrentDestination) {
if (isCurrentDestination) {
route.receiverId.toLongOrNull()?.let { detailViewModel.loadReceiverDetail(it) }
}
}

val phoneNumberState = rememberTextFieldState()
val emailState = rememberTextFieldState()
LaunchedEffect(detailState.receiverId, detailState.name) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,86 @@
package com.kuit.afternote.feature.setting.presentation.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuit.afternote.data.local.TokenManager
import com.kuit.afternote.feature.user.domain.usecase.WithdrawAccountUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException

/**
* UI state for the withdrawal password confirmation screen.
*/
data class WithdrawalPasswordUiState(
val showPasswordError: Boolean = false,
val withdrawalComplete: Boolean = false
val withdrawalComplete: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null
)

/**
* ViewModel for the withdrawal password screen. Handles submit and wrong-password error state.
* When the withdrawal API is available, [submitWithdrawal] should call it and set
* [WithdrawalPasswordUiState.showPasswordError] on wrong-password response.
* ViewModel for the withdrawal password screen. Calls DELETE /users/me (회원 탈퇴) on submit.
* On success clears tokens and sets [WithdrawalPasswordUiState.withdrawalComplete].
* On 401 or wrong-password response sets [WithdrawalPasswordUiState.showPasswordError].
*/
@HiltViewModel
class WithdrawalPasswordViewModel
@Inject
constructor() : ViewModel() {
@Inject
constructor(
private val withdrawAccountUseCase: WithdrawAccountUseCase,
private val tokenManager: TokenManager
) : ViewModel() {

private val _uiState = MutableStateFlow(WithdrawalPasswordUiState())
val uiState: StateFlow<WithdrawalPasswordUiState> = _uiState.asStateFlow()

/**
* Called when the user taps "탈퇴하기" with the entered password.
* Call withdrawal API; on success set [WithdrawalPasswordUiState.withdrawalComplete] true
* to show completion dialog. On wrong-password response set
* [WithdrawalPasswordUiState.showPasswordError] true.
* Called when the user taps "탈퇴하기". Validates password is non-empty, then calls
* DELETE /users/me. On success clears tokens and sets withdrawalComplete for the completion dialog.
* On 401 sets showPasswordError; on other errors sets errorMessage.
*
* @param password User-entered password; will be sent to withdrawal API when implemented.
* @param password User-entered password; validated locally (non-empty). API has no parameters.
*/
@Suppress("UNUSED_PARAMETER")
fun submitWithdrawal(password: String) {
// TODO: Call withdrawal API; on success set withdrawalComplete = true, on wrong password set showPasswordError = true
_uiState.update { it.copy(showPasswordError = true) }
if (password.isBlank()) {
_uiState.update { it.copy(showPasswordError = true) }
return
}
viewModelScope.launch {
_uiState.update {
it.copy(isLoading = true, showPasswordError = false, errorMessage = null)
}
withdrawAccountUseCase()
.onSuccess {
tokenManager.clearTokens()
_uiState.update {
it.copy(
isLoading = false,
errorMessage = null,
withdrawalComplete = true
)
}
}
.onFailure { e ->
val isUnauthorized = (e as? HttpException)?.code() == 401
val message = when (e) {
is HttpException -> "HTTP ${e.code()} ${e.message()}"
else -> e.message ?: "회원 탈퇴에 실패했습니다."
}
_uiState.update {
it.copy(
isLoading = false,
showPasswordError = isUnauthorized,
errorMessage = if (isUnauthorized) null else message
)
}
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import javax.inject.Named
* directory는 백엔드와 합의한 값 사용. 미지원 시 afternotes 등 기존 디렉터리로 변경 가능.
*/
private const val DIRECTORY_TIME_LETTERS = "timeletters"
private const val DEFAULT_EXTENSION = "jpg"
private const val DEFAULT_IMAGE_EXTENSION = "jpg"
private const val DEFAULT_AUDIO_EXTENSION = "m4a"
private const val DEFAULT_AUDIO_CONTENT_TYPE = "audio/mp4"

class TimeLetterMediaUploadRepositoryImpl
@Inject
Expand Down Expand Up @@ -69,15 +71,65 @@ class TimeLetterMediaUploadRepositoryImpl
presigned.fileUrl
}

override suspend fun uploadAudio(uriString: String): Result<String> =
runCatching {
val uri = Uri.parse(uriString)
val (extension, defaultContentType) = audioExtensionAndContentTypeFromUri(uri)
val presigned = imageApi.getPresignedUrl(
PresignedUrlRequestDto(
directory = DIRECTORY_TIME_LETTERS,
extension = extension
)
).requireData()
?: throw IllegalStateException("Presigned URL response data is null")

val bytes = withContext(ioDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read audio from URI")
}

val contentType = presigned.contentType.ifBlank { defaultContentType }
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val putRequest =
Request.Builder()
.url(presigned.presignedUrl)
.put(requestBody)
.header("Content-Type", contentType)
.build()

withContext(ioDispatcher) {
okHttpClient.newCall(putRequest).execute().use { response ->
check(response.isSuccessful) {
"S3 upload failed: ${response.code} ${response.message}"
}
}
}
presigned.fileUrl
}

private fun extensionFromUri(uri: Uri): String {
val mime = context.contentResolver.getType(uri) ?: return DEFAULT_EXTENSION
val mime = context.contentResolver.getType(uri) ?: return DEFAULT_IMAGE_EXTENSION
return when {
mime == "image/jpeg" || mime == "image/jpg" -> "jpg"
mime == "image/png" -> "png"
mime == "image/webp" -> "webp"
mime == "image/gif" -> "gif"
mime == "image/heic" -> "heic"
else -> DEFAULT_EXTENSION
else -> DEFAULT_IMAGE_EXTENSION
}
}

private fun audioExtensionAndContentTypeFromUri(uri: Uri): Pair<String, String> {
val mime = context.contentResolver.getType(uri)
?: return DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE
return when {
mime == "audio/mpeg" || mime == "audio/mp3" -> "mp3" to "audio/mpeg"
mime == "audio/mp4" || mime == "audio/x-m4a" -> "m4a" to DEFAULT_AUDIO_CONTENT_TYPE
mime == "audio/ogg" -> "ogg" to "audio/ogg"
mime == "audio/webm" -> "webm" to "audio/webm"
mime == "audio/amr" -> "amr" to "audio/amr"
mime == "audio/3gpp" -> "3gp" to "audio/3gpp"
else -> DEFAULT_AUDIO_EXTENSION to DEFAULT_AUDIO_CONTENT_TYPE
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package com.kuit.afternote.feature.timeletter.domain.repository

/**
* 타임레터 첨부 이미지 업로드 (POST /files/presigned-url 후 S3 PUT).
* 타임레터 첨부 미디어 업로드 (POST /files/presigned-url 후 S3 PUT).
*
* content URI 문자열을 받아 업로드 후 사용할 fileUrl을 반환합니다.
*/
fun interface TimeLetterMediaUploadRepository {
interface TimeLetterMediaUploadRepository {

/**
* [uriString]에 해당하는 이미지를 S3에 업로드하고, 타임레터 mediaList에 넣을 fileUrl을 반환합니다.
Expand All @@ -14,4 +14,12 @@ fun interface TimeLetterMediaUploadRepository {
* @return 성공 시 업로드된 이미지 URL, 실패 시 Result.failure
*/
suspend fun uploadImage(uriString: String): Result<String>

/**
* [uriString]에 해당하는 오디오를 S3에 업로드하고, 타임레터 mediaList에 넣을 fileUrl을 반환합니다.
*
* @param uriString content URI 문자열 (예: content://media/...)
* @return 성공 시 업로드된 오디오 URL, 실패 시 Result.failure
*/
suspend fun uploadAudio(uriString: String): Result<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.kuit.afternote.feature.timeletter.domain.usecase

import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository
import javax.inject.Inject

/**
* 타임레터 첨부 오디오 1개를 presigned URL로 S3에 업로드하고 fileUrl을 반환합니다.
*
* @param uriString content URI 문자열
* @return 성공 시 fileUrl, 실패 시 Result.failure
*/
class UploadTimeLetterAudioUseCase
@Inject
constructor(
private val repository: TimeLetterMediaUploadRepository
) {
suspend operator fun invoke(uriString: String): Result<String> =
repository.uploadAudio(uriString)
}
Loading
Loading