mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #2967 from vitorpamplona/claude/notification-settings-refactor-FZ1bx
Extract notification settings to dedicated screen
This commit is contained in:
+25
-15
@@ -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
|
||||
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
|
||||
)
|
||||
}
|
||||
|
||||
fun hasPushNotificationProvider(): Boolean = true
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+166
@@ -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<Entry> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
|
||||
@@ -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<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
|
||||
@@ -239,6 +239,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object CallSettings : Route()
|
||||
|
||||
@Serializable object NotificationSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
-104
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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.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
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.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
|
||||
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.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
|
||||
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),
|
||||
) {
|
||||
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
|
||||
|
||||
// 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<Map<String, NotificationChannels.ChannelStatus>>(emptyMap())
|
||||
}
|
||||
LifecycleResumeEffect(Unit) {
|
||||
statuses =
|
||||
entries.associate {
|
||||
val id = it.channelId(context)
|
||||
id to NotificationChannels.statusOf(context, id)
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
SettingsSection(R.string.notification_settings_section_categories) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
if (index > 0) SettingsDivider()
|
||||
val channelId = remember(entry) { entry.channelId(context) }
|
||||
// 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 = {
|
||||
// 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
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
|
||||
var isExempt by remember {
|
||||
mutableStateOf(BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context))
|
||||
}
|
||||
LifecycleResumeEffect(Unit) {
|
||||
isExempt = BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
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),
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NotificationSettingsScreenPreview() {
|
||||
ThemeComparisonColumn {
|
||||
NotificationSettingsScreen(mockAccountViewModel(), EmptyNav())
|
||||
}
|
||||
}
|
||||
+3
-24
@@ -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,
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
@@ -1676,9 +1676,18 @@
|
||||
<string name="read_only_user">Read-only user</string>
|
||||
<string name="no_reactions_setup">No reactions setup</string>
|
||||
|
||||
<string name="notification_settings">Notifications</string>
|
||||
<string name="notification_settings_section_delivery">Delivery</string>
|
||||
<string name="notification_settings_section_display">In-app display</string>
|
||||
<string name="notification_settings_section_categories">Categories</string>
|
||||
<string name="notification_settings_categories_explainer">Tap a category to open Android notification settings for it — sound, importance, badges and Do Not Disturb live there.</string>
|
||||
<string name="notification_channel_status_on">On</string>
|
||||
<string name="notification_channel_status_silent">Silent</string>
|
||||
<string name="notification_channel_status_off">Off</string>
|
||||
|
||||
<string name="select_push_server">Select a UnifiedPush App</string>
|
||||
<string name="push_server_title">Push Notification</string>
|
||||
<string name="push_server_explainer">From installed UnifiedPush apps</string>
|
||||
<string name="push_server_title">Push provider</string>
|
||||
<string name="push_server_explainer">Pick a UnifiedPush app to deliver notifications when Amethyst is closed.</string>
|
||||
<string name="push_server_none">None</string>
|
||||
<string name="push_server_none_explainer">Disables Push Notifications</string>
|
||||
<string name="push_server_uses_app_explainer">Uses app %1$s</string>
|
||||
|
||||
+3
-1
@@ -52,4 +52,6 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {}
|
||||
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {}
|
||||
|
||||
fun hasPushNotificationProvider(): Boolean = false
|
||||
|
||||
Reference in New Issue
Block a user