From 58ec6d2d04f2fae4f6dc9cd5fea47062ae041ab4 Mon Sep 17 00:00:00 2001 From: Clement Ozemoya Date: Tue, 11 Aug 2026 12:04:07 +0100 Subject: [PATCH 1/2] Migrate Files Settings screen to Jetpack Compose --- .../profile/cloud/CloudSettingsFragment.kt | 138 +------ .../profile/cloud/CloudSettingsPage.kt | 317 +++++++++++++++ .../profile/cloud/CloudSettingsViewModel.kt | 57 ++- .../res/layout/fragment_cloud_settings.xml | 373 ------------------ .../cloud/CloudSettingsViewModelTest.kt | 295 ++++++++++++++ 5 files changed, 683 insertions(+), 497 deletions(-) create mode 100644 modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt delete mode 100644 modules/features/profile/src/main/res/layout/fragment_cloud_settings.xml create mode 100644 modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModelTest.kt diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsFragment.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsFragment.kt index 2a32fab19b3..66b4fd36248 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsFragment.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsFragment.kt @@ -3,30 +3,21 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud import android.os.Bundle import android.view.LayoutInflater import android.view.Menu -import android.view.View import android.view.ViewGroup -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground +import au.com.shiftyjelly.pocketcasts.compose.extensions.contentWithoutConsumedInsets import au.com.shiftyjelly.pocketcasts.preferences.Settings -import au.com.shiftyjelly.pocketcasts.profile.R -import au.com.shiftyjelly.pocketcasts.profile.databinding.FragmentCloudSettingsBinding import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingFlow import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingLauncher import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingUpgradeSource -import au.com.shiftyjelly.pocketcasts.views.extensions.setup +import au.com.shiftyjelly.pocketcasts.utils.extensions.pxToDp import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment -import au.com.shiftyjelly.pocketcasts.views.helper.NavigationIcon.BackArrow import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.launch -import au.com.shiftyjelly.pocketcasts.localization.R as LR -import au.com.shiftyjelly.pocketcasts.ui.R as UR @AndroidEntryPoint class CloudSettingsFragment : BaseFragment() { @@ -39,16 +30,21 @@ class CloudSettingsFragment : BaseFragment() { @Inject lateinit var settings: Settings private val viewModel by viewModels() - private var binding: FragmentCloudSettingsBinding? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.onShown() - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - binding = FragmentCloudSettingsBinding.inflate(inflater, container, false) - return binding?.root + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = contentWithoutConsumedInsets { + val bottomInset = settings.bottomInset.collectAsStateWithLifecycle(initialValue = 0) + AppThemeWithBackground(theme.activeTheme) { + CloudSettingsPage( + viewModel = viewModel, + bottomInset = bottomInset.value.pxToDp(LocalContext.current).dp, + onBackPress = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onUpgradeClick = { openUpgradeSheet() }, + ) + } } @Deprecated("Deprecated in Java") @@ -58,100 +54,6 @@ class CloudSettingsFragment : BaseFragment() { menu.clear() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val binding = binding ?: return - - binding.toolbar.setup(title = getString(LR.string.profile_cloud_settings_title), navigationIcon = BackArrow, activity = activity, theme = theme) - - context?.let { context -> - AppCompatResources.getDrawable(context, R.drawable.ic_lock)?.let { - val color0 = ContextCompat.getColor(context, UR.color.plus_gold_dark) - val color1 = ContextCompat.getColor(context, UR.color.plus_gold_light) - binding.imgLock.setup(it, color0, color1) - } - } - - val lblDeleteCloudFileAfterPlaying = binding.lblDeleteCloudFileAfterPlaying - val swtDeleteCloudFileAfterPlaying = binding.swtDeleteCloudFileAfterPlaying - viewModel.signInState.observe(viewLifecycleOwner) { signInState -> - lblDeleteCloudFileAfterPlaying.isVisible = signInState.isSignedInAsPlusOrPatron - swtDeleteCloudFileAfterPlaying.isVisible = signInState.isSignedInAsPlusOrPatron - - binding.plusLayout.isEnabled = signInState.isSignedInAsPlusOrPatron - binding.plusLayout.alpha = if (signInState.isSignedInAsPlusOrPatron) 1.0f else 0.5f - binding.imgLock.isVisible = !signInState.isSignedInAsPlusOrPatron - binding.btnLock.isVisible = !signInState.isSignedInAsPlusOrPatron - - binding.swtAutoUploadToCloud.isEnabled = signInState.isSignedInAsPlusOrPatron - binding.swtAutoDownloadFromCloud.isEnabled = signInState.isSignedInAsPlusOrPatron - binding.swtCloudOnlyOnWiFi.isEnabled = signInState.isSignedInAsPlusOrPatron - - binding.upgradeLayout.isVisible = !signInState.isSignedInAsPlusOrPatron && !settings.getUpgradeClosedCloudSettings() - } - - with(binding.swtAutoAddToUpNext) { - isChecked = settings.cloudAddToUpNext.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setAddToUpNext(isChecked) - } - } - with(binding.swtDeleteLocalFileAfterPlaying) { - isChecked = settings.deleteLocalFileAfterPlaying.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setDeleteLocalFileAfterPlaying(isChecked) - } - } - with(binding.swtDeleteCloudFileAfterPlaying) { - isChecked = settings.deleteCloudFileAfterPlaying.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setDeleteCloudFileAfterPlaying(isChecked) - } - } - with(binding.swtAutoUploadToCloud) { - isChecked = settings.cloudAutoUpload.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setCloudAutoUpload(isChecked) - } - } - with(binding.swtAutoDownloadFromCloud) { - isChecked = settings.cloudAutoDownload.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setCloudAutoDownload(isChecked) - } - } - with(binding.swtCloudOnlyOnWiFi) { - isChecked = settings.cloudDownloadOnlyOnWifi.value - setOnCheckedChangeListener { _, isChecked -> - viewModel.setCloudOnlyWifi(isChecked) - } - } - - binding.btnClose.setOnClickListener { - viewModel.onUpgradeBannerDismissed(SourceView.FILES_SETTINGS) - settings.setUpgradeClosedCloudSettings(true) - binding.upgradeLayout.isVisible = false - } - - listOf( - binding.btnLock, - binding.imgLogo, - binding.lblGetMore, - binding.lblFindMore, - ).forEach { - it.setOnClickListener { openUpgradeSheet() } - } - - val scrollContainer = binding.scrollContainer - val initialPadding = scrollContainer.paddingBottom - viewLifecycleOwner.lifecycleScope.launch { - settings.bottomInset - .flowWithLifecycle(viewLifecycleOwner.lifecycle) - .collect { inset -> scrollContainer.updatePadding(bottom = initialPadding + inset) } - } - } - override fun onPause() { super.onPause() viewModel.onFragmentPause(activity?.isChangingConfigurations) diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt new file mode 100644 index 00000000000..b5ec0fcdf00 --- /dev/null +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt @@ -0,0 +1,317 @@ +package au.com.shiftyjelly.pocketcasts.profile.cloud + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground +import au.com.shiftyjelly.pocketcasts.compose.CallOnce +import au.com.shiftyjelly.pocketcasts.compose.PreviewRegularDevice +import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar +import au.com.shiftyjelly.pocketcasts.compose.components.Banner +import au.com.shiftyjelly.pocketcasts.compose.components.GradientIcon +import au.com.shiftyjelly.pocketcasts.compose.components.SettingRow +import au.com.shiftyjelly.pocketcasts.compose.components.SettingRowToggle +import au.com.shiftyjelly.pocketcasts.compose.components.SettingSection +import au.com.shiftyjelly.pocketcasts.compose.components.SettingSectionHeader +import au.com.shiftyjelly.pocketcasts.compose.components.SettingsSection +import au.com.shiftyjelly.pocketcasts.compose.components.TextH40 +import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider +import au.com.shiftyjelly.pocketcasts.compose.theme +import au.com.shiftyjelly.pocketcasts.profile.R +import au.com.shiftyjelly.pocketcasts.ui.theme.Theme +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.localization.R as LR +import au.com.shiftyjelly.pocketcasts.ui.R as UR + +@Composable +fun CloudSettingsPage( + bottomInset: Dp, + onBackPress: () -> Unit, + onUpgradeClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: CloudSettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + CallOnce { + viewModel.onShown() + } + + CloudSettingsContent( + uiState = uiState, + bottomInset = bottomInset, + onBackPress = onBackPress, + onUpgradeClick = onUpgradeClick, + onAddToUpNextChange = viewModel::setAddToUpNext, + onDeleteLocalFileChange = viewModel::setDeleteLocalFileAfterPlaying, + onDeleteCloudFileChange = viewModel::setDeleteCloudFileAfterPlaying, + onCloudAutoUploadChange = viewModel::setCloudAutoUpload, + onCloudAutoDownloadChange = viewModel::setCloudAutoDownload, + onCloudOnlyOnWifiChange = viewModel::setCloudOnlyWifi, + onUpgradeBannerDismiss = { viewModel.onUpgradeBannerDismissed(SourceView.FILES_SETTINGS) }, + modifier = modifier, + ) +} + +@Composable +private fun CloudSettingsContent( + uiState: CloudSettingsViewModel.UiState, + bottomInset: Dp, + onBackPress: () -> Unit, + onUpgradeClick: () -> Unit, + onAddToUpNextChange: (Boolean) -> Unit, + onDeleteLocalFileChange: (Boolean) -> Unit, + onDeleteCloudFileChange: (Boolean) -> Unit, + onCloudAutoUploadChange: (Boolean) -> Unit, + onCloudAutoDownloadChange: (Boolean) -> Unit, + onCloudOnlyOnWifiChange: (Boolean) -> Unit, + onUpgradeBannerDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + ThemedTopAppBar( + title = stringResource(LR.string.profile_cloud_settings_title), + bottomShadow = true, + onNavigationClick = onBackPress, + ) + LazyColumn( + contentPadding = PaddingValues(bottom = bottomInset), + ) { + item { + FileSettingsSection( + uiState = uiState, + onAddToUpNextChange = onAddToUpNextChange, + onDeleteLocalFileChange = onDeleteLocalFileChange, + onDeleteCloudFileChange = onDeleteCloudFileChange, + ) + } + item { + PlusFeaturesSection( + uiState = uiState, + onCloudAutoUploadChange = onCloudAutoUploadChange, + onCloudAutoDownloadChange = onCloudAutoDownloadChange, + onCloudOnlyOnWifiChange = onCloudOnlyOnWifiChange, + onUpgradeClick = onUpgradeClick, + ) + } + if (uiState.isUpgradeBannerVisible) { + item { + Banner( + title = stringResource(LR.string.pocket_casts_plus), + description = stringResource(LR.string.profile_get_plus), + actionLabel = stringResource(LR.string.plus_learn_more_button), + icon = painterResource(IR.drawable.ic_plus_feature_cloud_storage), + onActionClick = onUpgradeClick, + onDismiss = onUpgradeBannerDismiss, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsSection.horizontalPadding, vertical = 16.dp) + .clickable(role = Role.Button, onClick = onUpgradeClick), + ) + } + } + } + } +} + +@Composable +private fun FileSettingsSection( + uiState: CloudSettingsViewModel.UiState, + onAddToUpNextChange: (Boolean) -> Unit, + onDeleteLocalFileChange: (Boolean) -> Unit, + onDeleteCloudFileChange: (Boolean) -> Unit, +) { + SettingSection { + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_auto_add_to_up_next), + secondaryText = stringResource(LR.string.profile_cloud_all_files_added), + toggle = SettingRowToggle.Switch(checked = uiState.cloudAddToUpNext), + modifier = Modifier.toggleable( + value = uiState.cloudAddToUpNext, + role = Role.Switch, + ) { onAddToUpNextChange(!uiState.cloudAddToUpNext) }, + ) + SettingSectionHeader(text = stringResource(LR.string.profile_cloud_after_playing)) + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_delete_local_file), + toggle = SettingRowToggle.Switch(checked = uiState.deleteLocalFileAfterPlaying), + modifier = Modifier.toggleable( + value = uiState.deleteLocalFileAfterPlaying, + role = Role.Switch, + ) { onDeleteLocalFileChange(!uiState.deleteLocalFileAfterPlaying) }, + ) + if (uiState.isSignedInAsPlusOrPatron) { + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_delete_cloud_file), + toggle = SettingRowToggle.Switch(checked = uiState.deleteCloudFileAfterPlaying), + modifier = Modifier.toggleable( + value = uiState.deleteCloudFileAfterPlaying, + role = Role.Switch, + ) { onDeleteCloudFileChange(!uiState.deleteCloudFileAfterPlaying) }, + ) + } + } +} + +@Composable +private fun PlusFeaturesSection( + uiState: CloudSettingsViewModel.UiState, + onCloudAutoUploadChange: (Boolean) -> Unit, + onCloudAutoDownloadChange: (Boolean) -> Unit, + onCloudOnlyOnWifiChange: (Boolean) -> Unit, + onUpgradeClick: () -> Unit, +) { + val isPlus = uiState.isSignedInAsPlusOrPatron + SettingSection(showDivider = false) { + PlusFeaturesSectionHeader(isLocked = !isPlus) + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_auto_upload_to_cloud), + secondaryText = stringResource(LR.string.profile_cloud_files_added_uploaded), + enabled = isPlus, + toggle = SettingRowToggle.Switch(checked = uiState.cloudAutoUpload, enabled = isPlus), + modifier = Modifier.toggleable( + value = uiState.cloudAutoUpload, + role = Role.Switch, + ) { + if (isPlus) onCloudAutoUploadChange(!uiState.cloudAutoUpload) else onUpgradeClick() + }, + ) + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_auto_download_from_cloud), + secondaryText = stringResource(LR.string.profile_cloud_files_added_downloaded), + enabled = isPlus, + toggle = SettingRowToggle.Switch(checked = uiState.cloudAutoDownload, enabled = isPlus), + modifier = Modifier.toggleable( + value = uiState.cloudAutoDownload, + role = Role.Switch, + ) { + if (isPlus) onCloudAutoDownloadChange(!uiState.cloudAutoDownload) else onUpgradeClick() + }, + ) + SettingRow( + primaryText = stringResource(LR.string.profile_cloud_only_on_wifi), + enabled = isPlus, + toggle = SettingRowToggle.Switch(checked = uiState.cloudDownloadOnlyOnWifi, enabled = isPlus), + modifier = Modifier.toggleable( + value = uiState.cloudDownloadOnlyOnWifi, + role = Role.Switch, + ) { + if (isPlus) onCloudOnlyOnWifiChange(!uiState.cloudDownloadOnlyOnWifi) else onUpgradeClick() + }, + ) + } +} + +@Composable +private fun PlusFeaturesSectionHeader(isLocked: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + start = SettingsSection.indentedStartPadding, + end = SettingsSection.horizontalPadding, + top = SettingsSection.verticalPadding, + bottom = SettingsSection.verticalPadding, + ), + ) { + TextH40( + text = stringResource(LR.string.profile_cloud_plus_features), + color = MaterialTheme.theme.colors.primaryInteractive01, + ) + if (isLocked) { + Spacer(Modifier.width(8.dp)) + GradientIcon( + painter = painterResource(R.drawable.ic_lock), + colors = listOf( + colorResource(UR.color.plus_gold_dark), + colorResource(UR.color.plus_gold_light), + ), + modifier = Modifier.size(width = 12.dp, height = 16.dp), + ) + } + } +} + +@PreviewRegularDevice +@Composable +private fun CloudSettingsPagePlusPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType, +) { + AppThemeWithBackground(themeType) { + CloudSettingsContent( + uiState = CloudSettingsViewModel.UiState( + cloudAddToUpNext = true, + deleteLocalFileAfterPlaying = true, + deleteCloudFileAfterPlaying = false, + cloudAutoUpload = true, + cloudAutoDownload = false, + cloudDownloadOnlyOnWifi = true, + isSignedInAsPlusOrPatron = true, + isUpgradeBannerVisible = false, + ), + bottomInset = 0.dp, + onBackPress = {}, + onUpgradeClick = {}, + onAddToUpNextChange = {}, + onDeleteLocalFileChange = {}, + onDeleteCloudFileChange = {}, + onCloudAutoUploadChange = {}, + onCloudAutoDownloadChange = {}, + onCloudOnlyOnWifiChange = {}, + onUpgradeBannerDismiss = {}, + ) + } +} + +@PreviewRegularDevice +@Composable +private fun CloudSettingsPageFreePreview( + @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType, +) { + AppThemeWithBackground(themeType) { + CloudSettingsContent( + uiState = CloudSettingsViewModel.UiState( + cloudAddToUpNext = true, + deleteLocalFileAfterPlaying = false, + deleteCloudFileAfterPlaying = false, + cloudAutoUpload = false, + cloudAutoDownload = false, + cloudDownloadOnlyOnWifi = false, + isSignedInAsPlusOrPatron = false, + isUpgradeBannerVisible = true, + ), + bottomInset = 0.dp, + onBackPress = {}, + onUpgradeClick = {}, + onAddToUpNextChange = {}, + onDeleteLocalFileChange = {}, + onDeleteCloudFileChange = {}, + onCloudAutoUploadChange = {}, + onCloudAutoDownloadChange = {}, + onCloudOnlyOnWifiChange = {}, + onUpgradeBannerDismiss = {}, + ) + } +} diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt index 41198133dc0..49e30b5d0a6 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModel.kt @@ -1,10 +1,9 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.toLiveData +import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.models.type.SignInState +import au.com.shiftyjelly.pocketcasts.coroutines.flow.combine import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import com.automattic.eventhorizon.EventHorizon @@ -18,18 +17,51 @@ import com.automattic.eventhorizon.SettingsFilesShownEvent import com.automattic.eventhorizon.UpgradeBannerDismissedEvent import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.reactive.asFlow @HiltViewModel class CloudSettingsViewModel @Inject constructor( private val eventHorizon: EventHorizon, private val settings: Settings, - userManager: UserManager, + private val userManager: UserManager, ) : ViewModel() { - val signInState: LiveData = userManager.getSignInState().toLiveData() - private var isFragmentChangingConfigurations: Boolean = false + private val isUpgradeBannerDismissed = MutableStateFlow(settings.getUpgradeClosedCloudSettings()) + + val uiState: StateFlow = combine( + settings.cloudAddToUpNext.flow, + settings.deleteLocalFileAfterPlaying.flow, + settings.deleteCloudFileAfterPlaying.flow, + settings.cloudAutoUpload.flow, + settings.cloudAutoDownload.flow, + settings.cloudDownloadOnlyOnWifi.flow, + userManager.getSignInState().asFlow(), + isUpgradeBannerDismissed, + transform = { addToUpNext, deleteLocal, deleteCloud, autoUpload, autoDownload, onlyOnWifi, signInState, bannerDismissed -> + val isSignedInAsPlusOrPatron = signInState.isSignedInAsPlusOrPatron + UiState( + cloudAddToUpNext = addToUpNext, + deleteLocalFileAfterPlaying = deleteLocal, + deleteCloudFileAfterPlaying = deleteCloud, + cloudAutoUpload = autoUpload, + cloudAutoDownload = autoDownload, + cloudDownloadOnlyOnWifi = onlyOnWifi, + isSignedInAsPlusOrPatron = isSignedInAsPlusOrPatron, + isUpgradeBannerVisible = !isSignedInAsPlusOrPatron && !bannerDismissed, + ) + }, + ).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = UiState(), + ) + fun onShown() { if (!isFragmentChangingConfigurations) { eventHorizon.track(SettingsFilesShownEvent) @@ -95,10 +127,23 @@ class CloudSettingsViewModel @Inject constructor( } fun onUpgradeBannerDismissed(source: SourceView) { + settings.setUpgradeClosedCloudSettings(true) + isUpgradeBannerDismissed.value = true eventHorizon.track( UpgradeBannerDismissedEvent( source = source.analyticsValue, ), ) } + + data class UiState( + val cloudAddToUpNext: Boolean = false, + val deleteLocalFileAfterPlaying: Boolean = false, + val deleteCloudFileAfterPlaying: Boolean = false, + val cloudAutoUpload: Boolean = false, + val cloudAutoDownload: Boolean = false, + val cloudDownloadOnlyOnWifi: Boolean = false, + val isSignedInAsPlusOrPatron: Boolean = false, + val isUpgradeBannerVisible: Boolean = false, + ) } diff --git a/modules/features/profile/src/main/res/layout/fragment_cloud_settings.xml b/modules/features/profile/src/main/res/layout/fragment_cloud_settings.xml deleted file mode 100644 index 3f0ef3d2f88..00000000000 --- a/modules/features/profile/src/main/res/layout/fragment_cloud_settings.xml +++ /dev/null @@ -1,373 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModelTest.kt b/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModelTest.kt new file mode 100644 index 00000000000..1758c50979f --- /dev/null +++ b/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsViewModelTest.kt @@ -0,0 +1,295 @@ +package au.com.shiftyjelly.pocketcasts.profile.cloud + +import app.cash.turbine.test +import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.analytics.testing.TestEventSink +import au.com.shiftyjelly.pocketcasts.models.type.SignInState +import au.com.shiftyjelly.pocketcasts.models.type.Subscription +import au.com.shiftyjelly.pocketcasts.preferences.ReadWriteSetting +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.preferences.UserSetting +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager +import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule +import com.automattic.eventhorizon.EventHorizon +import com.automattic.eventhorizon.SettingsFilesAutoAddUpNextToggledEvent +import com.automattic.eventhorizon.SettingsFilesAutoDownloadFromCloudToggledEvent +import com.automattic.eventhorizon.SettingsFilesAutoUploadToCloudToggledEvent +import com.automattic.eventhorizon.SettingsFilesDeleteCloudFileAfterPlayingToggledEvent +import com.automattic.eventhorizon.SettingsFilesDeleteLocalFileAfterPlayingToggledEvent +import com.automattic.eventhorizon.SettingsFilesOnlyOnWifiToggledEvent +import com.automattic.eventhorizon.SettingsFilesShownEvent +import com.automattic.eventhorizon.UpgradeBannerDismissedEvent +import io.reactivex.Flowable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class CloudSettingsViewModelTest { + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val cloudAddToUpNextFlow = MutableStateFlow(false) + private val deleteLocalFileAfterPlayingFlow = MutableStateFlow(false) + private val deleteCloudFileAfterPlayingFlow = MutableStateFlow(false) + private val cloudAutoUploadFlow = MutableStateFlow(false) + private val cloudAutoDownloadFlow = MutableStateFlow(false) + private val cloudDownloadOnlyOnWifiFlow = MutableStateFlow(false) + + private val cloudAddToUpNextSetting = mock> { on { flow } doReturn cloudAddToUpNextFlow } + private val deleteLocalFileAfterPlayingSetting = mock> { on { flow } doReturn deleteLocalFileAfterPlayingFlow } + private val deleteCloudFileAfterPlayingSetting = mock> { on { flow } doReturn deleteCloudFileAfterPlayingFlow } + private val cloudAutoUploadSetting = mock> { on { flow } doReturn cloudAutoUploadFlow } + private val cloudAutoDownloadSetting = mock> { on { flow } doReturn cloudAutoDownloadFlow } + private val cloudDownloadOnlyOnWifiSetting = mock> { on { flow } doReturn cloudDownloadOnlyOnWifiFlow } + + private val settings = mock { + on { cloudAddToUpNext } doReturn cloudAddToUpNextSetting + on { deleteLocalFileAfterPlaying } doReturn deleteLocalFileAfterPlayingSetting + on { deleteCloudFileAfterPlaying } doReturn deleteCloudFileAfterPlayingSetting + on { cloudAutoUpload } doReturn cloudAutoUploadSetting + on { cloudAutoDownload } doReturn cloudAutoDownloadSetting + on { cloudDownloadOnlyOnWifi } doReturn cloudDownloadOnlyOnWifiSetting + on { getUpgradeClosedCloudSettings() } doReturn false + } + + private val userManager = mock() + + private lateinit var eventSink: TestEventSink + + @Before + fun setUp() { + eventSink = TestEventSink() + whenever(userManager.getSignInState()).thenReturn(Flowable.just(SignInState.SignedOut)) + } + + private fun createViewModel() = CloudSettingsViewModel( + eventHorizon = EventHorizon(eventSink), + settings = settings, + userManager = userManager, + ) + + private fun signedInAs(subscription: Subscription?) { + whenever(userManager.getSignInState()).thenReturn( + Flowable.just(SignInState.SignedIn(email = "user@example.com", subscription = subscription)), + ) + } + + @Test + fun `uiState reflects the initial preference values from settings`() = runTest { + cloudAddToUpNextFlow.value = true + deleteLocalFileAfterPlayingFlow.value = true + deleteCloudFileAfterPlayingFlow.value = false + cloudAutoUploadFlow.value = true + cloudAutoDownloadFlow.value = false + cloudDownloadOnlyOnWifiFlow.value = true + + createViewModel().uiState.test { + val state = awaitItem() + assertTrue(state.cloudAddToUpNext) + assertTrue(state.deleteLocalFileAfterPlaying) + assertFalse(state.deleteCloudFileAfterPlaying) + assertTrue(state.cloudAutoUpload) + assertFalse(state.cloudAutoDownload) + assertTrue(state.cloudDownloadOnlyOnWifi) + } + } + + @Test + fun `uiState emits updated preference values as settings flows change`() = runTest { + createViewModel().uiState.test { + assertFalse(awaitItem().cloudAutoUpload) + + cloudAutoUploadFlow.value = true + assertTrue(awaitItem().cloudAutoUpload) + + cloudDownloadOnlyOnWifiFlow.value = true + assertTrue(awaitItem().cloudDownloadOnlyOnWifi) + } + } + + @Test + fun `isSignedInAsPlusOrPatron is true when signed in as Plus`() = runTest { + signedInAs(Subscription.PlusPreview) + + createViewModel().uiState.test { + assertTrue(awaitItem().isSignedInAsPlusOrPatron) + } + } + + @Test + fun `isSignedInAsPlusOrPatron is true when signed in as Patron`() = runTest { + signedInAs(Subscription.PatronPreview) + + createViewModel().uiState.test { + assertTrue(awaitItem().isSignedInAsPlusOrPatron) + } + } + + @Test + fun `isSignedInAsPlusOrPatron is false when signed out`() = runTest { + // Flip a preference so the assertion can only pass if the combine actually emitted, + // rather than matching the UiState() default. + cloudAutoUploadFlow.value = true + + createViewModel().uiState.test { + val state = awaitItem() + assertTrue(state.cloudAutoUpload) + assertFalse(state.isSignedInAsPlusOrPatron) + } + } + + @Test + fun `isSignedInAsPlusOrPatron is false when signed in as a free user`() = runTest { + signedInAs(subscription = null) + cloudAutoUploadFlow.value = true + + createViewModel().uiState.test { + val state = awaitItem() + assertTrue(state.cloudAutoUpload) + assertFalse(state.isSignedInAsPlusOrPatron) + } + } + + @Test + fun `isUpgradeBannerVisible is true when not Plus or Patron and not previously dismissed`() = runTest { + createViewModel().uiState.test { + assertTrue(awaitItem().isUpgradeBannerVisible) + } + } + + @Test + fun `isUpgradeBannerVisible is false when signed in as Plus even if not previously dismissed`() = runTest { + signedInAs(Subscription.PlusPreview) + + createViewModel().uiState.test { + assertFalse(awaitItem().isUpgradeBannerVisible) + } + } + + @Test + fun `isUpgradeBannerVisible is false when previously dismissed even if not Plus or Patron`() = runTest { + whenever(settings.getUpgradeClosedCloudSettings()).thenReturn(true) + cloudAutoUploadFlow.value = true + + createViewModel().uiState.test { + val state = awaitItem() + assertTrue(state.cloudAutoUpload) + assertFalse(state.isSignedInAsPlusOrPatron) + assertFalse(state.isUpgradeBannerVisible) + } + } + + @Test + fun `onUpgradeBannerDismissed persists the setting, hides the banner, and tracks the event`() = runTest { + val viewModel = createViewModel() + + viewModel.uiState.test { + assertTrue(awaitItem().isUpgradeBannerVisible) + + viewModel.onUpgradeBannerDismissed(SourceView.FILES_SETTINGS) + + assertFalse(awaitItem().isUpgradeBannerVisible) + } + + verify(settings).setUpgradeClosedCloudSettings(true) + assertEquals( + UpgradeBannerDismissedEvent(source = SourceView.FILES_SETTINGS.analyticsValue), + eventSink.pollEvent(), + ) + } + + @Test + fun `setAddToUpNext persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setAddToUpNext(true) + + verify(cloudAddToUpNextSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesAutoAddUpNextToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setDeleteLocalFileAfterPlaying persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setDeleteLocalFileAfterPlaying(true) + + verify(deleteLocalFileAfterPlayingSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesDeleteLocalFileAfterPlayingToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setDeleteCloudFileAfterPlaying persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setDeleteCloudFileAfterPlaying(true) + + verify(deleteCloudFileAfterPlayingSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesDeleteCloudFileAfterPlayingToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setCloudAutoUpload persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setCloudAutoUpload(true) + + verify(cloudAutoUploadSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesAutoUploadToCloudToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setCloudAutoDownload persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setCloudAutoDownload(true) + + verify(cloudAutoDownloadSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesAutoDownloadFromCloudToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setCloudOnlyWifi persists the preference with updateModifiedAt and tracks the event`() { + createViewModel().setCloudOnlyWifi(true) + + verify(cloudDownloadOnlyOnWifiSetting).set(true, updateModifiedAt = true) + assertEquals(SettingsFilesOnlyOnWifiToggledEvent(enabled = true), eventSink.pollEvent()) + } + + @Test + fun `setters track the disabled state when a preference is turned off`() { + createViewModel().setAddToUpNext(false) + + verify(cloudAddToUpNextSetting).set(false, updateModifiedAt = true) + assertEquals(SettingsFilesAutoAddUpNextToggledEvent(enabled = false), eventSink.pollEvent()) + } + + @Test + fun `onShown tracks SettingsFilesShownEvent`() { + createViewModel().onShown() + + assertEquals(SettingsFilesShownEvent, eventSink.pollEvent()) + } + + @Test + fun `onShown does not track SettingsFilesShownEvent while the fragment is changing configurations`() { + val viewModel = createViewModel() + + viewModel.onFragmentPause(true) + viewModel.onShown() + + assertTrue(eventSink.isEmpty()) + } + + @Test + fun `onShown tracks again once the fragment is no longer changing configurations`() { + val viewModel = createViewModel() + + viewModel.onFragmentPause(true) + viewModel.onShown() + viewModel.onFragmentPause(false) + viewModel.onShown() + + assertEquals(SettingsFilesShownEvent, eventSink.pollEvent()) + assertTrue(eventSink.isEmpty()) + } +} From 25024b690355b03d800605b1a47790c8a92693cd Mon Sep 17 00:00:00 2001 From: Clement Ozemoya Date: Tue, 11 Aug 2026 17:13:11 +0100 Subject: [PATCH 2/2] Restore the original Files Settings upgrade banner layout --- .../profile/cloud/CloudSettingsPage.kt | 76 ++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt index b5ec0fcdf00..602093bc17e 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudSettingsPage.kt @@ -1,6 +1,9 @@ package au.com.shiftyjelly.pocketcasts.profile.cloud +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -9,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -21,6 +26,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -30,14 +36,15 @@ import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground import au.com.shiftyjelly.pocketcasts.compose.CallOnce import au.com.shiftyjelly.pocketcasts.compose.PreviewRegularDevice import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar -import au.com.shiftyjelly.pocketcasts.compose.components.Banner import au.com.shiftyjelly.pocketcasts.compose.components.GradientIcon +import au.com.shiftyjelly.pocketcasts.compose.components.HorizontalDivider import au.com.shiftyjelly.pocketcasts.compose.components.SettingRow import au.com.shiftyjelly.pocketcasts.compose.components.SettingRowToggle import au.com.shiftyjelly.pocketcasts.compose.components.SettingSection import au.com.shiftyjelly.pocketcasts.compose.components.SettingSectionHeader import au.com.shiftyjelly.pocketcasts.compose.components.SettingsSection import au.com.shiftyjelly.pocketcasts.compose.components.TextH40 +import au.com.shiftyjelly.pocketcasts.compose.components.TextH50 import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider import au.com.shiftyjelly.pocketcasts.compose.theme import au.com.shiftyjelly.pocketcasts.profile.R @@ -119,17 +126,9 @@ private fun CloudSettingsContent( } if (uiState.isUpgradeBannerVisible) { item { - Banner( - title = stringResource(LR.string.pocket_casts_plus), - description = stringResource(LR.string.profile_get_plus), - actionLabel = stringResource(LR.string.plus_learn_more_button), - icon = painterResource(IR.drawable.ic_plus_feature_cloud_storage), - onActionClick = onUpgradeClick, + UpgradeBanner( + onUpgradeClick = onUpgradeClick, onDismiss = onUpgradeBannerDismiss, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SettingsSection.horizontalPadding, vertical = 16.dp) - .clickable(role = Role.Button, onClick = onUpgradeClick), ) } } @@ -225,6 +224,61 @@ private fun PlusFeaturesSection( } } +/** + * Mirrors the layout of the `upgradeLayout` block from the deleted `fragment_cloud_settings.xml`: + * a divider, a centred themed Plus wordmark, the upsell copy, and a close affordance top-end. + */ +@Composable +private fun UpgradeBanner( + onUpgradeClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(top = 16.dp)) { + HorizontalDivider(color = MaterialTheme.theme.colors.secondaryUi02) + Box { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onUpgradeClick) + .padding(start = 64.dp, end = 64.dp, top = 16.dp, bottom = 64.dp), + ) { + val logoResource = if (MaterialTheme.theme.isLight) { + IR.drawable.plus_logo_horizontal_light + } else { + IR.drawable.plus_logo_horizontal_dark + } + Image( + painter = painterResource(logoResource), + contentDescription = stringResource(LR.string.pocket_casts_plus), + modifier = Modifier.widthIn(max = 232.dp), + ) + TextH40( + text = stringResource(LR.string.profile_get_plus), + color = MaterialTheme.theme.colors.primaryText02, + textAlign = TextAlign.Center, + ) + TextH50( + text = stringResource(LR.string.plus_learn_more_button), + color = MaterialTheme.theme.colors.primaryInteractive01, + textAlign = TextAlign.Center, + ) + } + Icon( + painter = painterResource(IR.drawable.ic_close), + contentDescription = stringResource(LR.string.close), + tint = MaterialTheme.theme.colors.primaryText02, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp) + .clickable(role = Role.Button, onClick = onDismiss), + ) + } + } +} + @Composable private fun PlusFeaturesSectionHeader(isLocked: Boolean) { Row(