From a3de12055c5a64c199c18eb18a05e0d16c198739 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 18 Feb 2026 18:19:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(timeletter):=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=20post=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/di/TimeLetterRepositoryModule.kt | 11 +- .../TimeLetterMediaUploadRepositoryImpl.kt | 83 ++++++++++ .../TimeLetterMediaUploadRepository.kt | 17 ++ .../usecase/UploadTimeLetterImageUseCase.kt | 19 +++ .../navgraph/TimeLetterNavGraph.kt | 5 +- .../screen/TimeLetterWriterScreen.kt | 17 +- .../uimodel/TimeLetterWriterUiState.kt | 4 + .../viewmodel/TimeLetterWriterViewModel.kt | 147 +++++++++++++----- .../UploadTimeLetterImageUseCaseTest.kt | 49 ++++++ 9 files changed, 303 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt create mode 100644 app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt create mode 100644 app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCase.kt create mode 100644 app/src/test/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCaseTest.kt diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/di/TimeLetterRepositoryModule.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/di/TimeLetterRepositoryModule.kt index 357298f0..fd9f0765 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/di/TimeLetterRepositoryModule.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/di/TimeLetterRepositoryModule.kt @@ -1,7 +1,9 @@ package com.kuit.afternote.feature.timeletter.data.di import com.kuit.afternote.feature.timeletter.data.api.TimeLetterApiService +import com.kuit.afternote.feature.timeletter.data.repository.TimeLetterMediaUploadRepositoryImpl import com.kuit.afternote.feature.timeletter.data.repository.TimeLetterRepositoryImpl +import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterRepository import dagger.Binds import dagger.Module @@ -18,9 +20,16 @@ abstract class TimeLetterRepositoryModule { @Singleton abstract fun bindTimeLetterRepository(impl: TimeLetterRepositoryImpl): TimeLetterRepository + @Binds + @Singleton + abstract fun bindTimeLetterMediaUploadRepository( + impl: TimeLetterMediaUploadRepositoryImpl + ): TimeLetterMediaUploadRepository + companion object { @Provides @Singleton - fun provideTimeLetterApiService(retrofit: Retrofit): TimeLetterApiService = retrofit.create(TimeLetterApiService::class.java) + fun provideTimeLetterApiService(retrofit: Retrofit): TimeLetterApiService = + retrofit.create(TimeLetterApiService::class.java) } } 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 new file mode 100644 index 00000000..bfae732d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterMediaUploadRepositoryImpl.kt @@ -0,0 +1,83 @@ +package com.kuit.afternote.feature.timeletter.data.repository + +import android.content.Context +import android.net.Uri +import com.kuit.afternote.data.remote.requireData +import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository +import com.kuit.afternote.feature.user.data.api.ImageApiService +import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Named + +/** + * Presigned URL을 받아 타임레터 첨부 이미지를 S3에 업로드하고 fileUrl을 반환합니다. + * + * directory는 백엔드와 합의한 값 사용. 미지원 시 afternotes 등 기존 디렉터리로 변경 가능. + */ +private const val DIRECTORY_TIME_LETTERS = "timeletters" +private const val DEFAULT_EXTENSION = "jpg" + +class TimeLetterMediaUploadRepositoryImpl + @Inject + constructor( + @ApplicationContext private val context: Context, + private val imageApi: ImageApiService, + @Named("S3Upload") private val okHttpClient: OkHttpClient, + @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher + ) : TimeLetterMediaUploadRepository { + + override suspend fun uploadImage(uriString: String): Result = + runCatching { + val uri = Uri.parse(uriString) + val extension = extensionFromUri(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 image from URI") + } + + val contentType = presigned.contentType.ifBlank { "image/jpeg" } + 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 + 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 + } + } +} 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 new file mode 100644 index 00000000..906aebf4 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/repository/TimeLetterMediaUploadRepository.kt @@ -0,0 +1,17 @@ +package com.kuit.afternote.feature.timeletter.domain.repository + +/** + * 타임레터 첨부 이미지 업로드 (POST /files/presigned-url 후 S3 PUT). + * + * content URI 문자열을 받아 업로드 후 사용할 fileUrl을 반환합니다. + */ +fun interface TimeLetterMediaUploadRepository { + + /** + * [uriString]에 해당하는 이미지를 S3에 업로드하고, 타임레터 mediaList에 넣을 fileUrl을 반환합니다. + * + * @param uriString content URI 문자열 (예: content://media/...) + * @return 성공 시 업로드된 이미지 URL, 실패 시 Result.failure + */ + suspend fun uploadImage(uriString: String): Result +} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCase.kt new file mode 100644 index 00000000..5e28da44 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCase.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 UploadTimeLetterImageUseCase + @Inject + constructor( + private val repository: TimeLetterMediaUploadRepository + ) { + suspend operator fun invoke(uriString: String): Result = + repository.uploadImage(uriString) + } 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 4f999f20..74d96d76 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 @@ -118,7 +118,10 @@ fun NavGraphBuilder.timeLetterNavGraph( showRegisteredPopUp = uiState.showRegisteredPopUp, showDraftSavePopUp = uiState.showDraftSavePopUp, showWaitingAgainPopUp = uiState.showWaitingAgainPopUp, - onBackCLick = {navController.popBackStack()} + onBackCLick = { navController.popBackStack() }, + selectedImageUriStrings = uiState.selectedImageUriStrings, + onAddImages = viewModel::addImageUris, + onRemoveImage = viewModel::removeImageUri ) } 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 2081b413..8da23c7d 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 @@ -123,7 +123,10 @@ fun TimeLetterWriterScreen( onReceiverSelected: (TimeLetterReceiver) -> Unit = {}, showRegisteredPopUp: Boolean = false, showDraftSavePopUp: Boolean = false, - showWaitingAgainPopUp: Boolean = false + showWaitingAgainPopUp: Boolean = false, + selectedImageUriStrings: List = emptyList(), + onAddImages: (List) -> Unit = {}, + onRemoveImage: (String) -> Unit = {} ) { val keyboardController = LocalSoftwareKeyboardController.current val density = LocalDensity.current @@ -137,9 +140,8 @@ fun TimeLetterWriterScreen( else -> stringResource(R.string.time_letter_recipients_count, receiverIds.size) } - // UI State + // UI State (이미지는 ViewModel 호이스팅, 나머지 미디어는 로컬 유지) var isMenuOpen by remember { mutableStateOf(false) } - var selectedImages by remember { mutableStateOf>(emptyList()) } var selectedVoices by remember { mutableStateOf>(emptyList()) } var selectedFiles by remember { mutableStateOf>(emptyList()) } var addedLinks by remember { mutableStateOf>(emptyList()) } @@ -148,7 +150,7 @@ fun TimeLetterWriterScreen( // Activity Result Launchers val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 6) - ) { uris -> if (uris.isNotEmpty()) selectedImages = selectedImages + uris } + ) { uris -> if (uris.isNotEmpty()) onAddImages(uris) } val voicePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments() @@ -459,8 +461,11 @@ fun TimeLetterWriterScreen( // [4] 첨부 파일 표시 영역 (본문 하단에 수직 배치) // (1) 이미지 - if (selectedImages.isNotEmpty()) { - SelectedImageGrid(images = selectedImages) { selectedImages = selectedImages - it } + if (selectedImageUriStrings.isNotEmpty()) { + SelectedImageGrid( + images = selectedImageUriStrings.map { Uri.parse(it) }, + onRemoveImage = { uri -> onRemoveImage(uri.toString()) } + ) Spacer(modifier = Modifier.height(16.dp)) } 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 cf751623..5abf4913 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 @@ -10,6 +10,8 @@ import com.kuit.afternote.feature.timeletter.data.dto.TimeLetterReceiver * @property content 편지 내용 * @property sendDate 발송 날짜 (yyyy. MM. dd 형식) * @property sendTime 발송 시간 (HH:mm 형식) + * @property selectedImageUriStrings 첨부할 이미지 URI 문자열 목록 (업로드 대기) + * @property existingMediaUrls draft 로드 시 기존 미디어 URL 목록 (IMAGE 타입만) * @property isLoading 로딩 상태 * @property isSaveEnabled 저장 버튼 활성화 여부 * @property showDatePicker DatePicker 표시 여부 @@ -29,6 +31,8 @@ data class TimeLetterWriterUiState( val content: String = "", val sendDate: String = "", val sendTime: String = "", + val selectedImageUriStrings: List = emptyList(), + val existingMediaUrls: List = emptyList(), val isLoading: Boolean = false, val isSaveEnabled: Boolean = false, val showDatePicker: Boolean = false, 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 d3d3129e..acd83caa 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 @@ -1,16 +1,19 @@ package com.kuit.afternote.feature.timeletter.presentation.viewmodel +import android.net.Uri import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterMediaType import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterStatus -import com.kuit.afternote.feature.timeletter.presentation.mapper.toTimeLetterReceivers import com.kuit.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase 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.UploadTimeLetterImageUseCase +import com.kuit.afternote.feature.timeletter.presentation.mapper.toTimeLetterReceivers import com.kuit.afternote.feature.timeletter.presentation.navgraph.TimeLetterRoute import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterWriterUiState import com.kuit.afternote.feature.user.domain.usecase.GetReceiversUseCase @@ -42,12 +45,14 @@ class TimeLetterWriterViewModel private val updateTimeLetterUseCase: UpdateTimeLetterUseCase, private val getTimeLetterUseCase: GetTimeLetterUseCase, private val getTemporaryTimeLettersUseCase: GetTemporaryTimeLettersUseCase, + private val uploadTimeLetterImageUseCase: UploadTimeLetterImageUseCase, private val getReceiversUseCase: GetReceiversUseCase, private val getUserIdUseCase: GetUserIdUseCase ) : ViewModel() { private companion object { private const val TAG = "TimeLetterWriterVM" private const val WAITING_AGAIN_POPUP_DURATION_MS = 2000L + private const val MAX_IMAGE_COUNT = 6 } private val draftIdFromRoute: Long? = savedStateHandle.toRoute().draftId @@ -73,6 +78,9 @@ class TimeLetterWriterViewModel getTimeLetterUseCase(timeLetterId) .onSuccess { letter -> val (date, time) = parseSendAtToDateAndTime(letter.sendAt) + val existingUrls = letter.mediaList + .filter { it.mediaType == TimeLetterMediaType.IMAGE } + .map { it.mediaUrl } _uiState.update { it.copy( draftId = letter.id, @@ -80,6 +88,8 @@ class TimeLetterWriterViewModel content = letter.content ?: "", sendDate = date, sendTime = time, + existingMediaUrls = existingUrls, + selectedImageUriStrings = emptyList(), isLoading = false ) } @@ -174,6 +184,31 @@ class TimeLetterWriterViewModel validateSaveEnabled() } + /** + * 첨부 이미지 추가. 최대 [MAX_IMAGE_COUNT]장까지 (기존 + 새로 추가 합계). + * + * @param uris 갤러리에서 선택한 이미지 URI 목록 + */ + fun addImageUris(uris: List) { + val newStrings = uris.map { it.toString() } + _uiState.update { state -> + val current = state.selectedImageUriStrings + state.existingMediaUrls + val toAdd = newStrings.take((MAX_IMAGE_COUNT - current.size).coerceAtLeast(0)) + state.copy(selectedImageUriStrings = state.selectedImageUriStrings + toAdd) + } + } + + /** + * 첨부 이미지 1장 제거 + * + * @param uriString 제거할 이미지 URI 문자열 + */ + fun removeImageUri(uriString: String) { + _uiState.update { + it.copy(selectedImageUriStrings = it.selectedImageUriStrings - uriString) + } + } + /** * 발송 날짜 업데이트 * @@ -278,6 +313,24 @@ class TimeLetterWriterViewModel return "$normalizedDate" + "T" + "$sendTime" + ":00" } + /** + * 현재 상태 기준으로 mediaList 구성. 선택 이미지 업로드 후 기존 URL + 새 URL을 (IMAGE, 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) { + uploadTimeLetterImageUseCase(uriString) + .onSuccess { newUrls.add(it) } + .onFailure { return Result.failure(it) } + } + val allUrls = existing + newUrls + return Result.success(allUrls.map { TimeLetterMediaType.IMAGE to it }) + } + /** * 등록 버튼 클릭 시: 등록 완료 팝업을 잠깐 보여준 뒤 실제 타임레터 정식등록을 수행합니다. * @@ -304,26 +357,32 @@ class TimeLetterWriterViewModel val state = _uiState.value val sendAt = buildSendAt(state.sendDate, state.sendTime) hideWaitingAgainPopUp() - val result = if (state.draftId != null) { - updateTimeLetterUseCase( - timeLetterId = state.draftId, - title = state.title.ifBlank { null }, - content = state.content.ifBlank { null }, - sendAt = sendAt, - status = TimeLetterStatus.SCHEDULED, - mediaList = null - ) - } else { - createTimeLetterUseCase( - title = state.title.ifBlank { null }, - content = state.content.ifBlank { null }, - sendAt = sendAt, - status = TimeLetterStatus.SCHEDULED, - mediaList = null, - receiverIds = state.receiverIds, - deliveredAt = sendAt - ) - } + val mediaListResult = buildMediaList(state) + val result = mediaListResult.fold( + onSuccess = { mediaList -> + if (state.draftId != null) { + updateTimeLetterUseCase( + timeLetterId = state.draftId, + title = state.title.ifBlank { null }, + content = state.content.ifBlank { null }, + sendAt = sendAt, + status = TimeLetterStatus.SCHEDULED, + mediaList = mediaList + ) + } else { + createTimeLetterUseCase( + title = state.title.ifBlank { null }, + content = state.content.ifBlank { null }, + sendAt = sendAt, + status = TimeLetterStatus.SCHEDULED, + mediaList = mediaList, + receiverIds = state.receiverIds, + deliveredAt = sendAt + ) + } + }, + onFailure = { Result.failure(it) } + ) _uiState.update { it.copy(isLoading = false) } result.onSuccess { _ -> Log.d(TAG, "saveTimeLetter onSuccess") @@ -357,26 +416,32 @@ class TimeLetterWriterViewModel _uiState.update { it.copy(isLoading = true) } val state = _uiState.value val sendAt = buildSendAt(state.sendDate, state.sendTime) - val result = if (state.draftId != null) { - updateTimeLetterUseCase( - timeLetterId = state.draftId, - title = state.title.ifBlank { null }, - content = state.content.ifBlank { null }, - sendAt = sendAt, - status = TimeLetterStatus.DRAFT, - mediaList = null - ) - } else { - createTimeLetterUseCase( - title = state.title.ifBlank { null }, - content = state.content.ifBlank { null }, - sendAt = sendAt, - status = TimeLetterStatus.DRAFT, - mediaList = null, - receiverIds = state.receiverIds, - deliveredAt = sendAt - ) - } + val mediaListResult = buildMediaList(state) + val result = mediaListResult.fold( + onSuccess = { mediaList -> + if (state.draftId != null) { + updateTimeLetterUseCase( + timeLetterId = state.draftId, + title = state.title.ifBlank { null }, + content = state.content.ifBlank { null }, + sendAt = sendAt, + status = TimeLetterStatus.DRAFT, + mediaList = mediaList + ) + } else { + createTimeLetterUseCase( + title = state.title.ifBlank { null }, + content = state.content.ifBlank { null }, + sendAt = sendAt, + status = TimeLetterStatus.DRAFT, + mediaList = mediaList, + receiverIds = state.receiverIds, + deliveredAt = sendAt + ) + } + }, + onFailure = { Result.failure(it) } + ) _uiState.update { it.copy(isLoading = false) } result.onSuccess { _ -> refreshDraftCount() diff --git a/app/src/test/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCaseTest.kt b/app/src/test/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCaseTest.kt new file mode 100644 index 00000000..90ff4c7d --- /dev/null +++ b/app/src/test/java/com/kuit/afternote/feature/timeletter/domain/usecase/UploadTimeLetterImageUseCaseTest.kt @@ -0,0 +1,49 @@ +package com.kuit.afternote.feature.timeletter.domain.usecase + +import com.kuit.afternote.feature.timeletter.domain.repository.TimeLetterMediaUploadRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [UploadTimeLetterImageUseCase] 단위 테스트. + */ +class UploadTimeLetterImageUseCaseTest { + + private lateinit var repository: TimeLetterMediaUploadRepository + private lateinit var useCase: UploadTimeLetterImageUseCase + + @Before + fun setUp() { + repository = mockk() + useCase = UploadTimeLetterImageUseCase(repository) + } + + @Test + fun invoke_whenSuccess_returnsFileUrl() = runTest { + val uriString = "content://media/image/123" + val fileUrl = "https://afternote-bucket.s3.ap-northeast-2.amazonaws.com/time-letters/uuid.jpg" + coEvery { repository.uploadImage(uriString) } returns Result.success(fileUrl) + + val result = useCase(uriString) + + assertTrue(result.isSuccess) + assertEquals(fileUrl, result.getOrNull()) + coVerify(exactly = 1) { repository.uploadImage(uriString) } + } + + @Test + fun invoke_whenFailure_returnsFailure() = runTest { + coEvery { repository.uploadImage(any()) } returns Result.failure(RuntimeException("S3 upload failed")) + + val result = useCase("content://uri") + + assertTrue(result.isFailure) + assertEquals("S3 upload failed", result.exceptionOrNull()?.message) + } +} From 738bac1f6f8757ec6b7155c1ebc07c9b0ea4a388 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 18 Feb 2026 18:31:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(timeletter):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=A0=88=ED=84=B0=20=EC=A1=B0=ED=9A=8Cdto=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=90=9C=20=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../afternote/feature/timeletter/data/dto/TimeLetterDto.kt | 3 ++- .../feature/timeletter/data/mapper/TimeLetterMapper.kt | 1 + .../timeletter/data/repository/TimeLetterRepositoryImpl.kt | 2 ++ .../feature/timeletter/domain/model/TimeLetterModel.kt | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt index 3dad22eb..46ba354a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/dto/TimeLetterDto.kt @@ -18,7 +18,8 @@ enum class TimeLetterStatus { enum class TimeLetterMediaType { IMAGE, VIDEO, - AUDIO + AUDIO, + DOCUMENT } @Serializable diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt index 4dcff412..6e0c8a89 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt @@ -27,6 +27,7 @@ object TimeLetterMapper { DtoMediaType.IMAGE -> DomainMediaType.IMAGE DtoMediaType.VIDEO -> DomainMediaType.VIDEO DtoMediaType.AUDIO -> DomainMediaType.AUDIO + DtoMediaType.DOCUMENT -> DomainMediaType.DOCUMENT } private fun toDomainMedia(dto: TimeLetterMediaResponse): TimeLetterMedia = diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt index e896ae38..6d36d638 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/data/repository/TimeLetterRepositoryImpl.kt @@ -54,6 +54,7 @@ class TimeLetterRepositoryImpl DomainMediaType.IMAGE -> DtoMediaType.IMAGE DomainMediaType.VIDEO -> DtoMediaType.VIDEO DomainMediaType.AUDIO -> DtoMediaType.AUDIO + DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT } TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) } @@ -102,6 +103,7 @@ class TimeLetterRepositoryImpl DomainMediaType.IMAGE -> DtoMediaType.IMAGE DomainMediaType.VIDEO -> DtoMediaType.VIDEO DomainMediaType.AUDIO -> DtoMediaType.AUDIO + DomainMediaType.DOCUMENT -> DtoMediaType.DOCUMENT } TimeLetterMediaRequest(mediaType = dtoType, mediaUrl = url) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/model/TimeLetterModel.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/model/TimeLetterModel.kt index 0a0281f9..d320cdd6 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/model/TimeLetterModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/domain/model/TimeLetterModel.kt @@ -13,7 +13,8 @@ enum class TimeLetterStatus { enum class TimeLetterMediaType { IMAGE, VIDEO, - AUDIO + AUDIO, + DOCUMENT } data class TimeLetterMedia( From d4b0d1427e4f860ea8ec0653f6b5e5c9d204d2ad Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 18 Feb 2026 19:10:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(timeletter):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=A0=88=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=EB=9E=91=20=EC=A1=B0=ED=9A=8C=20=EB=8B=A4=20?= =?UTF-8?q?=EB=90=A8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/component/list/TimeLetterBlockList.kt | 7 +- .../receiver/ReceiverTimeLetterListScreen.kt | 7 +- .../component/TImeLetterListItem.kt | 110 +++++++++----- .../component/TimeLetterBlockItem.kt | 139 ++++++++++++------ .../navgraph/TimeLetterNavGraph.kt | 4 +- .../presentation/navgraph/TimeLetterRoute.kt | 3 +- .../screen/TimeLetterDetailScreen.kt | 87 ++++++++++- .../presentation/screen/TimeLetterScreen.kt | 22 +-- .../presentation/uimodel/TimeLetterUiState.kt | 3 +- .../viewmodel/TimeLetterViewModel.kt | 7 +- 10 files changed, 280 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/core/ui/component/list/TimeLetterBlockList.kt b/app/src/main/java/com/kuit/afternote/core/ui/component/list/TimeLetterBlockList.kt index 61d6244a..78b90e22 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/component/list/TimeLetterBlockList.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/component/list/TimeLetterBlockList.kt @@ -35,12 +35,7 @@ fun TimeLetterBlockList( key = { it.id } ) { letter -> TimeLetterBlockItem( - receiverName = letter.receivername, - sendDate = letter.sendDate, - title = letter.title, - content = letter.content, - imageResId = letter.imageResId, - theme = letter.theme, + timeLetter = letter, onClick = { onItemClick(letter) } ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt index 044163ef..3bfa8fb7 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt @@ -76,12 +76,7 @@ fun ReceiverTimeLetterListScreen( key = { item -> item.id } ) { item -> TimeLetterBlockItem( - receiverName = item.receivername, - sendDate = item.sendDate, - title = item.title, - content = item.content, - imageResId = item.imageResId, - theme = item.theme, + timeLetter = item, onClick = { onItemClick(item) } ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TImeLetterListItem.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TImeLetterListItem.kt index c2eb465d..2b9b530d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TImeLetterListItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TImeLetterListItem.kt @@ -1,5 +1,6 @@ package com.kuit.afternote.feature.timeletter.presentation.component +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -20,6 +21,7 @@ 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 import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -28,28 +30,37 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.kuit.afternote.R +import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem /** * 타임레터 리스트형 아이템 */ +data class TimeLetterListItemCallbacks( + val onClick: () -> Unit = {}, + val onEditClick: () -> Unit = {}, + val onDeleteClick: () -> Unit = {} +) + +private const val TAG = "TimeLetterListItem" + @Composable fun TimeLetterListItem( - receiverName: String, - sendDate: String, - title: String, - content: String, - imageResId: Int?, // 이미지 (null이면 표시 안 함) modifier: Modifier = Modifier, - onEditClick: () -> Unit = {}, - onDeleteClick: () -> Unit = {}, - onClick: () -> Unit = {} + timeLetter: TimeLetterItem, + callbacks: TimeLetterListItemCallbacks = TimeLetterListItemCallbacks() ) { + val imageUrl = timeLetter.mediaUrls.firstOrNull() Column( modifier = modifier .fillMaxWidth() - .clickable(onClick = onClick) - .padding( vertical = 12.dp) + .clickable(onClick = callbacks.onClick) + .padding(vertical = 12.dp) ) { // 상단: 수신인 & 발송 예정일 Row( @@ -57,7 +68,7 @@ fun TimeLetterListItem( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "수신인: $receiverName", + text = "수신인: ${timeLetter.receivername}", color = Color(0xFF757575), fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -65,7 +76,7 @@ fun TimeLetterListItem( fontFamily = FontFamily(Font(R.font.sansneomedium)) ) Text( - text = "발송 예정일: $sendDate", + text = "발송 예정일: ${timeLetter.sendDate}", color = Color(0xFF757575), fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -75,7 +86,7 @@ fun TimeLetterListItem( } // 이미지가 있으면 표시 - if (imageResId != null) { + if (!imageUrl.isNullOrBlank() || timeLetter.imageResId != null) { Spacer(modifier = Modifier.height(12.dp)) Box( modifier = Modifier @@ -85,18 +96,43 @@ fun TimeLetterListItem( .width(350.dp) .clip(RoundedCornerShape(16.dp)) ) { - Image( - painter = painterResource(id = imageResId), - contentDescription = "편지 이미지", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + when { + !imageUrl.isNullOrBlank() -> { + val context = LocalContext.current + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageUrl) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = "편지 이미지", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + error = painterResource(R.drawable.img_time_letter_placeholder), + onError = { state: AsyncImagePainter.State.Error -> + Log.e(TAG, "Coil load failed: url=$imageUrl", state.result.throwable) + } + ) + } + + timeLetter.imageResId != null -> { + Image( + painter = painterResource(id = timeLetter.imageResId), + contentDescription = "편지 이미지", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } } } Spacer(modifier = Modifier.height(16.dp)) // 제목 Text( - text = title, + text = timeLetter.title, color = Color(0xFF212121), fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.sansneomedium)), @@ -107,7 +143,7 @@ fun TimeLetterListItem( // 내용 미리보기 Text( - text = content, + text = timeLetter.content, color = Color(0xFF757575), fontSize = 12.sp, fontWeight = FontWeight.Normal, @@ -129,7 +165,7 @@ fun TimeLetterListItem( contentDescription = "수정", modifier = Modifier .size(24.dp) - .clickable(onClick = onEditClick) + .clickable(onClick = callbacks.onEditClick) ) Spacer(modifier = Modifier.width(12.dp)) Image( @@ -137,7 +173,7 @@ fun TimeLetterListItem( contentDescription = "삭제", modifier = Modifier .size(24.dp) - .clickable(onClick = onDeleteClick) + .clickable(onClick = callbacks.onDeleteClick) ) } } @@ -147,11 +183,15 @@ fun TimeLetterListItem( @Composable private fun TimeLetterListItemWithImagePreview() { TimeLetterListItem( - receiverName = "박채연", - sendDate = "2027. 11. 24", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", - imageResId = R.drawable.ic_test_block + timeLetter = TimeLetterItem( + id = "preview", + receivername = "박채연", + sendDate = "2027. 11. 24", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", + imageResId = R.drawable.ic_test_block, + createDate = "2026. 11. 24" + ) ) } @@ -159,10 +199,14 @@ private fun TimeLetterListItemWithImagePreview() { @Composable private fun TimeLetterListItemWithoutImagePreview() { TimeLetterListItem( - receiverName = "김지은", - sendDate = "2029. 11. 20", - title = "지은아 결혼을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 결혼이라니..엄마가 없어도 씩씩하게 컸을 지은이를 상상하면 너무 기특해서 안아주고 싶구나 은데 벌써 스...", - imageResId = null + timeLetter = TimeLetterItem( + id = "preview", + receivername = "김지은", + sendDate = "2029. 11. 20", + title = "지은아 결혼을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 결혼이라니..엄마가 없어도 씩씩하게 컸을 지은이를 상상하면 너무 기특해서 안아주고 싶구나 은데 벌써 스...", + imageResId = null, + createDate = "2026. 11. 24" + ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt index e8d57d7c..e9d9f1b2 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/component/TimeLetterBlockItem.kt @@ -1,5 +1,6 @@ package com.kuit.afternote.feature.timeletter.presentation.component +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,6 +22,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -29,7 +31,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.kuit.afternote.R +import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem + +private const val TAG = "TimeLetterBlockItem" /** * 이미지 없을 때 배경 테마 (배경색 + 로고 세트) @@ -55,21 +65,17 @@ enum class LetterTheme( @Composable fun TimeLetterBlockItem( - receiverName: String, - sendDate: String, - title: String, - content: String, - // 배경 이미지 (null이면 테마 사용) - imageResId: Int?, - // 이미지 없을 때 테마 - theme: LetterTheme = LetterTheme.BLUE, modifier: Modifier = Modifier, + timeLetter: TimeLetterItem, onClick: () -> Unit = {} ) { + val imageUrl = timeLetter.mediaUrls.firstOrNull() + val hasImage = !imageUrl.isNullOrBlank() || timeLetter.imageResId != null + // 텍스트 색상 val textColor = Color(0xFF757575) // 항상 gray-6 val titleColor = Color.White // 항상 #FFF - val contentColor = if (imageResId != null) Color(0xFFE0E0E0) else Color(0xFF757575) // gray-3 + val contentColor = if (hasImage) Color(0xFFE0E0E0) else Color(0xFF757575) // gray-3 Box( modifier = modifier @@ -80,14 +86,40 @@ fun TimeLetterBlockItem( .clickable(onClick = onClick) ) { // 배경 처리 - if (imageResId != null) { + if (hasImage) { // 이미지가 있을 때: 이미지 + 그라데이션 - Image( - painter = painterResource(id = imageResId), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + when { + !imageUrl.isNullOrBlank() -> { + val context = LocalContext.current + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageUrl) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + error = painterResource(R.drawable.img_time_letter_placeholder), + onError = { state: AsyncImagePainter.State.Error -> + Log.e(TAG, "Coil load failed: url=$imageUrl", state.result.throwable) + } + ) + } + + timeLetter.imageResId != null -> { + Image( + painter = painterResource(id = timeLetter.imageResId), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + // 그라데이션 오버레이 (위: 투명, 아래: 40% 검정) Box( modifier = Modifier @@ -107,7 +139,7 @@ fun TimeLetterBlockItem( Box( modifier = Modifier .fillMaxSize() - .background(theme.backgroundColor.copy(alpha = 0.2f)) + .background(timeLetter.theme.backgroundColor.copy(alpha = 0.2f)) ) // 2. 그라데이션 오버레이 (50% 투명도) Box( @@ -130,7 +162,7 @@ fun TimeLetterBlockItem( contentAlignment = Alignment.Center ) { Image( - painter = painterResource(id = theme.logoResId), + painter = painterResource(id = timeLetter.theme.logoResId), contentDescription = "로고" ) } @@ -148,7 +180,7 @@ fun TimeLetterBlockItem( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "수신인: $receiverName", + text = "수신인: ${timeLetter.receivername}", color = textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -156,7 +188,7 @@ fun TimeLetterBlockItem( fontFamily = FontFamily(Font(R.font.sansneomedium)) ) Text( - text = "발송 예정일: $sendDate", + text = "발송 예정일: ${timeLetter.sendDate}", color = textColor, fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -169,7 +201,7 @@ fun TimeLetterBlockItem( // 하단: 제목 (BodyBase) Text( - text = title, + text = timeLetter.title, color = titleColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, // 500 @@ -181,7 +213,7 @@ fun TimeLetterBlockItem( // 하단: 내용 미리보기 (CaptionLarge-R) Text( - text = content, + text = timeLetter.content, color = contentColor, fontSize = 12.sp, fontWeight = FontWeight.Normal, // 400 @@ -201,11 +233,16 @@ fun TimeLetterBlockItem( @Composable private fun LetterBlockWithImagePreview() { TimeLetterBlockItem( - receiverName = "박채연", - sendDate = "2027. 11. 24", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", - imageResId = R.drawable.ic_test_block, // 이미지 있음 → 그라데이션 적용 + timeLetter = TimeLetterItem( + id = "preview", + receivername = "박채연", + sendDate = "2027. 11. 24", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", + imageResId = R.drawable.ic_test_block, + theme = LetterTheme.PEACH, + createDate = "2026.11.24" + ) ) } @@ -216,12 +253,16 @@ private fun LetterBlockWithImagePreview() { @Composable private fun LetterBlockPeachThemePreview() { TimeLetterBlockItem( - receiverName = "박채연", - sendDate = "2027. 11. 24", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", - imageResId = null, - theme = LetterTheme.PEACH // Y1 로고 + 살구색 + timeLetter = TimeLetterItem( + id = "preview", + receivername = "박채연", + sendDate = "2027. 11. 24", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", + imageResId = null, + theme = LetterTheme.PEACH, + createDate = "2026.11.24" + ) ) } @@ -232,12 +273,16 @@ private fun LetterBlockPeachThemePreview() { @Composable private fun LetterBlockBlueThemePreview() { TimeLetterBlockItem( - receiverName = "박채연", - sendDate = "2027. 11. 24", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", - imageResId = null, - theme = LetterTheme.BLUE // White 로고 + 연파랑 + timeLetter = TimeLetterItem( + id = "preview", + receivername = "박채연", + sendDate = "2027. 11. 24", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", + imageResId = null, + theme = LetterTheme.BLUE, + createDate = "2026.11.24" + ) ) } @@ -248,11 +293,15 @@ private fun LetterBlockBlueThemePreview() { @Composable private fun LetterBlockYellowThemePreview() { TimeLetterBlockItem( - receiverName = "박채연", - sendDate = "2027. 11. 24", - title = "채연아 20번째 생일을 축하해", - content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", - imageResId = null, - theme = LetterTheme.YELLOW // Orange 로고 + 노란색 + timeLetter = TimeLetterItem( + id = "preview", + receivername = "박채연", + sendDate = "2027. 11. 24", + title = "채연아 20번째 생일을 축하해", + content = "너가 태어난 게 엊그제같은데 벌써 스무살이라니..엄마가 없어도 씩씩하게 컸을 채연이를 상상하면 너무 기특해서 안아주고 싶...", + imageResId = null, + theme = LetterTheme.YELLOW, + createDate = "2026.11.24" + ) ) } 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 74d96d76..1cbe7af9 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 @@ -43,7 +43,8 @@ fun NavGraphBuilder.timeLetterNavGraph( sendDate = letter.sendDate, title = letter.title, content = letter.content, - createDate = letter.createDate + createDate = letter.createDate, + mediaUrls = letter.mediaUrls ) ) }, @@ -161,6 +162,7 @@ fun NavGraphBuilder.timeLetterNavGraph( title = route.title, content = route.content, createdAt = route.createDate, + mediaUrls = route.mediaUrls, 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 90ff9e20..55a00283 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 @@ -26,6 +26,7 @@ sealed interface TimeLetterRoute { val sendDate: String, val title: String, val content: String, - val createDate: String + val createDate: String, + val mediaUrls: 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 da34928d..ac4f9892 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,6 +1,8 @@ package com.kuit.afternote.feature.timeletter.presentation.screen +import android.util.Log import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,8 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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 import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -18,28 +24,42 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.navigation.TopBar -import com.kuit.afternote.ui.theme.Gray2 import com.kuit.afternote.ui.theme.Gray4 import com.kuit.afternote.ui.theme.Gray5 import com.kuit.afternote.ui.theme.Gray9 +private const val TAG = "TimeLetterDetailScreen" +private const val DETAIL_IMAGE_SIZE_DP = 100 + /** * 타임레터 상세 화면. * 리스트에서 선택한 타임레터의 수신인, 제목, 작성/발송 일자, 본문을 카드 형태로 표시한다. + * mediaList(IMAGE)가 있으면 본문 아래에 이미지를 표시한다. * * @param receiverName 수신인 이름 * @param sendDate 발송 일자 (표시용 문자열) * @param title 편지 제목 * @param content 편지 본문 * @param createdAt 작성 일자 (표시용, 없으면 "-") + * @param mediaUrls 미디어 이미지 URL 목록 (IMAGE 타입만, 없으면 빈 목록) * @param onBackClick 뒤로가기 콜백 */ @OptIn(ExperimentalMaterial3Api::class) @@ -50,6 +70,7 @@ fun TimeLetterDetailScreen( title: String, content: String, createdAt: String = "", + mediaUrls: List = emptyList(), onBackClick: () -> Unit = {} ) { val scrollState = rememberScrollState() @@ -137,6 +158,14 @@ fun TimeLetterDetailScreen( lineHeight = 22.sp ) + if (mediaUrls.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailMediaRow( + mediaUrls = mediaUrls, + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(40.dp)) } } @@ -146,6 +175,44 @@ fun TimeLetterDetailScreen( } } +@Composable +private fun DetailMediaRow( + mediaUrls: List, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val imageSizeDp = DETAIL_IMAGE_SIZE_DP.dp + val horizontalScrollState = rememberScrollState() + + Row( + modifier = modifier.horizontalScroll(horizontalScrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + mediaUrls.forEach { url -> + AsyncImage( + model = ImageRequest.Builder(context) + .data(url) + .httpHeaders( + NetworkHeaders.Builder() + .set("User-Agent", "Afternote Android App") + .build() + ) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(imageSizeDp) + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.img_time_letter_placeholder), + onError = { state: AsyncImagePainter.State.Error -> + Log.e(TAG, "Coil load failed: url=$url", state.result.throwable) + } + ) + } + } +} + @Preview(showBackground = true) @Composable private fun PreviewTimeLetterDetail() { @@ -159,3 +226,21 @@ private fun PreviewTimeLetterDetail() { ) } } + +@Preview(showBackground = true) +@Composable +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" + ) + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt index 3e31c0c2..44ff04ba 100644 --- a/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/timeletter/presentation/screen/TimeLetterScreen.kt @@ -49,6 +49,7 @@ import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme +import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterListItemCallbacks import com.kuit.afternote.feature.timeletter.presentation.component.TimeLetterListItem import com.kuit.afternote.feature.timeletter.presentation.component.ViewModeToggle import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem @@ -181,14 +182,12 @@ fun TimeLetterScreen( key = { it.id } ) { letter -> TimeLetterListItem( - receiverName = letter.receivername, - sendDate = letter.sendDate, - title = letter.title, - content = letter.content, - imageResId = letter.imageResId, - onClick = { onLetterClick(letter) }, - onEditClick = { onEditLetter(letter) }, - onDeleteClick = { viewModel.deleteTimeLetter(letter.id) } + timeLetter = letter, + callbacks = TimeLetterListItemCallbacks( + onClick = { onLetterClick(letter) }, + onEditClick = { onEditLetter(letter) }, + onDeleteClick = { viewModel.deleteTimeLetter(letter.id) } + ) ) Spacer(modifier = Modifier.height(18.dp)) } @@ -356,12 +355,7 @@ private fun TimeLetterScreenPreviewContent(initialViewMode: ViewMode) { key = { it.id } ) { letter -> TimeLetterListItem( - receiverName = letter.receivername, - sendDate = letter.sendDate, - title = letter.title, - content = letter.content, - imageResId = letter.imageResId, - onDeleteClick = {} + timeLetter = letter ) Spacer(modifier = Modifier.height(18.dp)) } 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 c23154b0..47c87376 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 @@ -20,5 +20,6 @@ data class TimeLetterItem( val content: String, val imageResId: Int? = null, val createDate: String, - val theme: LetterTheme = LetterTheme.BLUE + val theme: LetterTheme = LetterTheme.BLUE, + val mediaUrls: 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 9ef8599e..15155c47 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 @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kuit.afternote.feature.timeletter.domain.model.TimeLetter +import com.kuit.afternote.feature.timeletter.domain.model.TimeLetterMediaType import com.kuit.afternote.feature.timeletter.domain.usecase.DeleteTimeLettersUseCase import com.kuit.afternote.feature.timeletter.domain.usecase.GetTimeLettersUseCase import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme @@ -118,6 +119,9 @@ class TimeLetterViewModel receivers: List ): TimeLetterItem { val themes = listOf(LetterTheme.PEACH, LetterTheme.BLUE, LetterTheme.YELLOW) + val imageUrls = t.mediaList + .filter { it.mediaType == TimeLetterMediaType.IMAGE } + .map { it.mediaUrl } return TimeLetterItem( id = t.id.toString(), receivername = resolveReceiverDisplayText(t.receiverIds, receivers), @@ -126,7 +130,8 @@ class TimeLetterViewModel content = t.content ?: "", imageResId = null, theme = themes[index % themes.size], - createDate = formatSendAtForDisplay(t.createdAt) + createDate = formatSendAtForDisplay(t.createdAt), + mediaUrls = imageUrls ) }