From 9390c25b2c54cd988cd9f43cb342fe81ebecbfcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 17:49:45 +0000 Subject: [PATCH 1/4] feat: extract notification settings into dedicated screen Group every notification-related preference under a new "Notifications" entry in the settings menu, rendered with the modern card/section design used by SecurityFiltersScreen. Moves out of "UI preferences": - Push notification provider (UnifiedPush, fdroid) - Always-on notification service + battery optimization banner - Split notifications by Follows The fdroid PushNotificationSettingsRow becomes PushNotificationProviderTile built around SettingsBlockTile; the play stub gains a matching HasPushNotificationProvider() so the push section is hidden on Play builds. --- .../components/SelectNotificationProvider.kt | 41 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/settings/AllSettingsScreen.kt | 6 + .../loggedIn/settings/AppSettingsScreen.kt | 106 +--------- .../settings/NotificationSettingsScreen.kt | 192 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 +- .../components/SelectNotificationProvider.kt | 5 +- 8 files changed, 240 insertions(+), 122 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt 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 From 48d9e80e2059b5e33d4aa8efeb623a1877c99667 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:34:32 +0000 Subject: [PATCH 2/4] refactor: address self-audit on notification settings - Promote the duplicate `SwitchTile` from SecurityFiltersScreen and NotificationSettingsScreen into a shared `SettingsSwitchTile` in SettingsSectionCard so all settings switches share one implementation. - Render BatteryOptimizationBanner outside the in-app section card instead of nesting a Card inside a Card and splitting the section's divider away from the row it explains. - Refresh the battery-optimization exemption on LifecycleResumeEffect so the banner disappears after the user returns from the system settings page; drop the racy post-button re-read. - Demote `HasPushNotificationProvider` to a plain `hasPushNotificationProvider` since it returns a per-flavor constant and reads no Compose state. --- .../components/SelectNotificationProvider.kt | 3 +- .../settings/NotificationSettingsScreen.kt | 142 +++++++----------- .../settings/SecurityFiltersScreen.kt | 27 +--- .../loggedIn/settings/SettingsSectionCard.kt | 20 +++ .../components/SelectNotificationProvider.kt | 3 +- 5 files changed, 81 insertions(+), 114 deletions(-) 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 ee8b2139af..e61c08b852 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 @@ -207,8 +207,7 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList, Immuta ) } -@Composable -fun HasPushNotificationProvider(): Boolean = true +fun hasPushNotificationProvider(): Boolean = true @Composable fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) { 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 index 8b1c463713..5c2450d9c7 100644 --- 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 @@ -20,7 +20,6 @@ */ 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 @@ -32,7 +31,6 @@ 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 @@ -44,13 +42,13 @@ 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.LifecycleResumeEffect 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.components.hasPushNotificationProvider import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -75,109 +73,81 @@ fun NotificationSettingsScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - if (HasPushNotificationProvider()) { + if (hasPushNotificationProvider()) { SettingsSection(R.string.notification_settings_section_push) { PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) } } + val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + SettingsSection(R.string.notification_settings_section_in_app) { - AlwaysOnServiceTile(accountViewModel) + SettingsSwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = alwaysOn, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) SettingsDivider() - SplitByFollowsTile(accountViewModel) + SettingsSwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = splitByFollows, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) + } + + if (alwaysOn) { + BatteryOptimizationBanner() } } } } -@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)) } + LifecycleResumeEffect(Unit) { + isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + onPauseOrDispose {} + } - if (!isExempt) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), + if (isExempt) return + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - 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) }, ) { - 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)) - } + Text(stringRes(R.string.battery_optimization_fix_now)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 11faac4803..d3f9905105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,7 +20,6 @@ */ 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 @@ -31,7 +30,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,7 +38,6 @@ 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.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType @@ -122,7 +119,7 @@ private fun FilterSpamTile(accountViewModel: AccountViewModel) { .filterSpamFromStrangers .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.FilterAlt, title = R.string.filter_spam_from_strangers_title, description = R.string.filter_spam_from_strangers_explainer, @@ -136,7 +133,7 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations .collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Shield, title = R.string.hide_community_rules_violations_title, description = R.string.hide_community_rules_violations_explainer, @@ -151,7 +148,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() - SwitchTile( + SettingsSwitchTile( icon = MaterialSymbols.Report, title = R.string.warn_when_posts_have_reports_from_your_follows_title, description = R.string.warn_when_posts_have_reports_from_your_follows_explainer, @@ -194,24 +191,6 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { } } -@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 BlockedContentSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index d190d18803..a188839456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -248,6 +249,25 @@ internal fun SettingsControlRow( } } +/** A [SettingsControlRow] whose trailing control is a [Switch]; tapping anywhere toggles. */ +@Composable +internal fun SettingsSwitchTile( + 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) + } +} + /** * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, * used for controls hierarchically grouped under the row above (e.g. a threshold 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 2f086def3f..dc32830cc4 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 @@ -54,5 +54,4 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { @Composable fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {} -@Composable -fun HasPushNotificationProvider(): Boolean = false +fun hasPushNotificationProvider(): Boolean = false From 4d88444a5920211d2a67bd28ae4a9d5006bdfc9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:49:09 +0000 Subject: [PATCH 3/4] feat(notifications): split delivery vs display, add Categories section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize Notification settings into three sections that reflect what each control actually does: - Delivery: how notifications reach the device — push provider (fdroid) and the always-on relay service live together here. - In-app display: how the notifications screen renders incoming activity — currently the Split-by-Follows toggle. - Categories: one row per user-facing Android NotificationChannel (DMs, Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls), showing the current importance (On / Silent / Off) and opening the system per-channel settings page on tap. Foreground-service channels are intentionally omitted — disabling them breaks the service contract. The screen ensures every listed channel exists on first open and re-reads importance via LifecycleResumeEffect so the badge reflects changes made in system settings. API surface bumped for the channel registry: - CallNotifier.CALL_CHANNEL_ID is now public. - ScheduledPostNotifier.ensureChannel is now public. --- .../service/call/notification/CallNotifier.kt | 2 +- .../notifications/NotificationChannels.kt | 166 +++++++++++++++++ .../scheduledposts/ScheduledPostNotifier.kt | 2 +- .../settings/NotificationSettingsScreen.kt | 176 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 10 +- 5 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt index 529f11ef78..789f6ff535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt @@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext */ object CallNotifier { private var callChannel: NotificationChannel? = null - private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" + const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" private const val CALL_NOTIFICATION_ID = 0x50000 fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt new file mode 100644 index 0000000000..c538def017 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -0,0 +1,166 @@ +/* + * 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.service.notifications + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.app.NotificationManagerCompat +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.call.notification.CallNotifier +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log + +/** + * Registry of user-facing notification channels and helpers to read their + * current importance / open the system settings page for them. + * + * Android (post-Oreo) owns channel state — the app cannot toggle channel + * importance directly. The Notifications settings screen surfaces the + * channels here and routes the user to the system per-channel page. + * + * Foreground-service channels (relay-connection, nests audio) are + * intentionally omitted: they're functional indicators, not content + * notifications, and disabling them breaks the foreground service contract. + */ +object NotificationChannels { + private const val TAG = "NotificationChannels" + + enum class ChannelStatus { ON, SILENT, OFF } + + /** + * A single content-bearing notification channel exposed in the settings UI. + * [ensure] creates the channel if missing — needed so the system per-channel + * settings page has something to open even before the first notification fires. + */ + data class Entry( + val nameRes: Int, + val icon: MaterialSymbol, + val channelId: (Context) -> String, + val ensure: (Context) -> Unit, + ) + + val contentChannels: List = + listOf( + Entry( + nameRes = R.string.app_notification_dms_channel_name, + icon = MaterialSymbols.Mail, + channelId = { stringRes(it, R.string.app_notification_dms_channel_id) }, + ensure = { NotificationUtils.getOrCreateDMChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_mentions_channel_name, + icon = MaterialSymbols.AlternateEmail, + channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) }, + ensure = { NotificationUtils.getOrCreateMentionChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_replies_channel_name, + icon = MaterialSymbols.Chat, + channelId = { stringRes(it, R.string.app_notification_replies_channel_id) }, + ensure = { NotificationUtils.getOrCreateReplyChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_reactions_channel_name, + icon = MaterialSymbols.Favorite, + channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) }, + ensure = { NotificationUtils.getOrCreateReactionChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_zaps_channel_name, + icon = MaterialSymbols.Bolt, + channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) }, + ensure = { NotificationUtils.getOrCreateZapChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_chess_channel_name, + icon = MaterialSymbols.ChessKnight, + channelId = { stringRes(it, R.string.app_notification_chess_channel_id) }, + ensure = { NotificationUtils.getOrCreateChessChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_scheduled_posts_channel_name, + icon = MaterialSymbols.Schedule, + channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) }, + ensure = { ScheduledPostNotifier.ensureChannel(it) }, + ), + Entry( + nameRes = R.string.app_notification_calls_channel_name, + icon = MaterialSymbols.Call, + channelId = { CallNotifier.CALL_CHANNEL_ID }, + ensure = { CallNotifier.getOrCreateCallChannel(it) }, + ), + ) + + fun statusOf( + context: Context, + channelId: String, + ): ChannelStatus { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF + val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF + val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON + return when (channel.importance) { + NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF + NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT + else -> ChannelStatus.ON + } + } + + /** + * Opens the system per-channel notification settings page. Falls back to + * the app-level notification settings if the per-channel intent isn't + * supported (e.g. the channel was never created, or on stripped-down ROMs). + */ + fun openChannelSettings( + context: Context, + channelId: String, + ) { + try { + val intent = + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e) + openAppNotificationSettings(context) + } + } + + fun openAppNotificationSettings(context: Context) { + try { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to open app notification settings", e) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt index 6ac5b47415..d545ae7760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt @@ -122,7 +122,7 @@ object ScheduledPostNotifier { } } - private fun ensureChannel(context: Context) { + fun ensureChannel(context: Context) { if (channel != null) return channel = NotificationChannel( 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 index 5c2450d9c7..14a39ad7b0 100644 --- 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 @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -37,7 +40,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -47,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper +import com.vitorpamplona.amethyst.service.notifications.NotificationChannels import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -73,42 +80,147 @@ fun NotificationSettingsScreen( .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) - } - } - - val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService - .collectAsStateWithLifecycle() - val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled - .collectAsStateWithLifecycle() - - SettingsSection(R.string.notification_settings_section_in_app) { - SettingsSwitchTile( - icon = MaterialSymbols.Notifications, - title = R.string.always_on_notif_setting_title, - description = R.string.always_on_notif_setting_description, - checked = alwaysOn, - onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, - ) - SettingsDivider() - SettingsSwitchTile( - icon = MaterialSymbols.Forum, - title = R.string.split_notifications_setting_title, - description = R.string.split_notifications_setting_description, - checked = splitByFollows, - onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, - ) - } - - if (alwaysOn) { - BatteryOptimizationBanner() - } + DeliverySection(accountViewModel) + DisplaySection(accountViewModel) + CategoriesSection() } } } +@Composable +private fun DeliverySection(accountViewModel: AccountViewModel) { + val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SettingsSection(R.string.notification_settings_section_delivery) { + if (hasPushNotificationProvider()) { + PushNotificationProviderTile(accountViewModel.settings.uiSettingsFlow) + SettingsDivider() + } + SettingsSwitchTile( + icon = MaterialSymbols.Notifications, + title = R.string.always_on_notif_setting_title, + description = R.string.always_on_notif_setting_description, + checked = alwaysOn, + onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() }, + ) + } + + if (alwaysOn) { + BatteryOptimizationBanner() + } +} + +@Composable +private fun DisplaySection(accountViewModel: AccountViewModel) { + val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + SettingsSection(R.string.notification_settings_section_display) { + SettingsSwitchTile( + icon = MaterialSymbols.Forum, + title = R.string.split_notifications_setting_title, + description = R.string.split_notifications_setting_description, + checked = splitByFollows, + onCheckedChange = { accountViewModel.account.settings.toggleSplitNotificationsEnabled() }, + ) + } +} + +@Composable +private fun CategoriesSection() { + val context = LocalContext.current + val entries = NotificationChannels.contentChannels + + // Ensure every channel exists so the system per-channel page has something + // to open even on a fresh install where the user hasn't received that kind + // of notification yet. + remember(entries) { + entries.forEach { runCatching { it.ensure(context) } } + } + + // Re-read importance on resume so toggling sound/importance in the system + // page reflects back when the user returns. + var refreshKey by remember { mutableStateOf(0) } + LifecycleResumeEffect(Unit) { + refreshKey++ + onPauseOrDispose {} + } + + SettingsSection(R.string.notification_settings_section_categories) { + entries.forEachIndexed { index, entry -> + if (index > 0) SettingsDivider() + val channelId = remember(entry) { entry.channelId(context) } + val status = + remember(refreshKey, channelId) { + NotificationChannels.statusOf(context, channelId) + } + SettingsItem( + title = entry.nameRes, + icon = entry.icon, + trailing = { ChannelStatusBadge(status) }, + onClick = { NotificationChannels.openChannelSettings(context, channelId) }, + ) + } + } + + Text( + text = stringRes(R.string.notification_settings_categories_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +@Composable +private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) { + val (label, container, content) = + when (status) { + NotificationChannels.ChannelStatus.ON -> + Triple( + R.string.notification_channel_status_on, + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + ) + NotificationChannels.ChannelStatus.SILENT -> + Triple( + R.string.notification_channel_status_silent, + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.onSurfaceVariant, + ) + NotificationChannels.ChannelStatus.OFF -> + Triple( + R.string.notification_channel_status_off, + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer, + ) + } + + StatusChip(label = stringRes(label), containerColor = container, contentColor = content) +} + +@Composable +private fun StatusChip( + label: String, + containerColor: Color, + contentColor: Color, +) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(containerColor) + .padding(horizontal = 10.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = contentColor, + ) + } +} + @Composable private fun BatteryOptimizationBanner() { val context = LocalContext.current diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8e7d495a02..3311e94ea4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1675,8 +1675,14 @@ No reactions setup Notifications - In-app notifications - Push notifications + Delivery + In-app display + Categories + Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. + Open Android notification settings + On + Silent + Off Select a UnifiedPush App Push provider From b2b48955701da4a86131153f070cee610f54c288 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 21:53:32 +0000 Subject: [PATCH 4/4] refactor(notifications): cleaner Compose patterns in Categories section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `remember { side effect }` with lazy `entry.ensure(context)` in the row's onClick; the system per-channel page only needs the channel to exist at open time, not before, and the ensure call is idempotent. - Replace the `refreshKey++` invalidation trick with a real `Map` state holder updated inside `LifecycleResumeEffect`; downstream reads are direct map lookups. - Replace `Triple`-with-destructuring in `ChannelStatusBadge` with three direct `when` branches calling `StatusChip` — no tuples, no temporaries. - Drop the unused `notification_settings_open_system` string. --- .../settings/NotificationSettingsScreen.kt | 78 ++++++++++--------- amethyst/src/main/res/values/strings.xml | 1 - 2 files changed, 40 insertions(+), 39 deletions(-) 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 index 14a39ad7b0..b2914644d3 100644 --- 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 @@ -132,18 +132,18 @@ private fun CategoriesSection() { val context = LocalContext.current val entries = NotificationChannels.contentChannels - // Ensure every channel exists so the system per-channel page has something - // to open even on a fresh install where the user hasn't received that kind - // of notification yet. - remember(entries) { - entries.forEach { runCatching { it.ensure(context) } } + // Read each channel's importance after every resume so toggling + // sound/importance in the system page reflects back here. The map IS + // the state — no key-bump trick needed. + var statuses by remember { + mutableStateOf>(emptyMap()) } - - // Re-read importance on resume so toggling sound/importance in the system - // page reflects back when the user returns. - var refreshKey by remember { mutableStateOf(0) } LifecycleResumeEffect(Unit) { - refreshKey++ + statuses = + entries.associate { + val id = it.channelId(context) + id to NotificationChannels.statusOf(context, id) + } onPauseOrDispose {} } @@ -151,15 +151,20 @@ private fun CategoriesSection() { entries.forEachIndexed { index, entry -> if (index > 0) SettingsDivider() val channelId = remember(entry) { entry.channelId(context) } - val status = - remember(refreshKey, channelId) { - NotificationChannels.statusOf(context, channelId) - } + // Default to ON for channels not yet created — matches Android's + // own default importance, so the badge isn't misleading before the + // user has interacted with the channel. + val status = statuses[channelId] ?: NotificationChannels.ChannelStatus.ON SettingsItem( title = entry.nameRes, icon = entry.icon, trailing = { ChannelStatusBadge(status) }, - onClick = { NotificationChannels.openChannelSettings(context, channelId) }, + onClick = { + // Lazy-create the channel right before opening so the system + // per-channel page has something to display; idempotent. + entry.ensure(context) + NotificationChannels.openChannelSettings(context, channelId) + }, ) } } @@ -174,29 +179,26 @@ private fun CategoriesSection() { @Composable private fun ChannelStatusBadge(status: NotificationChannels.ChannelStatus) { - val (label, container, content) = - when (status) { - NotificationChannels.ChannelStatus.ON -> - Triple( - R.string.notification_channel_status_on, - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer, - ) - NotificationChannels.ChannelStatus.SILENT -> - Triple( - R.string.notification_channel_status_silent, - MaterialTheme.colorScheme.surfaceContainerHigh, - MaterialTheme.colorScheme.onSurfaceVariant, - ) - NotificationChannels.ChannelStatus.OFF -> - Triple( - R.string.notification_channel_status_off, - MaterialTheme.colorScheme.errorContainer, - MaterialTheme.colorScheme.onErrorContainer, - ) - } - - StatusChip(label = stringRes(label), containerColor = container, contentColor = content) + when (status) { + NotificationChannels.ChannelStatus.ON -> + StatusChip( + label = stringRes(R.string.notification_channel_status_on), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + NotificationChannels.ChannelStatus.SILENT -> + StatusChip( + label = stringRes(R.string.notification_channel_status_silent), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NotificationChannels.ChannelStatus.OFF -> + StatusChip( + label = stringRes(R.string.notification_channel_status_off), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3311e94ea4..385e0b61db 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1679,7 +1679,6 @@ In-app display Categories Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there. - Open Android notification settings On Silent Off