From b99c1c5e82e06ae163cb58144c9a1fd8fa25b87e Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 04:24:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(upload):=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=20=EB=B0=8F=20=EC=98=81=EC=A0=95=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=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 - 기존 `ProfileImageUploadRepository`를 삭제하고, 프로필, 영정 사진 등 다양한 이미지 업로드에 재사용할 수 있는 `PhotoUploadRepository`를 새로 구현했습니다. 이 리포지토리는 Presigned URL을 받아 S3에 이미지를 업로드하는 공통 로직을 담당합니다. - `UploadProfileImageUseCase`는 이제 새로운 `PhotoUploadRepository`를 사용하도록 수정되었습니다. - 애프터노트 '추모 가이드라인'에 영정 사진을 등록하고 수정하는 기능을 추가했습니다. - 사용자가 갤러리에서 영정 사진을 선택하면, 저장 시점에 `PhotoUploadRepository`를 통해 이미지를 업로드하고 반환된 URL을 서버로 전송합니다. - 상세 조회 API 응답에 `memorialPhotoUrl` 필드가 추가되었으며, 수정 시 이 값을 화면에 표시합니다. - 애프터노트 생성 및 수정 요청 DTO(`AfternotePlaylistDto`)에도 `memorialPhotoUrl` 필드를 추가했습니다. - `AfternoteEditViewModel`에서 영정 사진 URI(`content://`)를 실제 URL로 변환하는 로직을 추가하고, 저장 및 수정 관련 파라미터를 데이터 클래스(`SaveAfternoteMemorialMedia`)로 묶어 코드 가독성을 개선했습니다. --- .../data/upload/PhotoUploadRepository.kt | 15 +++ .../upload/PhotoUploadRepositoryImpl.kt} | 15 +-- .../data/di/AfternoteRepositoryModule.kt | 8 ++ .../afternote/data/dto/AfternoteDto.kt | 1 + .../afternote/data/mapper/AfternoteMapper.kt | 1 + .../afternote/domain/model/AfternoteDetail.kt | 1 + .../usecase/UploadMemorialPhotoUseCase.kt | 19 +++ .../navgraph/AfternoteNavgraph.kt | 11 +- .../screen/AfternoteEditScreen.kt | 2 +- .../presentation/screen/AfternoteEditState.kt | 8 +- .../screen/AfternoteEditViewModel.kt | 120 +++++++++++++++--- .../feature/user/data/di/UserModule.kt | 7 - .../ProfileImageUploadRepository.kt | 15 --- .../usecase/UploadProfileImageUseCase.kt | 12 +- 14 files changed, 175 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt rename app/src/main/java/com/kuit/afternote/{feature/user/data/repository/ProfileImageUploadRepositoryImpl.kt => data/upload/PhotoUploadRepositoryImpl.kt} (84%) create mode 100644 app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt delete mode 100644 app/src/main/java/com/kuit/afternote/feature/user/domain/repository/ProfileImageUploadRepository.kt diff --git a/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt b/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt new file mode 100644 index 00000000..c7780265 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepository.kt @@ -0,0 +1,15 @@ +package com.kuit.afternote.data.upload + +/** + * Core photo upload: content URI → presigned URL → S3 PUT → file URL. + * Reusable for profile image, memorial photo, and any other image upload that uses + * POST /files/presigned-url and the same S3 flow. + */ +fun interface PhotoUploadRepository { + /** + * @param uriString content URI of the selected image (e.g. from gallery). + * @param directory target directory for the file (e.g. "profiles", "afternotes"). + * @return Success with image URL (https) or failure. + */ + suspend fun upload(uriString: String, directory: String): Result +} diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/ProfileImageUploadRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt similarity index 84% rename from app/src/main/java/com/kuit/afternote/feature/user/data/repository/ProfileImageUploadRepositoryImpl.kt rename to app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt index 1799611d..0dab0673 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/ProfileImageUploadRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/data/upload/PhotoUploadRepositoryImpl.kt @@ -1,11 +1,10 @@ -package com.kuit.afternote.feature.user.data.repository +package com.kuit.afternote.data.upload import android.content.Context import android.net.Uri import com.kuit.afternote.data.remote.requireData import com.kuit.afternote.feature.user.data.api.ImageApiService import com.kuit.afternote.feature.user.data.dto.PresignedUrlRequestDto -import com.kuit.afternote.feature.user.domain.repository.ProfileImageUploadRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -16,28 +15,28 @@ import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject import javax.inject.Named -private const val DIRECTORY_PROFILES = "profiles" private const val DEFAULT_EXTENSION = "jpg" /** - * Presigned URL을 받아 프로필 이미지를 S3에 업로드하고 fileUrl을 반환합니다. + * Shared implementation: uploads image from content URI to the given directory + * via POST /files/presigned-url and S3 PUT. Used for profile photo, memorial photo, etc. */ -class ProfileImageUploadRepositoryImpl +class PhotoUploadRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, private val imageApi: ImageApiService, @Named("S3Upload") private val okHttpClient: OkHttpClient, @Named("IoDispatcher") private val ioDispatcher: CoroutineDispatcher - ) : ProfileImageUploadRepository { + ) : PhotoUploadRepository { - override suspend fun uploadProfileImage(uriString: String): Result = + override suspend fun upload(uriString: String, directory: String): Result = runCatching { val uri = Uri.parse(uriString) val extension = extensionFromUri(uri) val presigned = imageApi.getPresignedUrl( PresignedUrlRequestDto( - directory = DIRECTORY_PROFILES, + directory = directory, extension = extension ) ).requireData() diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt index 3c46c50e..5d8bffd7 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/di/AfternoteRepositoryModule.kt @@ -1,6 +1,8 @@ package com.kuit.afternote.feature.afternote.data.di import com.kuit.afternote.data.upload.MemorialThumbnailUploadRepositoryImpl +import com.kuit.afternote.data.upload.PhotoUploadRepository +import com.kuit.afternote.data.upload.PhotoUploadRepositoryImpl import com.kuit.afternote.data.upload.MemorialVideoUploadRepositoryImpl import com.kuit.afternote.feature.afternote.data.api.AfternoteApiService import com.kuit.afternote.feature.afternote.data.api.MusicApiService @@ -52,6 +54,12 @@ abstract class AfternoteRepositoryModule { ): MemorialThumbnailUploadRepository @Suppress("unused") + @Binds + @Singleton + abstract fun bindPhotoUploadRepository( + impl: PhotoUploadRepositoryImpl + ): PhotoUploadRepository + @Binds @Singleton abstract fun bindMemorialVideoUploadRepository( diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt index 9fd7a979..6eaf4a1e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/dto/AfternoteDto.kt @@ -120,6 +120,7 @@ data class AfternoteDetailReceiverDto( data class AfternotePlaylistDto( val profilePhoto: String? = null, val atmosphere: String? = null, + val memorialPhotoUrl: String? = null, val songs: List = emptyList(), val memorialVideo: AfternoteMemorialVideoDto? = null ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt index e8ccd0fb..8504f25d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/data/mapper/AfternoteMapper.kt @@ -56,6 +56,7 @@ object AfternoteMapper { AfternotePlaylistDetail( profilePhoto = p.profilePhoto, atmosphere = p.atmosphere, + memorialPhotoUrl = p.memorialPhotoUrl ?: p.profilePhoto, songs = p.songs.map { s -> AfternoteDetailSong( id = s.id, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt index 1af5e698..cc67dcbe 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/model/AfternoteDetail.kt @@ -44,6 +44,7 @@ data class AfternoteDetailReceiver( data class AfternotePlaylistDetail( val profilePhoto: String?, val atmosphere: String?, + val memorialPhotoUrl: String?, val songs: List, val memorialVideoUrl: String?, val memorialThumbnailUrl: String? diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt new file mode 100644 index 00000000..062a266d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/domain/usecase/UploadMemorialPhotoUseCase.kt @@ -0,0 +1,19 @@ +package com.kuit.afternote.feature.afternote.domain.usecase + +import com.kuit.afternote.data.upload.PhotoUploadRepository +import javax.inject.Inject + +private const val DIRECTORY_AFTERNOTES = "afternotes" + +/** + * Uploads memorial (playlist) photo from content URI via presigned URL. + * Returns the image URL to send as playlist.memorialPhotoUrl. + */ +class UploadMemorialPhotoUseCase + @Inject + constructor( + private val photoUploadRepository: PhotoUploadRepository + ) { + suspend operator fun invoke(uriString: String): Result = + photoUploadRepository.upload(uriString, DIRECTORY_AFTERNOTES) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt index 1f7da9a3..75682e54 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt @@ -65,6 +65,7 @@ import com.kuit.afternote.feature.afternote.presentation.screen.FingerprintLogin import com.kuit.afternote.feature.afternote.presentation.screen.MemorialPlaylistRouteScreen import com.kuit.afternote.feature.afternote.presentation.screen.MemorialPlaylistStateHolder import com.kuit.afternote.feature.afternote.presentation.screen.RegisterAfternotePayload +import com.kuit.afternote.feature.afternote.presentation.screen.SaveAfternoteMemorialMedia import com.kuit.afternote.feature.afternote.presentation.screen.rememberAfternoteEditState import com.kuit.afternote.ui.theme.AfternoteTheme @@ -369,7 +370,7 @@ private fun AfternoteMemorialGuidelineDetailContent( detailState = MemorialGuidelineDetailState( userName = userName, finalWriteDate = detail.updatedAt.ifEmpty { detail.createdAt }, - profileImageUri = detail.playlist?.profilePhoto, + profileImageUri = detail.playlist?.memorialPhotoUrl ?: detail.playlist?.profilePhoto, afternoteEditReceivers = detail.receivers.map { r -> AfternoteEditReceiver( id = "", @@ -497,8 +498,12 @@ private fun buildEditScreenCallbacks(params: EditScreenCallbacksParams): Afterno payload = payload, selectedReceiverIds = params.state.afternoteEditReceivers.mapNotNull { it.id.toLongOrNull() }, playlistStateHolder = params.playlistStateHolder, - funeralVideoUrl = params.state.funeralVideoUrl, - funeralThumbnailUrl = params.state.funeralThumbnailUrl + memorialMedia = SaveAfternoteMemorialMedia( + funeralVideoUrl = params.state.funeralVideoUrl, + funeralThumbnailUrl = params.state.funeralThumbnailUrl, + memorialPhotoUrl = params.state.memorialPhotoUrl, + pickedMemorialPhotoUri = params.state.pickedMemorialPhotoUri + ) ) }, onNavigateToAddSong = { params.navController.navigate(AfternoteRoute.MemorialPlaylistRoute) }, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt index cfc44137..36a59b4c 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt @@ -375,7 +375,7 @@ private fun CategoryContent( MemorialGuidelineEditContent( bottomPadding = bottomPadding, params = MemorialGuidelineEditContentParams( - displayMemorialPhotoUri = state.pickedMemorialPhotoUri, + displayMemorialPhotoUri = state.pickedMemorialPhotoUri ?: state.memorialPhotoUrl, playlistSongCount = livePlaylistSongCount, playlistAlbumCovers = albumCoversFromPlaylist, selectedLastWish = state.selectedLastWish, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt index 46713f8f..e114aa62 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditState.kt @@ -147,6 +147,9 @@ class AfternoteEditState( /** Memorial video thumbnail URL when available (e.g. from API on edit or future upload). */ var funeralThumbnailUrl by mutableStateOf(null) private set + /** Memorial(PLAYLIST) only: 영정 사진 URL from API (when editing). Sent as playlist.memorialPhotoUrl on save. */ + var memorialPhotoUrl by mutableStateOf(null) + private set var playlistSongCount by mutableIntStateOf(16) private set @@ -469,6 +472,7 @@ class AfternoteEditState( // Memorial only: 장례식에 남길 영상 URL and thumbnail from API. funeralVideoUrl = params.memorialVideoUrl funeralThumbnailUrl = params.memorialThumbnailUrl + memorialPhotoUrl = params.memorialPhotoUrl } } @@ -481,6 +485,7 @@ class AfternoteEditState( * not matching a default option, edit screen selects "기타(직접 입력)" and shows this text. * @param memorialVideoUrl Memorial(PLAYLIST) only: playlist memorial video URL from API (장례식에 남길 영상). * @param memorialThumbnailUrl Memorial(PLAYLIST) only: playlist memorial thumbnail URL from API. + * @param memorialPhotoUrl Memorial(PLAYLIST) only: playlist 영정 사진 URL from API. */ data class LoadFromExistingParams( val itemId: String, @@ -495,7 +500,8 @@ data class LoadFromExistingParams( val galleryProcessingMethodsList: List, val atmosphere: String? = null, val memorialVideoUrl: String? = null, - val memorialThumbnailUrl: String? = null + val memorialThumbnailUrl: String? = null, + val memorialPhotoUrl: String? = null ) @Composable diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt index cab7504b..7e601b75 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditViewModel.kt @@ -15,6 +15,7 @@ import com.kuit.afternote.feature.afternote.domain.usecase.CreatePlaylistAfterno import com.kuit.afternote.feature.afternote.domain.usecase.CreateSocialAfternoteUseCase import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternoteDetailUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UpdateAfternoteUseCase +import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialPhotoUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialThumbnailUseCase import com.kuit.afternote.feature.afternote.domain.usecase.UploadMemorialVideoUseCase import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem @@ -41,6 +42,9 @@ private const val CATEGORY_MEMORIAL = "추모 가이드라인" /** S3 presigned URLs contain this; we must not send them back on PATCH or the server overwrites the stored key. */ private const val PRESIGNED_URL_MARKER = "X-Amz-" +/** Content URI scheme; used to detect local picks that must be uploaded before save. */ +private const val CONTENT_SCHEME = "content://" + /** * 애프터노트 생성/수정 ViewModel. * @@ -59,7 +63,8 @@ class AfternoteEditViewModel private val getReceiversUseCase: GetReceiversUseCase, private val getUserIdUseCase: GetUserIdUseCase, private val uploadMemorialThumbnailUseCase: UploadMemorialThumbnailUseCase, - private val uploadMemorialVideoUseCase: UploadMemorialVideoUseCase + private val uploadMemorialVideoUseCase: UploadMemorialVideoUseCase, + private val uploadMemorialPhotoUseCase: UploadMemorialPhotoUseCase ) : ViewModel() { private val _saveState = MutableStateFlow(AfternoteSaveState()) @@ -127,8 +132,7 @@ class AfternoteEditViewModel * @param payload 편집 화면에서 수집된 데이터 * @param selectedReceiverIds 수신자 지정에서 선택한 수신자 ID 목록 (최소 1명 필요) * @param playlistStateHolder 추모 가이드라인의 플레이리스트 상태 - * @param funeralVideoUrl 추모 가이드라인 전용: 장례식에 남길 영상 URL. 있으면 요청에 memorialVideo 포함. - * @param funeralThumbnailUrl 추모 가이드라인 전용: 썸네일 URL (API 응답 또는 업로드 API 반환 시). 없으면 null. + * @param memorialMedia 추모 가이드라인 전용: 영상/썸네일/영정 URL 및 선택한 영정 URI */ fun saveAfternote( editingId: Long?, @@ -136,8 +140,7 @@ class AfternoteEditViewModel payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistStateHolder: MemorialPlaylistStateHolder?, - funeralVideoUrl: String? = null, - funeralThumbnailUrl: String? = null + memorialMedia: SaveAfternoteMemorialMedia ) { if (_saveState.value.isSaving) { Log.w(TAG, "saveAfternote: already saving, ignoring duplicate call") @@ -175,13 +178,34 @@ class AfternoteEditViewModel _saveState.update { it.copy(isSaving = true, error = null, validationError = null) } - val resolvedVideoUrl = resolveVideoUrlForSave(funeralVideoUrl) - if (resolvedVideoUrl == null && funeralVideoUrl != null && funeralVideoUrl.startsWith("content://")) { + val resolvedVideoUrl = resolveVideoUrlForSave(memorialMedia.funeralVideoUrl) + if (resolvedVideoUrl == null && memorialMedia.funeralVideoUrl != null && + memorialMedia.funeralVideoUrl.startsWith(CONTENT_SCHEME) + ) { + return@launch + } + val resolvedMemorialPhotoUrl = resolveMemorialPhotoUrlForSave( + memorialPhotoUrl = memorialMedia.memorialPhotoUrl, + pickedMemorialPhotoUri = memorialMedia.pickedMemorialPhotoUri + ) + if (resolvedMemorialPhotoUrl == null && memorialMedia.pickedMemorialPhotoUri != null && + memorialMedia.pickedMemorialPhotoUri.startsWith(CONTENT_SCHEME) + ) { return@launch } val videoUrlForUpdate = videoUrlForUpdateRequest(editingId != null, resolvedVideoUrl) val thumbnailForUpdate = - if (videoUrlForUpdate == null) null else funeralThumbnailUrl + if (videoUrlForUpdate == null) null else memorialMedia.funeralThumbnailUrl + val updateMedia = MemorialMediaUrls( + funeralVideoUrl = videoUrlForUpdate, + funeralThumbnailUrl = thumbnailForUpdate, + memorialPhotoUrl = resolvedMemorialPhotoUrl + ) + val createMedia = MemorialMediaUrls( + funeralVideoUrl = resolvedVideoUrl, + funeralThumbnailUrl = memorialMedia.funeralThumbnailUrl, + memorialPhotoUrl = resolvedMemorialPhotoUrl + ) (if (editingId != null) { performUpdate( afternoteId = editingId, @@ -189,8 +213,7 @@ class AfternoteEditViewModel payload = payload, selectedReceiverIds = selectedReceiverIds, playlistStateHolder = playlistStateHolder, - funeralVideoUrl = videoUrlForUpdate, - funeralThumbnailUrl = thumbnailForUpdate + memorialMedia = updateMedia ) } else { performCreate( @@ -198,8 +221,9 @@ class AfternoteEditViewModel payload = payload, selectedReceiverIds = selectedReceiverIds, playlistStateHolder = playlistStateHolder, - funeralVideoUrl = resolvedVideoUrl, - funeralThumbnailUrl = funeralThumbnailUrl + funeralVideoUrl = createMedia.funeralVideoUrl, + funeralThumbnailUrl = createMedia.funeralThumbnailUrl, + memorialPhotoUrl = createMedia.memorialPhotoUrl ) }) .onSuccess { id -> @@ -221,7 +245,7 @@ class AfternoteEditViewModel private suspend fun resolveVideoUrlForSave(funeralVideoUrl: String?): String? { when { funeralVideoUrl.isNullOrBlank() -> return null - !funeralVideoUrl.startsWith("content://") -> return funeralVideoUrl + !funeralVideoUrl.startsWith(CONTENT_SCHEME) -> return funeralVideoUrl } return uploadMemorialVideoUseCase(funeralVideoUrl).fold( onSuccess = { it }, @@ -238,6 +262,33 @@ class AfternoteEditViewModel ) } + /** + * Resolves memorial photo URL for save: returns [memorialPhotoUrl] if non-blank (e.g. from API on edit); + * uploads and returns file URL if [pickedMemorialPhotoUri] is content://; on upload failure updates + * [saveState] and returns null. + */ + private suspend fun resolveMemorialPhotoUrlForSave( + memorialPhotoUrl: String?, + pickedMemorialPhotoUri: String? + ): String? { + if (!pickedMemorialPhotoUri.isNullOrBlank() && pickedMemorialPhotoUri.startsWith(CONTENT_SCHEME)) { + return uploadMemorialPhotoUseCase(pickedMemorialPhotoUri).fold( + onSuccess = { it }, + onFailure = { e -> + Log.e(TAG, "saveAfternote: memorial photo upload failed", e) + _saveState.update { + it.copy( + isSaving = false, + error = e.message ?: "영정 사진 업로드에 실패했습니다." + ) + } + null + } + ) + } + return memorialPhotoUrl?.takeIf { it.isNotBlank() } + } + /** * For update (PATCH), do not send presigned URLs so the server does not overwrite the stored key. */ @@ -355,7 +406,8 @@ class AfternoteEditViewModel galleryProcessingMethodsList = if (isGalleryCategory) actionItems else emptyList(), atmosphere = detail.playlist?.atmosphere, memorialVideoUrl = detail.playlist?.memorialVideoUrl, - memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl + memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl, + memorialPhotoUrl = detail.playlist?.memorialPhotoUrl ?: detail.playlist?.profilePhoto ) } @@ -424,7 +476,8 @@ class AfternoteEditViewModel selectedReceiverIds: List, playlistStateHolder: MemorialPlaylistStateHolder?, funeralVideoUrl: String? = null, - funeralThumbnailUrl: String? = null + funeralThumbnailUrl: String? = null, + memorialPhotoUrl: String? = null ): Result { val actions = payload.processingMethods.map { it.text } + payload.galleryProcessingMethods.map { it.text } @@ -464,6 +517,7 @@ class AfternoteEditViewModel val playlistDto = buildPlaylistDto( playlistStateHolder = playlistStateHolder, atmosphere = payload.atmosphere, + memorialPhotoUrl = memorialPhotoUrl, funeralVideoUrl = funeralVideoUrl, funeralThumbnailUrl = funeralThumbnailUrl ) @@ -493,8 +547,7 @@ class AfternoteEditViewModel payload: RegisterAfternotePayload, selectedReceiverIds: List, playlistStateHolder: MemorialPlaylistStateHolder?, - funeralVideoUrl: String? = null, - funeralThumbnailUrl: String? = null + memorialMedia: MemorialMediaUrls ): Result { val body = if (category == CATEGORY_MEMORIAL) { @@ -502,8 +555,9 @@ class AfternoteEditViewModel title = payload.serviceName, atmosphere = payload.atmosphere, playlistStateHolder = playlistStateHolder, - funeralVideoUrl = funeralVideoUrl, - funeralThumbnailUrl = funeralThumbnailUrl + funeralVideoUrl = memorialMedia.funeralVideoUrl, + funeralThumbnailUrl = memorialMedia.funeralThumbnailUrl, + memorialPhotoUrl = memorialMedia.memorialPhotoUrl ) } else { buildNonMemorialUpdateBody( @@ -525,7 +579,8 @@ class AfternoteEditViewModel atmosphere: String, playlistStateHolder: MemorialPlaylistStateHolder?, funeralVideoUrl: String? = null, - funeralThumbnailUrl: String? = null + funeralThumbnailUrl: String? = null, + memorialPhotoUrl: String? = null ): AfternoteUpdateRequestDto = AfternoteUpdateRequestDto( category = "PLAYLIST", @@ -533,6 +588,7 @@ class AfternoteEditViewModel playlist = buildPlaylistDto( playlistStateHolder = playlistStateHolder, atmosphere = atmosphere, + memorialPhotoUrl = memorialPhotoUrl, funeralVideoUrl = funeralVideoUrl, funeralThumbnailUrl = funeralThumbnailUrl ) @@ -635,10 +691,12 @@ class AfternoteEditViewModel /** * Builds playlist DTO for create/update. memorialVideo is included whenever the user has * selected a video. thumbnailUrl is from POST /files/presigned-url upload or API on edit. + * memorialPhotoUrl: 영정 사진 URL (from API on edit or after upload). */ private fun buildPlaylistDto( playlistStateHolder: MemorialPlaylistStateHolder?, atmosphere: String = "", + memorialPhotoUrl: String? = null, funeralVideoUrl: String? = null, funeralThumbnailUrl: String? = null ): AfternotePlaylistDto { @@ -658,12 +716,34 @@ class AfternoteEditViewModel ) return AfternotePlaylistDto( atmosphere = atmosphere.ifEmpty { null }, + memorialPhotoUrl = memorialPhotoUrl?.takeIf { it.isNotBlank() }, songs = songs, memorialVideo = memorialVideo ) } } +/** + * Memorial-related media URLs and the picked photo URI for save. + * Groups 4 params to keep [saveAfternote] under the 7-parameter limit (S107). + */ +data class SaveAfternoteMemorialMedia( + val funeralVideoUrl: String? = null, + val funeralThumbnailUrl: String? = null, + val memorialPhotoUrl: String? = null, + val pickedMemorialPhotoUri: String? = null +) + +/** + * Resolved memorial media URLs for performUpdate/performCreate. + * Groups 3 params to keep [performUpdate] under the 7-parameter limit (S107). + */ +private data class MemorialMediaUrls( + val funeralVideoUrl: String? = null, + val funeralThumbnailUrl: String? = null, + val memorialPhotoUrl: String? = null +) + /** API 400 응답 body 파싱용 (code 475 등). */ @Serializable private data class ApiErrorBody( diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/di/UserModule.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/di/UserModule.kt index 5c0ddd0b..490f36d9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/di/UserModule.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/di/UserModule.kt @@ -2,9 +2,7 @@ package com.kuit.afternote.feature.user.data.di import com.kuit.afternote.feature.user.data.api.ImageApiService import com.kuit.afternote.feature.user.data.api.UserApiService -import com.kuit.afternote.feature.user.data.repository.ProfileImageUploadRepositoryImpl import com.kuit.afternote.feature.user.data.repository.UserRepositoryImpl -import com.kuit.afternote.feature.user.domain.repository.ProfileImageUploadRepository import com.kuit.afternote.feature.user.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -23,11 +21,6 @@ abstract class UserModule { @Binds abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository - @Binds - abstract fun bindProfileImageUploadRepository( - impl: ProfileImageUploadRepositoryImpl - ): ProfileImageUploadRepository - companion object { @Provides @Singleton diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/ProfileImageUploadRepository.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/ProfileImageUploadRepository.kt deleted file mode 100644 index 4db2fb4c..00000000 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/ProfileImageUploadRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.kuit.afternote.feature.user.domain.repository - -/** - * 프로필 이미지 업로드 (Presigned URL 획득 후 S3 PUT). - * 갤러리에서 선택한 이미지 URI 문자열을 받아 업로드 후 사용할 imageUrl을 반환합니다. - */ -fun interface ProfileImageUploadRepository { - /** - * [uriString]에 해당하는 이미지를 업로드하고, PATCH /users/me 등에서 사용할 imageUrl을 반환합니다. - * - * @param uriString content URI 문자열 (예: content://media/...) - * @return 성공 시 업로드된 이미지 URL, 실패 시 Result.failure - */ - suspend fun uploadProfileImage(uriString: String): Result -} diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt index 8a9bd68a..0a6891e2 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UploadProfileImageUseCase.kt @@ -1,16 +1,18 @@ package com.kuit.afternote.feature.user.domain.usecase -import com.kuit.afternote.feature.user.domain.repository.ProfileImageUploadRepository +import com.kuit.afternote.data.upload.PhotoUploadRepository import javax.inject.Inject +private const val DIRECTORY_PROFILES = "profiles" + /** * 갤러리에서 선택한 프로필 이미지를 업로드하고 사용할 imageUrl을 반환합니다. */ class UploadProfileImageUseCase @Inject constructor( - private val profileImageUploadRepository: ProfileImageUploadRepository + private val photoUploadRepository: PhotoUploadRepository ) { - suspend operator fun invoke(uriString: String): Result = - profileImageUploadRepository.uploadProfileImage(uriString) - } + suspend operator fun invoke(uriString: String): Result = + photoUploadRepository.upload(uriString, DIRECTORY_PROFILES) +} From fc67994f0f48c22c2f0256961e547837aa2b06a7 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 04:35:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(afternote):=20=EC=B6=94=EB=AA=A8=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EB=9D=BC=EC=9D=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=ED=91=9C=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 - 추모 가이드라인 상세 화면(`MemorialGuidelineDetailScreen`)에 '장례식에 남길 영상'을 표시하는 `VideoCard`를 추가했습니다. - 영상 URL이 없는 경우에는 카드가 표시되지 않습니다. - `VideoCard`는 제목과 썸네일로 구성되며, 썸네일 위에는 어두운 그라데이션 오버레이와 재생 아이콘이 표시됩니다. - 상세 정보 상태(`MemorialGuidelineDetailState`)에 영상 및 썸네일 URL 필드(`memorialVideoUrl`, `memorialThumbnailUrl`)를 추가하고, 내비게이션 시 `playlist` 데이터로부터 이 값들을 전달하도록 수정했습니다. --- .../MemorialGuidelineDetailScreen.kt | 100 +++++++++++++++++- .../navgraph/AfternoteNavgraph.kt | 4 +- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt index ca553fc3..8e3af1ed 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/MemorialGuidelineDetailScreen.kt @@ -1,5 +1,6 @@ package com.kuit.afternote.core.ui.screen.afternotedetail +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,9 +23,12 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -69,7 +73,9 @@ data class MemorialGuidelineDetailState( val albumCovers: List = emptyList(), val songCount: Int = 0, val lastWish: String = "", - val afternoteEditReceivers: List = emptyList() + val afternoteEditReceivers: List = emptyList(), + val memorialVideoUrl: String? = null, + val memorialThumbnailUrl: String? = null, ) /** @@ -212,6 +218,10 @@ private fun CardSection(detailState: MemorialGuidelineDetailState) { songCount = detailState.songCount ) LastWishCard(lastWish = detailState.lastWish) + VideoCard( + videoUrl = detailState.memorialVideoUrl, + thumbnailUrl = detailState.memorialThumbnailUrl, + ) } } @@ -250,6 +260,94 @@ private fun PhotoCard( ) } +/** + * 장례식에 남길 영상 카드 — 피그마 node 4813:15198 기준. + * + * InfoCard(Gray2) 안에 제목 + 썸네일(dark gradient overlay + 재생 아이콘) 구조. + * 영상 URL이 없으면 카드를 표시하지 않는다. + */ +@Composable +private fun VideoCard( + videoUrl: String?, + thumbnailUrl: String?, +) { + if (videoUrl.isNullOrBlank()) return + + InfoCard( + modifier = Modifier.fillMaxWidth(), + content = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "장례식에 남길 영상", + style = TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontFamily = Sansneo, + fontWeight = FontWeight.Medium, + color = Gray9 + ) + ) + VideoThumbnail(thumbnailUrl = thumbnailUrl) + } + } + ) +} + +/** + * 영상 썸네일 + 다크 그라데이션 오버레이 + 재생 아이콘. + * + * 피그마 기준: 높이 183dp, 16dp radius, gradient 0x99757575 → 0x99222222, 재생 아이콘 32dp 중앙. + */ +@Composable +private fun VideoThumbnail(thumbnailUrl: String?) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(183.dp) + .clip(RoundedCornerShape(16.dp)) + ) { + // 썸네일 이미지 + if (!thumbnailUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUrl) + .httpHeaders( + NetworkHeaders.Builder().apply { + this["User-Agent"] = "Afternote Android App" + }.build() + ) + .build(), + contentDescription = "장례식에 남길 영상 썸네일", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + // 다크 그라데이션 오버레이 + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color(0x99757575), + Color(0x99222222) + ) + ) + ) + ) + + // 재생 아이콘 + Image( + painter = painterResource(R.drawable.ic_playback), + contentDescription = "영상 재생", + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) + } +} + /** * 추모 플레이리스트 카드 — 피그마 node 4160:9168 기준. * diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt index 75682e54..66f326d3 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/navgraph/AfternoteNavgraph.kt @@ -387,7 +387,9 @@ private fun AfternoteMemorialGuidelineDetailContent( } ?: emptyList(), songCount = detail.playlist?.songs?.size ?: 0, // Memorial only: "남기고 싶은 당부" comes from playlist.atmosphere; other categories use leaveMessage. - lastWish = detail.playlist?.atmosphere ?: "" + lastWish = detail.playlist?.atmosphere ?: "", + memorialVideoUrl = detail.playlist?.memorialVideoUrl, + memorialThumbnailUrl = detail.playlist?.memorialThumbnailUrl, ), callbacks = MemorialGuidelineDetailCallbacks( onBackClick = { navController.popBackStack() },