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
Expand Up @@ -2,7 +2,6 @@ package com.afternote.feature.afternote.presentation.receiver.detail

import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
Expand All @@ -21,15 +20,20 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
Expand All @@ -50,6 +54,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.MessageSection
import com.afternote.feature.afternote.presentation.shared.detail.song.MemorialPlaylist
import com.afternote.feature.afternote.presentation.shared.model.AlbumCover
import com.afternote.feature.afternote.presentation.shared.model.MessageBlockUiModel
import kotlinx.coroutines.launch

/**
* MEMORIAL(추억 노트) 카테고리의 수신자 측 상세 화면.
Expand All @@ -73,11 +78,14 @@ fun MemorialReceivedDetailScreen(
songCount: Int = 16,
memorialVideoUrl: String? = null,
memorialThumbnailUrl: String? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
profileImageResId ?: R.drawable.receiver_img_default_profile_deceased
val onVideoClick = rememberReceivedMemorialVideoClickHandler(snackbarHostState)

Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
// statusBarsPadding: 엣지투엣지로 그려 콘텐츠가 상태바 아래까지 깔리므로, 상태바 높이만큼 top 패딩
// → 탑바가 상태바(시계·배터리) 밑에서 시작(겹침 방지). 동적 인셋이라 회전·분할화면에도 대응.
Expand Down Expand Up @@ -134,6 +142,7 @@ fun MemorialReceivedDetailScreen(
ReceiverVideoSection(
memorialVideoUrl = memorialVideoUrl,
memorialThumbnailUrl = memorialThumbnailUrl,
onVideoClick = onVideoClick,
)
}
},
Expand All @@ -153,13 +162,42 @@ fun MemorialReceivedDetailScreen(
}
}

/**
* 추모 영상 카드의 클릭 처리를 만든다.
*
* 실행 실패는 서버 작업 실패가 아니라 이 기기의 사정(막힌 URL·재생 앱 없음)이라 재시도 팝업(#446)이 아니라
* 스낵바로 알린다 — 작성자 쪽 상세(#1336)와 같은 채널이다. 원인이 다르면 문구도 다르므로 콜백 둘을 각각
* 다른 리소스에 붙인다 (#1391).
*/
@Composable
private fun rememberReceivedMemorialVideoClickHandler(snackbarHostState: SnackbarHostState): (String) -> Unit {
val context = LocalContext.current
val resources = LocalResources.current
val scope = rememberCoroutineScope()
return remember(context, resources, scope, snackbarHostState) {
{ videoUrl ->
launchReceivedMemorialVideo(
videoUrl = videoUrl,
startActivity = context::startActivity,
onRejected = {
val message = resources.getString(R.string.receiver_memorial_video_invalid_url)
scope.launch { snackbarHostState.showSnackbar(message) }
},
onUnavailable = {
val message = resources.getString(R.string.receiver_memorial_video_no_app)
scope.launch { snackbarHostState.showSnackbar(message) }
},
)
}
}
}

@Composable
private fun ReceiverVideoSection(
onVideoClick: (String) -> Unit,
memorialVideoUrl: String? = null,
memorialThumbnailUrl: String? = null,
) {
val context = LocalContext.current
val noVideoAppMessage = stringResource(R.string.receiver_memorial_video_no_app)
Column(modifier = Modifier.fillMaxWidth()) {
ReceiverSectionHeader()
Spacer(modifier = Modifier.height(12.dp))
Expand All @@ -170,31 +208,7 @@ private fun ReceiverVideoSection(
.fillMaxWidth()
// clip 을 clickable 앞에: 눌림 피드백이 InfoCard 의 12dp 둥근 모서리 안에서만 그려지게 (모서리 밖 사각 번짐 방지)
.clip(RoundedCornerShape(12.dp))
.clickable {
launchReceivedMemorialVideo(
videoUrl = memorialVideoUrl,
startActivity = context::startActivity,
// 원인이 다르면 문구도 달라야 한다 — 아래 둘을 한 문장으로 덮으면
// 스킴 차단 상황에 «앱이 없습니다» 라는 거짓 안내가 나간다.
// 채널(Snackbar) 전환·리소스화·표출 테스트는 #1391 건 3.
onRejected = {
Toast
.makeText(
context,
"영상 주소가 올바르지 않아 재생할 수 없습니다.",
Toast.LENGTH_SHORT,
).show()
},
onUnavailable = {
Toast
.makeText(
context,
noVideoAppMessage,
Toast.LENGTH_SHORT,
).show()
},
)
},
.clickable { onVideoClick(memorialVideoUrl) },
) {
ReceiverMemorialVideoThumbnail(thumbnailUrl = memorialThumbnailUrl)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<string name="receiver_afternote_item_daum_mail">다음 메일</string>
<string name="receiver_afternote_item_google_mail">구글 메일</string>
<string name="receiver_afternote_item_outlook">outlook</string>
<string name="receiver_memorial_video_invalid_url">영상 주소가 올바르지 않아 재생할 수 없습니다.</string>
<string name="receiver_memorial_video_no_app">영상을 재생할 수 있는 앱이 없습니다.</string>

<string name="receiver_timeletter_unread_count">%1$d개의 읽지 않은 타임 레터가 있습니다.</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.afternote.feature.afternote.presentation.receiver.detail

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.afternote.core.ui.theme.AfternoteTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

/**
* 추모 영상 실행 안내가 Toast 가 아니라 스낵바로 나가고, 두 원인이 서로 다른 문구로 갈리는지 단언한다 (#1391).
*
* Toast 는 Compose semantics 에 안 잡혀 어떤 테스트로도 단언할 수 없었다 — 채널 전환이 곧 검증 가능성이다.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ReceivedMemorialVideoNoticeTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun `막힌 URL 은 주소 문제로 안내한다`() {
showDetail(videoUrl = "javascript:alert(1)")

clickVideoCard()

composeRule.onNodeWithText("영상 주소가 올바르지 않아 재생할 수 없습니다.").assertIsDisplayed()
composeRule.onNodeWithText("영상을 재생할 수 있는 앱이 없습니다.").assertDoesNotExist()
}

@Test
fun `재생할 앱이 없으면 앱 부재로 안내한다`() {
// 실행 가능한 액티비티가 없으면 ActivityNotFoundException 을 던지게 해 OS 거부를 재현한다.
shadowOf(RuntimeEnvironment.getApplication()).checkActivities(true)
showDetail(videoUrl = "https://cdn.example.com/memorial.mp4")

clickVideoCard()

composeRule.onNodeWithText("영상을 재생할 수 있는 앱이 없습니다.").assertIsDisplayed()
composeRule.onNodeWithText("영상 주소가 올바르지 않아 재생할 수 없습니다.").assertDoesNotExist()
}

private fun showDetail(videoUrl: String) {
composeRule.setContent {
AfternoteTheme {
MemorialReceivedDetailScreen(
senderName = "김발신",
onNavigateToFullList = {},
onNavigateToPlaylist = {},
onBackClick = {},
albumCovers = emptyList(),
memorialVideoUrl = videoUrl,
)
}
}
}

private fun clickVideoCard() {
composeRule.onNodeWithContentDescription("영상 재생").performScrollTo().performClick()
composeRule.waitForIdle()
}
}
Loading