feat: notification settings screen

This commit is contained in:
infi 2026-05-24 21:48:38 +02:00
parent 2b84cbb342
commit 51c6dc7fc0
10 changed files with 314 additions and 12 deletions

View File

@ -75,7 +75,6 @@ import chat.stoat.R
import chat.stoat.StoatApplication
import chat.stoat.api.HitRateLimitException
import chat.stoat.api.StoatAPI
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.api.StoatHttp
import chat.stoat.api.api
import chat.stoat.api.routes.microservices.geo.queryGeo
@ -85,6 +84,7 @@ import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.GeoStateProvider
import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.SyncedSettings
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.composables.voice.VoiceSheet
@ -117,6 +117,7 @@ import chat.stoat.screens.settings.ChatSettingsScreen
import chat.stoat.screens.settings.DebugSettingsScreen
import chat.stoat.screens.settings.ExperimentsSettingsScreen
import chat.stoat.screens.settings.LanguagePickerSettingsScreen
import chat.stoat.screens.settings.NotificationsSettingsScreen
import chat.stoat.screens.settings.ProfileSettingsScreen
import chat.stoat.screens.settings.SessionSettingsScreen
import chat.stoat.screens.settings.SettingsScreen
@ -712,6 +713,7 @@ fun AppEntrypoint(
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/chat") { ChatSettingsScreen(navController) }
composable("settings/notifications") { NotificationsSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
composable("settings/language") { LanguagePickerSettingsScreen(navController) }

View File

@ -24,3 +24,7 @@ suspend fun subscribePush(
contentType(ContentType.Application.Json)
}
}
suspend fun unsubscribePush() {
StoatHttp.post("/push/unsubscribe".api())
}

View File

@ -84,19 +84,31 @@ class HandlerService : FirebaseMessagingService() {
val messageTimestamp = ULID.asTimestamp(messageId)
val db = Database(SqlStorage.driver)
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
when (it.channelType) {
"DirectMessage" -> authorName
"TextChannel" -> "#${it.name}"
else -> it.name ?: authorName
fun serverPrefix(serverId: String?): String? {
if (serverId == null) return null
return db.serverQueries.findById(serverId).executeAsOneOrNull()?.name
}
fun formatChannelName(type: String, name: String?, serverId: String?): String {
val base = when (type) {
"DirectMessage" -> return authorName
"TextChannel" -> "#${name}"
else -> name ?: return authorName
}
val prefix = serverPrefix(serverId) ?: return base
return "$prefix · $base"
}
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
formatChannelName(it.channelType, it.name, it.server)
} ?: runBlocking {
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
when (it.channelType) {
ChannelType.DirectMessage -> authorName
ChannelType.TextChannel -> "#${it.name}"
else -> it.name ?: authorName
}
formatChannelName(
it.channelType?.value ?: "",
it.name,
it.server
)
} ?: authorName
}

View File

@ -0,0 +1,56 @@
package chat.stoat.composables.generic
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@Composable
fun CenteredListItem(
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: @Composable (() -> Unit)? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation
) {
ListItem(
modifier = modifier.height(IntrinsicSize.Min),
headlineContent = headlineContent,
overlineContent = overlineContent,
supportingContent = supportingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
leadingContent = leadingContent?.let { content ->
{
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
content()
}
}
},
trailingContent = trailingContent?.let { content ->
{
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
content()
}
}
}
)
}

View File

