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/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..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 @@ -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 = "", @@ -386,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() }, @@ -497,8 +500,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) +}