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() {