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")