@ -8,6 +8,7 @@ import chat.stoat.screens.login.LoginViewModel
import chat.stoat.screens.login.MfaScreenViewModel
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
import chat.stoat.screens.settings.DebugSettingsScreenViewModel
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
import chat.stoat.screens.settings.SettingsScreenViewModel
import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel
@ -26,6 +27,7 @@ val viewModelModule = module {
viewModel { MfaScreenViewModel(get()) }
viewModel { SettingsScreenViewModel(get()) }
viewModel { DebugSettingsScreenViewModel(get()) }
viewModel { NotificationsSettingsScreenViewModel(get(), androidContext()) }
viewModel { LoginViewModel(get()) }
viewModel { ProfileSettingsScreenViewModel(androidApplication()) }
viewModel { AppearanceSettingsScreenViewModel(androidApplication()) }

View File

@ -0,0 +1,185 @@
package chat.stoat.screens.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.push.subscribePush
import chat.stoat.api.routes.push.unsubscribePush
import chat.stoat.composables.generic.CenteredListItem
import chat.stoat.dialogs.NotificationRationaleDialog
import chat.stoat.persistence.KVStorage
import chat.stoat.settings.dsl.SettingsPage
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@SuppressLint("StaticFieldLeak")
class NotificationsSettingsScreenViewModel(
private val kvStorage: KVStorage,
private val context: Context
) : ViewModel() {
var showRationale by mutableStateOf(false)
var isPushEnabled by mutableStateOf(false)
private set
var isUpdating by mutableStateOf(false)
private set
init {
viewModelScope.launch {
isPushEnabled = checkPushEnabled()
}
}
private suspend fun checkPushEnabled(): Boolean {
val hasPermission = NotificationManagerCompat.from(context).areNotificationsEnabled()
val hasToken = kvStorage.get("fcmToken") != null
return hasPermission && hasToken
}
fun onEnableRequested() {
showRationale = true
}
fun subscribeIfNeeded() {
if (isUpdating) return
isUpdating = true
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
isUpdating = false
return@OnCompleteListener
}
val newToken = task.result
viewModelScope.launch {
try {
val existingToken = kvStorage.get("fcmToken")
if (existingToken != newToken) {
subscribePush(auth = newToken)
kvStorage.set("fcmToken", newToken)
}
kvStorage.remove("pushNotificationsRejected")
isPushEnabled = checkPushEnabled()
} catch (e: Exception) {
// subscribe failed, leave state unchanged
} finally {
isUpdating = false
}
}
}
)
}
fun disablePush() {
if (isUpdating) return
isUpdating = true
viewModelScope.launch {
try {
val token = kvStorage.get("fcmToken")
if (token != null) {
runCatching { unsubscribePush() }
kvStorage.remove("fcmToken")
}
kvStorage.set("pushNotificationsRejected", true)
isPushEnabled = false
} finally {
isUpdating = false
}
}
}
}
@Composable
fun NotificationsSettingsScreen(
navController: NavController,
viewModel: NotificationsSettingsScreenViewModel = koinViewModel()
) {
val context = LocalContext.current
val askNotificationsPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) viewModel.subscribeIfNeeded()
}
if (viewModel.showRationale) {
NotificationRationaleDialog(
onSelected = { accepted ->
if (accepted) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
askNotificationsPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.subscribeIfNeeded()
}
}
},
onDismiss = { viewModel.showRationale = false }
)
}
SettingsPage(
navController = navController,
title = { Text(stringResource(R.string.settings_notifications)) }
) {
CenteredListItem(
headlineContent = { Text(stringResource(R.string.settings_notifications_push)) },
supportingContent = { Text(stringResource(R.string.settings_notifications_push_description)) },
trailingContent = {
Switch(
checked = viewModel.isPushEnabled,
onCheckedChange = null,
enabled = !viewModel.isUpdating
)
},
modifier = Modifier
.semantics { role = Role.Switch }
.clickable(enabled = !viewModel.isUpdating) {
if (viewModel.isPushEnabled) viewModel.disablePush()
else viewModel.onEnableRequested()
}
)
CenteredListItem(
headlineContent = { Text(stringResource(R.string.settings_notifications_system)) },
supportingContent = { Text(stringResource(R.string.settings_notifications_system_description)) },
trailingContent = {
Icon(
painter = painterResource(R.drawable.ic_arrow_forward_24dp),
contentDescription = null,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
modifier = Modifier.clickable {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
}
)
}
}

View File

@ -211,6 +211,26 @@ fun SettingsScreen(
navController.navigate("settings/chat")
}
)
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.settings_notifications)
)
},
leadingContent = {
SettingsIcon {
Icon(
painter = painterResource(R.drawable.ic_notifications_24dp),
contentDescription = null,
)
}
},
modifier = Modifier
.testTag("settings_view_notifications")
.clickable {
navController.navigate("settings/notifications")
}
)
ListHeader {
Text(stringResource(R.string.settings_category_miscellaneous))

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,460L480,460L480,460L480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM320,680L640,680L640,400Q640,334 593,287Q546,240 480,240Q414,240 367,287Q320,334 320,400L320,680Z"/>
</vector>

View File

@ -775,6 +775,12 @@
<string name="settings_chat_hint_poorly_formed_settings_keys_key_unknown">%1$s (unknown)</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_reset">Reset %1$s settings</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_notifications_push">Push Notifications</string>
<string name="settings_notifications_push_description">Receive notifications for new messages, mentions, and other updates, even when you\'re not actively using the app.</string>
<string name="settings_notifications_system">System Settings</string>
<string name="settings_notifications_system_description">Manage notification permissions and preferences from your device\'s system settings.</string>
<string name="settings_feedback">Feedback</string>
<string name="settings_feedback_description">Join the Stoat server to give feedback or suggestions and report bugs.</string>

View File

@ -32,3 +32,8 @@ delete:
DELETE
FROM Server
WHERE id = ?;
findById:
SELECT *
FROM Server
WHERE id = ?;