From 40b796692594356937cfe258abf538a9ef4b5cb8 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 13 Jul 2026 21:59:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B9=B4=EB=93=9C=20UI=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=A7=A4=ED=95=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `CurationCard` 컴포저블을 추가하여 아티클 등 큐레이션 콘텐츠 표시 기능 구현 - `CurationUiModel` 정의 및 도메인 모델에서 UI 모델로의 변환 로직 추가 - `PresentationUiModel.Upcoming`에서 도메인 모델 대신 `CurationUiModel`을 사용하도록 리팩터링 - `LocalUriHandler`를 사용하여 큐레이션 카드 클릭 시 외부 링크 연결 기능 적용 - `GetCurationResponse` 네트워크 응답 모델의 필드 순서 조정 및 최적화 --- .../model/presentation/GetCurationResponse.kt | 12 +-- .../impl/main/component/body/CurationCard.kt | 98 +++++++++++++++++++ .../home/impl/main/model/CurationUiModel.kt | 26 +++++ .../impl/main/model/PresentationUiModel.kt | 6 +- 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt index 23aa3371..053c1df0 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt @@ -7,14 +7,14 @@ import kotlinx.serialization.Serializable data class GetCurationResponse( @SerialName("guideMessage") val guideMessage: String, - @SerialName("imageUrl") - val imageUrl: String, - @SerialName("linkUrl") - val linkUrl: String, @SerialName("materialType") val materialType: String, - @SerialName("sourceChannel") - val sourceChannel: String, @SerialName("title") val title: String, + @SerialName("sourceChannel") + val sourceChannel: String, + @SerialName("linkUrl") + val linkUrl: String, + @SerialName("imageUrl") + val imageUrl: String, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt new file mode 100644 index 00000000..d4aef3fb --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt @@ -0,0 +1,98 @@ +package com.team.prezel.feature.home.impl.main.component.body + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelAsyncImage +import com.team.prezel.core.designsystem.component.chip.chip.ChipSize +import com.team.prezel.core.designsystem.component.chip.chip.ChipType +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.noRippleClickable +import com.team.prezel.feature.home.impl.main.model.CurationUiModel + +@Composable +internal fun CurationCard( + curation: CurationUiModel, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable(onClick = { uriHandler.openUri(curation.linkUrl) }), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + Box( + modifier = Modifier + .size(width = 152.dp, height = 86.dp) + .clip(PrezelTheme.shapes.V4), + ) { + PrezelAsyncImage( + url = curation.imageUrl, + contentDescription = curation.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + PrezelChip( + text = curation.materialType, + modifier = Modifier.padding(PrezelTheme.spacing.V4), + type = ChipType.FILLED, + size = ChipSize.SMALL, + ) + } + + Column( + modifier = Modifier.height(86.dp), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V4), + ) { + Text( + text = curation.title, + color = PrezelTheme.colors.textLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = PrezelTheme.typography.caption1Medium, + ) + Text( + text = curation.sourceChannel, + color = PrezelTheme.colors.textSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = PrezelTheme.typography.caption2Regular, + ) + } + } +} + +@BasicPreview +@Composable +private fun CurationCardPreview() { + PrezelTheme { + CurationCard( + curation = CurationUiModel( + guideMessage = "발표 흐름을 키워드별로 정리해보세요", + materialType = "아티클", + title = "제목이 두 줄로 넘어가면 자연스럽게 말줄임표로 전환돼요", + sourceChannel = "계정 이름", + linkUrl = "https://example.com", + imageUrl = "", + ), + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt new file mode 100644 index 00000000..278a1bc0 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt @@ -0,0 +1,26 @@ +package com.team.prezel.feature.home.impl.main.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.Curation + +@Immutable +internal data class CurationUiModel( + val guideMessage: String, + val materialType: String, + val title: String, + val sourceChannel: String, + val linkUrl: String, + val imageUrl: String, +) { + companion object { + fun Curation.toUiModel(): CurationUiModel = + CurationUiModel( + guideMessage = guideMessage, + materialType = materialType, + title = title, + sourceChannel = sourceChannel, + linkUrl = linkUrl, + imageUrl = imageUrl, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt index 25826b51..a3836ad4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt @@ -2,9 +2,9 @@ package com.team.prezel.feature.home.impl.main.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Category -import com.team.prezel.core.model.presentation.Curation import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords import com.team.prezel.core.ui.component.PracticeCardItem +import com.team.prezel.feature.home.impl.main.model.CurationUiModel.Companion.toUiModel import com.team.prezel.feature.home.impl.main.model.GrowthGraphData.Companion.toUiModel import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel.Companion.toUiModel import kotlinx.collections.immutable.ImmutableList @@ -41,7 +41,7 @@ internal sealed interface PresentationUiModel { override val date: LocalDate, override val dDay: String, override val practiceRecords: PracticeRecordsUiModel, - val curations: ImmutableList, + val curations: ImmutableList, ) : PresentationUiModel companion object { @@ -64,7 +64,7 @@ internal sealed interface PresentationUiModel { date = presentationDate, dDay = dDay, practiceRecords = practiceRecords.toUiModel(), - curations = curations.toImmutableList(), + curations = curations.map { curation -> curation.toUiModel() }.toImmutableList(), ) } } From 85e4f116a99d031c36e1385b102687f52c048f8f Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 13 Jul 2026 21:59:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=98=88=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=B0=9C=ED=91=9C=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PresentationSheet`의 예정된 발표 상태에 `CurationCard` 목록 표시 로직 추가 - 고정된 제목 대신 큐레이션 데이터의 `guideMessage`를 제목으로 사용하도록 변경 - 불필요해진 `feature_home_impl_bottom_sheet_upcoming_keywords_title` 리소스 제거 - 프리뷰 데이터에 다양한 타입(아티클, 영상)의 `CurationUiModel` 예시 추가 --- .../main/component/body/PresentationSheet.kt | 39 ++++++++++++++++++- .../home/impl/src/main/res/values/strings.xml | 1 - 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index 3d03f78e..a017ead0 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -1,6 +1,8 @@ package com.team.prezel.feature.home.impl.main.component.body +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -15,6 +17,7 @@ import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.ui.component.PracticeCard import com.team.prezel.core.ui.component.graph.CardGraph import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.main.model.CurationUiModel import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.persistentListOf @@ -59,7 +62,14 @@ internal fun PresentationSheet( } is PresentationUiModel.Upcoming -> { - HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_upcoming_keywords_title)) + presentation.curations.firstOrNull()?.let { firstCuration -> + HomeBottomSheetTitle(title = firstCuration.guideMessage) + Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + presentation.curations.forEach { curation -> + CurationCard(curation = curation) + } + } + } } } Spacer(modifier = Modifier.height(PrezelTheme.spacing.V36)) @@ -81,7 +91,32 @@ private fun PresentationContentPreview() { startDate = LocalDate(2026, 9, 26), endDate = LocalDate(2026, 10, 1), ), - curations = persistentListOf(), + curations = persistentListOf( + CurationUiModel( + guideMessage = "발표 흐름을 키워드별로 정리해보세요", + materialType = "아티클", + title = "제목이 한 줄이라면 이래요", + sourceChannel = "계정 이름", + linkUrl = "https://example.com/article", + imageUrl = "https://picsum.photos/280/160?random=1", + ), + CurationUiModel( + guideMessage = "발표 흐름을 키워드별로 정리해보세요", + materialType = "영상", + title = "제목이 두 줄로 넘어가면 자연스럽게 말줄임표로 전환돼요", + sourceChannel = "프레젠테이션 채널", + linkUrl = "https://example.com/video", + imageUrl = "https://picsum.photos/280/160?random=2", + ), + CurationUiModel( + guideMessage = "발표 흐름을 키워드별로 정리해보세요", + materialType = "아티클", + title = "청중을 설득하는 발표 구성 방법", + sourceChannel = "발표 연구소", + linkUrl = "https://example.com/presentation", + imageUrl = "https://picsum.photos/280/160?random=3", + ), + ), ) Box(modifier = Modifier.padding(top = 16.dp)) { diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 768d87d8..43610b50 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ 지금부터 연습해보세요 지금까지 %1$d번 연습했어요 나의 발표 변화를 확인해보세요 - 발표 흐름을 키워드별로 정리해보세요 안녕하세요 %1$s님! 어떤 발표를 앞두고 있나요? %1$d년 %2$02d월 %3$02d일 From 25a4a6490e760e75cde1e9f33a00f1d1051c3170 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 14 Jul 2026 10:57:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=A9=94=ED=83=80?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 큐레이션 도메인 및 UI 모델에 `category`, `purpose`, `style`, `audience` 필드 추가 - `CurationCard` 내 웹 URL 유효성 검사 및 링크 열기 예외 처리 로직 추가 - `PresentationSheet` 내 큐레이션 메타데이터를 칩(Chip) 형태로 표시하는 레이아웃 구현 - 큐레이션 링크 열기 실패 시 스낵바 알림 노출 기능 추가 - 각 메타데이터 타입별 문자열 리소스 정의 및 매핑 로직 추가 --- .../core/data/mapper/PresentationMapper.kt | 4 + .../core/model/presentation/Curation.kt | 4 + .../model/presentation/GetCurationResponse.kt | 8 + .../feature/home/impl/main/HomeScreen.kt | 11 + .../impl/main/component/HomeScreenContent.kt | 9 + .../impl/main/component/body/CurationCard.kt | 33 ++- .../component/body/HomeBottomSheetTitle.kt | 3 - .../main/component/body/PresentationSheet.kt | 200 +++++++++++++++--- .../home/impl/main/model/CurationUiModel.kt | 12 ++ .../home/impl/src/main/res/values/strings.xml | 11 + 10 files changed, 262 insertions(+), 33 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt index af647911..5c8726ca 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt @@ -158,6 +158,10 @@ private fun GetMainDataResponse.GrowthGraph.toDomain(): PresentationGrowthPoint internal fun GetCurationResponse.toDomain(): Curation = Curation( guideMessage = guideMessage, + category = Category.from(value = type), + purpose = Purpose.from(value = purpose), + style = Style.from(value = style), + audience = Audience.from(value = audience), materialType = materialType, title = title, sourceChannel = sourceChannel, diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt index e1e3ac1e..0abf44ab 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt @@ -2,6 +2,10 @@ package com.team.prezel.core.model.presentation data class Curation( val guideMessage: String, + val category: Category, + val purpose: Purpose, + val style: Style, + val audience: Audience, val materialType: String, val title: String, val sourceChannel: String, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt index 053c1df0..e801847e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt @@ -7,6 +7,14 @@ import kotlinx.serialization.Serializable data class GetCurationResponse( @SerialName("guideMessage") val guideMessage: String, + @SerialName("type") + val type: String, + @SerialName("purpose") + val purpose: String, + @SerialName("style") + val style: String, + @SerialName("audience") + val audience: String, @SerialName("materialType") val materialType: String, @SerialName("title") diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index e2c2f5af..5bc5c2f1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -28,6 +29,7 @@ import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @Composable @@ -44,6 +46,7 @@ internal fun HomeScreen( val pagerState = rememberPagerState(0) { uiState.presentationCount() } val snackbarHostState = LocalSnackbarHostState.current val resources = LocalResources.current + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.onIntent(HomeUiIntent.FetchData) @@ -80,6 +83,13 @@ internal fun HomeScreen( onClickCardGraphItemIndex = { presentationId, index -> viewModel.onIntent(HomeUiIntent.ClickCardGraphItem(presentationId = presentationId, index = index)) }, + onCurationLinkOpenFailed = { + scope.launch { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_home_impl_open_curation_link_failed), + ) + } + }, modifier = modifier, ) } @@ -156,6 +166,7 @@ private fun HomeScreenPreview(uiState: HomeUiState) { onClickVoiceRecordingAnalysis = {}, onClickFileUploadAnalysis = {}, onClickCardGraphItemIndex = { _, _ -> }, + onCurationLinkOpenFailed = {}, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt index c8773de4..bc47a791 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -42,6 +42,7 @@ internal fun HomeScreenContent( onClickVoiceRecordingAnalysis: () -> Unit, onClickFileUploadAnalysis: () -> Unit, onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, + onCurationLinkOpenFailed: () -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -85,6 +86,7 @@ internal fun HomeScreenContent( onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = onClickCardGraphItemIndex, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, ) } } @@ -102,6 +104,7 @@ private fun HomeContent( onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, + onCurationLinkOpenFailed: () -> Unit, ) { when (uiState) { HomeUiState.Loading -> Unit @@ -123,6 +126,7 @@ private fun HomeContent( onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(uiState.presentation.id, index) }, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, ) } @@ -136,6 +140,7 @@ private fun HomeContent( onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = onClickCardGraphItemIndex, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, ) } } @@ -170,6 +175,7 @@ private fun HomePresentationContent( onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickCardGraphItemIndex: (index: Int) -> Unit, + onCurationLinkOpenFailed: () -> Unit, ) { HomePageLayout( maxHeight = maxHeight, @@ -179,6 +185,7 @@ private fun HomePresentationContent( presentation = presentation, onClickPracticeRecording = onClickPracticeRecording, onClickCardGraphItemIndex = onClickCardGraphItemIndex, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, ) }, heroContent = { @@ -201,6 +208,7 @@ private fun HomeMultipleContent( onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, + onCurationLinkOpenFailed: () -> Unit, ) { HorizontalPager( state = pagerState, @@ -219,6 +227,7 @@ private fun HomeMultipleContent( onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(presentation.id, index) }, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt index d4aef3fb..355169b1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/CurationCard.kt @@ -23,12 +23,18 @@ import com.team.prezel.core.designsystem.component.chip.chip.ChipType import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.home.impl.main.model.CurationUiModel +import java.net.URI @Composable internal fun CurationCard( curation: CurationUiModel, + onLinkOpenFailed: () -> Unit, modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current @@ -36,7 +42,17 @@ internal fun CurationCard( Row( modifier = modifier .fillMaxWidth() - .noRippleClickable(onClick = { uriHandler.openUri(curation.linkUrl) }), + .noRippleClickable { + if (curation.linkUrl.isSupportedWebUrl()) { + try { + uriHandler.openUri(curation.linkUrl) + } catch (_: IllegalArgumentException) { + onLinkOpenFailed() + } + } else { + onLinkOpenFailed() + } + }, horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), ) { Box( @@ -46,7 +62,7 @@ internal fun CurationCard( ) { PrezelAsyncImage( url = curation.imageUrl, - contentDescription = curation.title, + contentDescription = "", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) @@ -80,6 +96,14 @@ internal fun CurationCard( } } +private fun String.isSupportedWebUrl(): Boolean = + runCatching { URI(this) } + .getOrNull() + ?.let { uri -> + !uri.host.isNullOrBlank() && + (uri.scheme.equals("http", ignoreCase = true) || uri.scheme.equals("https", ignoreCase = true)) + } == true + @BasicPreview @Composable private fun CurationCardPreview() { @@ -87,12 +111,17 @@ private fun CurationCardPreview() { CurationCard( curation = CurationUiModel( guideMessage = "발표 흐름을 키워드별로 정리해보세요", + category = Category.EDUCATION, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.GENERAL, materialType = "아티클", title = "제목이 두 줄로 넘어가면 자연스럽게 말줄임표로 전환돼요", sourceChannel = "계정 이름", linkUrl = "https://example.com", imageUrl = "", ), + onLinkOpenFailed = {}, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt index b04bae1e..9958c1b3 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt @@ -2,9 +2,7 @@ package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,7 +23,6 @@ internal fun HomeBottomSheetTitle( color = PrezelTheme.colors.textLarge, style = PrezelTheme.typography.body2Bold, ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index a017ead0..9255bcf2 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -1,19 +1,36 @@ package com.team.prezel.feature.home.impl.main.component.body +import androidx.annotation.StringRes +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.chip.chip.ChipSize +import com.team.prezel.core.designsystem.component.chip.chip.ChipType +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.component.PracticeCard import com.team.prezel.core.ui.component.graph.CardGraph import com.team.prezel.feature.home.impl.R @@ -28,24 +45,16 @@ internal fun PresentationSheet( presentation: PresentationUiModel, onClickPracticeRecording: () -> Unit, onClickCardGraphItemIndex: (index: Int) -> Unit, + onCurationLinkOpenFailed: () -> Unit, modifier: Modifier = Modifier, ) { HomeBottomSheetContent( modifier = modifier, contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), ) { - HomeBottomSheetTitle( - title = if (presentation.practiceCount == 0) { - stringResource(R.string.feature_home_impl_empty_sheet_practice_card_title) - } else { - stringResource(R.string.feature_home_impl_bottom_sheet_content_title, presentation.practiceCount) - }, - ) - PracticeCard( - dDay = presentation.practiceRecords.endDate, - items = presentation.practiceRecords.practices, - showActionButton = !presentation.isPastPresentation, - onClickAction = onClickPracticeRecording, + PresentationPracticeSection( + presentation = presentation, + onClickPracticeRecording = onClickPracticeRecording, ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) @@ -53,29 +62,117 @@ internal fun PresentationSheet( is PresentationUiModel.Past -> { if (presentation.growthGraphData.graphItems.isEmpty()) return@HomeBottomSheetContent - HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_past_graph_title)) - CardGraph( - items = presentation.growthGraphData.graphItems, - selectedItemIndex = presentation.growthGraphData.selectedItemIndex, - onSelectItem = { index -> onClickCardGraphItemIndex(index) }, + PastPresentationSection( + presentation = presentation, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, ) } - is PresentationUiModel.Upcoming -> { - presentation.curations.firstOrNull()?.let { firstCuration -> - HomeBottomSheetTitle(title = firstCuration.guideMessage) - Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { - presentation.curations.forEach { curation -> - CurationCard(curation = curation) - } - } - } - } + is PresentationUiModel.Upcoming -> UpcomingPresentationSection( + presentation = presentation, + onCurationLinkOpenFailed = onCurationLinkOpenFailed, + ) } Spacer(modifier = Modifier.height(PrezelTheme.spacing.V36)) } } +@Composable +private fun PresentationPracticeSection( + presentation: PresentationUiModel, + onClickPracticeRecording: () -> Unit, +) { + HomeBottomSheetTitle( + title = if (presentation.practiceCount == 0) { + stringResource(R.string.feature_home_impl_empty_sheet_practice_card_title) + } else { + stringResource(R.string.feature_home_impl_bottom_sheet_content_title, presentation.practiceCount) + }, + ) + PracticeCard( + dDay = presentation.practiceRecords.endDate, + items = presentation.practiceRecords.practices, + showActionButton = !presentation.isPastPresentation, + onClickAction = onClickPracticeRecording, + ) +} + +@Composable +private fun PastPresentationSection( + presentation: PresentationUiModel.Past, + onClickCardGraphItemIndex: (index: Int) -> Unit, +) { + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_past_graph_title)) + CardGraph( + items = presentation.growthGraphData.graphItems, + selectedItemIndex = presentation.growthGraphData.selectedItemIndex, + onSelectItem = onClickCardGraphItemIndex, + ) +} + +@Composable +private fun UpcomingPresentationSection( + presentation: PresentationUiModel.Upcoming, + onCurationLinkOpenFailed: () -> Unit, +) { + val firstCuration = presentation.curations.firstOrNull() ?: return + + HomeBottomSheetTitle(title = firstCuration.guideMessage) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + CurationMetadataRow( + presentationTitle = presentation.title, + curation = firstCuration, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + presentation.curations.forEach { curation -> + CurationCard( + curation = curation, + onLinkOpenFailed = onCurationLinkOpenFailed, + ) + } + } +} + +@Composable +private fun CurationMetadataRow( + presentationTitle: String, + curation: CurationUiModel, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .wrapContentWidth(align = Alignment.Start, unbounded = true) + .requiredWidth(maxWidth + PrezelTheme.spacing.V20) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = presentationTitle, + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.caption2Regular, + ) + VerticalDivider( + modifier = Modifier.height(PrezelTheme.spacing.V24), + color = PrezelTheme.colors.borderSmall, + ) + listOf( + curation.category.labelResId(), + curation.purpose.labelResId(), + curation.style.labelResId(), + curation.audience.labelResId(), + ).forEach { labelResId -> + PrezelChip( + text = stringResource(labelResId), + type = ChipType.FILLED, + size = ChipSize.SMALL, + ) + } + } + } +} + @BasicPreview @Composable private fun PresentationContentPreview() { @@ -83,7 +180,7 @@ private fun PresentationContentPreview() { val presentation = PresentationUiModel.Upcoming( id = 1L, category = Category.OFFER, - title = "설득하는 발표", + title = "사용자가 입력한 발표 제목", date = LocalDate(2026, 10, 1), dDay = "D-3", practiceRecords = PracticeRecordsUiModel( @@ -94,6 +191,10 @@ private fun PresentationContentPreview() { curations = persistentListOf( CurationUiModel( guideMessage = "발표 흐름을 키워드별로 정리해보세요", + category = Category.EDUCATION, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.GENERAL, materialType = "아티클", title = "제목이 한 줄이라면 이래요", sourceChannel = "계정 이름", @@ -102,6 +203,10 @@ private fun PresentationContentPreview() { ), CurationUiModel( guideMessage = "발표 흐름을 키워드별로 정리해보세요", + category = Category.EDUCATION, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.GENERAL, materialType = "영상", title = "제목이 두 줄로 넘어가면 자연스럽게 말줄임표로 전환돼요", sourceChannel = "프레젠테이션 채널", @@ -110,6 +215,10 @@ private fun PresentationContentPreview() { ), CurationUiModel( guideMessage = "발표 흐름을 키워드별로 정리해보세요", + category = Category.EDUCATION, + purpose = Purpose.INFO, + style = Style.FORMAL, + audience = Audience.GENERAL, materialType = "아티클", title = "청중을 설득하는 발표 구성 방법", sourceChannel = "발표 연구소", @@ -124,7 +233,42 @@ private fun PresentationContentPreview() { presentation = presentation, onClickPracticeRecording = {}, onClickCardGraphItemIndex = {}, + onCurationLinkOpenFailed = {}, ) } } } + +@StringRes +private fun Category.labelResId(): Int = + when (this) { + Category.EDUCATION -> R.string.feature_home_impl_category_education + Category.WORK -> R.string.feature_home_impl_category_report + Category.OFFER -> R.string.feature_home_impl_category_persuasion + Category.EVENT -> R.string.feature_home_impl_category_event + } + +@StringRes +private fun Purpose.labelResId(): Int = + when (this) { + Purpose.INFO -> R.string.feature_home_impl_purpose_info + Purpose.UNDERSTANDING -> R.string.feature_home_impl_purpose_understanding + Purpose.EMPATHY -> R.string.feature_home_impl_purpose_empathy + } + +@StringRes +private fun Style.labelResId(): Int = + when (this) { + Style.FORMAL -> R.string.feature_home_impl_style_formal + Style.FRIENDLY -> R.string.feature_home_impl_style_friendly + Style.CALM -> R.string.feature_home_impl_style_calm + Style.CASUAL -> R.string.feature_home_impl_style_casual + } + +@StringRes +private fun Audience.labelResId(): Int = + when (this) { + Audience.GENERAL -> R.string.feature_home_impl_audience_general + Audience.PROFESSIONAL -> R.string.feature_home_impl_audience_professional + Audience.TEAMMATE -> R.string.feature_home_impl_audience_teammate + } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt index 278a1bc0..42ae8cb3 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/CurationUiModel.kt @@ -1,11 +1,19 @@ package com.team.prezel.feature.home.impl.main.model import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.Audience +import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.Curation +import com.team.prezel.core.model.presentation.Purpose +import com.team.prezel.core.model.presentation.Style @Immutable internal data class CurationUiModel( val guideMessage: String, + val category: Category, + val purpose: Purpose, + val style: Style, + val audience: Audience, val materialType: String, val title: String, val sourceChannel: String, @@ -16,6 +24,10 @@ internal data class CurationUiModel( fun Curation.toUiModel(): CurationUiModel = CurationUiModel( guideMessage = guideMessage, + category = category, + purpose = purpose, + style = style, + audience = audience, materialType = materialType, title = title, sourceChannel = sourceChannel, diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 43610b50..151e7ae6 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -20,6 +20,16 @@ 행사·공개 학술·교육 업무·보고 + 정보 전달 + 이해도 향상 + 공감대 형성 + 전문적인 + 친근한 + 격식있는 + 일상적인 + 대중 + 교수/교사/강사 + 팀/동료 연습하기 @@ -29,4 +39,5 @@ 데이터를 불러오지 못했습니다. + 링크를 열 수 없습니다. From 6206ddd402675c6f0653bb0678599fa2c99cb7c5 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 14 Jul 2026 11:01:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20HomeBottomSheetTitle=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EC=97=AC=EB=B0=B1=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeBottomSheetTitle` 컴포넌트 내부에 `V16` 높이의 `Spacer` 추가 - `PresentationSheet`에서 타이틀 하단의 불필요한 `Spacer` 제거 --- .../home/impl/main/component/body/HomeBottomSheetTitle.kt | 3 +++ .../feature/home/impl/main/component/body/PresentationSheet.kt | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt index 9958c1b3..b04bae1e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt @@ -2,7 +2,9 @@ package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,6 +25,7 @@ internal fun HomeBottomSheetTitle( color = PrezelTheme.colors.textLarge, style = PrezelTheme.typography.body2Bold, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index 9255bcf2..6f383f1e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -118,7 +118,6 @@ private fun UpcomingPresentationSection( val firstCuration = presentation.curations.firstOrNull() ?: return HomeBottomSheetTitle(title = firstCuration.guideMessage) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) CurationMetadataRow( presentationTitle = presentation.title, curation = firstCuration,