Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 13:30:48 +03:00
parent 6c41eefe0b
commit 044efe36c5
19 changed files with 540 additions and 35 deletions

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.domain.pushNotificationPreferences)
/* AndroidX */
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/* Compose */
@ -50,7 +51,7 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
/* Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)

View file

@ -0,0 +1,96 @@
package com.tangem.features.pushnotificationsettings.impl.component
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.doOnResume
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.walletsettings.component.NetworksAvailableForNotificationsComponent
import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.entity.NetworksAvailableForNotificationBSConfig
import com.tangem.features.pushnotificationsettings.impl.model.PushNotificationSettingsModel
import com.tangem.features.pushnotificationsettings.impl.ui.PushNotificationSettingsScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultPushNotificationSettingsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: PushNotificationSettingsComponent.Params,
private val networksAvailableForNotificationsComponentFactory: NetworksAvailableForNotificationsComponent.Factory,
) : PushNotificationSettingsComponent, AppComponentContext by context {
private val model: PushNotificationSettingsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
key = "moreInfoBottomSheet",
childFactory = ::bottomSheetChild,
)
init {
lifecycle.doOnResume { model.onResume() }
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = model::onPermissionResult,
)
LaunchedEffect(Unit) {
model.requestPushPermission.collect {
val permission = getPushPermissionOrNull()
if (permission != null) {
permissionLauncher.launch(permission)
} else {
model.onPermissionResult(isGranted = false)
}
}
}
PushNotificationSettingsScreen(
modifier = modifier,
state = state,
onBackClick = router::pop,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
@Suppress("UNUSED_PARAMETER") config: NetworksAvailableForNotificationBSConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = networksAvailableForNotificationsComponentFactory.create(
context = childByContext(componentContext),
params = NetworksAvailableForNotificationsComponent.Params(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
@AssistedFactory
interface Factory : PushNotificationSettingsComponent.Factory {
override fun create(
context: AppComponentContext,
params: PushNotificationSettingsComponent.Params,
): DefaultPushNotificationSettingsComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.pushnotificationsettings.impl.di
import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent
import com.tangem.features.pushnotificationsettings.impl.component.DefaultPushNotificationSettingsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PushNotificationSettingsComponentModule {
@Binds
@Singleton
fun bindPushNotificationSettingsComponentFactory(
factory: DefaultPushNotificationSettingsComponent.Factory,
): PushNotificationSettingsComponent.Factory
}

View file

@ -1,7 +1,6 @@
package com.tangem.features.pushnotificationsettings.impl.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import kotlinx.collections.immutable.PersistentList
@Immutable
@ -12,7 +11,6 @@ internal sealed interface PushNotificationSettingsUM {
data class Content(
val banner: AllowPushNotificationsBannerUM?,
val toggles: PersistentList<ToggleUM>,
val requestPermissionEvent: StateEvent<Unit>,
val onMoreInfoClick: () -> Unit,
) : PushNotificationSettingsUM

View file

@ -9,9 +9,6 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -38,6 +35,8 @@ import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -69,7 +69,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val loadState = MutableStateFlow<LoadState>(LoadState.Loading)
private val osNotificationsEnabled = MutableStateFlow(systemNotificationsStateProvider.areNotificationsEnabled())
private val pendingRequest = MutableStateFlow<StateEvent<Unit>>(consumedEvent())
private var pendingPermissionToggle: ToggleSpec? = null
private val preferencesJobHolder = JobHolder()
@ -77,15 +76,19 @@ internal class PushNotificationSettingsModel @Inject constructor(
private val cachedPrefs: WalletPushNotificationPreferences?
get() = (loadState.value as? LoadState.Content)?.prefs
private val requestPushPermissionChannel = Channel<Unit>(Channel.BUFFERED)
/** One-shot requests to launch the system push permission prompt, consumed by the component. */
val requestPushPermission: Flow<Unit> = requestPushPermissionChannel.receiveAsFlow()
val uiState: StateFlow<PushNotificationSettingsUM> = combine(
loadState,
osNotificationsEnabled,
pendingRequest,
) { load, osEnabled, request ->
) { load, osEnabled ->
when (load) {
is LoadState.Failed -> PushNotificationSettingsUM.Error(onRetryClick = ::onRetry)
is LoadState.Loading -> PushNotificationSettingsUM.Loading
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled, request = request)
is LoadState.Content -> buildContent(prefs = load.prefs, osEnabled = osEnabled)
}
}.stateIn(
scope = modelScope,
@ -117,7 +120,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
}
fun onPermissionResult(isGranted: Boolean) {
pendingRequest.value = consumedEvent()
val tapped = pendingPermissionToggle
pendingPermissionToggle = null
modelScope.launch {
@ -145,12 +147,10 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun buildContent(
prefs: WalletPushNotificationPreferences,
osEnabled: Boolean,
request: StateEvent<Unit>,
): PushNotificationSettingsUM.Content {
return PushNotificationSettingsUM.Content(
banner = buildBanner(prefs = prefs, osEnabled = osEnabled),
toggles = buildToggles(prefs),
requestPermissionEvent = request,
onMoreInfoClick = ::onMoreInfoClick,
)
}
@ -190,7 +190,7 @@ internal class PushNotificationSettingsModel @Inject constructor(
private fun requestPermission(tapped: ToggleSpec? = null) {
pendingPermissionToggle = tapped
pendingRequest.value = triggeredEvent(data = Unit, onConsume = ::onPermissionEventConsumed)
requestPushPermissionChannel.trySend(Unit)
}
private fun onBannerCtaClick() {
@ -219,10 +219,6 @@ internal class PushNotificationSettingsModel @Inject constructor(
applyOptimisticToggle(spec, newValue)
}
private fun onPermissionEventConsumed() {
pendingRequest.value = consumedEvent()
}
private fun applyOptimisticToggle(spec: ToggleSpec, newValue: Boolean) {
val current = cachedPrefs ?: return
loadState.value = LoadState.Content(current.withCategory(spec.category, newValue))

View file

@ -0,0 +1,28 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
@Composable
internal fun AllowPushNotificationsBanner(state: AllowPushNotificationsBannerUM, modifier: Modifier = Modifier) {
Notification(
modifier = modifier.fillMaxWidth(),
config = NotificationConfig(
title = resourceReference(R.string.push_notification_settings_banner_title),
subtitle = resourceReference(R.string.push_notification_settings_banner_description),
iconResId = CoreUiR.drawable.ic_alert_circle_24,
iconTint = NotificationConfig.IconTint.Warning,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_open_settings_button_title),
onClick = state.onOpenSettingsClick,
),
),
)
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.pushnotificationsettings.impl.ui
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.styledResourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleUM
@Composable
internal fun NotificationSettingRow(
toggle: ToggleUM,
showInlineMoreInfoLink: Boolean,
onMoreInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
BlockCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(id = toggle.titleRes),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
TangemSwitch(
checked = toggle.isOn,
onCheckedChange = toggle.onCheckedChange,
)
}
}
SubtitleText(
base = toggle.subtitle,
showMoreInfo = showInlineMoreInfoLink,
onMoreInfoClick = onMoreInfoClick,
)
}
}
@Composable
private fun SubtitleText(base: TextReference, showMoreInfo: Boolean, onMoreInfoClick: () -> Unit) {
val reference: TextReference = if (showMoreInfo) {
combinedReference(
base,
stringReference(" "),
styledResourceReference(
id = R.string.push_notifications_more_info,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = onMoreInfoClick,
),
)
} else {
base
}
Text(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
text = reference.resolveAnnotatedReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
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 com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.entity.AllowPushNotificationsBannerUM
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
import com.tangem.features.pushnotificationsettings.impl.entity.ToggleId
@Composable
internal fun PushNotificationSettingsContent(
state: PushNotificationSettingsUM.Content,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
item(key = "banner") {
AnimatedBannerSlot(banner = state.banner)
}
items(items = state.toggles, key = { it.id.name }) { toggle ->
NotificationSettingRow(
modifier = Modifier.animateItem(),
toggle = toggle,
showInlineMoreInfoLink = toggle.id == ToggleId.TransactionAlerts,
onMoreInfoClick = state.onMoreInfoClick,
)
}
}
}
@Composable
private fun AnimatedBannerSlot(banner: AllowPushNotificationsBannerUM?) {
var lastVisible by remember { mutableStateOf(banner) }
if (banner != null) {
lastVisible = banner
}
AnimatedVisibility(
visible = banner != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
lastVisible?.let { AllowPushNotificationsBanner(state = it) }
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
@Composable
internal fun PushNotificationSettingsError(state: PushNotificationSettingsUM.Error, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = state.onRetryClick)
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.pushnotificationsettings.impl.ui
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun PushNotificationSettingsLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
repeat(SHIMMER_ROW_COUNT) {
ShimmerRow()
}
}
}
@Composable
private fun ShimmerRow(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
BlockCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
RectangleShimmer(
modifier = Modifier
.width(SHIMMER_TITLE_WIDTH)
.height(SHIMMER_TITLE_HEIGHT),
)
RectangleShimmer(
modifier = Modifier
.width(SHIMMER_SWITCH_WIDTH)
.height(SHIMMER_SWITCH_HEIGHT),
radius = TangemTheme.dimens.radius12,
)
}
}
RectangleShimmer(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.width(SHIMMER_SUBTITLE_WIDTH)
.height(SHIMMER_SUBTITLE_HEIGHT),
)
}
}
private const val SHIMMER_ROW_COUNT = 3
private val SHIMMER_TITLE_WIDTH = 160.dp
private val SHIMMER_TITLE_HEIGHT = 18.dp
private val SHIMMER_SWITCH_WIDTH = 40.dp
private val SHIMMER_SWITCH_HEIGHT = 22.dp
private val SHIMMER_SUBTITLE_WIDTH = 220.dp
private val SHIMMER_SUBTITLE_HEIGHT = 14.dp

