Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8d01d15
fix(afternote): 추억 노트 상세 기본 상호작용 보완 (#463)
OnlyReadOneBook Aug 28, 2026
c70e095
test: update screenshot baselines in CI
github-actions[bot] Aug 28, 2026
129cfd8
ci: 생성된 스크린샷 baseline 검증 (#463)
OnlyReadOneBook Aug 28, 2026
3b8608c
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
c1c6b82
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
3b3a14f
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
63bc199
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
c5c18eb
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
b4c0a64
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
e6c37e3
fix(afternote): onVideoClick no-op 디폴트 제거 — 배선 누락이 컴파일에서 잡히게
OnlyReadOneBook Aug 28, 2026
0dc1c28
refactor(afternote): 추억 노트 상세 제목 꼬리 문자열 리소스화
OnlyReadOneBook Aug 28, 2026
30ea09a
fix(afternote): 영상 재생 불가 안내를 Toast 에서 Snackbar 로 — 화면 내 실패 채널 통일
OnlyReadOneBook Aug 28, 2026
a2a00a0
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
011a31a
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
3b61f4d
test(afternote): Robolectric 과 중복인 계측 테스트 제거 (#463)
OnlyReadOneBook Aug 28, 2026
214198f
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 28, 2026
9aa2dd4
test(afternote): 추모 영상 가드 거부 목록에 intent·tel 스킴 2건 추가 (#463)
OnlyReadOneBook Aug 29, 2026
fbe83b2
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 29, 2026
4c2451d
Merge branch 'develop' into fix/463-memorial-detail-basics
OnlyReadOneBook Aug 29, 2026
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
Expand Up @@ -2,9 +2,11 @@ package com.afternote.feature.afternote.presentation.author.detail

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down Expand Up @@ -34,8 +36,10 @@ 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.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
Expand All @@ -57,6 +61,8 @@ import com.afternote.feature.afternote.presentation.shared.detail.ReceiversCard
import com.afternote.feature.afternote.presentation.shared.model.AlbumCover
import com.afternote.feature.afternote.presentation.shared.model.ReceiverUiModel

internal const val MEMORIAL_VIDEO_CARD_TEST_TAG = "memorialVideoCard"

/**
* 추억 노트 상세 표시 데이터.
*/
Expand Down Expand Up @@ -86,6 +92,7 @@ fun MemorialDetailScreen(
isEditable: Boolean = true,
onEditClick: () -> Unit = {},
onDeleteConfirm: () -> Unit = {},
onVideoClick: (String) -> Unit,
state: AfternoteDetailState = rememberAfternoteDetailState(),
) {
val memorialCategoryLabel = stringResource(R.string.afternote_category_memorial)
Expand Down Expand Up @@ -134,6 +141,7 @@ fun MemorialDetailScreen(
MemorialDetailScrollContent(
content = content,
categoryLabel = memorialCategoryLabel,
onVideoClick = onVideoClick,
modifier =
Modifier
.padding(paddingValues)
Expand All @@ -146,6 +154,7 @@ fun MemorialDetailScreen(
private fun MemorialDetailScrollContent(
content: MemorialDetailContent,
categoryLabel: String,
onVideoClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
Expand All @@ -158,7 +167,10 @@ private fun MemorialDetailScrollContent(
Spacer(modifier = Modifier.height(24.dp))
TitleSection(categoryLabel = categoryLabel, userName = content.userName)
Spacer(modifier = Modifier.height(24.dp))
CardSection(content = content)
CardSection(content = content, onVideoClick = onVideoClick)
Spacer(modifier = Modifier.height(24.dp))
SharingNotice()
Spacer(modifier = Modifier.height(32.dp))
}
}

Expand All @@ -167,22 +179,31 @@ private fun TitleSection(
categoryLabel: String,
userName: String,
) {
// 프로필 로드 실패·로딩 경합으로 이름이 비어 있으면 이름 세그먼트를 생략해
// "…에 대한 님의 기록" 렌더를 막는다.
val titleSuffix =
if (userName.isBlank()) {
stringResource(R.string.feature_afternote_memorial_detail_title_suffix_no_name)
} else {
stringResource(R.string.feature_afternote_memorial_detail_title_suffix, userName)
}
Text(
text =
buildAnnotatedString {
withStyle(style = SpanStyle(color = AfternoteDesign.colors.gray9)) {
append(categoryLabel)
}
// 프로필 로드 실패·로딩 경합으로 이름이 비어 있으면 이름 세그먼트를 생략해
// "…에 대한 님의 기록" 렌더를 막는다.
append(if (userName.isBlank()) "에 대한 기록" else "에 대한 ${userName}님의 기록")
append(titleSuffix)
},
style = AfternoteDesign.typography.bodyLargeB,
)
}

@Composable
private fun CardSection(content: MemorialDetailContent) {
private fun CardSection(
content: MemorialDetailContent,
onVideoClick: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PhotoCard(
finalWriteDate = content.finalWriteDate,
Expand All @@ -196,10 +217,33 @@ private fun CardSection(content: MemorialDetailContent) {
VideoCard(
videoUrl = content.memorialVideoUrl,
thumbnailUrl = content.memorialThumbnailUrl,
onClick = onVideoClick,
)
}
}

@Composable
private fun SharingNotice() {
InfoCard(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "•",
style =
AfternoteDesign.typography.bodySmallR.copy(
color = AfternoteDesign.colors.gray6,
),
)
Text(
text = stringResource(R.string.feature_afternote_memorial_detail_sharing_notice),
style =
AfternoteDesign.typography.bodySmallR.copy(
color = AfternoteDesign.colors.gray6,
),
)
}
}
}

@Composable
private fun PhotoCard(
finalWriteDate: String,
Expand Down Expand Up @@ -239,11 +283,17 @@ private fun PhotoCard(
private fun VideoCard(
videoUrl: String?,
thumbnailUrl: String?,
onClick: (String) -> Unit,
) {
if (videoUrl.isNullOrBlank()) return

InfoCard(
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(role = Role.Button) { onClick(videoUrl) }
.testTag(MEMORIAL_VIDEO_CARD_TEST_TAG),
content = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
Expand Down Expand Up @@ -453,6 +503,7 @@ private fun MemorialDetailScreenPreview() {
),
onBackClick = {},
onEditClick = {},
onVideoClick = {},
)
}
}
Expand Down Expand Up @@ -481,6 +532,7 @@ private fun MemorialDetailScreenDeleteDialogPreview() {
),
onBackClick = {},
onEditClick = {},
onVideoClick = {},
state = stateWithDialog,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.afternote.feature.afternote.presentation.author.navigation

import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
Expand All @@ -23,13 +25,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.afternote.core.ui.loading.LoadingBody
Expand Down Expand Up @@ -223,6 +227,7 @@ internal fun AfternoteDetailNavigation(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val onVideoClick = rememberMemorialVideoClickHandler(snackbarHostState)

ObserveDeleteResult(
deleteResult = (uiState as? AfternoteDetailUiState.Success)?.deleteResult,
Expand Down Expand Up @@ -250,6 +255,7 @@ internal fun AfternoteDetailNavigation(
onBackClick = onNavigateBack,
onNavigateToEditor = onNavigateToEditor,
onDeleteConfirm = viewModel::deleteAfternote,
onVideoClick = onVideoClick,
)
}
}
Expand All @@ -262,6 +268,7 @@ private fun AfternoteDetailSuccessContent(
onBackClick: () -> Unit,
onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit,
onDeleteConfirm: (itemId: Long) -> Unit,
onVideoClick: (String) -> Unit,
) {
Box {
when (val model = state.contentUiModel) {
Expand Down Expand Up @@ -310,6 +317,7 @@ private fun AfternoteDetailSuccessContent(
onNavigateToEditor(state.detailId, model.type)
},
onDeleteConfirm = { onDeleteConfirm(state.detailId) },
onVideoClick = onVideoClick,
)
}

Expand All @@ -323,3 +331,69 @@ private fun AfternoteDetailSuccessContent(
}
}
}

@Composable
private fun rememberMemorialVideoClickHandler(snackbarHostState: SnackbarHostState): (String) -> Unit {
val context = LocalContext.current
val resources = LocalResources.current
val scope = rememberCoroutineScope()
return remember(context, snackbarHostState, resources, scope) {
{ videoUrl ->
launchMemorialVideo(
videoUrl = videoUrl,
startActivity = context::startActivity,
onUnavailable = {
val message = resources.getString(R.string.feature_afternote_memorial_video_no_app)
scope.launch {
snackbarHostState.showSnackbar(message = message)
}
},
)
}
}
}

/**
* 서버가 준 추모 영상 URL을 외부 재생 앱으로 연다.
*
* Android 11+ 패키지 가시성에서는 외부 앱 사전 조회가 실제 처리 가능한 앱이 있어도 실패할 수 있다.
* 따라서 http/https URL만 선별한 뒤 실행을 직접 시도하고, OS가 명시적으로 거부한 경우에만
* [onUnavailable] 로 폴백한다.
*/
internal fun launchMemorialVideo(
videoUrl: String,
startActivity: (Intent) -> Unit,
onUnavailable: () -> Unit,
) {
val uri =
try {
videoUrl
.takeUnless { it.isBlank() || it.any(Char::isWhitespace) }
?.toUri()
?.takeIf {
val scheme = it.scheme
(
scheme.equals("http", ignoreCase = true) ||
scheme.equals("https", ignoreCase = true)
) &&
!it.host.isNullOrBlank()
}
} catch (_: IllegalArgumentException) {
null
}

if (uri == null) {
onUnavailable()
return
}

try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (_: ActivityNotFoundException) {
onUnavailable()
} catch (_: SecurityException) {
onUnavailable()
} catch (_: IllegalArgumentException) {
onUnavailable()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@
<string name="feature_afternote_detail_password_mask">••••••••</string>
<string name="feature_afternote_detail_quote_mark">❝ </string>
<string name="feature_afternote_detail_section_additional_receiver">추가 수신자</string>
<string name="feature_afternote_memorial_detail_title_suffix">에 대한\n%1$s님의 기록</string>
<string name="feature_afternote_memorial_detail_title_suffix_no_name">에 대한\n기록</string>
<string name="feature_afternote_memorial_detail_sharing_notice">이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다.</string>
<string name="feature_afternote_memorial_video_no_app">영상을 재생할 수 있는 앱이 없습니다.</string>

<!-- Afternote editor (author) — UI strings -->
<string name="afternote_editor_screen_title">애프터노트 작성하기</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal fun memorialDetailScreenScreenshot() {
AfternoteTheme {
MemorialDetailScreen(
onBackClick = {},
onVideoClick = {},
content =
MemorialDetailContent(
userName = "서영",
Expand All @@ -20,3 +21,21 @@ internal fun memorialDetailScreenScreenshot() {
)
}
}

@PreviewTest
@Preview(showBackground = true)
@Composable
internal fun memorialDetailScreenWithVideoScreenshot() {
AfternoteTheme {
MemorialDetailScreen(
onBackClick = {},
onVideoClick = {},
content =
MemorialDetailContent(
userName = "서영",
finalWriteDate = "2025.11.26",
memorialVideoUrl = "https://cdn.example.com/memorial.mp4",
),
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading