Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.webkit)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ val WritingIcon: ImageVector
val FindSquareIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.find_square)

val RefreshIcon: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.refresh)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,20 @@ package com.lyrics.feelin.core.domain.model
*/
enum class SignUpTerm(
val title: String,
/**
* 서버가 전달받는, 사용자가 동의한 약관의 URL
*
* 서버 약관 동의 payload는 기존 Notion 원본 URL 계약을 유지한다.
* */
val agreement: String,
/**
* 실제로 웹뷰에 표시하는 URL
*
* WebView에 WebViewClient를 할당해도
* Notion 원본 URL을 직접 열면 리다이렉트 안내 화면이 간헐적으로 노출될 수 있어,
* 서버 전송용 agreement와 화면 표시용 URL을 분리한다.
* */
val webViewUrl: String = agreement,
val required: Boolean = true,
) {
AGE_AGREEMENT(
Expand All @@ -18,10 +31,12 @@ enum class SignUpTerm(
SERVICE_USAGE(
title = "서비스 이용약관 동의",
agreement = "https://www.notion.so/Feelin-424aa52fb951444fa95f3966672ec670?pvs=4",
webViewUrl = "https://zircon-taste-62f.notion.site/Feelin-424aa52fb951444fa95f3966672ec670",
),
PERSONAL_INFO(
title = "개인정보처리방침 동의",
agreement = "https://www.notion.so/Feelin-2f586ef1b7c947d89ad8cac8a83b61d1?pvs=4",
webViewUrl = "https://zircon-taste-62f.notion.site/Feelin-2f586ef1b7c947d89ad8cac8a83b61d1",
);

fun toAgreementStatus(agree: Boolean): TermAgreementStatus {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package com.lyrics.feelin.navigation

import android.net.Uri

sealed class FeelinDestination(
val route: String,
) {
companion object {
private const val WEB_VIEW_URL_ARGUMENT = "url"
}

// Navigation Graph Routes
object OnboardingGraph : FeelinDestination(route = "onboarding_graph")
object MainGraph : FeelinDestination(route = "main_graph")
Expand All @@ -24,4 +30,14 @@ sealed class FeelinDestination(
object MyPage : FeelinDestination(route = "my_page")
object Setting : FeelinDestination(route = "setting")
object UserInfo : FeelinDestination(route = "user_info")

object InternalWebView : FeelinDestination(
route = "internal_webview?$WEB_VIEW_URL_ARGUMENT={$WEB_VIEW_URL_ARGUMENT}"
) {
const val UrlArgument = WEB_VIEW_URL_ARGUMENT

fun createRoute(url: String): String {
return "internal_webview?$UrlArgument=${Uri.encode(url)}"
}
}
}
80 changes: 61 additions & 19 deletions app/src/main/java/com/lyrics/feelin/navigation/FeelinNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.lyrics.feelin.core.designsystem.component.BottomNavItem
import com.lyrics.feelin.core.designsystem.component.FeelinBottomNavigation
Expand All @@ -35,6 +38,7 @@ import com.lyrics.feelin.core.designsystem.icon.MyPageActiveIcon
import com.lyrics.feelin.core.designsystem.icon.MyPageInactiveIcon
import com.lyrics.feelin.core.designsystem.icon.NoteSearchingActiveIcon
import com.lyrics.feelin.core.designsystem.icon.NoteSearchingInactiveIcon
import com.lyrics.feelin.presentation.util.openExternalBrowser
import com.lyrics.feelin.presentation.view.community.CommunityMainScreen
import com.lyrics.feelin.presentation.view.login.LoginScreen
import com.lyrics.feelin.presentation.view.mypage.MyPageScreen
Expand All @@ -48,6 +52,7 @@ import com.lyrics.feelin.presentation.view.onboarding.genderage.OnboardingGender
import com.lyrics.feelin.presentation.view.onboarding.profile.ProfileScreen
import com.lyrics.feelin.presentation.view.onboarding.terms.OnboardingTermsScreen
import com.lyrics.feelin.presentation.view.onboarding.welcome.WelcomeScreen
import com.lyrics.feelin.presentation.view.webview.InternalWebViewScreen

@Composable
fun FeelinNavHost(
Expand All @@ -63,6 +68,8 @@ fun FeelinNavHost(
onboardingNavGraph(navController)

mainNavGraph(navController)

internalWebViewScreen(navController)
}
}

Expand Down Expand Up @@ -113,7 +120,9 @@ private fun NavGraphBuilder.onboardingTermsScreen(navController: NavHostControll
termAgreements = onboardingState.termAgreements,
onAllCheckedChange = viewModel::setAllTermsAgreed,
onTermCheckedChange = viewModel::setTermAgreed,
onDetailClick = {},
onDetailClick = { term ->
navController.navigate(FeelinDestination.InternalWebView.createRoute(term.webViewUrl))
},
)
}
}
Expand Down Expand Up @@ -236,33 +245,66 @@ private fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {
}
}

navigation(startDestination = FeelinDestination.MyPage.route, route = FeelinDestination.MyPageGraph.route) {
composable(FeelinDestination.MyPage.route) {
MainScaffold(navController = navController, selectedIndex = 2) {
MyPageScreen(
onSettingClick = { navController.navigate(FeelinDestination.Setting.route) }
)
}
myPageNavGraph(navController)
}
}

private fun NavGraphBuilder.myPageNavGraph(navController: NavHostController) {
navigation(startDestination = FeelinDestination.MyPage.route, route = FeelinDestination.MyPageGraph.route) {
composable(FeelinDestination.MyPage.route) {
MainScaffold(navController = navController, selectedIndex = 2) {
MyPageScreen(
onSettingClick = { navController.navigate(FeelinDestination.Setting.route) }
)
}
}

composable(FeelinDestination.Setting.route) {
MainScaffold(navController = navController, selectedIndex = 2) {
SettingScreen(
onBackClick = { navController.popBackStack() },
onUserInfoClick = { navController.navigate(FeelinDestination.UserInfo.route) }
)
}
composable(FeelinDestination.Setting.route) {
val context = LocalContext.current

MainScaffold(navController = navController, selectedIndex = 2) {
SettingScreen(
onBackClick = { navController.popBackStack() },
onUserInfoClick = { navController.navigate(FeelinDestination.UserInfo.route) },
onInternalWebViewClick = { url ->
navController.navigate(FeelinDestination.InternalWebView.createRoute(url))
},
onExternalBrowserClick = { url ->
context.openExternalBrowser(url)
},
)
}
}

composable(FeelinDestination.UserInfo.route) {
MainScaffold(navController = navController, selectedIndex = 2) {
UserInfoScreen(onBackClick = { navController.popBackStack() })
}
composable(FeelinDestination.UserInfo.route) {
MainScaffold(navController = navController, selectedIndex = 2) {
UserInfoScreen(onBackClick = { navController.popBackStack() })
}
}
}
}

private fun NavGraphBuilder.internalWebViewScreen(navController: NavHostController) {
composable(
route = FeelinDestination.InternalWebView.route,
arguments = listOf(
navArgument(FeelinDestination.InternalWebView.UrlArgument) {
type = NavType.StringType
defaultValue = ""
}
),
) { backStackEntry ->
val url = backStackEntry.arguments
?.getString(FeelinDestination.InternalWebView.UrlArgument)
.orEmpty()
Comment on lines +291 to +299
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty-URL fallback will silently load a blank WebView.

defaultValue = "" means any navigation to this destination without the URL argument (e.g., malformed deep link) gives InternalWebViewScreen an empty string, resulting in a blank, error-free WebView. Prefer explicit null handling so the screen can bail out early or show an error.

🛡️ Proposed fix
 navArgument(FeelinDestination.InternalWebView.UrlArgument) {
     type = NavType.StringType
-    defaultValue = ""
+    nullable = true
+    defaultValue = null
 }

Then guard at the call site:

-val url = backStackEntry.arguments
-    ?.getString(FeelinDestination.InternalWebView.UrlArgument)
-    .orEmpty()
-
-InternalWebViewScreen(
-    url = url,
-    onCloseClick = { navController.popBackStack() },
-)
+val url = backStackEntry.arguments
+    ?.getString(FeelinDestination.InternalWebView.UrlArgument)
+
+if (url.isNullOrBlank()) {
+    navController.popBackStack()
+} else {
+    InternalWebViewScreen(
+        url = url,
+        onCloseClick = { navController.popBackStack() },
+    )
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/lyrics/feelin/navigation/FeelinNavHost.kt` around lines
291 - 299, The navArgument currently sets defaultValue = "" which masks missing
URLs; change the argument to be nullable (remove the empty default and set
nullable = true on the navArgument for
FeelinDestination.InternalWebView.UrlArgument) so
backStackEntry.arguments?.getString(FeelinDestination.InternalWebView.UrlArgument)
can return null, then add an explicit guard at the call site in FeelinNavHost
(where you read backStackEntry.arguments?.getString(...).orEmpty()) to check for
null/blank (e.g., if (url.isNullOrBlank()) { show error UI / navigateUp / log
and return }) instead of silently loading an empty WebView.


InternalWebViewScreen(
url = url,
onCloseClick = { navController.popBackStack() },
)
}
}

@Composable
private fun MainScaffold(
navController: NavHostController,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.lyrics.feelin.presentation.util

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.Toast
import androidx.core.net.toUri

private const val EXTERNAL_BROWSER_LAUNCHER_TAG = "ExternalBrowserLauncher"

fun Context.openExternalBrowser(url: String) {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure { throwable ->
if (throwable is ActivityNotFoundException) {
Log.w(EXTERNAL_BROWSER_LAUNCHER_TAG, "No browser found for url: $url", throwable)
Toast.makeText(this, "링크를 띄울 웹 브라우저를 설치해주세요.", Toast.LENGTH_SHORT).show()
} else {
throw throwable
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.lyrics.feelin.presentation.view.mypage.setting

enum class SettingInfoLink(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

도메인 요소로 보기는 뭐하고, 화면에 표시하는 요소는 맞아서 같은 디렉토리에 프레젠테이션 모델로 외부 링크 데이터를 두었어요.

val title: String,
val url: String,
val opensInternally: Boolean,
) {
SERVICE_USAGE(
title = "서비스 이용 약관",
url = "https://zircon-taste-62f.notion.site/Feelin-424aa52fb951444fa95f3966672ec670",
opensInternally = true,
),
PERSONAL_INFO(
title = "개인정보처리방침",
url = "https://zircon-taste-62f.notion.site/Feelin-2f586ef1b7c947d89ad8cac8a83b61d1",
opensInternally = true,
),
FAQ(
title = "자주 묻는 질문",
url = "https://noon-spaghetti-8cf.notion.site/357546f268d580d18a56ded47121c7ab",
opensInternally = true
),
SERVICE_INQUIRY(
title = "서비스 문의하기",
url = "https://docs.google.com/forms/d/1ottTpPuoiDfQnZaMYwwi75WXdEInq6KHN8jY4L9Qc00/viewform",
opensInternally = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import com.lyrics.feelin.presentation.view.mypage.component.SettingMenuItem
fun SettingScreen(
onBackClick: () -> Unit,
onUserInfoClick: () -> Unit,
onInternalWebViewClick: (String) -> Unit,
onExternalBrowserClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val feelinColors = LocalFeelinColors.current
Expand Down Expand Up @@ -58,11 +60,21 @@ fun SettingScreen(

SettingCategoryDivider()

SettingMenuItem(title = "서비스 이용 약관", onClick = {})
Spacer(modifier = Modifier.height(16.dp))
SettingMenuItem(title = "개인정보처리방침", onClick = {})
Spacer(modifier = Modifier.height(16.dp))
SettingMenuItem(title = "서비스 문의하기", onClick = {})
SettingInfoLink.entries.forEachIndexed { index, link ->
SettingMenuItem(
title = link.title,
onClick = {
if (link.opensInternally) {
onInternalWebViewClick(link.url)
} else {
onExternalBrowserClick(link.url)
}
},
)
if (index < SettingInfoLink.entries.lastIndex) {
Spacer(modifier = Modifier.height(16.dp))
}
}

SettingCategoryDivider()

Expand Down Expand Up @@ -107,6 +119,8 @@ private fun SettingScreenPreview() {
SettingScreen(
onBackClick = {},
onUserInfoClick = {},
onInternalWebViewClick = {},
onExternalBrowserClick = {},
)
}
}
Loading
Loading