View file

@ -0,0 +1,46 @@
package com.tangem.features.pushnotificationsettings.impl.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.pushnotificationsettings.impl.R
import com.tangem.features.pushnotificationsettings.impl.entity.PushNotificationSettingsUM
@Composable
internal fun PushNotificationSettingsScreen(
state: PushNotificationSettingsUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.push_notification_settings_title),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when (state) {
is PushNotificationSettingsUM.Loading -> PushNotificationSettingsLoading()
is PushNotificationSettingsUM.Content -> PushNotificationSettingsContent(state = state)
is PushNotificationSettingsUM.Error -> PushNotificationSettingsError(state = state)
}
}
}
}

View file

@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
@Suppress("LongParameterList")
class PushNotificationSettingsModelTest {
@ -204,13 +204,15 @@ class PushNotificationSettingsModelTest {
val model = model(osEnabled = false, preferencesFlow = flow)
advanceUntilIdle()
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true)
advanceUntilIdle()
model.requestPushPermission.test {
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
offers.onCheckedChange(true)
advanceUntilIdle()
val content = model.uiState.value as PushNotificationSettingsUM.Content
assertThat(content.requestPermissionEvent.javaClass.simpleName).isEqualTo("Triggered")
awaitItem()
expectNoEvents()
}
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
}
@ -224,12 +226,14 @@ class PushNotificationSettingsModelTest {
val banner = requireNotNull(
(model.uiState.value as PushNotificationSettingsUM.Content).banner,
)
banner.onOpenSettingsClick()
advanceUntilIdle()
model.requestPushPermission.test {
banner.onOpenSettingsClick()
advanceUntilIdle()
// The banner CTA opens system settings directly and never asks for the permission.
expectNoEvents()
}
verify(exactly = 1) { settingsManager.openAppNotificationSettings() }
val refreshed = model.uiState.value as PushNotificationSettingsUM.Content
assertThat(refreshed.requestPermissionEvent.javaClass.simpleName).isEqualTo("Consumed")
}
@Test