feat: notification settings screen
This commit is contained in:
parent
2b84cbb342
commit
51c6dc7fc0
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -24,3 +24,7 @@ suspend fun subscribePush(
|
|||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unsubscribePush() {
|
||||
StoatHttp.post("/push/unsubscribe".api())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,8 @@ delete:
|
|||
DELETE
|
||||
FROM Server
|
||||
WHERE id = ?;
|
||||
|
||||
findById:
|
||||
SELECT *
|
||||
FROM Server
|
||||
WHERE id = ?;
|
||||
Loading…
Reference in New Issue