From 14aad289ef50ff4b36b89322f27291f21244fc6b Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 05:47:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(user):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DELETE /users/me` API를 호출하는 회원 탈퇴 기능을 구현했습니다. - `WithdrawAccountUseCase`를 추가하여 회원 탈퇴 비즈니스 로직을 처리합니다. - `WithdrawalPasswordViewModel`에 탈퇴 로직을 연결하여, 성공 시 저장된 토큰을 삭제하고 완료 상태를 업데이트합니다. - 비밀번호가 비어있을 경우, 또는 API 응답이 401(Unauthorized)일 경우 비밀번호 오류를 표시합니다. 그 외 HTTP 에러나 네트워크 오류 발생 시에는 에러 메시지를 표시하도록 처리했습니다. - 기능 구현에 따라 `UserApiService`, `UserRepository` 인터페이스 및 `UserRepositoryImpl` 구현체에 관련 코드를 추가했습니다. - `WithdrawAccountUseCase`와 `WithdrawalPasswordViewModel`에 대한 단위 테스트를 추가하여 기능의 정확성과 다양한 예외 상황 처리를 검증했습니다. --- .../presentation/navgraph/SettingNavGraph.kt | 7 + .../viewmodel/WithdrawalPasswordViewModel.kt | 68 +++++-- .../feature/user/data/api/UserApiService.kt | 11 +- .../data/repository/UserRepositoryImpl.kt | 10 + .../user/domain/repository/UserRepository.kt | 5 + .../domain/usecase/WithdrawAccountUseCase.kt | 17 ++ .../viewmodel/ReceiverDetailViewModel.kt | 4 +- .../WithdrawalPasswordViewModelTest.kt | 173 ++++++++++++++++++ .../usecase/WithdrawAccountUseCaseTest.kt | 51 ++++++ 9 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCase.kt create mode 100644 app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt create mode 100644 app/src/test/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCaseTest.kt diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt index c832df02..1e8c48e9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt @@ -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) { diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt index 75e60d41..95cbfa18 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModel.kt @@ -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 = _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 + ) + } + } + } } /** diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt index f4a163c4..29468820 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt @@ -13,6 +13,7 @@ import com.kuit.afternote.feature.user.data.dto.UserResponse import com.kuit.afternote.feature.user.data.dto.UserUpdateProfileRequest import com.kuit.afternote.feature.user.data.dto.UserUpdatePushSettingRequest import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.POST @@ -22,7 +23,7 @@ import retrofit2.http.Query /** * User API 서비스. (스웨거 기준) * - * - GET /users/me, PATCH /users/me + * - GET /users/me, PATCH /users/me, DELETE /users/me (회원 탈퇴) * - GET /users/push-settings, PATCH /users/push-settings * - GET /users/receivers, POST /users/receivers, GET /users/receivers/{receiverId} * - GET /users/receivers/{receiverId}/daily-questions @@ -39,6 +40,14 @@ interface UserApiService { @Body body: UserUpdateProfileRequest ): ApiResponse + /** + * DELETE /users/me — 회원 탈퇴. 로그인한 사용자의 계정을 삭제합니다. 모든 데이터가 영구 삭제되며 복구할 수 없습니다. + * + * @return 200 OK, data: {} + */ + @DELETE("users/me") + suspend fun withdrawAccount(): ApiResponse + /** * 푸시 알림 설정 조회. * GET /users/push-settings — 로그인한 사용자의 푸시 알림 수신 설정을 불러옵니다. diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt index cd248927..35cfb69b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.user.data.repository import android.util.Log import com.kuit.afternote.data.remote.ApiException import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.data.remote.requireSuccess import com.kuit.afternote.feature.user.data.api.UserApiService import com.kuit.afternote.feature.user.data.dto.RegisterReceiverRequestDto import com.kuit.afternote.feature.user.data.dto.UserUpdateProfileRequest @@ -57,6 +58,15 @@ class UserRepositoryImpl UserMapper.toUserProfile(response.requireData()) } + override suspend fun withdrawAccount(): Result = + runCatching { + Log.d(TAG, "withdrawAccount: request") + val response = api.withdrawAccount() + response.requireSuccess() + Log.d(TAG, "withdrawAccount: success") + Unit + } + override suspend fun getMyPushSettings(userId: Long): Result = runCatching { Log.d(TAG, "getMyPushSettings: userId=$userId") diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt index 70d31e73..a5d63a12 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt @@ -21,6 +21,11 @@ interface UserRepository { profileImageUrl: String? ): Result + /** + * DELETE /users/me — 회원 탈퇴. 로그인한 사용자의 계정을 삭제합니다. 모든 데이터가 영구 삭제되며 복구할 수 없습니다. + */ + suspend fun withdrawAccount(): Result + /** GET /users/push-settings — 푸시 알림 설정 조회. */ suspend fun getMyPushSettings(userId: Long): Result diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCase.kt new file mode 100644 index 00000000..05a7affb --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCase.kt @@ -0,0 +1,17 @@ +package com.kuit.afternote.feature.user.domain.usecase + +import com.kuit.afternote.feature.user.domain.repository.UserRepository +import javax.inject.Inject + +/** + * DELETE /users/me — 회원 탈퇴 UseCase. + * + * 로그인한 사용자의 계정을 삭제합니다. 모든 데이터가 영구적으로 삭제되며 복구할 수 없습니다. + */ +class WithdrawAccountUseCase + @Inject + constructor( + private val userRepository: UserRepository + ) { + suspend operator fun invoke(): Result = userRepository.withdrawAccount() + } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt index b2de61b0..b10534f6 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt @@ -33,9 +33,7 @@ class ReceiverDetailViewModel .receiverId.toLongOrNull() init { - if (receiverId != null) { - loadReceiverDetail(receiverId) - } else { + if (receiverId == null) { _uiState.update { it.copy(name = "수신인", relation = "") } diff --git a/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt new file mode 100644 index 00000000..1bae81e2 --- /dev/null +++ b/app/src/test/java/com/kuit/afternote/feature/setting/presentation/viewmodel/WithdrawalPasswordViewModelTest.kt @@ -0,0 +1,173 @@ +package com.kuit.afternote.feature.setting.presentation.viewmodel + +import com.kuit.afternote.data.local.TokenManager +import com.kuit.afternote.feature.user.domain.usecase.WithdrawAccountUseCase +import com.kuit.afternote.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response + +/** + * [WithdrawalPasswordViewModel] 단위 테스트. + * + * DELETE /users/me (회원 탈퇴) 호출 및 HTTP 에러/네트워크 에러 처리 검증. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WithdrawalPasswordViewModelTest { + @get:Rule + val mainRule = MainCoroutineRule() + + private lateinit var withdrawAccountUseCase: WithdrawAccountUseCase + private lateinit var tokenManager: TokenManager + private lateinit var viewModel: WithdrawalPasswordViewModel + + @Before + fun setUp() { + withdrawAccountUseCase = mockk() + tokenManager = mockk() + coJustRun { tokenManager.clearTokens() } + viewModel = WithdrawalPasswordViewModel(withdrawAccountUseCase, tokenManager) + } + + @Test + fun submitWithdrawal_whenBlankPassword_setsShowPasswordError() { + viewModel.submitWithdrawal("") + + assertTrue(viewModel.uiState.value.showPasswordError) + assertFalse(viewModel.uiState.value.withdrawalComplete) + } + + @Test + fun submitWithdrawal_whenSuccess_clearsTokensAndSetsWithdrawalComplete() = + runTest { + coEvery { withdrawAccountUseCase() } returns Result.success(Unit) + + viewModel.submitWithdrawal("password123") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.withdrawalComplete) + assertNull(viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.showPasswordError) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun submitWithdrawal_when401Unauthorized_setsShowPasswordError() = + runTest { + val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" + .toResponseBody("application/json".toMediaType()) + val httpException = HttpException(Response.error(401, errorBody)) + coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) + + viewModel.submitWithdrawal("wrongPassword") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.showPasswordError) + assertFalse(viewModel.uiState.value.withdrawalComplete) + assertNull(viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun submitWithdrawal_when400BadRequest_setsErrorMessage() = + runTest { + val errorBody = """{"status":400,"code":400,"message":"Bad request"}""" + .toResponseBody("application/json".toMediaType()) + val httpException = HttpException(Response.error(400, errorBody)) + coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) + + viewModel.submitWithdrawal("password") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.errorMessage!!.contains("400")) + assertFalse(viewModel.uiState.value.withdrawalComplete) + assertFalse(viewModel.uiState.value.showPasswordError) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun submitWithdrawal_when404NotFound_setsErrorMessage() = + runTest { + val errorBody = """{"status":404,"code":404,"message":"Not found"}""" + .toResponseBody("application/json".toMediaType()) + val httpException = HttpException(Response.error(404, errorBody)) + coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) + + viewModel.submitWithdrawal("password") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.errorMessage!!.contains("404")) + assertFalse(viewModel.uiState.value.withdrawalComplete) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun submitWithdrawal_when500ServerError_setsErrorMessage() = + runTest { + val errorBody = """{"status":500,"code":500,"message":"Server error"}""" + .toResponseBody("application/json".toMediaType()) + val httpException = HttpException(Response.error(500, errorBody)) + coEvery { withdrawAccountUseCase() } returns Result.failure(httpException) + + viewModel.submitWithdrawal("password") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.errorMessage!!.contains("500")) + assertFalse(viewModel.uiState.value.withdrawalComplete) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun submitWithdrawal_whenNetworkError_setsErrorMessage() = + runTest { + coEvery { withdrawAccountUseCase() } returns Result.failure( + java.io.IOException("Network unavailable") + ) + + viewModel.submitWithdrawal("password") + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.errorMessage!!.contains("Network") || + viewModel.uiState.value.errorMessage == "회원 탈퇴에 실패했습니다." + ) + assertFalse(viewModel.uiState.value.withdrawalComplete) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun clearWithdrawalComplete_resetsWithdrawalComplete() = + runTest { + coEvery { withdrawAccountUseCase() } returns Result.success(Unit) + viewModel.submitWithdrawal("password") + advanceUntilIdle() + assertTrue(viewModel.uiState.value.withdrawalComplete) + + viewModel.clearWithdrawalComplete() + + assertFalse(viewModel.uiState.value.withdrawalComplete) + } + + @Test + fun clearPasswordError_clearsShowPasswordError() { + viewModel.submitWithdrawal("") + assertTrue(viewModel.uiState.value.showPasswordError) + + viewModel.clearPasswordError() + + assertFalse(viewModel.uiState.value.showPasswordError) + } +} diff --git a/app/src/test/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCaseTest.kt new file mode 100644 index 00000000..07ce9a20 --- /dev/null +++ b/app/src/test/java/com/kuit/afternote/feature/user/domain/usecase/WithdrawAccountUseCaseTest.kt @@ -0,0 +1,51 @@ +package com.kuit.afternote.feature.user.domain.usecase + +import com.kuit.afternote.feature.user.domain.repository.UserRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [WithdrawAccountUseCase] 단위 테스트. + * + * DELETE /users/me (회원 탈퇴) API 호출 검증. + */ +class WithdrawAccountUseCaseTest { + private lateinit var userRepository: UserRepository + private lateinit var withdrawAccountUseCase: WithdrawAccountUseCase + + @Before + fun setUp() { + userRepository = mockk() + withdrawAccountUseCase = WithdrawAccountUseCase(userRepository) + } + + @Test + fun invoke_whenSuccess_returnsUnit() = + runTest { + coEvery { userRepository.withdrawAccount() } returns Result.success(Unit) + + val result = withdrawAccountUseCase() + + assertTrue(result.isSuccess) + coVerify(exactly = 1) { userRepository.withdrawAccount() } + } + + @Test + fun invoke_whenFailure_returnsFailure() = + runTest { + coEvery { userRepository.withdrawAccount() } returns Result.failure( + RuntimeException("withdraw failed") + ) + + val result = withdrawAccountUseCase() + + assertFalse(result.isSuccess) + assertTrue(result.exceptionOrNull()?.message == "withdraw failed") + } +} From b42b61c4022256b8831d76a1ce17ce41f2e00a27 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 06:32:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(timeletter):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=A0=88=ED=84=B0=20=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=EC=9D=8C?= =?UTF-8?q?=EC=84=B1=20=EC=B2=A8=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **음성 파일 첨부 기능 추가** - `TimeLetterWriterScreen`에 음성 파일을 선택하고 첨부하는 기능을 추가했습니다. - `TimeLetterMediaUploadRepository`에 `uploadAudio` 인터페이스를 추가하고, `TimeLetterMediaUploadRepositoryImpl`에서 Presigned URL을 통해 S3에 오디오 파일을 업로드하는 로직을 구현했습니다. - `UploadTimeLetterAudioUseCase`를 추가하여 뷰모델에서 오디오 파일을 업로드할 수 있도록 했습니다. - `TimeLetterWriterViewModel`에서 음성 URI를 상태로 관리하고, 등록/임시저장 시 `AUDIO` 타입의 미디어로 서버에 전송하는 로직을 추가했습니다. - **`TimeLetterWriterScreen` 리팩토링** - 복잡한 `TimeLetterWriterScreen` Composable을 상태(`TimeLetterWriterScreenState`), 이벤트(`TimeLetterWriterScreenEvents`), 그리고 여러 하위 Composable(Content, TopBar, Attachments 등)으로 분리하여 구조를 개선하고 가독성을 높였습니다. - 파라미터를 그룹화하기 위해 `TimeLetterWriterScreenContentParams`, `TimeLetterWriterBottomBarParams` 등의 데이터 클래스를 도입했습니다. - `LazyColumn`을 `Column`과 `verticalScroll`로 변경하여, 중첩 스크롤 문제를 방지하고 Compose 1.6의 비권장 API 사용을 해결했습니다. - UI 상태와 이벤트를 명확히 분리하기 위해 `TimeLetterWriterScreen`의 파라미터를 `state`와 `events` 객체로 변경하고, `TimeLetterNavGraph`의 호출부를 수정했습니다. - **기타 개선** - `TimeLetterWriterUiState`에 `selectedVoiceUriStrings`와 `errorMessage` 필드를 추가하여, 첨부된 음성 URI와 저장 실패 시 에러 메시지를 관리하도록 했습니다. - `TimeLetterMediaUploadRepository`를 `fun interface`에서 일반 `interface`로 변경하여 여러 메소드를 포함할 수 있도록 수정했습니다. --- .../TimeLetterMediaUploadRepositoryImpl.kt | 58 +- .../TimeLetterMediaUploadRepository.kt | 12 +- .../usecase/UploadTimeLetterAudioUseCase.kt | 19 + .../component/TimeLetterWriterBottomBar.kt | 100 +- .../navgraph/TimeLetterNavGraph.kt | 108 +- .../screen/TimeLetterWriterScreen.kt | 1125 ++++++++++------- .../uimodel/TimeLetterWriterUiState.kt | 6 +- .../viewmodel/TimeLetterWriterViewModel.kt | 81 +- 8 files changed, 893 insertions(+), 616 deletions(-) create mode 100644 app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterAudioUseCase.kt diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt index bfae732d..2adf420e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt @@ -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 @@ -69,15 +71,65 @@ class TimeLetterMediaUploadRepositoryImpl presigned.fileUrl } + override suspend fun uploadAudio(uriString: String): Result = + 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 { + 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 } } } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt index 906aebf4..77e70cec 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt @@ -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을 반환합니다. @@ -14,4 +14,12 @@ fun interface TimeLetterMediaUploadRepository { * @return 성공 시 업로드된 이미지 URL, 실패 시 Result.failure */ suspend fun uploadImage(uriString: String): Result + + /** + * [uriString]에 해당하는 오디오를 S3에 업로드하고, 타임레터 mediaList에 넣을 fileUrl을 반환합니다. + * + * @param uriString content URI 문자열 (예: content://media/...) + * @return 성공 시 업로드된 오디오 URL, 실패 시 Result.failure + */ + suspend fun uploadAudio(uriString: String): Result } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterAudioUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterAudioUseCase.kt new file mode 100644 index 00000000..6f016822 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterAudioUseCase.kt @@ -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 = + repository.uploadAudio(uriString) + } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterWriterBottomBar.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterWriterBottomBar.kt index da6825ba..df71de2d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterWriterBottomBar.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterWriterBottomBar.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontVariation.width import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -27,75 +26,74 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.kuit.afternote.R +/** + * Parameters for [TimeLetterWriterBottomBar]. + */ +data class TimeLetterWriterBottomBarParams( + val draftCount: Int, + val onLinkClick: () -> Unit, + val onAddClick: () -> Unit, + val onSaveDraftClick: () -> Unit, + val onDraftCountClick: () -> Unit, + val isMenuOpen: Boolean, + val onMenuDismiss: () -> Unit, + val onImageAddClick: () -> Unit, + val onFileAddClick: () -> Unit, + val onVoiceAddClick: () -> Unit, + val onLinkAddClick: () -> Unit +) + /** * 타임레터 작성 화면 하단 바 * - * @param draftCount 임시저장된 레터 개수 - * @param onLinkClick 링크 클릭 콜백 - * @param onAddClick 추가 클릭 콜백 - * @param onSaveDraftClick 임시저장 클릭 콜백 - * @param onDraftCountClick 임시저장 개수 클릭 콜백 (DraftLetterScreen으로 이동) + * @param params [TimeLetterWriterBottomBarParams] * @param modifier Modifier */ - - @Composable fun TimeLetterWriterBottomBar( - draftCount: Int, - onLinkClick: () -> Unit, - onAddClick: () -> Unit, - onSaveDraftClick: () -> Unit, - onDraftCountClick: () -> Unit, modifier: Modifier = Modifier, - isMenuOpen: Boolean, - onMenuDismiss: () -> Unit, - onImageAddClick: () -> Unit, - onFileAddClick: () -> Unit, - onVoiceAddClick: () -> Unit, - onLinkAddClick: () -> Unit + params: TimeLetterWriterBottomBarParams ) { + val p = params Row( modifier = modifier .fillMaxWidth() - .padding(horizontal=24.dp) + .padding(horizontal = 24.dp) .height(88.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - ) { + Box { Image( painter = painterResource(R.drawable.ic_link), contentDescription = "링크", modifier = Modifier .size(24.dp) - .clickable { onLinkClick() } + .clickable { p.onLinkClick() } ) } Spacer(Modifier.width(16.dp)) - Box(){ + Box { Image( - painterResource(R.drawable.ic_additional), + painter = painterResource(R.drawable.ic_additional), contentDescription = "더보기", modifier = Modifier .size(24.dp) - .clickable { onAddClick() } // 클릭 이벤트 연결 + .clickable { p.onAddClick() } ) - if (isMenuOpen) { + if (p.isMenuOpen) { Popup( - // 아이콘 위로 16dp 정도 띄워서 표시 popupPositionProvider = DropUpPositionProvider(yOffset = 16), - onDismissRequest = onMenuDismiss, - properties = PopupProperties(focusable = true) // 외부 클릭 시 닫힘 + onDismissRequest = p.onMenuDismiss, + properties = PopupProperties(focusable = true) ) { - // 사용자 정의 메뉴 컴포넌트 WritingPlusMenu( onImageClick = { - onMenuDismiss() - onImageAddClick() + p.onMenuDismiss() + p.onImageAddClick() }, - onVoiceClick = { onVoiceAddClick() }, - onFileClick = { onFileAddClick() }, - onLinkClick = { onLinkAddClick() } + onVoiceClick = p.onVoiceAddClick, + onFileClick = p.onFileAddClick, + onLinkClick = p.onLinkAddClick ) } } @@ -105,7 +103,7 @@ fun TimeLetterWriterBottomBar( Text( text = "임시저장", modifier = Modifier - .clickable { onSaveDraftClick() }, + .clickable { p.onSaveDraftClick() }, fontSize = 16.sp, fontWeight = FontWeight.W500, fontFamily = FontFamily(Font(R.font.sansneoregular)), @@ -120,9 +118,9 @@ fun TimeLetterWriterBottomBar( ) Spacer(Modifier.width(16.dp)) Text( - text = draftCount.toString(), + text = p.draftCount.toString(), modifier = Modifier - .clickable { onDraftCountClick() }, + .clickable { p.onDraftCountClick() }, fontSize = 16.sp, fontWeight = FontWeight.W500, fontFamily = FontFamily(Font(R.font.sansneoregular)), @@ -136,16 +134,18 @@ fun TimeLetterWriterBottomBar( @Composable private fun TimeLetterWriterBottomBarPreview() { TimeLetterWriterBottomBar( - draftCount = 3, - onAddClick = {}, - onLinkClick = {}, - onSaveDraftClick = {}, - onDraftCountClick = {}, - isMenuOpen = false, - onMenuDismiss = {}, - onImageAddClick = {}, - onFileAddClick = {}, - onVoiceAddClick = {}, - onLinkAddClick = {} + params = TimeLetterWriterBottomBarParams( + draftCount = 3, + onLinkClick = {}, + onAddClick = {}, + onSaveDraftClick = {}, + onDraftCountClick = {}, + isMenuOpen = false, + onMenuDismiss = {}, + onImageAddClick = {}, + onFileAddClick = {}, + onVoiceAddClick = {}, + onLinkAddClick = {} ) + ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt index c9f03e89..39bf6b42 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt @@ -16,6 +16,8 @@ import com.kuit.afternote.feature.timeletter.presentation.screen.ReceiveListScre import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterDetailScreen import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterScreen import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterWriterScreen +import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterWriterScreenEvents +import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterWriterScreenState import com.kuit.afternote.feature.timeletter.presentation.viewmodel.ReceiveListViewModel import com.kuit.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriterViewModel @@ -76,59 +78,59 @@ fun NavGraphBuilder.timeLetterNavGraph( } TimeLetterWriterScreen( - receiverIds = uiState.receiverIds, - title = uiState.title, - content = uiState.content, - sendDate = uiState.sendDate, - sendTime = uiState.sendTime, - showDatePicker = uiState.showDatePicker, - showTimePicker = uiState.showTimePicker, - draftCount = uiState.draftCount, - onTitleChange = viewModel::updateTitle, - onContentChange = viewModel::updateContent, - onNavigateBack = { navController.popBackStack() }, - onRecipientClick = { navController.navigate(ReceiverRoute.ReceiverListRoute) }, - receivers = uiState.receivers, - showRecipientDropdown = uiState.showRecipientDropdown, - onRecipientDropdownDismiss = { viewModel.hideRecipientDropdown() }, - onReceiverSelected = { receiver -> - viewModel.updateSelectedReceiverIds(listOf(receiver.id)) - viewModel.hideRecipientDropdown() - }, - onRegisterClick = { - viewModel.registerWithPopUpThenSave { - navController.popBackStack() - } - }, - onSaveDraftClick = { - viewModel.saveDraft { - navController.navigate(TimeLetterRoute.DraftLetterRoute) - } - }, - onDraftCountClick = { - navController.navigate(TimeLetterRoute.DraftLetterRoute) - }, - onDateClick = viewModel::showDatePicker, - onTimeClick = viewModel::showTimePicker, - onDatePickerDismiss = viewModel::hideDatePicker, - onDateSelected = { year, month, day -> - val formattedDate = "$year. ${month.toString().padStart(2, '0')}. ${day.toString().padStart(2, '0')}" - viewModel.updateSendDate(formattedDate) - }, - onTimePickerDismiss = viewModel::hideTimePicker, - onTimeSelected = { hour, minute -> - viewModel.updateSendTime("%02d:%02d".format(hour, minute)) - }, - showWritingPlusMenu = uiState.showWritingPlusMenu, - onMoreClick = viewModel::showPlusMenu, - onDismissPlusMenu = viewModel::hidePlusMenu, - showRegisteredPopUp = uiState.showRegisteredPopUp, - showDraftSavePopUp = uiState.showDraftSavePopUp, - showWaitingAgainPopUp = uiState.showWaitingAgainPopUp, - onBackCLick = { navController.popBackStack() }, - selectedImageUriStrings = uiState.selectedImageUriStrings, - onAddImages = viewModel::addImageUris, - onRemoveImage = viewModel::removeImageUri + state = TimeLetterWriterScreenState( + receiverIds = uiState.receiverIds, + title = uiState.title, + content = uiState.content, + sendDate = uiState.sendDate, + sendTime = uiState.sendTime, + showDatePicker = uiState.showDatePicker, + showTimePicker = uiState.showTimePicker, + draftCount = uiState.draftCount, + receivers = uiState.receivers, + showWritingPlusMenu = uiState.showWritingPlusMenu, + showRegisteredPopUp = uiState.showRegisteredPopUp, + showDraftSavePopUp = uiState.showDraftSavePopUp, + showWaitingAgainPopUp = uiState.showWaitingAgainPopUp, + selectedImageUriStrings = uiState.selectedImageUriStrings, + selectedVoiceUriStrings = uiState.selectedVoiceUriStrings + ), + events = TimeLetterWriterScreenEvents( + onTitleChange = viewModel::updateTitle, + onContentChange = viewModel::updateContent, + onNavigateBack = { navController.popBackStack() }, + onRecipientClick = { navController.navigate(ReceiverRoute.ReceiverListRoute) }, + onRegisterClick = { + viewModel.registerWithPopUpThenSave { + navController.popBackStack() + } + }, + onSaveDraftClick = { + viewModel.saveDraft { + navController.navigate(TimeLetterRoute.DraftLetterRoute) + } + }, + onDraftCountClick = { navController.navigate(TimeLetterRoute.DraftLetterRoute) }, + onDateClick = viewModel::showDatePicker, + onTimeClick = viewModel::showTimePicker, + onBackClick = { navController.popBackStack() }, + onDatePickerDismiss = viewModel::hideDatePicker, + onDateSelected = { year, month, day -> + val formattedDate = + "$year. ${month.toString().padStart(2, '0')}. ${day.toString().padStart(2, '0')}" + viewModel.updateSendDate(formattedDate) + }, + onTimePickerDismiss = viewModel::hideTimePicker, + onTimeSelected = { hour, minute -> + viewModel.updateSendTime("%02d:%02d".format(hour, minute)) + }, + onMoreClick = viewModel::showPlusMenu, + onDismissPlusMenu = viewModel::hidePlusMenu, + onAddImages = viewModel::addImageUris, + onRemoveImage = viewModel::removeImageUri, + onAddVoiceUris = viewModel::addVoiceUris, + onRemoveVoiceUri = viewModel::removeVoiceUri + ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt index 8da23c7d..093ca111 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt @@ -1,7 +1,10 @@ +// K2 false positive: state assigned in callback lambdas is read at recomposition (KT-78881). +@file:Suppress("AssignedValueIsNeverRead") package com.kuit.afternote.feature.timeletter.presentation.screen import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image @@ -13,6 +16,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -22,10 +26,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -56,8 +58,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -65,13 +65,13 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex +import androidx.core.net.toUri import coil3.compose.AsyncImage import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.DateWheelPicker @@ -81,526 +81,673 @@ import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterReceiver import com.kuit.afternote.feature.timeletter.presentation.component.DraftSavePopUp import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterRegisteredPopUp import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterWriterBottomBar +import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterWriterBottomBarParams import com.kuit.afternote.feature.timeletter.presentation.component.TimeWheelPicker -import com.kuit.afternote.feature.timeletter.presentation.component.WritingPlusMenu import com.kuit.afternote.feature.timeletter.presentation.component.WaitingAgainPopUp import java.time.LocalDate import java.time.LocalTime -private val RecipientDropdownMaxHeight = 300.dp +/** + * UI state for [TimeLetterWriterScreen]. + */ +data class TimeLetterWriterScreenState( + val receiverIds: List = emptyList(), + val title: String = "", + val content: String = "", + val sendDate: String = "", + val sendTime: String = "", + val showDatePicker: Boolean = false, + val showTimePicker: Boolean = false, + val draftCount: Int = 0, + val receivers: List = emptyList(), + val showWritingPlusMenu: Boolean = false, + val showRegisteredPopUp: Boolean = false, + val showDraftSavePopUp: Boolean = false, + val showWaitingAgainPopUp: Boolean = false, + val selectedImageUriStrings: List = emptyList(), + val selectedVoiceUriStrings: List = emptyList() +) + +/** + * Parameters for [TimeLetterWriterScreenContent]. + */ +data class TimeLetterWriterScreenContentParams( + val state: TimeLetterWriterScreenState, + val events: TimeLetterWriterScreenEvents, + val recipientDisplayText: String, + val selectedFiles: List, + val addedLinks: List, + val showLinkDialog: Boolean, + val onSelectedFilesChange: (List) -> Unit, + val onAddedLinksChange: (List) -> Unit, + val onShowLinkDialogChange: (Boolean) -> Unit, + val imagePickerLauncher: ActivityResultLauncher, + val voicePickerLauncher: ActivityResultLauncher>, + val filePickerLauncher: ActivityResultLauncher> +) + +/** + * Parameters for [TimeLetterWriterContent]. + */ +data class TimeLetterWriterContentParams( + val state: TimeLetterWriterScreenState, + val events: TimeLetterWriterScreenEvents, + val innerPadding: PaddingValues, + val selectedFiles: List, + val addedLinks: List, + val onSelectedFilesChange: (List) -> Unit, + val onAddedLinksChange: (List) -> Unit +) + +/** + * Parameters for [TimeLetterWriterAttachmentsSection]. + */ +data class TimeLetterWriterAttachmentsParams( + val selectedImageUriStrings: List, + val onRemoveImage: (String) -> Unit, + val selectedVoiceUriStrings: List, + val onRemoveVoiceUri: (String) -> Unit, + val selectedFiles: List, + val addedLinks: List, + val onSelectedFilesChange: (List) -> Unit, + val onAddedLinksChange: (List) -> Unit +) + +/** + * Callbacks for [TimeLetterWriterScreen]. + */ +data class TimeLetterWriterScreenEvents( + val onTitleChange: (String) -> Unit, + val onContentChange: (String) -> Unit, + val onNavigateBack: () -> Unit, + val onRecipientClick: () -> Unit, + val onRegisterClick: () -> Unit, + val onSaveDraftClick: () -> Unit, + val onDraftCountClick: () -> Unit, + val onDateClick: () -> Unit, + val onTimeClick: () -> Unit, + val onBackClick: () -> Unit, + val onDatePickerDismiss: () -> Unit = {}, + val onDateSelected: (year: Int, month: Int, day: Int) -> Unit = { _, _, _ -> }, + val onTimePickerDismiss: () -> Unit = {}, + val onTimeSelected: (hour: Int, minute: Int) -> Unit = { _, _ -> }, + val onMoreClick: () -> Unit = {}, + val onDismissPlusMenu: () -> Unit = {}, + val onAddImages: (List) -> Unit = {}, + val onRemoveImage: (String) -> Unit = {}, + val onAddVoiceUris: (List) -> Unit = {}, + val onRemoveVoiceUri: (String) -> Unit = {} +) @Composable fun TimeLetterWriterScreen( modifier: Modifier = Modifier, - receiverIds: List = emptyList(), - title: String, - content: String, - sendDate: String = "", - sendTime: String = "", - showDatePicker: Boolean = false, - showTimePicker: Boolean = false, - draftCount: Int = 0, - onTitleChange: (String) -> Unit, - onContentChange: (String) -> Unit, - onNavigateBack: () -> Unit, - onRecipientClick: () -> Unit, - onRegisterClick: () -> Unit, - onSaveDraftClick: () -> Unit, - onDraftCountClick: () -> Unit, - onDateClick: () -> Unit, - onTimeClick: () -> Unit, - onBackCLick: () -> Unit, - onDatePickerDismiss: () -> Unit = {}, - onDateSelected: (year: Int, month: Int, day: Int) -> Unit = { _, _, _ -> }, - onTimePickerDismiss: () -> Unit = {}, - onTimeSelected: (hour: Int, minute: Int) -> Unit = { _, _ -> }, - showWritingPlusMenu: Boolean = false, - onMoreClick: () -> Unit = {}, - onDismissPlusMenu: () -> Unit = {}, - receivers: List = emptyList(), - showRecipientDropdown: Boolean = false, - onRecipientDropdownDismiss: () -> Unit = {}, - onReceiverSelected: (TimeLetterReceiver) -> Unit = {}, - showRegisteredPopUp: Boolean = false, - showDraftSavePopUp: Boolean = false, - showWaitingAgainPopUp: Boolean = false, - selectedImageUriStrings: List = emptyList(), - onAddImages: (List) -> Unit = {}, - onRemoveImage: (String) -> Unit = {} + state: TimeLetterWriterScreenState, + events: TimeLetterWriterScreenEvents ) { - val keyboardController = LocalSoftwareKeyboardController.current - val density = LocalDensity.current - val recipientDisplayText = when { - receiverIds.isEmpty() -> stringResource(R.string.content_description_select_receiver) - receiverIds.size == 1 -> { - val name = receivers.find { it.id == receiverIds[0] }?.receiver_name - if (name != null) stringResource(R.string.time_letter_detail_receiver_to, name) - else stringResource(R.string.content_description_select_receiver) - } - else -> stringResource(R.string.time_letter_recipients_count, receiverIds.size) - } - - // UI State (이미지는 ViewModel 호이스팅, 나머지 미디어는 로컬 유지) - var isMenuOpen by remember { mutableStateOf(false) } - var selectedVoices by remember { mutableStateOf>(emptyList()) } + val recipientDisplayText = recipientDisplayText( + receiverIds = state.receiverIds, + receivers = state.receivers + ) var selectedFiles by remember { mutableStateOf>(emptyList()) } var addedLinks by remember { mutableStateOf>(emptyList()) } var showLinkDialog by remember { mutableStateOf(false) } - - // Activity Result Launchers val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 6) - ) { uris -> if (uris.isNotEmpty()) onAddImages(uris) } - + ) { uris -> if (uris.isNotEmpty()) events.onAddImages(uris) } val voicePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments() - ) { uris -> if (uris.isNotEmpty()) selectedVoices = selectedVoices + uris } - + ) { uris -> if (uris.isNotEmpty()) events.onAddVoiceUris(uris) } val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments() ) { uris -> if (uris.isNotEmpty()) selectedFiles = selectedFiles + uris } - Box(modifier = modifier.fillMaxSize()) { - // [Popup & Overlays] - if (showWritingPlusMenu) { - Popup( - alignment = Alignment.BottomStart, - offset = IntOffset( - with(density) { 64.dp.roundToPx() }, - with(density) { (-232).dp.roundToPx() } - ), - onDismissRequest = onDismissPlusMenu, - properties = PopupProperties(dismissOnBackPress = true, dismissOnClickOutside = true) - ) { - WritingPlusMenu( - onImageClick = {}, - onVoiceClick = {}, - onFileClick = {}, - onLinkClick = {} - ) - } - } - - if (showRegisteredPopUp) { - Popup( - alignment = Alignment.Center, - onDismissRequest = { }, - properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) { - TimeLetterRegisteredPopUp() - } - } - - if (showDraftSavePopUp) { - Popup( - alignment = Alignment.Center, - onDismissRequest = { }, - properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) { - DraftSavePopUp() - } - } + TimeLetterWriterScreenContent( + modifier = modifier, + params = TimeLetterWriterScreenContentParams( + state = state, + events = events, + recipientDisplayText = recipientDisplayText, + selectedFiles = selectedFiles, + addedLinks = addedLinks, + showLinkDialog = showLinkDialog, + onSelectedFilesChange = { selectedFiles = it }, + onAddedLinksChange = { addedLinks = it }, + onShowLinkDialogChange = { showLinkDialog = it }, + imagePickerLauncher = imagePickerLauncher, + voicePickerLauncher = voicePickerLauncher, + filePickerLauncher = filePickerLauncher + ) + ) +} - if (showWaitingAgainPopUp) { - Popup( - alignment = Alignment.Center, - onDismissRequest = { }, - properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) { - WaitingAgainPopUp() - } - } +@Composable +private fun TimeLetterWriterScreenContent( + modifier: Modifier, + params: TimeLetterWriterScreenContentParams +) { + val state = params.state + val events = params.events + val onImageAddClick: () -> Unit = { + params.imagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + val onLinkAddClick: () -> Unit = { params.onShowLinkDialogChange(true) } - // [Main Scaffold] + Box(modifier = modifier.fillMaxSize()) { + TimeLetterWriterPopups(state = state) Scaffold( modifier = Modifier.fillMaxWidth(), topBar = { - Column(modifier = Modifier.statusBarsPadding()) { - TopBar( - title = recipientDisplayText, - onBackClick = onNavigateBack, - onActionClick = onRegisterClick, - actionText = "등록", - navigationIcon = { - IconButton(onClick = { onBackCLick() }) { - Image( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = "뒤로가기", - modifier = Modifier.size(width = 6.dp, height = 12.dp) - ) - } - }, - titleContent = { - Row( - modifier = Modifier.clickable { onRecipientClick() }, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = recipientDisplayText, - fontSize = 20.sp, - fontWeight = FontWeight(700), - fontFamily = FontFamily(Font(R.font.sansneobold)), - color = Color(0xFF212121) - ) - Image( - painter = painterResource(R.drawable.ic_down), - contentDescription = stringResource(R.string.content_description_select_receiver), - modifier = Modifier.padding(start = 14.dp) - ) - } - } - ) - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFF9E9E9E), - thickness = 0.4.dp - ) - } + TimeLetterWriterTopBar( + recipientDisplayText = params.recipientDisplayText, + onNavigateBack = events.onNavigateBack, + onRegisterClick = events.onRegisterClick, + onBackClick = events.onBackClick, + onRecipientClick = events.onRecipientClick + ) }, bottomBar = { TimeLetterWriterBottomBar( - draftCount = draftCount, - onLinkClick = { showLinkDialog = true }, - onAddClick = { isMenuOpen = !isMenuOpen }, - onSaveDraftClick = onSaveDraftClick, - onDraftCountClick = onDraftCountClick, - isMenuOpen = isMenuOpen, - onMenuDismiss = { isMenuOpen = false }, - onImageAddClick = { - imagePickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }, - onVoiceAddClick = { - voicePickerLauncher.launch(arrayOf("audio/*")) - }, - onFileAddClick = { - filePickerLauncher.launch(arrayOf("*/*")) - }, - onLinkAddClick = { - showLinkDialog = true - } + params = TimeLetterWriterBottomBarParams( + draftCount = state.draftCount, + onLinkClick = onLinkAddClick, + onAddClick = events.onMoreClick, + onSaveDraftClick = events.onSaveDraftClick, + onDraftCountClick = events.onDraftCountClick, + isMenuOpen = state.showWritingPlusMenu, + onMenuDismiss = events.onDismissPlusMenu, + onImageAddClick = onImageAddClick, + onVoiceAddClick = { params.voicePickerLauncher.launch(arrayOf("audio/*")) }, + onFileAddClick = { params.filePickerLauncher.launch(arrayOf("*/*")) }, + onLinkAddClick = onLinkAddClick + ) ) } ) { innerPadding -> - // [Content Area - Vertical Scroll] - Column( - modifier = Modifier - .fillMaxWidth() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - ) { - Spacer(modifier = Modifier.height(24.dp)) - - // [1] 발송 날짜/시간 선택 영역 (Row) - Row( - modifier = Modifier - .height(62.dp) - .padding(horizontal = 20.dp) - ) { - // 날짜 - Column( - modifier = Modifier - .weight(1f) - .height(62.dp) - .clickable { onDateClick() } - ) { - Text( - text = "발송 날짜", - color = Color(0xFF000000), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 12.sp, - fontWeight = FontWeight.Normal - ) - Box(modifier = Modifier.fillMaxWidth()) { - if (sendDate.isNotEmpty()) { - Text( - text = sendDate, - color = Color(0xFF212121), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 14.sp, - fontWeight = FontWeight.Normal - ) - } - Image( - painter = painterResource(R.drawable.ic_down_vector), - contentDescription = "날짜 선택", - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(top = 15.dp) - .size(width = 12.dp, height = 6.dp) - ) - } - HorizontalDivider( - modifier = Modifier.padding(top = 15.dp), - color = Color(0xFF9E9E9E), - thickness = 0.4.dp - ) - } + TimeLetterWriterContent( + params = TimeLetterWriterContentParams( + state = state, + events = events, + innerPadding = innerPadding, + selectedFiles = params.selectedFiles, + addedLinks = params.addedLinks, + onSelectedFilesChange = params.onSelectedFilesChange, + onAddedLinksChange = params.onAddedLinksChange + ) + ) + } + if (state.showDatePicker) { + TimeLetterWriterDatePickerOverlay( + onDatePickerDismiss = events.onDatePickerDismiss, + onDateSelected = events.onDateSelected + ) + } + if (params.showLinkDialog) { + LinkInputDialog( + onDismiss = { params.onShowLinkDialogChange(false) }, + onConfirm = { link -> + if (link.isNotBlank()) params.onAddedLinksChange(params.addedLinks + link) + params.onShowLinkDialogChange(false) + } + ) + } + if (state.showTimePicker) { + TimeLetterWriterTimePickerOverlay( + sendTime = state.sendTime, + onTimePickerDismiss = events.onTimePickerDismiss, + onTimeSelected = events.onTimeSelected + ) + } + } +} - Spacer(modifier = Modifier.width(26.dp)) +@Composable +private fun recipientDisplayText( + receiverIds: List, + receivers: List +): String { + return when { + receiverIds.isEmpty() -> stringResource(R.string.content_description_select_receiver) + receiverIds.size == 1 -> { + val name = receivers.find { it.id == receiverIds[0] }?.receiver_name + if (name != null) stringResource(R.string.time_letter_detail_receiver_to, name) + else stringResource(R.string.content_description_select_receiver) + } + else -> stringResource(R.string.time_letter_recipients_count, receiverIds.size) + } +} - // 시간 - Column( - modifier = Modifier - .width(106.dp) - .height(62.dp) - .clickable { onTimeClick() } - ) { - Text( - text = "발송 시간", - color = Color(0xFF000000), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 12.sp, - fontWeight = FontWeight.Normal - ) - Box(modifier = Modifier.fillMaxWidth()) { - if (sendTime.isNotEmpty()) { - Text( - text = sendTime, - color = Color(0xFF212121), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 14.sp, - fontWeight = FontWeight.Normal - ) - } - Image( - painter = painterResource(R.drawable.ic_down_vector), - contentDescription = "시간 선택", - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(top = 15.dp) - .size(width = 12.dp, height = 6.dp) - ) - } - HorizontalDivider( - modifier = Modifier.padding(top = 15.dp), - color = Color(0xFF9E9E9E), - thickness = 0.4.dp - ) - } - } // <--- [중요] Row Close +@Composable +private fun TimeLetterWriterPopups(state: TimeLetterWriterScreenState) { + // WritingPlusMenu is shown only by TimeLetterWriterBottomBar (anchored to add icon). + // Do not show it here to avoid duplicate menus. + if (state.showRegisteredPopUp) { + Popup( + alignment = Alignment.Center, + onDismissRequest = { }, + properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + TimeLetterRegisteredPopUp() + } + } + if (state.showDraftSavePopUp) { + Popup( + alignment = Alignment.Center, + onDismissRequest = { }, + properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + DraftSavePopUp() + } + } + if (state.showWaitingAgainPopUp) { + Popup( + alignment = Alignment.Center, + onDismissRequest = { }, + properties = PopupProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + WaitingAgainPopUp() + } + } +} - // [2] 제목 입력 영역 - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(top = 16.dp) +@Composable +private fun TimeLetterWriterTopBar( + recipientDisplayText: String, + onNavigateBack: () -> Unit, + onRegisterClick: () -> Unit, + onBackClick: () -> Unit, + onRecipientClick: () -> Unit +) { + Column(modifier = Modifier.statusBarsPadding()) { + TopBar( + title = recipientDisplayText, + onBackClick = onNavigateBack, + onActionClick = onRegisterClick, + actionText = "등록", + navigationIcon = { + IconButton(onClick = onBackClick) { + Image( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "뒤로가기", + modifier = Modifier.size(width = 6.dp, height = 12.dp) + ) + } + }, + titleContent = { + Row( + modifier = Modifier.clickable { onRecipientClick() }, + verticalAlignment = Alignment.CenterVertically ) { - BasicTextField( - value = title, - onValueChange = onTitleChange, - textStyle = TextStyle( - color = Color(0xFF000000), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 18.sp, - fontWeight = FontWeight(400), - lineHeight = 24.sp - ), - cursorBrush = SolidColor(Color(0xFF000000)), - singleLine = true, - decorationBox = { innerTextField -> - Box { - if (title.isEmpty()) { - Text( - text = "제목", - style = TextStyle( - fontSize = 18.sp, - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontWeight = FontWeight(400), - color = Color(0xFF212121), - ) - ) - } - innerTextField() - } - }, - modifier = Modifier.fillMaxWidth() + Text( + text = recipientDisplayText, + fontSize = 20.sp, + fontWeight = FontWeight(700), + fontFamily = FontFamily(Font(R.font.sansneobold)), + color = Color(0xFF212121) ) - HorizontalDivider( - modifier = Modifier.padding(top = 15.dp), - color = Color(0xFF9E9E9E), - thickness = 0.4.dp + Image( + painter = painterResource(R.drawable.ic_down), + contentDescription = stringResource(R.string.content_description_select_receiver), + modifier = Modifier.padding(start = 14.dp) ) } + } + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFF9E9E9E), + thickness = 0.4.dp + ) + } +} - Spacer(modifier = Modifier.height(17.dp)) +@Composable +private fun TimeLetterWriterContent(params: TimeLetterWriterContentParams) { + val state = params.state + val events = params.events + Column( + modifier = Modifier + .fillMaxWidth() + .padding(params.innerPadding) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + TimeLetterWriterDateTimeRow( + sendDate = state.sendDate, + sendTime = state.sendTime, + onDateClick = events.onDateClick, + onTimeClick = events.onTimeClick + ) + TimeLetterWriterTitleSection( + title = state.title, + onTitleChange = events.onTitleChange + ) + Spacer(modifier = Modifier.height(17.dp)) + TimeLetterWriterBodySection( + content = state.content, + onContentChange = events.onContentChange + ) + Spacer(modifier = Modifier.height(24.dp)) + TimeLetterWriterAttachmentsSection( + params = TimeLetterWriterAttachmentsParams( + selectedImageUriStrings = state.selectedImageUriStrings, + onRemoveImage = events.onRemoveImage, + selectedVoiceUriStrings = state.selectedVoiceUriStrings, + onRemoveVoiceUri = events.onRemoveVoiceUri, + selectedFiles = params.selectedFiles, + addedLinks = params.addedLinks, + onSelectedFilesChange = params.onSelectedFilesChange, + onAddedLinksChange = params.onAddedLinksChange + ) + ) + Spacer(modifier = Modifier.height(100.dp)) + } +} - // [3] 본문 입력 영역 - BasicTextField( - value = content, - onValueChange = onContentChange, - textStyle = TextStyle( +@Composable +private fun TimeLetterWriterDateTimeRow( + sendDate: String, + sendTime: String, + onDateClick: () -> Unit, + onTimeClick: () -> Unit +) { + Row( + modifier = Modifier + .height(62.dp) + .padding(horizontal = 20.dp) + ) { + Column( + modifier = Modifier + .weight(1f) + .height(62.dp) + .clickable { onDateClick() } + ) { + Text( + text = "발송 날짜", + color = Color(0xFF000000), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 12.sp, + fontWeight = FontWeight.Normal + ) + Box(modifier = Modifier.fillMaxWidth()) { + if (sendDate.isNotEmpty()) { + Text( + text = sendDate, color = Color(0xFF212121), fontFamily = FontFamily(Font(R.font.sansneoregular)), fontSize = 14.sp, fontWeight = FontWeight.Normal - ), - cursorBrush = SolidColor(Color(0xFF212121)), - decorationBox = { innerTextField -> - Box(modifier = Modifier.fillMaxSize()) { - if (content.isEmpty()) { - Text( - text = "소중한 사람에게 타임 레터를 작성하세요.", - color = Color(0xFF9E9E9E), - fontFamily = FontFamily(Font(R.font.sansneoregular)), - fontSize = 14.sp, - fontWeight = FontWeight.Normal - ) - } - innerTextField() - } - }, - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .heightIn(min = 200.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // [4] 첨부 파일 표시 영역 (본문 하단에 수직 배치) - - // (1) 이미지 - if (selectedImageUriStrings.isNotEmpty()) { - SelectedImageGrid( - images = selectedImageUriStrings.map { Uri.parse(it) }, - onRemoveImage = { uri -> onRemoveImage(uri.toString()) } - ) - Spacer(modifier = Modifier.height(16.dp)) - } - - // (2) 음성 - if (selectedVoices.isNotEmpty()) { - AttachmentList( - items = selectedVoices, - iconRes = R.drawable.ic_sound, - labelExtractor = { "음성 녹음 파일 ${selectedVoices.indexOf(it) + 1}" }, - onRemove = { selectedVoices = selectedVoices - it } - ) - Spacer(modifier = Modifier.height(16.dp)) - } - - // (3) 파일 - if (selectedFiles.isNotEmpty()) { - AttachmentList( - items = selectedFiles, - iconRes = R.drawable.ic_file, - labelExtractor = { "첨부 파일 ${selectedFiles.indexOf(it) + 1}" }, - onRemove = { selectedFiles = selectedFiles - it } - ) - Spacer(modifier = Modifier.height(16.dp)) - } - - // (4) 링크 - if (addedLinks.isNotEmpty()) { - AttachmentList( - items = addedLinks, - iconRes = R.drawable.ic_plus_link, - labelExtractor = { it }, - onRemove = { addedLinks = addedLinks - it } ) - Spacer(modifier = Modifier.height(16.dp)) } - - // 하단 여백 (스크롤 시 마지막 내용이 잘리지 않도록) - Spacer(modifier = Modifier.height(100.dp)) + Image( + painter = painterResource(R.drawable.ic_down_vector), + contentDescription = "날짜 선택", + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 15.dp) + .size(width = 12.dp, height = 6.dp) + ) } + HorizontalDivider( + modifier = Modifier.padding(top = 15.dp), + color = Color(0xFF9E9E9E), + thickness = 0.4.dp + ) } - - // [Dialogs & Pickers Overlays] - // 1. DatePicker - if (showDatePicker) { - var selectedDate by remember { mutableStateOf(LocalDate.now()) } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - onDateSelected(selectedDate.year, selectedDate.monthValue, selectedDate.dayOfMonth) - onDatePickerDismiss() - } - .zIndex(1f) + Spacer(modifier = Modifier.width(26.dp)) + Column( + modifier = Modifier + .width(106.dp) + .height(62.dp) + .clickable { onTimeClick() } + ) { + Text( + text = "발송 시간", + color = Color(0xFF000000), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 12.sp, + fontWeight = FontWeight.Normal ) - - Box( - modifier = Modifier - .zIndex(2f) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White) - .padding(vertical = 16.dp) - .align(Alignment.Center) // 중앙 정렬 추가 - ) { - DateWheelPicker( - modifier = Modifier.width(DateWheelPickerDefaults.ContainerWidth), - currentDate = selectedDate, - onDateChanged = { date -> - selectedDate = date - onDateSelected(date.year, date.monthValue, date.dayOfMonth) - }, - minDate = LocalDate.now() + Box(modifier = Modifier.fillMaxWidth()) { + if (sendTime.isNotEmpty()) { + Text( + text = sendTime, + color = Color(0xFF212121), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 14.sp, + fontWeight = FontWeight.Normal + ) + } + Image( + painter = painterResource(R.drawable.ic_down_vector), + contentDescription = "시간 선택", + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 15.dp) + .size(width = 12.dp, height = 6.dp) ) } + HorizontalDivider( + modifier = Modifier.padding(top = 15.dp), + color = Color(0xFF9E9E9E), + thickness = 0.4.dp + ) } + } +} - // 2. Link Input Dialog - if (showLinkDialog) { - LinkInputDialog( - onDismiss = { showLinkDialog = false }, - onConfirm = { link -> - if (link.isNotBlank()) { - addedLinks = addedLinks + link +@Composable +private fun TimeLetterWriterTitleSection(title: String, onTitleChange: (String) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 16.dp) + ) { + BasicTextField( + value = title, + onValueChange = onTitleChange, + textStyle = TextStyle( + color = Color(0xFF000000), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 18.sp, + fontWeight = FontWeight(400), + lineHeight = 24.sp + ), + cursorBrush = SolidColor(Color(0xFF000000)), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (title.isEmpty()) { + Text( + text = "제목", + style = TextStyle( + fontSize = 18.sp, + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontWeight = FontWeight(400), + color = Color(0xFF212121), + ) + ) } - showLinkDialog = false + innerTextField() } - ) - } + }, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider( + modifier = Modifier.padding(top = 15.dp), + color = Color(0xFF9E9E9E), + thickness = 0.4.dp + ) + } +} - // 3. TimePicker - if (showTimePicker) { - val now = LocalTime.now() - val (initialHour, initialMinute) = if (sendTime.isNotBlank()) { - val parts = sendTime.split(":") - if (parts.size == 2) { - val h = parts[0].toIntOrNull() ?: now.hour - val m = parts[1].toIntOrNull() ?: now.minute - h to m - } else { - now.hour to now.minute +@Composable +private fun TimeLetterWriterBodySection( + content: String, + onContentChange: (String) -> Unit +) { + BasicTextField( + value = content, + onValueChange = onContentChange, + textStyle = TextStyle( + color = Color(0xFF212121), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 14.sp, + fontWeight = FontWeight.Normal + ), + cursorBrush = SolidColor(Color(0xFF212121)), + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxSize()) { + if (content.isEmpty()) { + Text( + text = "소중한 사람에게 타임 레터를 작성하세요.", + color = Color(0xFF9E9E9E), + fontFamily = FontFamily(Font(R.font.sansneoregular)), + fontSize = 14.sp, + fontWeight = FontWeight.Normal + ) } - } else { - now.hour to now.minute + innerTextField() } + }, + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .heightIn(min = 200.dp) + ) +} - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { onTimePickerDismiss() } - .zIndex(1f) +@Composable +private fun TimeLetterWriterAttachmentsSection(params: TimeLetterWriterAttachmentsParams) { + if (params.selectedImageUriStrings.isNotEmpty()) { + SelectedImageGrid( + images = params.selectedImageUriStrings.map { it.toUri() }, + onRemoveImage = { uri -> params.onRemoveImage(uri.toString()) } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (params.selectedVoiceUriStrings.isNotEmpty()) { + AttachmentList( + items = params.selectedVoiceUriStrings, + iconRes = R.drawable.ic_sound, + labelExtractor = { "음성 녹음 파일 ${params.selectedVoiceUriStrings.indexOf(it) + 1}" }, + onRemove = { params.onRemoveVoiceUri(it) } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (params.selectedFiles.isNotEmpty()) { + AttachmentList( + items = params.selectedFiles, + iconRes = R.drawable.ic_file, + labelExtractor = { "첨부 파일 ${params.selectedFiles.indexOf(it) + 1}" }, + onRemove = { params.onSelectedFilesChange(params.selectedFiles - it) } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (params.addedLinks.isNotEmpty()) { + AttachmentList( + items = params.addedLinks, + iconRes = R.drawable.ic_plus_link, + labelExtractor = { it }, + onRemove = { params.onAddedLinksChange(params.addedLinks - it) } + ) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun TimeLetterWriterDatePickerOverlay( + onDatePickerDismiss: () -> Unit, + onDateSelected: (year: Int, month: Int, day: Int) -> Unit +) { + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onDateSelected( + selectedDate.year, + selectedDate.monthValue, + selectedDate.dayOfMonth + ) + onDatePickerDismiss() + } + .zIndex(1f) + ) + Box( + modifier = Modifier + .zIndex(2f) + .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(vertical = 16.dp) + .align(Alignment.Center) + ) { + DateWheelPicker( + modifier = Modifier.width(DateWheelPickerDefaults.ContainerWidth), + currentDate = selectedDate, + onDateChanged = { date -> + selectedDate = date + onDateSelected(date.year, date.monthValue, date.dayOfMonth) + }, + minDate = LocalDate.now() ) + } + } +} - Box( - modifier = Modifier - .zIndex(2f) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White) - .padding(vertical = 16.dp) - .align(Alignment.Center) // 중앙 정렬 추가 - ) { - TimeWheelPicker( - initialHour = initialHour, - initialMinute = initialMinute, - onTimeChanged = { hour, minute -> onTimeSelected(hour, minute) } - ) - } +@Composable +private fun TimeLetterWriterTimePickerOverlay( + sendTime: String, + onTimePickerDismiss: () -> Unit, + onTimeSelected: (hour: Int, minute: Int) -> Unit +) { + val now = LocalTime.now() + val (initialHour, initialMinute) = if (sendTime.isNotBlank()) { + val parts = sendTime.split(":") + if (parts.size == 2) { + val h = parts[0].toIntOrNull() ?: now.hour + val m = parts[1].toIntOrNull() ?: now.minute + h to m + } else { + now.hour to now.minute + } + } else { + now.hour to now.minute + } + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onTimePickerDismiss() } + .zIndex(1f) + ) + Box( + modifier = Modifier + .zIndex(2f) + .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(vertical = 16.dp) + .align(Alignment.Center) + ) { + TimeWheelPicker( + initialHour = initialHour, + initialMinute = initialMinute, + onTimeChanged = { hour, minute -> onTimeSelected(hour, minute) } + ) } } } @@ -772,34 +919,32 @@ fun LinkInputDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { @Composable private fun TimeLetterWriterScreenPreview() { TimeLetterWriterScreen( - receiverIds = listOf(1L), - title = "", - content = "", - sendDate = "", - sendTime = "", - draftCount = 3, - onTitleChange = {}, - onContentChange = {}, - onNavigateBack = {}, - onRecipientClick = {}, - onRegisterClick = {}, - onSaveDraftClick = {}, - onDraftCountClick = {}, - onDateClick = {}, - onTimeClick = {}, - onTimePickerDismiss = {}, - onBackCLick = {}, - onTimeSelected = { _, _ -> }, - receivers = listOf( - TimeLetterReceiver( - id = 1L, - receiver_name = "박채연", - send_at = "2025-01-01", - title = "제목", - content = "내용", - image_url = null, - relation = "친구" + state = TimeLetterWriterScreenState( + receiverIds = listOf(1L), + draftCount = 3, + receivers = listOf( + TimeLetterReceiver( + id = 1L, + receiver_name = "박채연", + send_at = "2025-01-01", + title = "제목", + content = "내용", + image_url = null, + relation = "친구" + ) ) + ), + events = TimeLetterWriterScreenEvents( + onTitleChange = {}, + onContentChange = {}, + onNavigateBack = {}, + onRecipientClick = {}, + onRegisterClick = {}, + onSaveDraftClick = {}, + onDraftCountClick = {}, + onDateClick = {}, + onTimeClick = {}, + onBackClick = {} ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt index 5abf4913..084403d0 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt @@ -11,6 +11,7 @@ import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterReceiver * @property sendDate 발송 날짜 (yyyy. MM. dd 형식) * @property sendTime 발송 시간 (HH:mm 형식) * @property selectedImageUriStrings 첨부할 이미지 URI 문자열 목록 (업로드 대기) + * @property selectedVoiceUriStrings 첨부할 음성/오디오 URI 문자열 목록 (업로드 대기) * @property existingMediaUrls draft 로드 시 기존 미디어 URL 목록 (IMAGE 타입만) * @property isLoading 로딩 상태 * @property isSaveEnabled 저장 버튼 활성화 여부 @@ -24,6 +25,7 @@ import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterReceiver * @property showDraftSavePopUp 임시저장 완료 팝업 표시 여부 * @property showWaitingAgainPopUp 입력값 재확인 안내 팝업 표시 여부 * @property draftId 수정 중인 임시저장 레터 ID (null이면 신규 작성) + * @property errorMessage 저장 실패 시 표시할 에러 메시지 (null이면 없음) */ data class TimeLetterWriterUiState( val receiverIds: List = emptyList(), @@ -32,6 +34,7 @@ data class TimeLetterWriterUiState( val sendDate: String = "", val sendTime: String = "", val selectedImageUriStrings: List = emptyList(), + val selectedVoiceUriStrings: List = emptyList(), val existingMediaUrls: List = emptyList(), val isLoading: Boolean = false, val isSaveEnabled: Boolean = false, @@ -44,5 +47,6 @@ data class TimeLetterWriterUiState( val showRegisteredPopUp: Boolean = false, val showDraftSavePopUp: Boolean = false, val showWaitingAgainPopUp: Boolean = false, - val draftId: Long? = null + val draftId: Long? = null, + val errorMessage: String? = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt index acd83caa..805eb095 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt @@ -12,6 +12,7 @@ import com.kuit.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseC import com.kuit.afternote.feature.timeletter.domain.usecase.GetTimeLetterUseCase import com.kuit.afternote.feature.timeletter.domain.usecase.GetTemporaryTimeLettersUseCase import com.kuit.afternote.feature.timeletter.domain.usecase.UpdateTimeLetterUseCase +import com.kuit.afternote.feature.timeletter.domain.usecase.UploadTimeLetterAudioUseCase import com.kuit.afternote.feature.timeletter.domain.usecase.UploadTimeLetterImageUseCase import com.kuit.afternote.feature.timeletter.presentation.mapper.toTimeLetterReceivers import com.kuit.afternote.feature.timeletter.presentation.navgraph.TimeLetterRoute @@ -46,6 +47,7 @@ class TimeLetterWriterViewModel private val getTimeLetterUseCase: GetTimeLetterUseCase, private val getTemporaryTimeLettersUseCase: GetTemporaryTimeLettersUseCase, private val uploadTimeLetterImageUseCase: UploadTimeLetterImageUseCase, + private val uploadTimeLetterAudioUseCase: UploadTimeLetterAudioUseCase, private val getReceiversUseCase: GetReceiversUseCase, private val getUserIdUseCase: GetUserIdUseCase ) : ViewModel() { @@ -90,6 +92,7 @@ class TimeLetterWriterViewModel sendTime = time, existingMediaUrls = existingUrls, selectedImageUriStrings = emptyList(), + selectedVoiceUriStrings = emptyList(), isLoading = false ) } @@ -209,6 +212,29 @@ class TimeLetterWriterViewModel } } + /** + * 첨부 음성/오디오 추가 + * + * @param uris 문서 선택기에서 선택한 오디오 URI 목록 + */ + fun addVoiceUris(uris: List) { + val newStrings = uris.map { it.toString() } + _uiState.update { state -> + state.copy(selectedVoiceUriStrings = state.selectedVoiceUriStrings + newStrings) + } + } + + /** + * 첨부 음성/오디오 1개 제거 + * + * @param uriString 제거할 오디오 URI 문자열 + */ + fun removeVoiceUri(uriString: String) { + _uiState.update { + it.copy(selectedVoiceUriStrings = it.selectedVoiceUriStrings - uriString) + } + } + /** * 발송 날짜 업데이트 * @@ -314,21 +340,34 @@ class TimeLetterWriterViewModel } /** - * 현재 상태 기준으로 mediaList 구성. 선택 이미지 업로드 후 기존 URL + 새 URL을 (IMAGE, url) 쌍으로 반환. - * 이미지가 하나도 없으면 null 반환. + * 현재 상태 기준으로 mediaList 구성. 선택 이미지·오디오 업로드 후 (타입, url) 쌍 목록 반환. + * 이미지·오디오가 하나도 없으면 null 반환. */ private suspend fun buildMediaList(state: TimeLetterWriterUiState): Result>?> { - val existing = state.existingMediaUrls - val pending = state.selectedImageUriStrings - if (existing.isEmpty() && pending.isEmpty()) return Result.success(null) - val newUrls = mutableListOf() - for (uriString in pending) { + val existingImages = state.existingMediaUrls + val pendingImages = state.selectedImageUriStrings + val pendingVoices = state.selectedVoiceUriStrings + if (existingImages.isEmpty() && pendingImages.isEmpty() && pendingVoices.isEmpty()) { + return Result.success(null) + } + val mediaList = mutableListOf>() + + val newImageUrls = mutableListOf() + for (uriString in pendingImages) { uploadTimeLetterImageUseCase(uriString) - .onSuccess { newUrls.add(it) } + .onSuccess { newImageUrls.add(it) } + .onFailure { return Result.failure(it) } + } + val allImageUrls = existingImages + newImageUrls + mediaList.addAll(allImageUrls.map { TimeLetterMediaType.IMAGE to it }) + + for (uriString in pendingVoices) { + uploadTimeLetterAudioUseCase(uriString) + .onSuccess { url -> mediaList.add(TimeLetterMediaType.AUDIO to url) } .onFailure { return Result.failure(it) } } - val allUrls = existing + newUrls - return Result.success(allUrls.map { TimeLetterMediaType.IMAGE to it }) + + return Result.success(mediaList.ifEmpty { null }) } /** @@ -353,7 +392,7 @@ class TimeLetterWriterViewModel onSuccess: () -> Unit ) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } val state = _uiState.value val sendAt = buildSendAt(state.sendDate, state.sendTime) hideWaitingAgainPopUp() @@ -396,10 +435,14 @@ class TimeLetterWriterViewModel onSuccess() } } - result.onFailure { - Log.e(TAG, "saveTimeLetter failed", it) + result.onFailure { error -> + Log.e(TAG, "saveTimeLetter failed", error) showWaitingAgainPopUp() - // TODO: 에러 메시지 UiState에 반영 + _uiState.update { + it.copy( + errorMessage = error.message ?: error.localizedMessage ?: "저장에 실패했습니다." + ) + } } } } @@ -413,7 +456,7 @@ class TimeLetterWriterViewModel */ fun saveDraft(onSuccess: () -> Unit) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } val state = _uiState.value val sendAt = buildSendAt(state.sendDate, state.sendTime) val mediaListResult = buildMediaList(state) @@ -450,8 +493,12 @@ class TimeLetterWriterViewModel _uiState.update { it.copy(showDraftSavePopUp = false) } onSuccess() } - result.onFailure { - // TODO: 에러 메시지 UiState에 반영 + result.onFailure { error -> + _uiState.update { + it.copy( + errorMessage = error.message ?: error.localizedMessage ?: "임시저장에 실패했습니다." + ) + } } } } From 64293efff539b76b2dc93d95a3986ba6849761b6 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 07:10:30 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(timeletter):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=A0=88=ED=84=B0=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=EC=B2=A8=EB=B6=80?= =?UTF-8?q?=20=EB=A7=81=ED=81=AC=20=EB=B0=8F=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **타임레터 상세조회 화면 기능 확장:** - 기존에 이미지 URL만 표시하던 것을 음성 파일(`AUDIO`) URL도 전달받아 표시하도록 수정했습니다. - 첨부된 음성 파일은 작성 화면과 유사한 UI로 목록에 표시됩니다. - 파라미터가 7개를 초과함에 따라, 가독성 및 S107 규칙 준수를 위해 `TimeLetterDetailParams` 데이터 클래스로 그룹화했습니다. - **타임레터 작성 및 수정 기능 개선:** - 링크 첨부 기능이 추가되었습니다. UI 상태(`TimeLetterWriterUiState`)에 `addedLinks` 필드를 추가하여 링크 목록을 관리합니다. - 임시저장된 편지를 불러올 때, 기존에 저장된 링크(`DOCUMENT` 타입)와 음성 파일(`AUDIO` 타입)을 함께 불러오도록 로직을 수정했습니다. - 타임레터 등록 시, 첨부된 이미지, 음성 파일, 링크를 종합하여 `mediaList`를 생성하도록 `buildMediaList` 로직을 업데이트했습니다. - **UI 모델 및 네비게이션 업데이트:** - `TimeLetterItem`, `TimeLetterRoute`, `TimeLetterUiState` 등 관련 UI 모델에 `audioUrls` 필드를 추가하여 음성 파일 목록을 전달할 수 있도록 했습니다. - **기타 UI 수정:** - 등록 불가 팝업(`WaitingAgainPopUp`)의 레이아웃을 `Column`으로 변경하여 텍스트 줄바꿈을 명확히 했습니다. - `TimeLetterDetailScreen`의 Coil 이미지 로더에서 HTTP 헤더 설정 방식을 개선했습니다. --- .../component/WritingAgainPopUp.kt | 57 +++--- .../navgraph/TimeLetterNavGraph.kt | 27 +-- .../presentation/navgraph/TimeLetterRoute.kt | 3 +- .../screen/TimeLetterDetailScreen.kt | 165 +++++++++++++----- .../screen/TimeLetterWriterScreen.kt | 11 +- .../presentation/uimodel/TimeLetterUiState.kt | 3 +- .../uimodel/TimeLetterWriterUiState.kt | 2 + .../viewmodel/TimeLetterViewModel.kt | 8 +- .../viewmodel/TimeLetterWriterViewModel.kt | 20 ++- app/src/main/res/values/strings.xml | 1 + 10 files changed, 215 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/WritingAgainPopUp.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/WritingAgainPopUp.kt index f0bf98d7..8792a2df 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/WritingAgainPopUp.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/WritingAgainPopUp.kt @@ -2,6 +2,8 @@ package com.kuit.afternote.feature.timeletter.presentation.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -24,29 +26,44 @@ import com.kuit.afternote.R @Composable fun WaitingAgainPopUp(modifier: Modifier = Modifier) { Box( - modifier = Modifier - .shadow(elevation = 10.dp, spotColor = Color(0x26000000), ambientColor = Color(0x26000000)) + modifier = modifier + .shadow( + elevation = 10.dp, + spotColor = Color(0x26000000), + ambientColor = Color(0x26000000) + ) .width(350.dp) - .height(54.dp) - .background(color = Color(0xFFFFFFFF), shape = RoundedCornerShape(size = 16.dp)) + .background( + color = Color(0xFFFFFFFF), + shape = RoundedCornerShape(size = 16.dp) + ) .padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 16.dp), contentAlignment = Alignment.Center ) { - Text( - text = "타임레터를 등록할 수 없습니다.", - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sansneomedium)), - fontWeight = FontWeight(500), - color = Color(0xFF212121), - textAlign = TextAlign.Center - ) - Text( - text = "발송일자 및 시간을 다시 한번 확인해주세요.", - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sansneolight)), - fontWeight = FontWeight(400), - color = Color(0xFF9E9E9E), - textAlign = TextAlign.Center - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "타임레터를 등록할 수 없습니다.", + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sansneomedium)), + fontWeight = FontWeight(500), + color = Color(0xFF212121), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "발송일자 및 시간을 다시 한번 확인해주세요.", + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sansneolight)), + fontWeight = FontWeight(400), + color = Color(0xFF9E9E9E), + textAlign = TextAlign.Center + ) + } } } + +@Preview(showBackground = true) +@Composable +private fun PreviewWaitingAgainPopUp() { + WaitingAgainPopUp() +} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt index 39bf6b42..5df7b35d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt @@ -13,6 +13,7 @@ import com.kuit.afternote.core.navigation.SELECTED_RECEIVER_ID_KEY import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.feature.timeletter.presentation.screen.DraftLetterScreen import com.kuit.afternote.feature.timeletter.presentation.screen.ReceiveListScreen +import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterDetailParams import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterDetailScreen import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterScreen import com.kuit.afternote.feature.timeletter.presentation.screen.TimeLetterWriterScreen @@ -52,7 +53,8 @@ fun NavGraphBuilder.timeLetterNavGraph( title = letter.title, content = letter.content, createDate = letter.createDate, - mediaUrls = letter.mediaUrls + mediaUrls = letter.mediaUrls, + audioUrls = letter.audioUrls ) ) }, @@ -93,7 +95,8 @@ fun NavGraphBuilder.timeLetterNavGraph( showDraftSavePopUp = uiState.showDraftSavePopUp, showWaitingAgainPopUp = uiState.showWaitingAgainPopUp, selectedImageUriStrings = uiState.selectedImageUriStrings, - selectedVoiceUriStrings = uiState.selectedVoiceUriStrings + selectedVoiceUriStrings = uiState.selectedVoiceUriStrings, + addedLinks = uiState.addedLinks ), events = TimeLetterWriterScreenEvents( onTitleChange = viewModel::updateTitle, @@ -129,7 +132,8 @@ fun NavGraphBuilder.timeLetterNavGraph( onAddImages = viewModel::addImageUris, onRemoveImage = viewModel::removeImageUri, onAddVoiceUris = viewModel::addVoiceUris, - onRemoveVoiceUri = viewModel::removeVoiceUri + onRemoveVoiceUri = viewModel::removeVoiceUri, + onAddedLinksChange = viewModel::updateAddedLinks ) ) } @@ -165,13 +169,16 @@ fun NavGraphBuilder.timeLetterNavGraph( composable { backStackEntry -> val route = backStackEntry.toRoute() TimeLetterDetailScreen( - receiverName = route.receiverName, - sendDate = route.sendDate, - title = route.title, - content = route.content, - createdAt = route.createDate, - mediaUrls = route.mediaUrls, - onBackClick = { navController.popBackStack() } + params = TimeLetterDetailParams( + receiverName = route.receiverName, + sendDate = route.sendDate, + title = route.title, + content = route.content, + createdAt = route.createDate, + mediaUrls = route.mediaUrls, + audioUrls = route.audioUrls, + onBackClick = { navController.popBackStack() } + ) ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt index 8e808ecd..efd66510 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt @@ -24,6 +24,7 @@ sealed interface TimeLetterRoute { val title: String, val content: String, val createDate: String, - val mediaUrls: List = emptyList() + val mediaUrls: List = emptyList(), + val audioUrls: List = emptyList() ) : TimeLetterRoute } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt index ac4f9892..2e4b2e79 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt @@ -1,7 +1,9 @@ package com.kuit.afternote.feature.timeletter.presentation.screen import android.util.Log +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -27,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -49,34 +51,44 @@ import com.kuit.afternote.ui.theme.Gray9 private const val TAG = "TimeLetterDetailScreen" private const val DETAIL_IMAGE_SIZE_DP = 100 +/** + * 타임레터 상세 화면에 전달할 파라미터 (S107 준수: 7개 초과 시 그룹화) + */ +data class TimeLetterDetailParams( + val receiverName: String, + val sendDate: String, + val title: String, + val content: String, + val createdAt: String = "", + val mediaUrls: List = emptyList(), + val audioUrls: List = emptyList(), + val onBackClick: () -> Unit = {} +) + /** * 타임레터 상세 화면. * 리스트에서 선택한 타임레터의 수신인, 제목, 작성/발송 일자, 본문을 카드 형태로 표시한다. - * mediaList(IMAGE)가 있으면 본문 아래에 이미지를 표시한다. + * mediaList(IMAGE)가 있으면 본문 아래에 이미지를, AUDIO가 있으면 음성 첨부를 작성 화면과 동일한 스타일로 표시한다. * - * @param receiverName 수신인 이름 - * @param sendDate 발송 일자 (표시용 문자열) - * @param title 편지 제목 - * @param content 편지 본문 - * @param createdAt 작성 일자 (표시용, 없으면 "-") - * @param mediaUrls 미디어 이미지 URL 목록 (IMAGE 타입만, 없으면 빈 목록) - * @param onBackClick 뒤로가기 콜백 + * @param modifier 모디파이어 + * @param params 화면 파라미터 (receiverName, sendDate, title, content, createdAt, mediaUrls, audioUrls, onBackClick) */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimeLetterDetailScreen( - receiverName: String, - sendDate: String, - title: String, - content: String, - createdAt: String = "", - mediaUrls: List = emptyList(), - onBackClick: () -> Unit = {} + modifier: Modifier = Modifier, + params: TimeLetterDetailParams ) { val scrollState = rememberScrollState() val recipientLabel = stringResource(R.string.time_letter_detail_recipient_label) - val createdLabel = stringResource(R.string.time_letter_detail_created_at, createdAt.ifEmpty { "-" }) - val deliveryLabel = stringResource(R.string.time_letter_detail_delivery_date, sendDate.ifEmpty { "-" }) + val createdLabel = stringResource( + R.string.time_letter_detail_created_at, + params.createdAt.ifEmpty { "-" } + ) + val deliveryLabel = stringResource( + R.string.time_letter_detail_delivery_date, + params.sendDate.ifEmpty { "-" } + ) val screenTitle = stringResource(R.string.nav_timeletter) Scaffold( @@ -84,13 +96,13 @@ fun TimeLetterDetailScreen( Column(modifier = Modifier.statusBarsPadding()) { TopBar( title = screenTitle, - onBackClick = onBackClick + onBackClick = params.onBackClick ) } } ) { paddingValues -> Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(paddingValues) .verticalScroll(scrollState) @@ -113,7 +125,7 @@ fun TimeLetterDetailScreen( fontWeight = FontWeight.Medium ) Text( - text = " ${receiverName.ifEmpty { "-" }}", + text = " ${params.receiverName.ifEmpty { "-" }}", fontSize = 14.sp, color = Gray9, fontWeight = FontWeight.Medium @@ -123,7 +135,7 @@ fun TimeLetterDetailScreen( Spacer(modifier = Modifier.height(16.dp)) Text( - text = title, + text = params.title, fontSize = 20.sp, fontWeight = FontWeight(700), color = Gray9 @@ -152,20 +164,25 @@ fun TimeLetterDetailScreen( Spacer(modifier = Modifier.height(24.dp)) Text( - text = content, + text = params.content, fontSize = 14.sp, color = Gray9, lineHeight = 22.sp ) - if (mediaUrls.isNotEmpty()) { + if (params.mediaUrls.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) DetailMediaRow( - mediaUrls = mediaUrls, + mediaUrls = params.mediaUrls, modifier = Modifier.fillMaxWidth() ) } + if (params.audioUrls.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailAudioSection(audioUrls = params.audioUrls) + } + Spacer(modifier = Modifier.height(40.dp)) } } @@ -175,6 +192,48 @@ fun TimeLetterDetailScreen( } } +/** + * 첨부 음성 목록을 타임레터 작성 화면과 동일한 스타일(아이콘 + 라벨)로 표시한다. + * 상세 화면은 읽기 전용이므로 삭제 버튼은 없다. + */ +@Composable +private fun DetailAudioSection( + audioUrls: List, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.padding(horizontal = 20.dp)) { + audioUrls.forEachIndexed { index, _ -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .background( + Color(0xFFF5F5F5), + RoundedCornerShape(8.dp) + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_sound), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.time_letter_voice_file_label, index + 1), + fontSize = 14.sp, + color = Color(0xFF333333), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + maxLines = 1 + ) + } + } + } +} + @Composable private fun DetailMediaRow( mediaUrls: List, @@ -194,9 +253,9 @@ private fun DetailMediaRow( model = ImageRequest.Builder(context) .data(url) .httpHeaders( - NetworkHeaders.Builder() - .set("User-Agent", "Afternote Android App") - .build() + NetworkHeaders.Builder().apply { + this["User-Agent"] = "Afternote Android App" + }.build() ) .build(), contentDescription = null, @@ -218,11 +277,13 @@ private fun DetailMediaRow( private fun PreviewTimeLetterDetail() { MaterialTheme { TimeLetterDetailScreen( - receiverName = "박채연", - sendDate = "2026. 11. 24. 00:00", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제 같은데 벌써 스무살이라니,, 엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶구나.\n\n요즘 학교는 어떻게 다니고 있니? 공부는 잘하고 있지? 친구는 많이 사귀었니? 채연이가 가고싶어했던 그 학교, 학과에 갔을지 엄마는 너무 궁금해하면서 이 편지를 쓰고 있어. 엄마랑 다시 만나면 그동안 어떻게 지냈는지 하나하나 다 알려줘야 해. 사랑한다 생일 축하해 내 딸.", - createdAt = "2023. 11. 21. 18:34" + params = TimeLetterDetailParams( + receiverName = "박채연", + sendDate = "2026. 11. 24. 10:00", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제 같은데 벌써 스무살이라니,, 엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶구나.\n\n요즘 학교는 어떻게 다니고 있니? 공부는 잘하고 있지? 친구는 많이 사귀었니? 채연이가 가고싶어했던 그 학교, 학과에 갔을지 엄마는 너무 궁금해하면서 이 편지를 쓰고 있어. 엄마랑 다시 만나면 그동안 어떻게 지냈는지 하나하나 다 알려줘야 해. 사랑한다 생일 축하해 내 딸.", + createdAt = "2023. 11. 21. 18:24" + ) ) } } @@ -232,14 +293,36 @@ private fun PreviewTimeLetterDetail() { private fun PreviewTimeLetterDetailWithMedia() { MaterialTheme { TimeLetterDetailScreen( - receiverName = "박채연", - sendDate = "2026. 11. 24. 00:00", - title = "채연아 20번째 생일을 축하해", - content = "사랑한다 생일 축하해 내 딸.", - createdAt = "2023. 11. 21. 18:34", - mediaUrls = listOf( - "https://example.com/sample1.jpg", - "https://example.com/sample2.jpg" + params = TimeLetterDetailParams( + receiverName = "박채연", + sendDate = "2026. 11. 24. 20:00", + title = "채연아 21번째 생일을 축하해", + content = "사랑한다 생일 축하해 내 딸.", + createdAt = "2023. 11. 21. 18:44", + mediaUrls = listOf( + "https://example.com/sample1.jpg", + "https://example.com/sample2.jpg" + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewTimeLetterDetailWithAudio() { + MaterialTheme { + TimeLetterDetailScreen( + params = TimeLetterDetailParams( + receiverName = "박채연", + sendDate = "2026. 11. 24. 00:10", + title = "채연아 20번째 생일을 축하해", + content = "사랑한다 생일 축하해 내 딸.", + createdAt = "2023. 11. 21. 18:34", + audioUrls = listOf( + "https://example.com/voice1.m4a", + "https://example.com/voice2.m4a" + ) ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt index 093ca111..44c2d35b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterWriterScreen.kt @@ -105,7 +105,8 @@ data class TimeLetterWriterScreenState( val showDraftSavePopUp: Boolean = false, val showWaitingAgainPopUp: Boolean = false, val selectedImageUriStrings: List = emptyList(), - val selectedVoiceUriStrings: List = emptyList() + val selectedVoiceUriStrings: List = emptyList(), + val addedLinks: List = emptyList() ) /** @@ -176,7 +177,8 @@ data class TimeLetterWriterScreenEvents( val onAddImages: (List) -> Unit = {}, val onRemoveImage: (String) -> Unit = {}, val onAddVoiceUris: (List) -> Unit = {}, - val onRemoveVoiceUri: (String) -> Unit = {} + val onRemoveVoiceUri: (String) -> Unit = {}, + val onAddedLinksChange: (List) -> Unit = {} ) @Composable @@ -190,7 +192,6 @@ fun TimeLetterWriterScreen( receivers = state.receivers ) var selectedFiles by remember { mutableStateOf>(emptyList()) } - var addedLinks by remember { mutableStateOf>(emptyList()) } var showLinkDialog by remember { mutableStateOf(false) } val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 6) @@ -209,10 +210,10 @@ fun TimeLetterWriterScreen( events = events, recipientDisplayText = recipientDisplayText, selectedFiles = selectedFiles, - addedLinks = addedLinks, + addedLinks = state.addedLinks, showLinkDialog = showLinkDialog, onSelectedFilesChange = { selectedFiles = it }, - onAddedLinksChange = { addedLinks = it }, + onAddedLinksChange = events.onAddedLinksChange, onShowLinkDialogChange = { showLinkDialog = it }, imagePickerLauncher = imagePickerLauncher, voicePickerLauncher = voicePickerLauncher, diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt index d2f3bbf4..bbf9c1c9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt @@ -23,5 +23,6 @@ data class TimeLetterItem( val imageResId: Int? = null, val createDate: String, val theme: LetterTheme = LetterTheme.BLUE, - val mediaUrls: List = emptyList() + val mediaUrls: List = emptyList(), + val audioUrls: List = emptyList() ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt index 084403d0..8b7663ef 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterWriterUiState.kt @@ -12,6 +12,7 @@ import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterReceiver * @property sendTime 발송 시간 (HH:mm 형식) * @property selectedImageUriStrings 첨부할 이미지 URI 문자열 목록 (업로드 대기) * @property selectedVoiceUriStrings 첨부할 음성/오디오 URI 문자열 목록 (업로드 대기) + * @property addedLinks 첨부 링크 URL 목록 (mediaList DOCUMENT로 전송) * @property existingMediaUrls draft 로드 시 기존 미디어 URL 목록 (IMAGE 타입만) * @property isLoading 로딩 상태 * @property isSaveEnabled 저장 버튼 활성화 여부 @@ -35,6 +36,7 @@ data class TimeLetterWriterUiState( val sendTime: String = "", val selectedImageUriStrings: List = emptyList(), val selectedVoiceUriStrings: List = emptyList(), + val addedLinks: List = emptyList(), val existingMediaUrls: List = emptyList(), val isLoading: Boolean = false, val isSaveEnabled: Boolean = false, diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt index cbb7cf34..f58136dd 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt @@ -60,7 +60,7 @@ class TimeLetterViewModel viewModelScope.launch { _uiState.value = TimeLetterUiState.Loading val receivers = loadReceiversOrEmpty() - val selectedReceiverId = savedStateHandle.get(SELECTED_RECEIVER_ID_KEY) + val selectedReceiverId = savedStateHandle[SELECTED_RECEIVER_ID_KEY] as? Long getTimeLettersUseCase() .onSuccess { list -> val filteredLetters = @@ -140,6 +140,9 @@ class TimeLetterViewModel val imageUrls = t.mediaList .filter { it.mediaType == TimeLetterMediaType.IMAGE } .map { it.mediaUrl } + val audioUrls = t.mediaList + .filter { it.mediaType == TimeLetterMediaType.AUDIO } + .map { it.mediaUrl } return TimeLetterItem( id = t.id.toString(), receivername = resolveReceiverDisplayText(t.receiverIds, receivers), @@ -149,7 +152,8 @@ class TimeLetterViewModel imageResId = null, theme = themes[index % themes.size], createDate = formatSendAtForDisplay(t.createdAt), - mediaUrls = imageUrls + mediaUrls = imageUrls, + audioUrls = audioUrls ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt index 805eb095..aad65522 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriterViewModel.kt @@ -83,6 +83,9 @@ class TimeLetterWriterViewModel val existingUrls = letter.mediaList .filter { it.mediaType == TimeLetterMediaType.IMAGE } .map { it.mediaUrl } + val existingLinks = letter.mediaList + .filter { it.mediaType == TimeLetterMediaType.DOCUMENT } + .map { it.mediaUrl } _uiState.update { it.copy( draftId = letter.id, @@ -91,6 +94,7 @@ class TimeLetterWriterViewModel sendDate = date, sendTime = time, existingMediaUrls = existingUrls, + addedLinks = existingLinks, selectedImageUriStrings = emptyList(), selectedVoiceUriStrings = emptyList(), isLoading = false @@ -235,6 +239,15 @@ class TimeLetterWriterViewModel } } + /** + * 첨부 링크 목록 업데이트 (mediaList DOCUMENT로 전송됨) + * + * @param links 링크 URL 목록 + */ + fun updateAddedLinks(links: List) { + _uiState.update { it.copy(addedLinks = links) } + } + /** * 발송 날짜 업데이트 * @@ -341,13 +354,14 @@ class TimeLetterWriterViewModel /** * 현재 상태 기준으로 mediaList 구성. 선택 이미지·오디오 업로드 후 (타입, url) 쌍 목록 반환. - * 이미지·오디오가 하나도 없으면 null 반환. + * 링크는 URL 그대로 DOCUMENT 타입으로 추가. 이미지·오디오·링크가 하나도 없으면 null 반환. */ private suspend fun buildMediaList(state: TimeLetterWriterUiState): Result>?> { val existingImages = state.existingMediaUrls val pendingImages = state.selectedImageUriStrings val pendingVoices = state.selectedVoiceUriStrings - if (existingImages.isEmpty() && pendingImages.isEmpty() && pendingVoices.isEmpty()) { + val links = state.addedLinks + if (existingImages.isEmpty() && pendingImages.isEmpty() && pendingVoices.isEmpty() && links.isEmpty()) { return Result.success(null) } val mediaList = mutableListOf>() @@ -367,6 +381,8 @@ class TimeLetterWriterViewModel .onFailure { return Result.failure(it) } } + mediaList.addAll(links.map { TimeLetterMediaType.DOCUMENT to it }) + return Result.success(mediaList.ifEmpty { null }) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55d24575..f6045a28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,6 +128,7 @@ 발송 예정일: %1$s 발송 일자: %1$s 작성 일자: %1$s + 음성 녹음 파일 %1$d 수신인: %1$s님께 %d명에게 From 3cc1b08d37362baa084d84409a0fdec40673f8f7 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 07:20:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(timeletter):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=A0=88=ED=84=B0=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EC=B2=A8=EB=B6=80=20=EB=A7=81=ED=81=AC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `TimeLetterViewModel`: API 응답의 미디어 리스트에서 `DOCUMENT` 타입을 필터링하여 `linkUrls` 목록을 생성하고, 이를 `TimeLetterItem` UI 모델에 추가했습니다. - `TimeLetterDetailScreen.kt`: 상세 화면에 `linkUrls` 파라미터를 추가하고, 링크가 존재할 경우 이를 표시하는 `DetailLinkSection` 컴포저블을 새로 구현했습니다. - `TimeLetterUiState.kt`, `TimeLetterRoute.kt`, `TimeLetterNavGraph.kt`: `linkUrls` 필드를 추가하여 타임레터 목록 화면에서 상세 화면으로 링크 데이터를 전달할 수 있도록 수정했습니다. --- .../navgraph/TimeLetterNavGraph.kt | 4 +- .../presentation/navgraph/TimeLetterRoute.kt | 3 +- .../screen/TimeLetterDetailScreen.kt | 78 +++++++++++++++++-- .../presentation/uimodel/TimeLetterUiState.kt | 3 +- .../viewmodel/TimeLetterViewModel.kt | 6 +- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt index 5df7b35d..0b0ce51c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterNavGraph.kt @@ -54,7 +54,8 @@ fun NavGraphBuilder.timeLetterNavGraph( content = letter.content, createDate = letter.createDate, mediaUrls = letter.mediaUrls, - audioUrls = letter.audioUrls + audioUrls = letter.audioUrls, + linkUrls = letter.linkUrls ) ) }, @@ -177,6 +178,7 @@ fun NavGraphBuilder.timeLetterNavGraph( createdAt = route.createDate, mediaUrls = route.mediaUrls, audioUrls = route.audioUrls, + linkUrls = route.linkUrls, onBackClick = { navController.popBackStack() } ) ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt index efd66510..6a54d609 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/navgraph/TimeLetterRoute.kt @@ -25,6 +25,7 @@ sealed interface TimeLetterRoute { val content: String, val createDate: String, val mediaUrls: List = emptyList(), - val audioUrls: List = emptyList() + val audioUrls: List = emptyList(), + val linkUrls: List = emptyList() ) : TimeLetterRoute } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt index 2e4b2e79..4ef003d8 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterDetailScreen.kt @@ -62,13 +62,14 @@ data class TimeLetterDetailParams( val createdAt: String = "", val mediaUrls: List = emptyList(), val audioUrls: List = emptyList(), + val linkUrls: List = emptyList(), val onBackClick: () -> Unit = {} ) /** * 타임레터 상세 화면. * 리스트에서 선택한 타임레터의 수신인, 제목, 작성/발송 일자, 본문을 카드 형태로 표시한다. - * mediaList(IMAGE)가 있으면 본문 아래에 이미지를, AUDIO가 있으면 음성 첨부를 작성 화면과 동일한 스타일로 표시한다. + * mediaList(IMAGE)가 있으면 본문 아래에 이미지를, AUDIO면 음성, DOCUMENT면 링크를 작성 화면과 동일한 스타일로 표시한다. * * @param modifier 모디파이어 * @param params 화면 파라미터 (receiverName, sendDate, title, content, createdAt, mediaUrls, audioUrls, onBackClick) @@ -183,6 +184,11 @@ fun TimeLetterDetailScreen( DetailAudioSection(audioUrls = params.audioUrls) } + if (params.linkUrls.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailLinkSection(linkUrls = params.linkUrls) + } + Spacer(modifier = Modifier.height(40.dp)) } } @@ -234,6 +240,48 @@ private fun DetailAudioSection( } } +/** + * 첨부 링크 목록을 타임레터 작성 화면과 동일한 스타일(아이콘 + URL)로 표시한다. + * 상세 화면은 읽기 전용이므로 삭제 버튼은 없다. + */ +@Composable +private fun DetailLinkSection( + linkUrls: List, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.padding(horizontal = 20.dp)) { + linkUrls.forEach { url -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .background( + Color(0xFFF5F5F5), + RoundedCornerShape(8.dp) + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_plus_link), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = url, + fontSize = 14.sp, + color = Color(0xFF333333), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + maxLines = 1 + ) + } + } + } +} + @Composable private fun DetailMediaRow( mediaUrls: List, @@ -280,7 +328,7 @@ private fun PreviewTimeLetterDetail() { params = TimeLetterDetailParams( receiverName = "박채연", sendDate = "2026. 11. 24. 10:00", - title = "채연아 20번째 생일을 축하해", + title = "채연아 202번째 생일을 축하해", content = "너가 태어난 게 엊그제 같은데 벌써 스무살이라니,, 엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶구나.\n\n요즘 학교는 어떻게 다니고 있니? 공부는 잘하고 있지? 친구는 많이 사귀었니? 채연이가 가고싶어했던 그 학교, 학과에 갔을지 엄마는 너무 궁금해하면서 이 편지를 쓰고 있어. 엄마랑 다시 만나면 그동안 어떻게 지냈는지 하나하나 다 알려줘야 해. 사랑한다 생일 축하해 내 딸.", createdAt = "2023. 11. 21. 18:24" ) @@ -297,7 +345,7 @@ private fun PreviewTimeLetterDetailWithMedia() { receiverName = "박채연", sendDate = "2026. 11. 24. 20:00", title = "채연아 21번째 생일을 축하해", - content = "사랑한다 생일 축하해 내 딸.", + content = "사랑한다 생일 축하해 내 딸2.", createdAt = "2023. 11. 21. 18:44", mediaUrls = listOf( "https://example.com/sample1.jpg", @@ -316,8 +364,8 @@ private fun PreviewTimeLetterDetailWithAudio() { params = TimeLetterDetailParams( receiverName = "박채연", sendDate = "2026. 11. 24. 00:10", - title = "채연아 20번째 생일을 축하해", - content = "사랑한다 생일 축하해 내 딸.", + title = "채연아 204번째 생일을 축하해", + content = "사랑한다 생일 축하해 내 딸3.", createdAt = "2023. 11. 21. 18:34", audioUrls = listOf( "https://example.com/voice1.m4a", @@ -327,3 +375,23 @@ private fun PreviewTimeLetterDetailWithAudio() { ) } } + +@Preview(showBackground = true) +@Composable +private fun PreviewTimeLetterDetailWithLinks() { + MaterialTheme { + TimeLetterDetailScreen( + params = TimeLetterDetailParams( + receiverName = "박채연", + sendDate = "2026. 11. 24. 00:10", + title = "채연아 20번째 생일을 축하해", + content = "사랑한다 생일 축하해 내 딸1.", + createdAt = "2023. 11. 21. 18:34", + linkUrls = listOf( + "https://example.com/page1", + "https://example.com/page2" + ) + ) + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt index bbf9c1c9..eada6f06 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/uimodel/TimeLetterUiState.kt @@ -24,5 +24,6 @@ data class TimeLetterItem( val createDate: String, val theme: LetterTheme = LetterTheme.BLUE, val mediaUrls: List = emptyList(), - val audioUrls: List = emptyList() + val audioUrls: List = emptyList(), + val linkUrls: List = emptyList() ) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt index f58136dd..f109b13a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/viewmodel/TimeLetterViewModel.kt @@ -143,6 +143,9 @@ class TimeLetterViewModel val audioUrls = t.mediaList .filter { it.mediaType == TimeLetterMediaType.AUDIO } .map { it.mediaUrl } + val linkUrls = t.mediaList + .filter { it.mediaType == TimeLetterMediaType.DOCUMENT } + .map { it.mediaUrl } return TimeLetterItem( id = t.id.toString(), receivername = resolveReceiverDisplayText(t.receiverIds, receivers), @@ -153,7 +156,8 @@ class TimeLetterViewModel theme = themes[index % themes.size], createDate = formatSendAtForDisplay(t.createdAt), mediaUrls = imageUrls, - audioUrls = audioUrls + audioUrls = audioUrls, + linkUrls = linkUrls ) }