diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a93c3d6..5e60c23b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + suspend fun setOnboardingCompleted(isCompleted: Boolean) + + val isNotificationEnabled: Flow + + suspend fun setNotificationEnabled(isEnabled: Boolean) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt index f9cf7741..14531cfe 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt @@ -3,6 +3,7 @@ package com.ninecraft.booket.core.data.impl.repository import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.data.impl.mapper.toModel +import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource import com.ninecraft.booket.core.network.request.TermsAgreementRequest import com.ninecraft.booket.core.network.service.ReedService @@ -11,6 +12,7 @@ import javax.inject.Inject internal class DefaultUserRepository @Inject constructor( private val service: ReedService, private val onboardingDataSource: OnboardingDataSource, + private val notificationDataSource: NotificationDataSource, ) : UserRepository { override suspend fun agreeTerms(termsAgreed: Boolean) = runSuspendCatching { service.agreeTerms(TermsAgreementRequest(termsAgreed)).toModel() @@ -25,4 +27,10 @@ internal class DefaultUserRepository @Inject constructor( override suspend fun setOnboardingCompleted(isCompleted: Boolean) { onboardingDataSource.setOnboardingCompleted(isCompleted) } + + override val isNotificationEnabled = notificationDataSource.isNotificationEnabled + + override suspend fun setNotificationEnabled(isEnabled: Boolean) { + notificationDataSource.setNotificationEnabled(isEnabled) + } } diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt new file mode 100644 index 00000000..6b0d1483 --- /dev/null +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt @@ -0,0 +1,8 @@ +package com.ninecraft.booket.core.datastore.api.datasource + +import kotlinx.coroutines.flow.Flow + +interface NotificationDataSource { + val isNotificationEnabled: Flow + suspend fun setNotificationEnabled(isEnabled: Boolean) +} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt new file mode 100644 index 00000000..24db08c0 --- /dev/null +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt @@ -0,0 +1,32 @@ +package com.ninecraft.booket.core.datastore.impl.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource +import com.ninecraft.booket.core.datastore.impl.di.NotificationDataStore +import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DefaultNotificationDataSource @Inject constructor( + @NotificationDataStore private val dataStore: DataStore, +) : NotificationDataSource { + override val isNotificationEnabled: Flow = dataStore.data + .handleIOException() + .map { prefs -> + prefs[NOTIFICATION_ENABLED] ?: true + } + + override suspend fun setNotificationEnabled(isEnabled: Boolean) { + dataStore.edit { prefs -> + prefs[NOTIFICATION_ENABLED] = isEnabled + } + } + + companion object Companion { + private val NOTIFICATION_ENABLED = booleanPreferencesKey("NOTIFICATION_ENABLED") + } +} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt index 08e11837..88a2c3cc 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt @@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLibraryRecentSearchDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultOnboardingDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultBookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultNotificationDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultTokenDataSource import dagger.Binds import dagger.Module @@ -27,11 +29,13 @@ object DataStoreModule { private const val BOOK_RECENT_SEARCH_DATASTORE_NAME = "BOOK_RECENT_SEARCH_DATASTORE" private const val LIBRARY_RECENT_SEARCH_DATASTORE_NAME = "LIBRARY_RECENT_SEARCH_DATASTORE" private const val ONBOARDING_DATASTORE_NAME = "ONBOARDING_DATASTORE" + private const val NOTIFICATION_DATASTORE_NAME = "NOTIFICATION_DATASTORE" private val Context.tokenDataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME) private val Context.bookRecentSearchDataStore by preferencesDataStore(name = BOOK_RECENT_SEARCH_DATASTORE_NAME) private val Context.libraryRecentSearchDataStore by preferencesDataStore(name = LIBRARY_RECENT_SEARCH_DATASTORE_NAME) private val Context.onboardingDataStore by preferencesDataStore(name = ONBOARDING_DATASTORE_NAME) + private val Context.notificationDataStore by preferencesDataStore(name = NOTIFICATION_DATASTORE_NAME) @TokenDataStore @Provides @@ -60,6 +64,13 @@ object DataStoreModule { fun provideOnboardingDataStore( @ApplicationContext context: Context, ): DataStore = context.onboardingDataStore + + @NotificationDataStore + @Provides + @Singleton + fun provideNotificationDataStore( + @ApplicationContext context: Context, + ): DataStore = context.notificationDataStore } @Module @@ -89,4 +100,10 @@ abstract class DataStoreBindModule { abstract fun bindOnboardingDataSource( defaultOnboardingDataSource: DefaultOnboardingDataSource, ): OnboardingDataSource + + @Binds + @Singleton + abstract fun bindNotificationDataSource( + defaultNotificationDataSource: DefaultNotificationDataSource, + ): NotificationDataSource } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt index 49262242..8a65ab8c 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt @@ -17,3 +17,7 @@ annotation class LibraryRecentSearchDataStore @Qualifier @Retention(AnnotationRetention.BINARY) annotation class OnboardingDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class NotificationDataStore diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 8c09a08c..840963c2 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementations( libs.logger, + libs.androidx.activity.compose, libs.lottie.compose, ) } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt index 175b1804..3bfb3f5c 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt @@ -11,9 +11,9 @@ import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.RecentBookModel import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.feature.screens.BookDetailScreen +import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.RecordScreen -import com.ninecraft.booket.feature.screens.BookSearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject @@ -89,6 +89,10 @@ class HomePresenter @AssistedInject constructor( restoreState = true, ) } + + is HomeUiEvent.OnNotificationPermissionResult -> { + // TODO: 서버 동기화, FCM 토큰 전송 + } } } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt index 8307716e..8219946c 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt @@ -1,5 +1,9 @@ package com.ninecraft.booket.feature.home +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,10 +23,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.theme.HomeBg import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -48,6 +55,28 @@ internal fun HomeUi( ) { HandleHomeSideEffects(state = state) + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + state.eventSink(HomeUiEvent.OnNotificationPermissionResult(granted)) + } + + if (!state.isGuestMode) { + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permission = android.Manifest.permission.POST_NOTIFICATIONS + val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + if (!isGranted) { + permissionLauncher.launch(permission) + } + } else { + state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true)) + } + } + } + ReedScaffold( modifier = modifier.fillMaxSize(), bottomBar = { diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt index c90d001a..39377b53 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt @@ -43,4 +43,5 @@ sealed interface HomeUiEvent : CircuitUiEvent { ) : HomeUiEvent data object OnRetryClick : HomeUiEvent data class OnTabSelected(val tab: MainTab) : HomeUiEvent + data class OnNotificationPermissionResult(val isGranted: Boolean) : HomeUiEvent } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt index cc14a049..d2b13b71 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt @@ -98,19 +98,21 @@ internal fun SettingsUi( ) }, ) - SettingItem( - title = stringResource(R.string.settings_notification), - onItemClick = { - state.eventSink(SettingsUiEvent.OnNotificationClick) - }, - action = { - Icon( - imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), - contentDescription = "Right Chevron Icon", - tint = Color.Unspecified, - ) - }, - ) + if (!state.isGuestMode) { + SettingItem( + title = stringResource(R.string.settings_notification), + onItemClick = { + state.eventSink(SettingsUiEvent.OnNotificationClick) + }, + action = { + Icon( + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), + contentDescription = "Right Chevron Icon", + tint = Color.Unspecified, + ) + }, + ) + } SettingItem( title = stringResource(R.string.settings_terms_of_service), onItemClick = { diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt index 6b868357..706b8488 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt @@ -2,24 +2,27 @@ package com.ninecraft.booket.feature.settings.notification import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.feature.screens.NotificationScreen import com.slack.circuit.codegen.annotations.CircuitInject -import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.coroutines.launch class NotificationPresenter @AssistedInject constructor( @Assisted val navigator: Navigator, + private val userRepository: UserRepository, ) : Presenter { @Composable override fun present(): NotificationUiState { - var isNotificationEnabled by rememberRetained { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isNotificationEnabled by userRepository.isNotificationEnabled.collectAsRetainedState(initial = false) fun handleEvent(event: NotificationUiEvent) { when (event) { @@ -28,7 +31,9 @@ class NotificationPresenter @AssistedInject constructor( } is NotificationUiEvent.OnNotificationToggle -> { - isNotificationEnabled = event.enabled + scope.launch { + userRepository.setNotificationEnabled(event.enabled) + } } } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt index 3c1d475a..39377518 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt @@ -1,10 +1,12 @@ package com.ninecraft.booket.feature.settings.notification +import android.content.Context import android.content.Intent -import android.net.Uri +import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,12 +20,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.ninecraft.booket.core.common.extensions.noRippleClickable import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -43,6 +51,31 @@ internal fun NotificationUi( state: NotificationUiState, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val isGranted by produceState( + initialValue = checkNotificationPermission(context), + key1 = lifecycleOwner, + ) { + // 포그라운드 복귀 시 OS 권한 동기화 + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + value = checkNotificationPermission(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + awaitDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { _ -> } + + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + ReedScaffold( modifier = modifier.fillMaxSize(), containerColor = White, @@ -59,15 +92,24 @@ internal fun NotificationUi( state.eventSink(NotificationUiEvent.OnBackClick) }, ) + if (!isGranted) { + NotificationGuideItem( + onClick = { + settingsLauncher.launch(intent) + }, + ) + } Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - NotificationGuideItem() - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) ToggleItem( title = stringResource(R.string.notification_toggle_title), description = stringResource(R.string.notification_toggle_description), - isChecked = state.isNotificationEnabled, + isChecked = isGranted && state.isNotificationEnabled, onCheckedChange = { enabled -> - state.eventSink(NotificationUiEvent.OnNotificationToggle(enabled)) + if (isGranted) { + state.eventSink(NotificationUiEvent.OnNotificationToggle(enabled)) + } else { + settingsLauncher.launch(intent) + } }, ) } @@ -75,26 +117,22 @@ internal fun NotificationUi( } @Composable -internal fun NotificationGuideItem() { - val context = LocalContext.current - val settingsLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { _ -> } - +internal fun NotificationGuideItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row( - modifier = Modifier - .padding(horizontal = ReedTheme.spacing.spacing5) + modifier = modifier + .padding( + vertical = ReedTheme.spacing.spacing2, + horizontal = ReedTheme.spacing.spacing5, + ) .fillMaxWidth() .background( color = ReedTheme.colors.baseSecondary, shape = RoundedCornerShape(ReedTheme.radius.md), ) - .noRippleClickable { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - settingsLauncher.launch(intent) - } + .noRippleClickable { onClick() } .padding( vertical = ReedTheme.spacing.spacing6, horizontal = ReedTheme.spacing.spacing5, @@ -122,6 +160,12 @@ internal fun NotificationGuideItem() { } } +private fun checkNotificationPermission(context: Context): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return notificationManager.areNotificationsEnabled() +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) @DevicePreview @Composable private fun NotificationUiPreview() {