diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index d0c5b3397d..ee8b2139af 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList @@ -208,23 +208,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList, Immuta } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) { +fun HasPushNotificationProvider(): Boolean = true + +@Composable +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) { val context = LocalContext.current LoadDistributors { currentDistributor, list, readableListWithExplainer -> - SettingsRow( - R.string.push_server_title, - R.string.push_server_explainer, - selectedItems = readableListWithExplainer, - selectedIndex = list.indexOf(currentDistributor), - ) { index -> - if (list[index] == "None") { - sharedPrefs.dontAskForNotificationPermissions() - sharedPrefs.dontShowPushNotificationSelector() - PushDistributorHandler.forceRemoveDistributor(context) - } else { - PushDistributorHandler.saveDistributor(list[index]) - } + val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0) + SettingsBlockTile( + icon = MaterialSymbols.CloudSync, + title = stringRes(R.string.push_server_title), + description = stringRes(R.string.push_server_explainer), + ) { + TextSpinner( + label = null, + placeholder = readableListWithExplainer[selectedIndex].title, + options = readableListWithExplainer, + onSelect = { index -> + if (list[index] == "None") { + sharedPrefs.dontAskForNotificationPermissions() + sharedPrefs.dontShowPushNotificationSelector() + PushDistributorHandler.forceRemoveDistributor(context) + } else { + PushDistributorHandler.saveDistributor(list[index]) + } + }, + modifier = Modifier.fillMaxWidth(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 04055ec777..10834421dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -164,6 +164,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen @@ -326,6 +327,7 @@ fun BuildNavigation( composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } + composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3d5ece6229..b9e4ae495e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -239,6 +239,8 @@ sealed class Route { @Serializable object CallSettings : Route() + @Serializable object NotificationSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index bf15f61607..7fb17f1bee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -218,6 +218,12 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.Settings) }, ) SettingsDivider() + SettingsItem( + title = R.string.notification_settings, + icon = MaterialSymbols.Notifications, + onClick = { nav.nav(Route.NotificationSettings) }, + ) + SettingsDivider() SettingsItem( title = R.string.compose_settings, icon = MaterialSymbols.Edit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 9be0e54f6b..0d3a39e992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -46,14 +42,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType @@ -65,8 +58,6 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType -import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper -import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,7 +88,7 @@ fun SettingsScreen( }, ) { Column(Modifier.padding(it)) { - SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel) + SettingsScreen(accountViewModel.settings.uiSettingsFlow) } } } @@ -111,10 +102,7 @@ fun SettingsScreenPreview() { } @Composable -fun SettingsScreen( - sharedPrefs: UiSettingsFlow, - accountViewModel: AccountViewModel? = null, -) { +fun SettingsScreen(sharedPrefs: UiSettingsFlow) { Column( Modifier .fillMaxSize() @@ -128,18 +116,11 @@ fun SettingsScreen( ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) - if (BuildConfig.FLAVOR == "play") { - } ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) GalleryChoice(sharedPrefs) - PushNotificationSettingsRow(sharedPrefs) - if (accountViewModel != null) { - AlwaysOnNotificationServiceChoice(accountViewModel) - SplitNotificationsChoice(accountViewModel) - } } } @@ -489,86 +470,3 @@ fun SettingsRow( } } } - -@Composable -fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.always_on_notif_setting_title, - R.string.always_on_notif_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleAlwaysOnNotificationService() - }, - ) - } - - if (enabled) { - BatteryOptimizationBanner() - } -} - -@Composable -fun SplitNotificationsChoice(accountViewModel: AccountViewModel) { - val enabled by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SettingsRow( - R.string.split_notifications_setting_title, - R.string.split_notifications_setting_description, - ) { - Switch( - checked = enabled, - onCheckedChange = { - accountViewModel.account.settings.toggleSplitNotificationsEnabled() - }, - ) - } -} - -@Composable -fun BatteryOptimizationBanner() { - val context = LocalContext.current - val isExempt = - remember { - BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) - } - - if (!isExempt) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringRes(R.string.battery_optimization_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringRes(R.string.battery_optimization_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Button( - onClick = { - BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) - }, - ) { - Text(stringRes(R.string.battery_optimization_fix_now)) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..8b1c463713 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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 androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper +import com.vitorpamplona.amethyst.ui.components.HasPushNotificationProvider +import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun NotificationSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.notification_settings), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (HasPushNotificationProvider()) { + SettingsSection(R.string.notification_settings_section_push) { + PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) + } + } + + SettingsSection(R.string.notification_settings_section_in_app) { + AlwaysOnServiceTile(accountViewModel) + SettingsDivider() + SplitByFollowsTile(accountViewModel) + } + } + } +} + +@Composable +private fun AlwaysOnServiceTile(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = enabled, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) + + if (enabled) { + BatteryOptimizationBanner() + } +} + +@Composable +private fun SplitByFollowsTile(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = enabled, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) +} + +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun BatteryOptimizationBanner() { + val context = LocalContext.current + var isExempt by remember { + mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)) + } + + if (!isExempt) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { + BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) + isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + }, + ) { + Text(stringRes(R.string.battery_optimization_fix_now)) + } + } + } + } +} + +@Preview +@Composable +fun NotificationSettingsScreenPreview() { + ThemeComparisonColumn { + NotificationSettingsScreen(mockAccountViewModel(), EmptyNav()) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c5..8e7d495a02 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1674,9 +1674,13 @@ Read-only user No reactions setup + Notifications + In-app notifications + Push notifications + Select a UnifiedPush App - Push Notification - From installed UnifiedPush apps + Push provider + Pick a UnifiedPush app to deliver notifications when Amethyst is closed. None Disables Push Notifications Uses app %1$s diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 5091181653..2f086def3f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -52,4 +52,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { } @Composable -fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {} +fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} + +@Composable +fun HasPushNotificationProvider(): Boolean = false