diff --git a/.gitignore b/.gitignore
index 915ccc3a..f233adc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,9 @@ google-services.json
# Claude Code local settings
.claude/settings.local.json
.mcp.json
+AGENTS.md
+CLAUDE.md
+.omc/
# Android Profiling
*.hprof
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d875703e..8383cde9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -64,6 +64,7 @@ dependencies {
implementation(project(":feature:home"))
implementation(project(":feature:travel"))
implementation(project(":feature:travel-helper"))
+ implementation(project(":feature:content-recommendation"))
implementation(project(":data:core"))
diff --git a/app/src/main/java/com/yapp/ndgl/ui/NDGLApp.kt b/app/src/main/java/com/yapp/ndgl/ui/NDGLApp.kt
index 2255ba69..cc1d1f9b 100644
--- a/app/src/main/java/com/yapp/ndgl/ui/NDGLApp.kt
+++ b/app/src/main/java/com/yapp/ndgl/ui/NDGLApp.kt
@@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
+import com.yapp.ndgl.feature.contentrecommendation.navigation.contentRecommendationEntry
import com.yapp.ndgl.feature.home.navigation.homeEntry
import com.yapp.ndgl.feature.travel.navigation.travelEntry
import com.yapp.ndgl.feature.travelhelper.navigation.travelHelperEntry
@@ -33,6 +34,7 @@ fun NDGLApp() {
homeEntry(navigator)
travelEntry(navigator)
travelHelperEntry(navigator)
+ contentRecommendationEntry(navigator)
}
NavDisplay(
diff --git a/core/ui/src/main/res/drawable/ic_24_alert.xml b/core/ui/src/main/res/drawable/ic_24_alert.xml
new file mode 100644
index 00000000..75224ddf
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_24_alert.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_24_asterisk.xml b/core/ui/src/main/res/drawable/ic_24_asterisk.xml
new file mode 100644
index 00000000..e7d7814e
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_24_asterisk.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_24_youtube.xml b/core/ui/src/main/res/drawable/ic_24_youtube.xml
new file mode 100644
index 00000000..15d179fe
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_24_youtube.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt
index 249515df..5bf5bc65 100644
--- a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt
+++ b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt
@@ -145,6 +145,15 @@ object NetworkModule {
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(httpLoggingInterceptor)
.build()
+
+ @YoutubeOembedClient
+ @Singleton
+ @Provides
+ fun provideYoutubeOembedOkHttpClient(
+ httpLoggingInterceptor: HttpLoggingInterceptor,
+ ): OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(httpLoggingInterceptor)
+ .build()
}
@Qualifier
@@ -190,3 +199,7 @@ annotation class ExchangeRateApiKey
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ExchangeRateClient
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class YoutubeOembedClient
diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/YoutubeOembedApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/YoutubeOembedApi.kt
new file mode 100644
index 00000000..cb13c8ea
--- /dev/null
+++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/YoutubeOembedApi.kt
@@ -0,0 +1,13 @@
+package com.yapp.ndgl.data.travel.api
+
+import com.yapp.ndgl.data.travel.model.YoutubeOembedResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface YoutubeOembedApi {
+ @GET("oembed")
+ suspend fun getMetadata(
+ @Query("url") url: String,
+ @Query("format") format: String = "json",
+ ): YoutubeOembedResponse
+}
diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt
index 41bbe31c..976effc5 100644
--- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt
+++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt
@@ -12,6 +12,7 @@ import com.yapp.ndgl.data.core.di.RouteBaseUrl
import com.yapp.ndgl.data.core.di.RouteClient
import com.yapp.ndgl.data.core.di.WeatherApiKey
import com.yapp.ndgl.data.core.di.WeatherClient
+import com.yapp.ndgl.data.core.di.YoutubeOembedClient
import com.yapp.ndgl.data.travel.BuildConfig
import com.yapp.ndgl.data.travel.api.ExchangeRateApi
import com.yapp.ndgl.data.travel.api.GeocodingApi
@@ -21,6 +22,7 @@ import com.yapp.ndgl.data.travel.api.TravelProgramApi
import com.yapp.ndgl.data.travel.api.TravelTemplateApi
import com.yapp.ndgl.data.travel.api.UserTravelApi
import com.yapp.ndgl.data.travel.api.WeatherApi
+import com.yapp.ndgl.data.travel.api.YoutubeOembedApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -30,8 +32,13 @@ import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
+import javax.inject.Qualifier
import javax.inject.Singleton
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class YoutubeOembedClient
+
@Module
@InstallIn(SingletonComponent::class)
object TravelNetworkModule {
@@ -39,6 +46,7 @@ object TravelNetworkModule {
private const val WEATHER_BASE_URL = "https://weather.googleapis.com/"
private const val GEOCODING_BASE_URL = "https://maps.googleapis.com/"
private const val EXCHANGE_RATE_BASE_URL = "https://v6.exchangerate-api.com/"
+ private const val YOUTUBE_OEMBED_BASE_URL = "https://www.youtube.com/"
@Provides
@Singleton
@@ -167,4 +175,22 @@ object TravelNetworkModule {
fun provideExchangeRateApi(
@ExchangeRateClient retrofit: Retrofit,
): ExchangeRateApi = retrofit.create(ExchangeRateApi::class.java)
+
+ @YoutubeOembedClient
+ @Provides
+ @Singleton
+ fun provideYoutubeOembedRetrofit(
+ @YoutubeOembedClient okHttpClient: OkHttpClient,
+ json: Json,
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl(YOUTUBE_OEMBED_BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideYoutubeOembedApi(
+ @YoutubeOembedClient retrofit: Retrofit,
+ ): YoutubeOembedApi = retrofit.create(YoutubeOembedApi::class.java)
}
diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/YoutubeOembedResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/YoutubeOembedResponse.kt
new file mode 100644
index 00000000..1ceacd7b
--- /dev/null
+++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/YoutubeOembedResponse.kt
@@ -0,0 +1,11 @@
+package com.yapp.ndgl.data.travel.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class YoutubeOembedResponse(
+ val title: String,
+ @SerialName("author_name") val authorName: String,
+ @SerialName("thumbnail_url") val thumbnailUrl: String,
+)
diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ContentMetadataRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ContentMetadataRepository.kt
new file mode 100644
index 00000000..684557e1
--- /dev/null
+++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ContentMetadataRepository.kt
@@ -0,0 +1,14 @@
+package com.yapp.ndgl.data.travel.repository
+
+import com.yapp.ndgl.data.travel.api.YoutubeOembedApi
+import com.yapp.ndgl.data.travel.model.YoutubeOembedResponse
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ContentMetadataRepository @Inject constructor(
+ private val api: YoutubeOembedApi,
+) {
+ suspend fun getMetadata(videoUrl: String): YoutubeOembedResponse =
+ api.getMetadata(url = videoUrl)
+}
diff --git a/feature/content-recommendation/.gitignore b/feature/content-recommendation/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/content-recommendation/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/content-recommendation/build.gradle.kts b/feature/content-recommendation/build.gradle.kts
new file mode 100644
index 00000000..6f157e70
--- /dev/null
+++ b/feature/content-recommendation/build.gradle.kts
@@ -0,0 +1,25 @@
+import java.util.Properties
+
+plugins {
+ id("ndgl.feature")
+}
+
+android {
+ namespace = "com.yapp.ndgl.feature.contentrecommendation"
+
+ val localProperties = Properties().apply {
+ load(rootProject.file("local.properties").bufferedReader())
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ defaultConfig {
+ buildConfigField("String", "NDGL_INQUIRY_URL", "\"${localProperties.getProperty("NDGL_INQUIRY_URL", "")}\"")
+ }
+}
+
+dependencies {
+ implementation(project(":data:travel"))
+}
diff --git a/feature/content-recommendation/consumer-rules.pro b/feature/content-recommendation/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/content-recommendation/proguard-rules.pro b/feature/content-recommendation/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/content-recommendation/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/feature/content-recommendation/src/main/AndroidManifest.xml b/feature/content-recommendation/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/content-recommendation/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentLinkSection.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentLinkSection.kt
new file mode 100644
index 00000000..78c01bed
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentLinkSection.kt
@@ -0,0 +1,367 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.SubcomposeAsyncImage
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.core.ui.util.launchBrowser
+import com.yapp.ndgl.feature.contentrecommendation.ContentRecommendationState.MetadataState
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun ContentLinkSection(
+ contentUrl: String,
+ metadataState: MetadataState,
+ onUrlChange: (String) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.content_recommendation_link_label),
+ style = NDGLTheme.typography.bodyMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ Icon(
+ imageVector = ImageVector.vectorResource(CoreR.drawable.ic_24_asterisk),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = NDGLTheme.colors.green500,
+ )
+ }
+ UrlInputField(url = contentUrl, onUrlChange = onUrlChange)
+ when (metadataState) {
+ MetadataState.Empty -> NoLinkHint()
+ MetadataState.InvalidUrl -> InvalidUrlError()
+ MetadataState.Loading -> ContentPreviewSkeleton()
+ is MetadataState.Success -> ContentPreviewSuccess(metadata = metadataState)
+ MetadataState.Error -> ContentPreviewError()
+ }
+ }
+}
+
+@Composable
+private fun UrlInputField(
+ url: String,
+ onUrlChange: (String) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(NDGLTheme.colors.black50)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(CoreR.drawable.ic_24_youtube),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = NDGLTheme.colors.black400,
+ )
+ BasicTextField(
+ value = url,
+ onValueChange = onUrlChange,
+ modifier = Modifier.weight(1f),
+ textStyle = NDGLTheme.typography.bodyMdRegular.copy(color = NDGLTheme.colors.black700),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done,
+ ),
+ decorationBox = { innerTextField ->
+ if (url.isEmpty()) {
+ Text(
+ text = stringResource(R.string.content_recommendation_link_placeholder),
+ style = NDGLTheme.typography.bodyMdRegular,
+ color = NDGLTheme.colors.black300,
+ )
+ }
+ innerTextField()
+ },
+ )
+ }
+}
+
+@Composable
+private fun NoLinkHint() {
+ val context = LocalContext.current
+ val prefix = stringResource(R.string.content_recommendation_no_link_prefix)
+ val channel = stringResource(R.string.content_recommendation_no_link_channel)
+ val suffix = stringResource(R.string.content_recommendation_no_link_suffix)
+ Text(
+ text = buildAnnotatedString {
+ append(prefix)
+ withLink(
+ LinkAnnotation.Clickable(
+ tag = "CHANNEL",
+ styles = TextLinkStyles(
+ style = SpanStyle(color = NDGLTheme.colors.black500, textDecoration = TextDecoration.Underline),
+ ),
+ linkInteractionListener = { context.launchBrowser(BuildConfig.NDGL_INQUIRY_URL) },
+ ),
+ ) {
+ append(channel)
+ }
+ append(suffix)
+ },
+ style = NDGLTheme.typography.bodySmRegular.copy(color = NDGLTheme.colors.black400),
+ )
+}
+
+@Composable
+private fun InvalidUrlError() {
+ Text(
+ text = stringResource(R.string.content_recommendation_invalid_url),
+ style = NDGLTheme.typography.bodySmRegular,
+ color = NDGLTheme.colors.red500,
+ )
+}
+
+@Composable
+private fun rememberSkeletonAlpha(): Float {
+ val infiniteTransition = rememberInfiniteTransition(label = "skeleton")
+ return infiniteTransition.animateFloat(
+ initialValue = 0.4f,
+ targetValue = 1.0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 800, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "skeletonAlpha",
+ ).value
+}
+
+@Composable
+private fun ContentPreviewSkeleton() {
+ val alpha = rememberSkeletonAlpha()
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(1.dp, NDGLTheme.colors.black50, RoundedCornerShape(12.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(width = 120.dp, height = 80.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(NDGLTheme.colors.black100.copy(alpha = alpha)),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.8f)
+ .height(20.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(NDGLTheme.colors.black200.copy(alpha = alpha)),
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.6f)
+ .height(20.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(NDGLTheme.colors.black100.copy(alpha = alpha)),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ContentPreviewSuccess(metadata: MetadataState.Success) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(1.dp, NDGLTheme.colors.black50, RoundedCornerShape(12.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ThumbnailImage(thumbnailUrl = metadata.thumbnailUrl)
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = metadata.title,
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black700,
+ maxLines = 2,
+ )
+ Text(
+ text = metadata.channelName,
+ style = NDGLTheme.typography.bodySmRegular,
+ color = NDGLTheme.colors.black400,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ThumbnailImage(thumbnailUrl: String) {
+ SubcomposeAsyncImage(
+ model = thumbnailUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(width = 120.dp, height = 80.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ contentScale = ContentScale.Crop,
+ loading = { ThumbnailSkeleton() },
+ error = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NDGLTheme.colors.black50),
+ )
+ },
+ )
+}
+
+@Composable
+private fun ThumbnailSkeleton() {
+ val alpha = rememberSkeletonAlpha()
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NDGLTheme.colors.black50.copy(alpha = alpha)),
+ )
+}
+
+@Composable
+private fun ContentPreviewError() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(1.dp, NDGLTheme.colors.black50, RoundedCornerShape(12.dp))
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(width = 120.dp, height = 80.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(NDGLTheme.colors.black50),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(CoreR.drawable.ic_24_alert),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = NDGLTheme.colors.black300,
+ )
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = stringResource(R.string.content_recommendation_preview_error_title),
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black700,
+ )
+ Text(
+ text = stringResource(R.string.content_recommendation_preview_error_subtitle),
+ style = NDGLTheme.typography.bodySmRegular,
+ color = NDGLTheme.colors.black400,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentLinkSectionEmptyPreview() {
+ NDGLTheme {
+ ContentLinkSection(
+ contentUrl = "",
+ metadataState = MetadataState.Empty,
+ onUrlChange = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentLinkSectionLoadingPreview() {
+ NDGLTheme {
+ ContentLinkSection(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Loading,
+ onUrlChange = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentLinkSectionSuccessPreview() {
+ NDGLTheme {
+ ContentLinkSection(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Success(
+ title = "서울 힐링 여행 브이로그 | 경복궁, 북촌한옥마을",
+ channelName = "여행유튜버",
+ thumbnailUrl = "",
+ ),
+ onUrlChange = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentLinkSectionErrorPreview() {
+ NDGLTheme {
+ ContentLinkSection(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Error,
+ onUrlChange = {},
+ )
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationContract.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationContract.kt
new file mode 100644
index 00000000..e2c2bf91
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationContract.kt
@@ -0,0 +1,46 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.runtime.Stable
+import com.yapp.ndgl.core.base.UiIntent
+import com.yapp.ndgl.core.base.UiSideEffect
+import com.yapp.ndgl.core.base.UiState
+import com.yapp.ndgl.feature.contentrecommendation.model.TravelTheme
+import kotlinx.collections.immutable.PersistentSet
+import kotlinx.collections.immutable.persistentSetOf
+
+@Stable
+data class ContentRecommendationState(
+ val contentUrl: String = "",
+ val metadataState: MetadataState = MetadataState.Empty,
+ val selectedThemes: PersistentSet = persistentSetOf(),
+ val reason: String = "",
+ val isSubmitEnabled: Boolean = false,
+) : UiState {
+ sealed interface MetadataState {
+ data object Empty : MetadataState
+ data object Loading : MetadataState
+
+ data class Success(
+ val title: String,
+ val channelName: String,
+ val thumbnailUrl: String,
+ ) : MetadataState
+
+ data object Error : MetadataState
+ data object InvalidUrl : MetadataState
+ }
+}
+
+sealed interface ContentRecommendationIntent : UiIntent {
+ data class UpdateUrl(val url: String) : ContentRecommendationIntent
+ data class ToggleTheme(val theme: TravelTheme) : ContentRecommendationIntent
+ data class UpdateReason(val reason: String) : ContentRecommendationIntent
+ data object Submit : ContentRecommendationIntent
+ data object NavigateBack : ContentRecommendationIntent
+}
+
+sealed interface ContentRecommendationSideEffect : UiSideEffect {
+ data object NavigateBack : ContentRecommendationSideEffect
+ data object ShowSubmitSuccess : ContentRecommendationSideEffect
+ data object ShowSubmitError : ContentRecommendationSideEffect
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationScreen.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationScreen.kt
new file mode 100644
index 00000000..7d0d1dfe
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationScreen.kt
@@ -0,0 +1,240 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton
+import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr
+import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar
+import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr
+import com.yapp.ndgl.core.ui.designsystem.NDGLSnackbar
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.feature.contentrecommendation.ContentRecommendationState.MetadataState
+import com.yapp.ndgl.feature.contentrecommendation.model.TravelTheme
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.coroutines.launch
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun ContentRecommendationRoute(
+ navigateBack: () -> Unit,
+ viewModel: ContentRecommendationViewModel = hiltViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val coroutineScope = rememberCoroutineScope()
+ val successMessage = stringResource(R.string.content_recommendation_submit_success)
+ val errorMessage = stringResource(R.string.content_recommendation_submit_error)
+
+ ContentRecommendationScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onUrlChange = { viewModel.onIntent(ContentRecommendationIntent.UpdateUrl(it)) },
+ onThemeToggle = { viewModel.onIntent(ContentRecommendationIntent.ToggleTheme(it)) },
+ onReasonChange = { viewModel.onIntent(ContentRecommendationIntent.UpdateReason(it)) },
+ onSubmit = { viewModel.onIntent(ContentRecommendationIntent.Submit) },
+ onBackClick = { viewModel.onIntent(ContentRecommendationIntent.NavigateBack) },
+ )
+
+ viewModel.collectSideEffect { sideEffect ->
+ when (sideEffect) {
+ ContentRecommendationSideEffect.NavigateBack -> navigateBack()
+ ContentRecommendationSideEffect.ShowSubmitSuccess -> {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar(successMessage)
+ }
+ }
+
+ ContentRecommendationSideEffect.ShowSubmitError -> {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar(errorMessage)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContentRecommendationScreen(
+ state: ContentRecommendationState,
+ snackbarHostState: SnackbarHostState,
+ onUrlChange: (String) -> Unit,
+ onThemeToggle: (TravelTheme) -> Unit,
+ onReasonChange: (String) -> Unit,
+ onSubmit: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ Scaffold(
+ snackbarHost = {
+ SnackbarHost(snackbarHostState) { data ->
+ NDGLSnackbar(
+ modifier = Modifier.padding(bottom = 100.dp),
+ snackbarData = data,
+ )
+ }
+ },
+ topBar = {
+ NDGLNavigationBar(
+ textAlignType = NDGLNavigationBarAttr.TextAlignType.CENTER,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = NDGLTheme.colors.white)
+ .statusBarsPadding(),
+ leadingIcon = CoreR.drawable.ic_28_chevron_left,
+ onLeadingIconClick = onBackClick,
+ )
+ },
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NDGLTheme.colors.white)
+ .navigationBarsPadding()
+ .padding(horizontal = 24.dp)
+ .padding(top = 16.dp, bottom = 16.dp),
+ ) {
+ NDGLCTAButton(
+ type = NDGLCTAButtonAttr.Type.PRIMARY,
+ size = NDGLCTAButtonAttr.Size.LARGE,
+ status = if (state.isSubmitEnabled) {
+ NDGLCTAButtonAttr.Status.ACTIVE
+ } else {
+ NDGLCTAButtonAttr.Status.DISABLED
+ },
+ label = stringResource(R.string.content_recommendation_submit_button),
+ onClick = onSubmit,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 24.dp)
+ .padding(top = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(40.dp),
+ ) {
+ item {
+ HeaderSection()
+ }
+ item {
+ ContentLinkSection(
+ contentUrl = state.contentUrl,
+ metadataState = state.metadataState,
+ onUrlChange = onUrlChange,
+ )
+ }
+ item {
+ TravelThemeSection(
+ selectedThemes = state.selectedThemes,
+ onThemeToggle = onThemeToggle,
+ )
+ }
+ item {
+ ReasonSection(
+ reason = state.reason,
+ onReasonChange = onReasonChange,
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentRecommendationScreenEmptyPreview() {
+ NDGLTheme {
+ ContentRecommendationScreen(
+ state = ContentRecommendationState(),
+ snackbarHostState = SnackbarHostState(),
+ onUrlChange = {},
+ onThemeToggle = {},
+ onReasonChange = {},
+ onSubmit = {},
+ onBackClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentRecommendationScreenLoadingPreview() {
+ NDGLTheme {
+ ContentRecommendationScreen(
+ state = ContentRecommendationState(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Loading,
+ ),
+ snackbarHostState = SnackbarHostState(),
+ onUrlChange = {},
+ onThemeToggle = {},
+ onReasonChange = {},
+ onSubmit = {},
+ onBackClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentRecommendationScreenSuccessPreview() {
+ NDGLTheme {
+ ContentRecommendationScreen(
+ state = ContentRecommendationState(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Success(
+ title = "서울 힐링 여행 브이로그 | 경복궁, 북촌한옥마을",
+ channelName = "여행유튜버",
+ thumbnailUrl = "",
+ ),
+ selectedThemes = persistentSetOf(TravelTheme.HEALING_SCENERY, TravelTheme.LANDMARK),
+ reason = "경복궁과 북촌이 너무 예뻐요",
+ ),
+ snackbarHostState = SnackbarHostState(),
+ onUrlChange = {},
+ onThemeToggle = {},
+ onReasonChange = {},
+ onSubmit = {},
+ onBackClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContentRecommendationScreenErrorPreview() {
+ NDGLTheme {
+ ContentRecommendationScreen(
+ state = ContentRecommendationState(
+ contentUrl = "https://youtube.com/watch?v=example",
+ metadataState = MetadataState.Error,
+ ),
+ snackbarHostState = SnackbarHostState(),
+ onUrlChange = {},
+ onThemeToggle = {},
+ onReasonChange = {},
+ onSubmit = {},
+ onBackClick = {},
+ )
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationViewModel.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationViewModel.kt
new file mode 100644
index 00000000..135b0221
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ContentRecommendationViewModel.kt
@@ -0,0 +1,103 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.lifecycle.viewModelScope
+import com.yapp.ndgl.core.base.BaseViewModel
+import com.yapp.ndgl.core.util.suspendRunCatching
+import com.yapp.ndgl.data.travel.repository.ContentMetadataRepository
+import com.yapp.ndgl.feature.contentrecommendation.ContentRecommendationState.MetadataState
+import com.yapp.ndgl.feature.contentrecommendation.model.TravelTheme
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ContentRecommendationViewModel @Inject constructor(
+ private val contentMetadataRepository: ContentMetadataRepository,
+) : BaseViewModel(
+ initialState = ContentRecommendationState(),
+) {
+
+ override suspend fun handleIntent(intent: ContentRecommendationIntent) {
+ when (intent) {
+ is ContentRecommendationIntent.UpdateUrl -> updateUrl(intent.url)
+ is ContentRecommendationIntent.ToggleTheme -> toggleTheme(intent.theme)
+ is ContentRecommendationIntent.UpdateReason -> updateReason(intent.reason)
+ is ContentRecommendationIntent.Submit -> submit()
+ is ContentRecommendationIntent.NavigateBack -> postSideEffect(ContentRecommendationSideEffect.NavigateBack)
+ }
+ }
+
+ private fun updateUrl(url: String) {
+ when {
+ url.isBlank() -> reduce {
+ copy(contentUrl = url, metadataState = MetadataState.Empty, isSubmitEnabled = false)
+ }
+
+ !isYoutubeUrl(url) -> reduce {
+ copy(contentUrl = url, metadataState = MetadataState.InvalidUrl, isSubmitEnabled = false)
+ }
+
+ else -> {
+ reduce { copy(contentUrl = url, metadataState = MetadataState.Loading) }
+ fetchMetadata(url)
+ }
+ }
+ }
+
+ private fun fetchMetadata(url: String) = viewModelScope.launch {
+ suspendRunCatching {
+ contentMetadataRepository.getMetadata(url)
+ }
+ .onSuccess { response ->
+ reduce {
+ copy(
+ metadataState = MetadataState.Success(
+ title = response.title,
+ channelName = response.authorName,
+ thumbnailUrl = response.thumbnailUrl,
+ ),
+ isSubmitEnabled = selectedThemes.isNotEmpty(),
+ )
+ }
+ }
+ .onFailure {
+ reduce {
+ copy(
+ metadataState = MetadataState.Error,
+ isSubmitEnabled = selectedThemes.isNotEmpty(),
+ )
+ }
+ }
+ }
+
+ private fun toggleTheme(theme: TravelTheme) {
+ reduce {
+ val updated = if (selectedThemes.contains(theme)) {
+ selectedThemes.remove(theme)
+ } else {
+ selectedThemes.add(theme)
+ }
+ copy(selectedThemes = updated, isSubmitEnabled = isYoutubeUrl(contentUrl) && updated.isNotEmpty())
+ }
+ }
+
+ private fun isYoutubeUrl(url: String): Boolean =
+ url.contains("youtube.com", ignoreCase = true) || url.contains("youtu.be", ignoreCase = true)
+
+ private fun updateReason(reason: String) {
+ if (reason.length <= REASON_MAX_LENGTH) {
+ reduce { copy(reason = reason) }
+ }
+ }
+
+ private fun submit() {
+ val currentState = state.value
+ if (!currentState.isSubmitEnabled) return
+
+ // TODO 콘텐츠 제안 API연동
+ }
+
+ companion object {
+ private const val REASON_MAX_LENGTH = 200
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/HeaderSection.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/HeaderSection.kt
new file mode 100644
index 00000000..92892b2f
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/HeaderSection.kt
@@ -0,0 +1,34 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+
+@Composable
+internal fun HeaderSection() {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.content_recommendation_title),
+ style = NDGLTheme.typography.titleMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ Text(
+ text = stringResource(R.string.content_recommendation_subtitle),
+ style = NDGLTheme.typography.bodyLgRegular,
+ color = NDGLTheme.colors.black400,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HeaderSectionPreview() {
+ NDGLTheme {
+ HeaderSection()
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ReasonSection.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ReasonSection.kt
new file mode 100644
index 00000000..9636a500
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/ReasonSection.kt
@@ -0,0 +1,71 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+
+@Composable
+internal fun ReasonSection(
+ reason: String,
+ onReasonChange: (String) -> Unit,
+) {
+ Column(modifier = Modifier.imePadding(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.content_recommendation_reason_label),
+ style = NDGLTheme.typography.bodyMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ BasicTextField(
+ value = reason,
+ onValueChange = onReasonChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(NDGLTheme.colors.black50)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ textStyle = NDGLTheme.typography.bodyMdRegular.copy(color = NDGLTheme.colors.black700),
+ decorationBox = { innerTextField ->
+ if (reason.isEmpty()) {
+ Text(
+ text = stringResource(R.string.content_recommendation_reason_placeholder),
+ style = NDGLTheme.typography.bodyMdRegular,
+ color = NDGLTheme.colors.black300,
+ )
+ }
+ innerTextField()
+ },
+ )
+ Text(
+ text = stringResource(R.string.content_recommendation_reason_count, reason.length),
+ modifier = Modifier.fillMaxWidth(),
+ style = NDGLTheme.typography.bodySmRegular,
+ color = NDGLTheme.colors.black400,
+ textAlign = TextAlign.End,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ReasonSectionPreview() {
+ NDGLTheme {
+ ReasonSection(
+ reason = "경복궁과 북촌이 너무 예뻐요",
+ onReasonChange = {},
+ )
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/TravelThemeSection.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/TravelThemeSection.kt
new file mode 100644
index 00000000..2b1b4cc5
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/TravelThemeSection.kt
@@ -0,0 +1,105 @@
+package com.yapp.ndgl.feature.contentrecommendation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.feature.contentrecommendation.model.TravelTheme
+import kotlinx.collections.immutable.persistentSetOf
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun TravelThemeSection(
+ selectedThemes: Set,
+ onThemeToggle: (TravelTheme) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.content_recommendation_theme_label),
+ style = NDGLTheme.typography.bodyMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ Icon(
+ imageVector = ImageVector.vectorResource(CoreR.drawable.ic_24_asterisk),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = NDGLTheme.colors.green500,
+ )
+ }
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ TravelTheme.entries.forEach { theme ->
+ TravelThemeChip(
+ theme = theme,
+ isSelected = selectedThemes.contains(theme),
+ onToggle = { onThemeToggle(theme) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TravelThemeChip(
+ theme: TravelTheme,
+ isSelected: Boolean,
+ onToggle: () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .border(
+ width = 1.dp,
+ color = if (isSelected) Color.Transparent else NDGLTheme.colors.black200,
+ shape = CircleShape,
+ )
+ .clip(CircleShape)
+ .background(if (isSelected) NDGLTheme.colors.black900 else NDGLTheme.colors.white)
+ .clickable(onClick = onToggle)
+ .padding(horizontal = 14.dp, vertical = 6.dp),
+ ) {
+ Text(
+ text = theme.displayName,
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = if (isSelected) NDGLTheme.colors.white else NDGLTheme.colors.black400,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun TravelThemeSectionPreview() {
+ NDGLTheme {
+ TravelThemeSection(
+ selectedThemes = persistentSetOf(TravelTheme.FOOD, TravelTheme.CAFE_DESSERT),
+ onThemeToggle = {},
+ )
+ }
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/model/TravelTheme.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/model/TravelTheme.kt
new file mode 100644
index 00000000..b0b7813b
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/model/TravelTheme.kt
@@ -0,0 +1,12 @@
+package com.yapp.ndgl.feature.contentrecommendation.model
+
+enum class TravelTheme(val displayName: String) {
+ FOOD("맛집"),
+ CAFE_DESSERT("카페/디저트"),
+ HEALING_SCENERY("힐링/풍경"),
+ LANDMARK("명소"),
+ LOCAL("로컬"),
+ SHOPPING("쇼핑"),
+ ACTIVITY("액티비티/체험"),
+ VALUE_FOR_MONEY("가성비"),
+}
diff --git a/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/navigation/ContentRecommendationEntry.kt b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/navigation/ContentRecommendationEntry.kt
new file mode 100644
index 00000000..17b63bc3
--- /dev/null
+++ b/feature/content-recommendation/src/main/java/com/yapp/ndgl/feature/contentrecommendation/navigation/ContentRecommendationEntry.kt
@@ -0,0 +1,15 @@
+package com.yapp.ndgl.feature.contentrecommendation.navigation
+
+import androidx.navigation3.runtime.EntryProviderScope
+import androidx.navigation3.runtime.NavKey
+import com.yapp.ndgl.feature.contentrecommendation.ContentRecommendationRoute
+import com.yapp.ndgl.navigation.Navigator
+import com.yapp.ndgl.navigation.Route
+
+fun EntryProviderScope.contentRecommendationEntry(navigator: Navigator) {
+ entry {
+ ContentRecommendationRoute(
+ navigateBack = { navigator.goBack() },
+ )
+ }
+}
diff --git a/feature/content-recommendation/src/main/res/values/strings.xml b/feature/content-recommendation/src/main/res/values/strings.xml
new file mode 100644
index 00000000..82baa558
--- /dev/null
+++ b/feature/content-recommendation/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+
+
+ 국내 여행 콘텐츠를 추천해주세요
+ 확인 후 템플릿으로 추가되면 알려드릴게요
+ 콘텐츠 링크
+ https://youtu.be/...
+ 미리보기를 불러올 수 없어요
+ 추천은 계속 진행할 수 있어요
+ 여행 유형
+ 추천 이유
+ 기억에 남는 장면이나 여행지를 알려주세요
+ %d/200
+ 추천 보내기
+ 추천이 전송되었어요
+ 추천 전송에 실패했어요. 다시 시도해주세요
+ 올바른 링크를 입력해주세요
+ 링크가 없나요?\u0020
+ 문의 채널
+ 로 알려주세요
+
diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt
index fabe4119..31f1ca50 100644
--- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt
+++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt
@@ -62,6 +62,9 @@ fun EntryProviderScope.homeEntry(
goBack = {
navigator.goBack()
},
+ navigateToContentRecommendation = {
+ navigator.navigate(Route.ContentRecommendation)
+ },
)
}
}
diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsContract.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsContract.kt
index 3d1e4b15..7ac582fe 100644
--- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsContract.kt
+++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsContract.kt
@@ -29,6 +29,10 @@ data class SettingsState(
override val labelRes = R.string.home_settings_identification_code
}
+ data object ContentRecommendation : SettingsMenu {
+ override val labelRes = R.string.home_settings_recommend_link
+ }
+
data class AppVersion(
val versionName: String,
) : SettingsMenu {
@@ -44,10 +48,6 @@ data class SettingsState(
labelRes = R.string.home_settings_faq,
url = "https://repeated-tapir-33f.notion.site/FAQ-30ccbdc5a38380d6af4af7b7c412921e?source=copy_link",
),
- RECOMMEND_LINK(
- labelRes = R.string.home_settings_recommend_link,
- url = "https://forms.gle/3q1uhQVeeKRrz11y5",
- ),
TERMS_OF_SERVICE(
labelRes = R.string.home_settings_terms_of_service,
url = "https://repeated-tapir-33f.notion.site/2c8cbdc5a3838070a8d8ccdcd0631c9a?source=copy_link",
@@ -62,9 +62,11 @@ data class SettingsState(
sealed interface SettingsIntent : UiIntent {
data class ClickUrlMenu(val menu: UrlMenu) : SettingsIntent
data object ClickCopyIdentifierCodeMenu : SettingsIntent
+ data object ClickContentRecommendationMenu : SettingsIntent
}
sealed interface SettingsSideEffect : UiSideEffect {
data class OpenUrl(val url: String) : SettingsSideEffect
data class CopyIdentifierCode(val code: String) : SettingsSideEffect
+ data object NavigateToContentRecommendation : SettingsSideEffect
}
diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsScreen.kt
index 13b03f73..2edce89e 100644
--- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsScreen.kt
+++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsScreen.kt
@@ -40,6 +40,7 @@ import com.yapp.ndgl.core.ui.R as CoreR
internal fun SettingsRoute(
viewModel: SettingsViewModel = hiltViewModel(),
goBack: () -> Unit,
+ navigateToContentRecommendation: () -> Unit,
) {
val context = LocalContext.current
@@ -54,6 +55,9 @@ internal fun SettingsRoute(
onCopyIdentifierCodeClick = {
viewModel.onIntent(SettingsIntent.ClickCopyIdentifierCodeMenu)
},
+ onContentRecommendationClick = {
+ viewModel.onIntent(SettingsIntent.ClickContentRecommendationMenu)
+ },
)
viewModel.collectSideEffect { sideEffect ->
@@ -67,6 +71,7 @@ internal fun SettingsRoute(
)
clipboard?.setPrimaryClip(clip)
}
+ SettingsSideEffect.NavigateToContentRecommendation -> navigateToContentRecommendation()
}
}
}
@@ -77,6 +82,7 @@ private fun SettingsScreen(
goBack: () -> Unit,
onUrlItemClick: (SettingsState.UrlMenu) -> Unit,
onCopyIdentifierCodeClick: () -> Unit,
+ onContentRecommendationClick: () -> Unit,
) {
Scaffold(
topBar = {
@@ -113,6 +119,11 @@ private fun SettingsScreen(
onClick = onCopyIdentifierCodeClick,
)
+ SettingsMenu.ContentRecommendation -> SettingsMenuItem(
+ text = stringResource(item.labelRes),
+ onClick = onContentRecommendationClick,
+ )
+
is SettingsMenu.AppVersion -> VersionItem(versionName = item.versionName)
}
}
diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsViewModel.kt
index f1b8dd6a..237e5918 100644
--- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsViewModel.kt
+++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/settings/SettingsViewModel.kt
@@ -18,7 +18,7 @@ class SettingsViewModel @Inject constructor(
initialState = SettingsState(
menuItems = persistentListOf(
SettingsMenu.OpenUrl(SettingsState.UrlMenu.FAQ),
- SettingsMenu.OpenUrl(SettingsState.UrlMenu.RECOMMEND_LINK),
+ SettingsMenu.ContentRecommendation,
SettingsMenu.CopyIdentifierCode,
SettingsMenu.OpenUrl(SettingsState.UrlMenu.TERMS_OF_SERVICE),
SettingsMenu.OpenUrl(SettingsState.UrlMenu.PRIVACY_POLICY),
@@ -30,6 +30,9 @@ class SettingsViewModel @Inject constructor(
when (intent) {
is SettingsIntent.ClickUrlMenu -> postOpenUrl(intent.menu)
SettingsIntent.ClickCopyIdentifierCodeMenu -> postCopyIdentifierCode()
+ SettingsIntent.ClickContentRecommendationMenu -> {
+ postSideEffect(SettingsSideEffect.NavigateToContentRecommendation)
+ }
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9a1d1ca5..13c90295 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -67,7 +67,7 @@ espressoCore = "3.7.0"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.4.1"
baselineprofile = "1.2.4"
-profileinstaller = "1.4.1" #https://github.com/pinterest/ktlint/releases
+profileinstaller = "1.4.1"
[libraries]
# AndroidX
diff --git a/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt b/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt
index 6c2d7cee..270348c8 100644
--- a/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt
+++ b/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt
@@ -65,4 +65,7 @@ sealed interface Route : NavKey {
@Serializable
data object TravelHelper : Route
+
+ @Serializable
+ data object ContentRecommendation : Route
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e6d15bd3..8c23c63a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,11 +28,15 @@ include(":navigation")
include(":core:base")
include(":core:ui")
include(":core:util")
+
include(":feature:home")
include(":feature:travel")
include(":feature:travel-helper")
include(":feature:splash")
+include(":feature:content-recommendation")
+
include(":data:core")
include(":data:auth")
include(":data:travel")
+
include(":baselineprofile")