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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -69,7 +73,9 @@ data class MemorialGuidelineDetailState(
val albumCovers: List<AlbumCover> = emptyList(),
val songCount: Int = 0,
val lastWish: String = "",
val afternoteEditReceivers: List<AfternoteEditReceiver> = emptyList()
val afternoteEditReceivers: List<AfternoteEditReceiver> = emptyList(),
val memorialVideoUrl: String? = null,
val memorialThumbnailUrl: String? = null,
)

/**
Expand Down Expand Up @@ -212,6 +218,10 @@ private fun CardSection(detailState: MemorialGuidelineDetailState) {
songCount = detailState.songCount
)
LastWishCard(lastWish = detailState.lastWish)
VideoCard(
videoUrl = detailState.memorialVideoUrl,
thumbnailUrl = detailState.memorialThumbnailUrl,
)
}
}

Expand Down Expand Up @@ -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 기준.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String> =
override suspend fun upload(uriString: String, directory: String): Result<String> =
runCatching {
val uri = Uri.parse(uriString)
val extension = extensionFromUri(uri)
val presigned = imageApi.getPresignedUrl(
PresignedUrlRequestDto(
directory = DIRECTORY_PROFILES,
directory = directory,
extension = extension
)
).requireData()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -52,6 +54,12 @@ abstract class AfternoteRepositoryModule {
): MemorialThumbnailUploadRepository

@Suppress("unused")
@Binds
@Singleton
abstract fun bindPhotoUploadRepository(
impl: PhotoUploadRepositoryImpl
): PhotoUploadRepository

@Binds
@Singleton
abstract fun bindMemorialVideoUploadRepository(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AfternoteSongDto> = emptyList(),
val memorialVideo: AfternoteMemorialVideoDto? = null
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ data class AfternoteDetailReceiver(
data class AfternotePlaylistDetail(
val profilePhoto: String?,
val atmosphere: String?,
val memorialPhotoUrl: String?,
val songs: List<AfternoteDetailSong>,
val memorialVideoUrl: String?,
val memorialThumbnailUrl: String?
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
photoUploadRepository.upload(uriString, DIRECTORY_AFTERNOTES)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = "",
Expand All @@ -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() },
Expand Down Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null)
private set
/** Memorial(PLAYLIST) only: 영정 사진 URL from API (when editing). Sent as playlist.memorialPhotoUrl on save. */
var memorialPhotoUrl by mutableStateOf<String?>(null)
private set
var playlistSongCount by mutableIntStateOf(16)
private set

Expand Down Expand Up @@ -469,6 +472,7 @@ class AfternoteEditState(
// Memorial only: 장례식에 남길 영상 URL and thumbnail from API.
funeralVideoUrl = params.memorialVideoUrl
funeralThumbnailUrl = params.memorialThumbnailUrl
memorialPhotoUrl = params.memorialPhotoUrl
}
}

Expand All @@ -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,
Expand All @@ -495,7 +500,8 @@ data class LoadFromExistingParams(
val galleryProcessingMethodsList: List<ProcessingMethodItem>,
val atmosphere: String? = null,
val memorialVideoUrl: String? = null,
val memorialThumbnailUrl: String? = null
val memorialThumbnailUrl: String? = null,
val memorialPhotoUrl: String? = null
)

@Composable
Expand Down
Loading
Loading