diff --git a/core/src/main/java/in/koreatech/koin/core/util/ContextUtil.kt b/core/src/main/java/in/koreatech/koin/core/util/ContextUtil.kt new file mode 100644 index 000000000..4d8794bfe --- /dev/null +++ b/core/src/main/java/in/koreatech/koin/core/util/ContextUtil.kt @@ -0,0 +1,9 @@ +package `in`.koreatech.koin.core.util + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri + +fun Context.goToContactUrl() = startActivity(Intent(Intent.ACTION_VIEW, KOIN_ASK_FORM.toUri())) + +const val KOIN_ASK_FORM = "https://forms.gle/Yo1WNR5mLQdi1pMh6" diff --git a/feature/setting/.gitignore b/feature/setting/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/setting/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts new file mode 100644 index 000000000..34be694d4 --- /dev/null +++ b/feature/setting/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.koin.feature) + alias(libs.plugins.koin.hilt) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "in.koreatech.koin.feature.setting" +} + +dependencies { + + implementation(projects.core) + implementation(projects.domain) + implementation(projects.core.designsystem) + implementation(projects.core.navigation) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + implementation(libs.kotlinx.collections.immutable) + + implementation(libs.timber) + implementation(libs.play.services.oss.licenses) +} diff --git a/feature/setting/consumer-rules.pro b/feature/setting/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/setting/proguard-rules.pro b/feature/setting/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/feature/setting/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/setting/src/main/AndroidManifest.xml b/feature/setting/src/main/AndroidManifest.xml new file mode 100644 index 000000000..22e77079a --- /dev/null +++ b/feature/setting/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingItem.kt new file mode 100644 index 000000000..4e1201b40 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingItem.kt @@ -0,0 +1,61 @@ +package `in`.koreatech.koin.feature.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.feature.setting.R + +@Composable +fun SettingItem( + text: String, + modifier: Modifier = Modifier, + showIcon: Boolean = false, + textStyle: TextStyle = KoinTheme.typography.regular16, + backgroundColor: Color = KoinTheme.colors.neutral0, + onClick: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .clickable(onClick = onClick) + .padding(vertical = 13.dp, horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = text, + style = textStyle + ) + if (showIcon) { + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = "" + ) + } + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Preview(showBackground = true) +@Composable +private fun SettingItemPreview() { + SettingItem( + text = "프로필" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingTitle.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingTitle.kt new file mode 100644 index 000000000..08cdeda50 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingTitle.kt @@ -0,0 +1,38 @@ +package `in`.koreatech.koin.feature.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme + +@Composable +fun SettingTitle( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = KoinTheme.typography.medium14, + backgroundColor: Color = KoinTheme.colors.neutral50 +) { + BasicText( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .padding(vertical = 8.dp, horizontal = 24.dp), + text = text, + style = textStyle + ) +} + +@Preview(showBackground = true) +@Composable +private fun SettingTitlePreview() { + SettingTitle( + text = "일반" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingVersionItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingVersionItem.kt new file mode 100644 index 000000000..2dd27383b --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/SettingVersionItem.kt @@ -0,0 +1,102 @@ +package `in`.koreatech.koin.feature.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.feature.setting.R + +@Composable +fun SettingVersionItem( + currentVersion: String, + latestVersion: String, + showVersionInfo: Boolean, + modifier: Modifier = Modifier, + textStyle: TextStyle = KoinTheme.typography.regular16, + backgroundColor: Color = KoinTheme.colors.neutral0 +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .padding( + vertical = if (showVersionInfo) 5.dp else 13.dp, + horizontal = 24.dp + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = stringResource(R.string.setting_item_app_version), + style = textStyle + ) + if (showVersionInfo) { + Column( + horizontalAlignment = Alignment.End + ) { + BasicText( + text = currentVersion, + style = KoinTheme.typography.regular14 + ) + BasicText( + text = if (currentVersion == latestVersion) { + stringResource(R.string.setting_item_newest_version_info) + } else { + stringResource(R.string.setting_item_not_newest_version_info, latestVersion) + }, + style = KoinTheme.typography.regular12.copy( + color = if (currentVersion == latestVersion) { + KoinTheme.colors.neutral500 + } else { + KoinTheme.colors.primary500 + } + ) + ) + } + } + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Preview(showBackground = true) +@Composable +private fun SettingVersionItemPreviewVersion() { + SettingVersionItem( + currentVersion = "4.2.3", + latestVersion = "4.2.4", + showVersionInfo = true + ) +} + +@Preview(showBackground = true) +@Composable +private fun SettingVersionItemPreviewLatest() { + SettingVersionItem( + currentVersion = "4.2.2", + latestVersion = "4.2.2", + showVersionInfo = true + ) +} + +@Preview(showBackground = true) +@Composable +private fun SettingVersionItemPreviewNotShow() { + SettingVersionItem( + currentVersion = "4.2.2", + latestVersion = "4.2.2", + showVersionInfo = false + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/switch/KoinSwitch.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/switch/KoinSwitch.kt new file mode 100644 index 000000000..5e37d312d --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/component/switch/KoinSwitch.kt @@ -0,0 +1,116 @@ +package `in`.koreatech.koin.feature.setting.component.switch + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme + +@Composable +fun KoinSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + switchColors: KoinSwitchColors = KoinSwitchDefaults.koinSwitchColors() +) { + var isChecked by remember(checked) { mutableStateOf(checked) } + val thumbOffset = remember { Animatable(if (isChecked) 1f else 0f) } + + LaunchedEffect(isChecked) { + thumbOffset.animateTo(if (isChecked) 1f else 0f, animationSpec = tween(durationMillis = 300)) + } + + Box( + modifier = modifier + .height(24.dp) + .aspectRatio(2.1f) + .background(Color.Gray, CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isChecked = !isChecked + onCheckedChange(isChecked) + } + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val trackWidth = size.width + val trackHeight = size.height + val thumbMargin = size.height / 8 + val thumbRadius = trackHeight / 2 - thumbMargin + + // Draw track + drawRoundRect( + color = if (isChecked) switchColors.selectedContainerColor else switchColors.unselectedContainerColor, + size = size, + cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2) + ) + + // Draw thumb + val thumbX = (thumbOffset.value * (trackWidth - (thumbRadius + thumbMargin) * 2)) + (thumbRadius + thumbMargin) + drawCircle( + color = if (isChecked) switchColors.selectedContentColor else switchColors.unselectedContentColor, + radius = thumbRadius, + center = Offset(thumbX, trackHeight / 2) + ) + } + } +} + +object KoinSwitchDefaults { + @Composable + fun koinSwitchColors( + selectedContainerColor: Color = KoinTheme.colors.primary500, + selectedContentColor: Color = Color.White, + unselectedContainerColor: Color = KoinTheme.colors.neutral300, + unselectedContentColor: Color = KoinTheme.colors.neutral0 + ) = KoinSwitchColors( + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor, + unselectedContainerColor = unselectedContainerColor, + unselectedContentColor = unselectedContentColor + ) +} + +class KoinSwitchColors internal constructor( + val selectedContainerColor: Color, + val selectedContentColor: Color, + val unselectedContainerColor: Color, + val unselectedContentColor: Color +) + +@Preview +@Composable +private fun KoinSwitchCheckedPreview() { + KoinSwitch( + checked = true, + onCheckedChange = {} + ) +} + +@Preview +@Composable +private fun KoinSwitchUnCheckedPreview() { + KoinSwitch( + checked = false, + onCheckedChange = {} + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/Constant.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/Constant.kt new file mode 100644 index 000000000..0b665c0c0 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/Constant.kt @@ -0,0 +1,3 @@ +package `in`.koreatech.koin.feature.setting.constant + +val ARTICLE_KEYWORD_URL = "koin://article/activity?fragment=article_keyword" diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/TermConstant.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/TermConstant.kt new file mode 100644 index 000000000..9d6baa116 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/constant/TermConstant.kt @@ -0,0 +1,10 @@ +package `in`.koreatech.koin.feature.setting.constant + +enum class TermConstant( + val type: String +) { + TERM_UNKNOWN("termUnknown"), + TERM_KOIN("termKoin"), + TERM_PRIVACY_POLICY("termPrivacyPolicy"), + TERM_MARKETING("termMarketing") +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/Navigation.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/Navigation.kt new file mode 100644 index 000000000..db09991b5 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/Navigation.kt @@ -0,0 +1,83 @@ +package `in`.koreatech.koin.feature.setting.navigation + +import android.app.Activity +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import `in`.koreatech.koin.feature.setting.constant.TermConstant +import `in`.koreatech.koin.feature.setting.ui.SettingScreen +import `in`.koreatech.koin.feature.setting.ui.notification.NotificationScreen +import `in`.koreatech.koin.feature.setting.ui.term.TermScreen + +fun NavGraphBuilder.koinSettingGraph( + navController: NavController +) { + composable( + route = SettingNavType.Setting.route + ) { + val context = LocalContext.current + + SettingScreen( + onTopbarBackClick = { + if (!navController.popBackStack()) { + (context as? Activity)?.finish() + } + }, + onNotificationClick = { + navController.navigate(SettingNavType.Notification.route) + }, + onPrivacyPolicyClick = { + navController.navigate("${SettingNavType.Term.route}/${TermConstant.TERM_PRIVACY_POLICY.type}") + }, + onKoinTermsClick = { + navController.navigate("${SettingNavType.Term.route}/${TermConstant.TERM_KOIN.type}") + }, + onMarketingTermsClick = { + navController.navigate("${SettingNavType.Term.route}/${TermConstant.TERM_MARKETING.type}") + } + ) + } + + composable( + route = "${SettingNavType.Term.route}/{${TERM_TYPE}}", + arguments = listOf( + navArgument(TERM_TYPE) { type = NavType.StringType } + ) + ) { + val context = LocalContext.current + val termType = when (it.arguments?.getString(TERM_TYPE)) { + TermConstant.TERM_KOIN.type -> TermConstant.TERM_KOIN + TermConstant.TERM_PRIVACY_POLICY.type -> TermConstant.TERM_PRIVACY_POLICY + TermConstant.TERM_MARKETING.type -> TermConstant.TERM_MARKETING + else -> TermConstant.TERM_UNKNOWN + } + + TermScreen( + termType = termType, + onTopbarBackClick = { + if (!navController.popBackStack()) { + (context as? Activity)?.finish() + } + } + ) + } + + composable( + route = SettingNavType.Notification.route + ) { + val context = LocalContext.current + + NotificationScreen( + onTopbarBackClick = { + if (!navController.popBackStack()) { + (context as? Activity)?.finish() + } + } + ) + } +} + +const val TERM_TYPE = "termType" diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/SettingNavType.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/SettingNavType.kt new file mode 100644 index 000000000..72a12f5b1 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/navigation/SettingNavType.kt @@ -0,0 +1,7 @@ +package `in`.koreatech.koin.feature.setting.navigation + +sealed class SettingNavType(val route: String) { + data object Setting : SettingNavType("Setting") + data object Term : SettingNavType("Term") + data object Notification : SettingNavType("Notification") +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingActivity.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingActivity.kt new file mode 100644 index 000000000..4cb3598e1 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingActivity.kt @@ -0,0 +1,44 @@ +package `in`.koreatech.koin.feature.setting.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.core.designsystem.util.enableEdgeToEdgeWithDarkStatusBar +import `in`.koreatech.koin.feature.setting.navigation.SettingNavType +import `in`.koreatech.koin.feature.setting.navigation.koinSettingGraph + +@AndroidEntryPoint +class SettingActivity : ComponentActivity() { + private lateinit var navController: NavHostController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdgeWithDarkStatusBar() + + setContent { + KoinTheme { + var startDestination by remember { mutableStateOf(SettingNavType.Setting.route) } + navController = rememberNavController() + NavHost( + modifier = Modifier.Companion, + navController = navController, + startDestination = startDestination + ) { + koinSettingGraph( + navController = navController + ) + } + } + } + } +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingScreen.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingScreen.kt new file mode 100644 index 000000000..fbf9b9e9f --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingScreen.kt @@ -0,0 +1,222 @@ +package `in`.koreatech.koin.feature.setting.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import `in`.koreatech.koin.core.designsystem.component.topbar.KoinTopAppBar +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.core.navigation.Navigator +import `in`.koreatech.koin.core.navigation.utils.rememberNavigator +import `in`.koreatech.koin.core.util.goToContactUrl +import `in`.koreatech.koin.feature.setting.R +import `in`.koreatech.koin.feature.setting.component.SettingItem +import `in`.koreatech.koin.feature.setting.component.SettingTitle +import `in`.koreatech.koin.feature.setting.component.SettingVersionItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingScreen( + modifier: Modifier = Modifier, + viewModel: SettingViewModel = hiltViewModel(), + onTopbarBackClick: () -> Unit = {}, + onProfileClick: () -> Unit = {}, + onChangePasswordClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, + onPrivacyPolicyClick: () -> Unit = {}, + onKoinTermsClick: () -> Unit = {}, + onMarketingTermsClick: () -> Unit = {} +) { + val versionState by viewModel.versionState.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val navigator = rememberNavigator() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + Scaffold( + containerColor = KoinTheme.colors.neutral0, + topBar = { + KoinTopAppBar( + title = stringResource(R.string.setting_appbar_title), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = KoinTheme.colors.primary500, + navigationIconContentColor = Color.White, + titleContentColor = Color.White, + actionIconContentColor = Color.White + ), + onNavigationIconClick = onTopbarBackClick + ) + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.systemBarsPadding() + ) + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { contentPadding -> + var currentVersionName by remember { mutableStateOf("") } + var latestVersionName by remember { mutableStateOf("") } + when (versionState) { // smartcast not working + is VersionState.Outdated -> { + currentVersionName = (versionState as VersionState.Outdated).currentVersion + latestVersionName = (versionState as VersionState.Outdated).latestVersion + } + is VersionState.Latest -> { + currentVersionName = (versionState as VersionState.Latest).currentVersion + latestVersionName = currentVersionName + } + else -> {} + } + SettingScreenImpl( + currentVersionName = currentVersionName, + latestVersionName = latestVersionName, + modifier = modifier + .padding(contentPadding) + .consumeWindowInsets(contentPadding) + .systemBarsPadding(), + onNotificationClick = { + if (viewModel.isLoggedIn) { + onNotificationClick() + } else { + scope.launch { + showLoginSnackBar( + context = context, + navigator = navigator, + snackbarHostState = snackbarHostState + ) + } + } + }, + onPrivacyPolicyClick = onPrivacyPolicyClick, + onKoinTermsClick = onKoinTermsClick, + onMarketingTermsClick = onMarketingTermsClick + ) + } +} + +private suspend fun showLoginSnackBar( + context: Context, + navigator: Navigator, + snackbarHostState: SnackbarHostState +) { + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.setting_snackbar_login), + actionLabel = context.getString(R.string.setting_snackbar_login_button), + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + navigator.navigateToSignIn(context).let { + context.startActivity(it) + } + } +} + +@Composable +private fun SettingScreenImpl( + currentVersionName: String, + latestVersionName: String, + modifier: Modifier = Modifier, + onProfileClick: () -> Unit = {}, + onChangePasswordClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, + onPrivacyPolicyClick: () -> Unit = {}, + onKoinTermsClick: () -> Unit = {}, + onMarketingTermsClick: () -> Unit = {} +) { + val context = LocalContext.current + Column( + modifier = modifier + .fillMaxSize() + ) { + SettingTitle( + text = stringResource(R.string.setting_title_normal) + ) + SettingItem( + text = stringResource(R.string.setting_item_profile), + showIcon = true, + onClick = {} + ) + SettingItem( + text = stringResource(R.string.setting_item_change_password), + showIcon = true, + onClick = {} + ) + SettingItem( + text = stringResource(R.string.setting_item_notification), + showIcon = true, + onClick = onNotificationClick + ) + SettingTitle( + text = stringResource(R.string.setting_title_service) + ) + SettingItem( + text = stringResource(R.string.setting_item_privacy_policy), + onClick = onPrivacyPolicyClick + ) + SettingItem( + text = stringResource(R.string.setting_item_koin_terms), + onClick = onKoinTermsClick + ) + SettingItem( + text = stringResource(R.string.setting_item_marketing_terms), + onClick = onMarketingTermsClick + ) + SettingItem( + text = stringResource(R.string.setting_item_open_source_license), + onClick = { + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + } + ) + SettingVersionItem( + currentVersion = currentVersionName, + latestVersion = latestVersionName, + showVersionInfo = currentVersionName.isNotEmpty() && latestVersionName.isNotEmpty() + ) + Spacer(Modifier.weight(1f)) + SettingItem( + text = stringResource(R.string.setting_item_contact), + onClick = { + context.goToContactUrl() + } + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun SettingScreenPreview() { + SettingScreenImpl( + currentVersionName = "4.5.2", + latestVersionName = "4.5.3" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingViewModel.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingViewModel.kt new file mode 100644 index 000000000..b9f94ddf5 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/SettingViewModel.kt @@ -0,0 +1,55 @@ +package `in`.koreatech.koin.feature.setting.ui + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.koreatech.koin.core.viewmodel.BaseViewModel +import `in`.koreatech.koin.domain.model.user.User +import `in`.koreatech.koin.domain.usecase.user.GetUserInfoUseCase +import `in`.koreatech.koin.domain.usecase.version.GetLatestVersionUseCase +import `in`.koreatech.koin.domain.util.onSuccess +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingViewModel @Inject constructor( + private val getUserInfoUseCase: GetUserInfoUseCase, + private val getLatestVersionUseCase: GetLatestVersionUseCase +) : BaseViewModel() { + private val _versionState: MutableStateFlow = + MutableStateFlow(VersionState.Init) + val versionState: StateFlow get() = _versionState.asStateFlow() + + private val _userInfo: MutableStateFlow = MutableStateFlow(User.Anonymous) + + val isLoggedIn: Boolean get() = _userInfo.value.isStudent || _userInfo.value.isGeneral + + init { + fetchVersion() + fetchUserInfo() + } + + fun fetchUserInfo() = viewModelScope.launch { + getUserInfoUseCase() + .onSuccess { + _userInfo.value = it + } + } + + fun fetchVersion() = viewModelScope.launch { + getLatestVersionUseCase() + .onSuccess { (currentVersion, latestVersion) -> + _versionState.value = + if (currentVersion == latestVersion) { + VersionState.Latest(currentVersion) + } else { + VersionState.Outdated(currentVersion, latestVersion) + } + } + .onFailure { + _versionState.value = VersionState.Failure + } + } +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/VersionState.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/VersionState.kt new file mode 100644 index 000000000..f931e197b --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/VersionState.kt @@ -0,0 +1,11 @@ +package `in`.koreatech.koin.feature.setting.ui + +sealed class VersionState { + data class Outdated(val currentVersion: String, val latestVersion: String) : VersionState() + + data class Latest(val currentVersion: String) : VersionState() + + data object Init : VersionState() + + data object Failure : VersionState() +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationScreen.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationScreen.kt new file mode 100644 index 000000000..11c4370d6 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationScreen.kt @@ -0,0 +1,284 @@ +package `in`.koreatech.koin.feature.setting.ui.notification + +import android.content.Intent +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import `in`.koreatech.koin.core.designsystem.component.topbar.KoinTopAppBar +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.domain.model.notification.SubscribesDetailType +import `in`.koreatech.koin.domain.model.notification.SubscribesType +import `in`.koreatech.koin.feature.setting.R +import `in`.koreatech.koin.feature.setting.component.SettingTitle +import `in`.koreatech.koin.feature.setting.constant.ARTICLE_KEYWORD_URL +import `in`.koreatech.koin.feature.setting.ui.notification.component.NotificationItem +import `in`.koreatech.koin.feature.setting.ui.notification.component.NotificationSwitchItem +import `in`.koreatech.koin.feature.setting.ui.notification.component.NotificationSwitchSubItem +import `in`.koreatech.koin.feature.setting.util.isDetailTypePermitted +import `in`.koreatech.koin.feature.setting.util.isTypePermitted + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationScreen( + modifier: Modifier = Modifier, + viewModel: NotificationViewModel = hiltViewModel(), + onTopbarBackClick: () -> Unit = {} +) { + LaunchedEffect(Unit) { + viewModel.getPermissionInfo() + } + val notificationState by viewModel.notificationUiState.collectAsState() + + Scaffold( + containerColor = KoinTheme.colors.neutral0, + topBar = { + KoinTopAppBar( + title = stringResource(R.string.notification_appbar_title), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = KoinTheme.colors.primary500, + navigationIconContentColor = Color.White, + titleContentColor = Color.White, + actionIconContentColor = Color.White + ), + onNavigationIconClick = onTopbarBackClick + ) + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { contentPadding -> + when (notificationState) { + is NotificationUiState.Success -> { + val notificationInfos = remember { (notificationState as NotificationUiState.Success).notificationPermissionInfo } + NotificationScreenImpl( + isMarketingSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.MARKETING), + isSoldoutSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.DINING_SOLD_OUT), + isBreakfastSubscribed = notificationInfos.subscribes.isDetailTypePermitted(SubscribesDetailType.BREAKFAST), + isLunchSubscribed = notificationInfos.subscribes.isDetailTypePermitted(SubscribesDetailType.LUNCH), + isDinnerSubscribed = notificationInfos.subscribes.isDetailTypePermitted(SubscribesDetailType.BREAKFAST), + isDiningImageUploadedSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.DINING_IMAGE_UPLOAD), + isChatSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.LOST_ITEM_CHAT), + isEventSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.SHOP_EVENT), + isReviewSubscribed = notificationInfos.subscribes.isTypePermitted(SubscribesType.REVIEW_PROMPT), + updateSubscription = viewModel::updateSubscription, + deleteSubscription = viewModel::deleteSubscription, + updateSubscriptionDetail = viewModel::updateSubscriptionDetail, + deleteSubscriptionDetail = viewModel::deleteSubscriptionDetail, + modifier = modifier + .padding(contentPadding) + .consumeWindowInsets(contentPadding) + .systemBarsPadding() + ) + } + else -> {} + } + } +} + +@Composable +private fun NotificationScreenImpl( + isMarketingSubscribed: Boolean, + isSoldoutSubscribed: Boolean, + isBreakfastSubscribed: Boolean, + isLunchSubscribed: Boolean, + isDinnerSubscribed: Boolean, + isDiningImageUploadedSubscribed: Boolean, + isChatSubscribed: Boolean, + isEventSubscribed: Boolean, + isReviewSubscribed: Boolean, + modifier: Modifier = Modifier, + updateSubscription: (SubscribesType) -> Unit = {}, + deleteSubscription: (SubscribesType) -> Unit = {}, + updateSubscriptionDetail: (SubscribesDetailType) -> Unit = {}, + deleteSubscriptionDetail: (SubscribesDetailType) -> Unit = {} +) { + val context = LocalContext.current + var isMarketingSubscribed by remember { mutableStateOf(isMarketingSubscribed) } + var isSoldoutSubscribed by remember { mutableStateOf(isSoldoutSubscribed) } + var isBreakfastSubscribed by remember { mutableStateOf(isBreakfastSubscribed) } + var isLunchSubscribed by remember { mutableStateOf(isLunchSubscribed) } + var isDinnerSubscribed by remember { mutableStateOf(isDinnerSubscribed) } + var isDiningImageUploadedSubscribed by remember { mutableStateOf(isDiningImageUploadedSubscribed) } + var isChatSubscribed by remember { mutableStateOf(isChatSubscribed) } + var isEventSubscribed by remember { mutableStateOf(isEventSubscribed) } + var isReviewSubscribed by remember { mutableStateOf(isReviewSubscribed) } + LazyColumn( + modifier = modifier + .fillMaxSize() + ) { + item { + NotificationSwitchItem( + text = stringResource(R.string.notification_item_marketing), + description = stringResource(R.string.notification_item_marketing_description), + checked = isMarketingSubscribed, + onClick = { + if (isMarketingSubscribed) { + deleteSubscription(SubscribesType.MARKETING) + } else { + updateSubscription(SubscribesType.MARKETING) + } + isMarketingSubscribed = !isMarketingSubscribed + } + ) + } + item { + SettingTitle( + text = stringResource(R.string.notification_item_dining) + ) + NotificationSwitchItem( + text = stringResource(R.string.notification_item_dining_soldout), + description = stringResource(R.string.notification_item_dining_soldout_description), + checked = isSoldoutSubscribed, + onClick = { + if (isSoldoutSubscribed) { + deleteSubscription(SubscribesType.DINING_SOLD_OUT) + } else { + updateSubscription(SubscribesType.DINING_SOLD_OUT) + } + isSoldoutSubscribed = !isSoldoutSubscribed + } + ) + if (isSoldoutSubscribed) { + NotificationSwitchSubItem( + text = stringResource(R.string.notification_item_dining_breakfast), + checked = isBreakfastSubscribed, + onClick = { + if (isBreakfastSubscribed) { + deleteSubscriptionDetail(SubscribesDetailType.BREAKFAST) + } else { + updateSubscriptionDetail(SubscribesDetailType.BREAKFAST) + } + isBreakfastSubscribed = !isBreakfastSubscribed + } + ) + NotificationSwitchSubItem( + text = stringResource(R.string.notification_item_dining_launch), + checked = isLunchSubscribed, + onClick = { + if (isLunchSubscribed) { + deleteSubscriptionDetail(SubscribesDetailType.LUNCH) + } else { + updateSubscriptionDetail(SubscribesDetailType.LUNCH) + } + isLunchSubscribed = !isLunchSubscribed + } + ) + NotificationSwitchSubItem( + text = stringResource(R.string.notification_item_dining_dinner), + checked = isDinnerSubscribed, + onClick = { + if (isDinnerSubscribed) { + deleteSubscriptionDetail(SubscribesDetailType.DINNER) + } else { + updateSubscriptionDetail(SubscribesDetailType.DINNER) + } + isDinnerSubscribed = !isDinnerSubscribed + } + ) + } + NotificationSwitchItem( + text = stringResource(R.string.notification_item_dining_image_uploaded), + description = stringResource(R.string.notification_item_dining_image_uploaded_description), + checked = isDiningImageUploadedSubscribed, + onClick = { + if (isDiningImageUploadedSubscribed) { + deleteSubscription(SubscribesType.DINING_IMAGE_UPLOAD) + } else { + updateSubscription(SubscribesType.DINING_IMAGE_UPLOAD) + } + isDiningImageUploadedSubscribed = !isDiningImageUploadedSubscribed + } + ) + } + item { + SettingTitle( + text = stringResource(R.string.notification_item_article) + ) + NotificationItem( + text = stringResource(R.string.notification_item_article_keyword), + description = stringResource(R.string.notification_item_article_keyword_description), + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, ARTICLE_KEYWORD_URL.toUri())) + } + ) + NotificationSwitchItem( + text = stringResource(R.string.notification_item_chat), + description = stringResource(R.string.notification_item_chat_description), + checked = isChatSubscribed, + onClick = { + if (isChatSubscribed) { + deleteSubscription(SubscribesType.LOST_ITEM_CHAT) + } else { + updateSubscription(SubscribesType.LOST_ITEM_CHAT) + } + isChatSubscribed = !isChatSubscribed + } + ) + } + item { + SettingTitle( + text = stringResource(R.string.notification_item_nearby) + ) + NotificationSwitchItem( + text = stringResource(R.string.notification_item_nearby_event), + description = stringResource(R.string.notification_item_nearby_event_description), + checked = isEventSubscribed, + onClick = { + if (isEventSubscribed) { + deleteSubscription(SubscribesType.SHOP_EVENT) + } else { + updateSubscription(SubscribesType.SHOP_EVENT) + } + isEventSubscribed = !isEventSubscribed + } + ) + NotificationSwitchItem( + text = stringResource(R.string.notification_item_nearby_review), + description = stringResource(R.string.notification_item_nearby_review_description), + checked = isReviewSubscribed, + onClick = { + if (isReviewSubscribed) { + deleteSubscription(SubscribesType.REVIEW_PROMPT) + } else { + updateSubscription(SubscribesType.REVIEW_PROMPT) + } + isReviewSubscribed = !isReviewSubscribed + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationScreenPreview() { + NotificationScreenImpl( + isMarketingSubscribed = false, + isSoldoutSubscribed = true, + isBreakfastSubscribed = false, + isLunchSubscribed = false, + isDinnerSubscribed = false, + isDiningImageUploadedSubscribed = false, + isChatSubscribed = false, + isEventSubscribed = false, + isReviewSubscribed = false + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationViewModel.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationViewModel.kt new file mode 100644 index 000000000..3bba93cfb --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/NotificationViewModel.kt @@ -0,0 +1,69 @@ +package `in`.koreatech.koin.feature.setting.ui.notification + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.koreatech.koin.domain.model.notification.NotificationPermissionInfo +import `in`.koreatech.koin.domain.model.notification.SubscribesDetailType +import `in`.koreatech.koin.domain.model.notification.SubscribesType +import `in`.koreatech.koin.domain.usecase.notification.DeleteNotificationSubscriptionDetailUseCase +import `in`.koreatech.koin.domain.usecase.notification.DeleteNotificationSubscriptionUseCase +import `in`.koreatech.koin.domain.usecase.notification.GetNotificationPermissionInfoUseCase +import `in`.koreatech.koin.domain.usecase.notification.UpdateNotificationSubscriptionDetailUseCase +import `in`.koreatech.koin.domain.usecase.notification.UpdateNotificationSubscriptionUseCase +import `in`.koreatech.koin.domain.util.onFailure +import `in`.koreatech.koin.domain.util.onSuccess +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val getNotificationPermissionInfoUseCase: GetNotificationPermissionInfoUseCase, + private val updateNotificationSubscriptionUseCase: UpdateNotificationSubscriptionUseCase, + private val updateNotificationSubscriptionDetailUseCase: UpdateNotificationSubscriptionDetailUseCase, + private val deleteNotificationSubscriptionUseCase: DeleteNotificationSubscriptionUseCase, + private val deleteNotificationSubscriptionDetailUseCase: DeleteNotificationSubscriptionDetailUseCase +) : ViewModel() { + private val _notificationUiState = + MutableStateFlow(NotificationUiState.Nothing) + val notificationUiState = _notificationUiState.asStateFlow() + + fun getPermissionInfo() = viewModelScope.launch { + getNotificationPermissionInfoUseCase().onSuccess { info -> + _notificationUiState.update { + NotificationUiState.Success(info) + } + }.onFailure { + _notificationUiState.update { NotificationUiState.Failed } + } + } + + fun updateSubscription(type: SubscribesType) = viewModelScope.launch { + updateNotificationSubscriptionUseCase(type) + } + + fun updateSubscriptionDetail(type: SubscribesDetailType) = viewModelScope.launch { + updateNotificationSubscriptionDetailUseCase(type) + } + + fun deleteSubscription(type: SubscribesType) = viewModelScope.launch { + deleteNotificationSubscriptionUseCase(type) + } + + fun deleteSubscriptionDetail(type: SubscribesDetailType) = viewModelScope.launch { + deleteNotificationSubscriptionDetailUseCase(type) + } +} + +sealed class NotificationUiState { + data class Success( + val notificationPermissionInfo: NotificationPermissionInfo + ) : NotificationUiState() + + data object Failed : NotificationUiState() + + data object Nothing : NotificationUiState() +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationItem.kt new file mode 100644 index 000000000..c2c9a62f7 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationItem.kt @@ -0,0 +1,78 @@ +package `in`.koreatech.koin.feature.setting.ui.notification.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.feature.setting.R + +@Composable +fun NotificationItem( + text: String, + modifier: Modifier = Modifier, + description: String = "", + textStyle: TextStyle = KoinTheme.typography.medium18, + descriptionTextStyle: TextStyle = KoinTheme.typography.regular16, + descriptionColor: Color = KoinTheme.colors.neutral500, + backgroundColor: Color = KoinTheme.colors.neutral0, + onClick: () -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .clickable(onClick = onClick) + .padding(vertical = 13.dp, horizontal = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = text, + style = textStyle + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = "" + ) + } + if (description.isNotEmpty()) { + Spacer(Modifier.height(5.dp)) + BasicText( + text = description, + style = descriptionTextStyle.copy( + color = descriptionColor + ) + ) + } + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Preview(showBackground = true) +@Composable +private fun NotificationItemPreview() { + NotificationItem( + text = "title", + description = "description" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchItem.kt new file mode 100644 index 000000000..6674e00e7 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchItem.kt @@ -0,0 +1,85 @@ +package `in`.koreatech.koin.feature.setting.ui.notification.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.feature.setting.component.switch.KoinSwitch + +@Composable +fun NotificationSwitchItem( + text: String, + checked: Boolean, + modifier: Modifier = Modifier, + description: String = "", + textStyle: TextStyle = KoinTheme.typography.medium18, + descriptionTextStyle: TextStyle = KoinTheme.typography.regular16, + descriptionColor: Color = KoinTheme.colors.neutral500, + backgroundColor: Color = KoinTheme.colors.neutral0, + onClick: (Boolean) -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .padding(vertical = 13.dp, horizontal = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = text, + style = textStyle + ) + KoinSwitch( + checked = checked, + onCheckedChange = onClick + ) + } + if (description.isNotEmpty()) { + Spacer(Modifier.height(5.dp)) + BasicText( + text = description, + style = descriptionTextStyle.copy( + color = descriptionColor + ) + ) + } + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Preview(showBackground = true) +@Composable +private fun NotificationSwitchItemPreview() { + NotificationSwitchItem( + text = "title", + checked = false + ) +} + +@Preview(showBackground = true) +@Composable +private fun NotificationSwitchItemDescriptionPreview() { + NotificationSwitchItem( + text = "title", + checked = true, + description = "description" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchSubItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchSubItem.kt new file mode 100644 index 000000000..0eb4a25a4 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/notification/component/NotificationSwitchSubItem.kt @@ -0,0 +1,57 @@ +package `in`.koreatech.koin.feature.setting.ui.notification.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.feature.setting.component.switch.KoinSwitch + +@Composable +fun NotificationSwitchSubItem( + text: String, + checked: Boolean, + modifier: Modifier = Modifier, + textStyle: TextStyle = KoinTheme.typography.regular16, + backgroundColor: Color = KoinTheme.colors.neutral0, + onClick: (Boolean) -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .padding(vertical = 16.dp, horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + modifier = Modifier.padding(start = 8.dp), + text = text, + style = textStyle + ) + KoinSwitch( + checked = checked, + onCheckedChange = onClick + ) + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Preview(showBackground = true) +@Composable +private fun NotificationSwitchSubItemPreview() { + NotificationSwitchSubItem( + text = "title", + checked = false + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermScreen.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermScreen.kt new file mode 100644 index 000000000..2842fe1fb --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermScreen.kt @@ -0,0 +1,186 @@ +package `in`.koreatech.koin.feature.setting.ui.term + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import `in`.koreatech.koin.core.designsystem.component.topbar.KoinTopAppBar +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme +import `in`.koreatech.koin.domain.model.term.TermArticle +import `in`.koreatech.koin.feature.setting.R +import `in`.koreatech.koin.feature.setting.constant.TermConstant +import `in`.koreatech.koin.feature.setting.ui.term.component.TermDescriptionItem +import `in`.koreatech.koin.feature.setting.ui.term.component.TermMenuItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TermScreen( + termType: TermConstant, + modifier: Modifier = Modifier, + viewModel: TermViewModel = hiltViewModel(), + onTopbarBackClick: () -> Unit = {} +) { + val snackbarHostState = remember { SnackbarHostState() } + val termUnknownMessage = stringResource(R.string.term_unknown_message) + LaunchedEffect(termType) { + when (termType) { + TermConstant.TERM_UNKNOWN -> { + snackbarHostState.showSnackbar( + message = termUnknownMessage, + duration = SnackbarDuration.Short + ) + } + TermConstant.TERM_KOIN -> { + viewModel.loadKoinTerm() + } + TermConstant.TERM_PRIVACY_POLICY -> { + viewModel.loadPrivacyTerm() + } + TermConstant.TERM_MARKETING -> { + viewModel.loadMarketingTerm() + } + } + } + val termState by viewModel.term.collectAsState() + LaunchedEffect(termState) { + if (termState is TermState.Failure) { // smartcast not working + snackbarHostState.showSnackbar( + message = (termState as TermState.Failure).message, + duration = SnackbarDuration.Short + ) + } + } + Scaffold( + containerColor = KoinTheme.colors.neutral0, + topBar = { + KoinTopAppBar( + title = stringResource(R.string.term_appbar_title), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = KoinTheme.colors.primary500, + navigationIconContentColor = Color.White, + titleContentColor = Color.White, + actionIconContentColor = Color.White + ), + onNavigationIconClick = onTopbarBackClick + ) + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.systemBarsPadding() + ) + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { contentPadding -> + if (termState is TermState.Success) { // smartcast not working + TermScreenImpl( + modifier = modifier + .padding(contentPadding) + .consumeWindowInsets(contentPadding) + .systemBarsPadding(), + title = (termState as TermState.Success).term.header, + articles = (termState as TermState.Success).term.articles + ) + } + } +} + +@Composable +private fun TermScreenImpl( + title: String, + articles: List, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + + val termLazyState = rememberLazyListState() + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(color = KoinTheme.colors.neutral0), + state = termLazyState + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 13.dp, horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = title, + style = KoinTheme.typography.regular16 + ) + } + HorizontalDivider(color = KoinTheme.colors.neutral100) + } + item { + articles.forEachIndexed { index, article -> + TermMenuItem( + text = article.article, + onClick = { + scope.launch { + termLazyState.animateScrollToItem(index + 2) + } + } + ) + } + HorizontalDivider( + modifier = Modifier + .padding(vertical = 28.dp, horizontal = 24.dp), + color = KoinTheme.colors.neutral800 + ) + } + items(items = articles) { + TermDescriptionItem( + title = it.article, + description = it.content + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun TermScreenPreview() { + TermScreenImpl( + title = "코인 이용약관", + articles = listOf( + TermArticle("제 1조 ---", listOf("1조 내용")), + TermArticle("제 2조 ---", listOf("2조 내용1", "2조 내용2")), + TermArticle("제 3조 ---", listOf("3조 내용1", "3조 내용2", "3조 내용3")) + ) + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermState.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermState.kt new file mode 100644 index 000000000..91c436988 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermState.kt @@ -0,0 +1,11 @@ +package `in`.koreatech.koin.feature.setting.ui.term + +import `in`.koreatech.koin.domain.model.term.Term + +sealed class TermState { + data object Init : TermState() + + data class Success(val term: Term) : TermState() + + data class Failure(val message: String) : TermState() +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermViewModel.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermViewModel.kt new file mode 100644 index 000000000..21ce99385 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/TermViewModel.kt @@ -0,0 +1,59 @@ +package `in`.koreatech.koin.feature.setting.ui.term + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.koreatech.koin.domain.usecase.signup.GetKoinTermUseCase +import `in`.koreatech.koin.domain.usecase.signup.GetMarketingTermUseCase +import `in`.koreatech.koin.domain.usecase.signup.GetPrivacyTermUseCase +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class TermViewModel @Inject constructor( + private val getKoinTermUseCase: GetKoinTermUseCase, + private val getPrivacyTermUseCase: GetPrivacyTermUseCase, + private val getMarketingTermUseCase: GetMarketingTermUseCase +) : ViewModel() { + private val _term: MutableStateFlow = MutableStateFlow(TermState.Init) + val term: StateFlow get() = _term.asStateFlow() + + fun loadKoinTerm() { + viewModelScope.launch { + getKoinTermUseCase() + .onSuccess { + _term.value = TermState.Success(it) + } + .onFailure { + _term.value = TermState.Failure(it.message ?: "") + } + } + } + + fun loadPrivacyTerm() { + viewModelScope.launch { + getPrivacyTermUseCase() + .onSuccess { + _term.value = TermState.Success(it) + } + .onFailure { + _term.value = TermState.Failure(it.message ?: "") + } + } + } + + fun loadMarketingTerm() { + viewModelScope.launch { + getMarketingTermUseCase() + .onSuccess { + _term.value = TermState.Success(it) + } + .onFailure { + _term.value = TermState.Failure(it.message ?: "") + } + } + } +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermDescriptionItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermDescriptionItem.kt new file mode 100644 index 000000000..636e31df9 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermDescriptionItem.kt @@ -0,0 +1,62 @@ +package `in`.koreatech.koin.feature.setting.ui.term.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme + +@Composable +fun TermDescriptionItem( + title: String, + description: List, + modifier: Modifier = Modifier, + backgroundColor: Color = KoinTheme.colors.neutral0 +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = title, + style = KoinTheme.typography.bold15 + ) + } + HorizontalDivider(color = KoinTheme.colors.neutral100) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 26.dp) + ) { + BasicText( + text = description.joinToString(separator = "\n"), + style = KoinTheme.typography.regular12 + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun TermDescriptionItemPreview() { + TermDescriptionItem( + title = "제 1조 ---", + description = listOf("① ('koreatech.in'이하 '코인')은(는) 다음의 개인정보 항목을 처리하고 있습니다.\n 어쩌구 저쩌구..") + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermMenuItem.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermMenuItem.kt new file mode 100644 index 000000000..f0b3c6de7 --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/ui/term/component/TermMenuItem.kt @@ -0,0 +1,58 @@ +package `in`.koreatech.koin.feature.setting.ui.term.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import `in`.koreatech.koin.core.designsystem.theme.KoinTheme + +@Composable +fun TermMenuItem( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = KoinTheme.typography.regular14, + backgroundColor: Color = KoinTheme.colors.neutral0, + onClick: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(color = backgroundColor) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onClick() + } + .padding(vertical = 10.dp, horizontal = 48.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = text, + style = textStyle + ) + } + HorizontalDivider(color = KoinTheme.colors.neutral100) +} + +@Composable +@Preview(showBackground = true) +private fun TermMenuItemPreview() { + TermMenuItem( + text = "제 1조 ---" + ) +} diff --git a/feature/setting/src/main/java/in/koreatech/koin/feature/setting/util/NotificationInfoUtil.kt b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/util/NotificationInfoUtil.kt new file mode 100644 index 000000000..56d5d79ba --- /dev/null +++ b/feature/setting/src/main/java/in/koreatech/koin/feature/setting/util/NotificationInfoUtil.kt @@ -0,0 +1,21 @@ +package `in`.koreatech.koin.feature.setting.util + +import androidx.compose.ui.util.fastForEach +import `in`.koreatech.koin.domain.model.notification.Subscribes +import `in`.koreatech.koin.domain.model.notification.SubscribesDetailType +import `in`.koreatech.koin.domain.model.notification.SubscribesType + +fun List.isTypePermitted(type: SubscribesType): Boolean { + this.fastForEach { subscribe -> + if (subscribe.type == type) return subscribe.isPermit + } + return false +} +fun List.isDetailTypePermitted(type: SubscribesDetailType): Boolean { + this.fastForEach { subscribe -> + subscribe.detailSubscribes.forEach { subscribeDetail -> + if (subscribeDetail.type == type) return subscribeDetail.isPermit + } + } + return false +} diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml new file mode 100644 index 000000000..71b5dc330 --- /dev/null +++ b/feature/setting/src/main/res/values/strings.xml @@ -0,0 +1,49 @@ + + 설정 + 일반 + 프로필 + 비밀번호 변경 + 알림 + + 로그인이 필요한 기능이에요. + 로그인 하기 + + 서비스 + 개인정보 처리방침 + 코인 이용약관 + 마케팅 수신 동의 약관 + 오픈소스 라이선스 + 앱 버전 + 문의하기 + + 코인 이용약관 + 약관 타입을 명시해야 합니다. + + 알림 설정 + 마케팅 정보 수신 동의 + 마케팅 정보 수신 동의/해제 + + 식단 + 품절 알림 + 식단이 품절될 경우 알림을 받습니다. + 아침 식단 + 점심 식단 + 저녁 식단 + 식단사진 업로드 알림 + 식단사진이 업로드 될 경우 알림을 받습니다. + + 게시판 + 공지사항 키워드 알림 + 키워드가 포함된 글이 게시될 경우 알림을 받습니다. + 쪽지 알림 + 쪽지가 도착하면 알림을 받습니다. + + 주변상점 + 이벤트 알림 + 모든 주변상점의 이벤트 알림을 받습니다. + 리뷰 작성 요청 알림 + 전화 주문 시 리뷰 작성 요청 알림을 받습니다. + + 최신 버전 %1$s + 현재 최신 버전 입니다. + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 00e4ef378..9b52e155a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ stickyScrollView = "1.0.2" strictVersionMatcherPlugin = "1.2.4" timber = "5.0.1" turbine = "1.2.0" +playServicesOssLicenses = "17.3.0" [libraries] android-gradle-tool = { module = "com.android.tools.build:gradle", version.ref = "androidGradle" } @@ -182,6 +183,7 @@ stickyScrollView = { module = "com.github.amarjain07:StickyScrollView", version. strict-version-matcher-plugin = { module = "com.google.android.gms:strict-version-matcher-plugin", version.ref = "strictVersionMatcherPlugin" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +play-services-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "playServicesOssLicenses" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradle" } diff --git a/koin/build.gradle.kts b/koin/build.gradle.kts index 9a2e8c62d..4ccaa985d 100644 --- a/koin/build.gradle.kts +++ b/koin/build.gradle.kts @@ -112,6 +112,7 @@ dependencies { implementation(projects.feature.user) implementation(projects.feature.club) implementation(projects.feature.dining) + implementation(projects.feature.setting) implementation(libs.guava) diff --git a/settings.gradle b/settings.gradle index 9e5dd10e1..bc15d2e31 100644 --- a/settings.gradle +++ b/settings.gradle @@ -37,3 +37,4 @@ include ':feature:user' include ':feature:club' include ':core:network' include ':feature:dining' +include ':feature:setting'