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.StoatApplication
|
||||||
import chat.stoat.api.HitRateLimitException
|
import chat.stoat.api.HitRateLimitException
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.c2dm.NotificationDeepLink
|
|
||||||
import chat.stoat.api.StoatHttp
|
import chat.stoat.api.StoatHttp
|
||||||
import chat.stoat.api.api
|
import chat.stoat.api.api
|
||||||
import chat.stoat.api.routes.microservices.geo.queryGeo
|
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.GeoStateProvider
|
||||||
import chat.stoat.api.settings.LoadedSettings
|
import chat.stoat.api.settings.LoadedSettings
|
||||||
import chat.stoat.api.settings.SyncedSettings
|
import chat.stoat.api.settings.SyncedSettings
|
||||||
|
import chat.stoat.c2dm.NotificationDeepLink
|
||||||
import chat.stoat.composables.generic.HealthAlert
|
import chat.stoat.composables.generic.HealthAlert
|
||||||
import chat.stoat.composables.voice.VoicePermissionSwitch
|
import chat.stoat.composables.voice.VoicePermissionSwitch
|
||||||
import chat.stoat.composables.voice.VoiceSheet
|
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.DebugSettingsScreen
|
||||||
import chat.stoat.screens.settings.ExperimentsSettingsScreen
|
import chat.stoat.screens.settings.ExperimentsSettingsScreen
|
||||||
import chat.stoat.screens.settings.LanguagePickerSettingsScreen
|
import chat.stoat.screens.settings.LanguagePickerSettingsScreen
|
||||||
|
import chat.stoat.screens.settings.NotificationsSettingsScreen
|
||||||
import chat.stoat.screens.settings.ProfileSettingsScreen
|
import chat.stoat.screens.settings.ProfileSettingsScreen
|
||||||
import chat.stoat.screens.settings.SessionSettingsScreen
|
import chat.stoat.screens.settings.SessionSettingsScreen
|
||||||
import chat.stoat.screens.settings.SettingsScreen
|
import chat.stoat.screens.settings.SettingsScreen
|
||||||
|
|
@ -712,6 +713,7 @@ fun AppEntrypoint(
|
||||||
composable("settings/sessions") { SessionSettingsScreen(navController) }
|
composable("settings/sessions") { SessionSettingsScreen(navController) }
|
||||||
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
|
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
|
||||||
composable("settings/chat") { ChatSettingsScreen(navController) }
|
composable("settings/chat") { ChatSettingsScreen(navController) }
|
||||||
|
composable("settings/notifications") { NotificationsSettingsScreen(navController) }
|
||||||
composable("settings/debug") { DebugSettingsScreen(navController) }
|
composable("settings/debug") { DebugSettingsScreen(navController) }
|
||||||
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
|
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
|
||||||
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
|
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
|
||||||
|
|
@ -738,7 +740,7 @@ fun AppEntrypoint(
|
||||||
composable("about/oss") { AttributionScreen(navController) }
|
composable("about/oss") { AttributionScreen(navController) }
|
||||||
|
|
||||||
composable("labs") { LabsRootScreen(navController) }
|
composable("labs") { LabsRootScreen(navController) }
|
||||||
|
|
||||||
composable("changelog/{id}") { ReadChangelogScreen(navController) }
|
composable("changelog/{id}") { ReadChangelogScreen(navController) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,8 @@ suspend fun subscribePush(
|
||||||
setBody(data)
|
setBody(data)
|
||||||
contentType(ContentType.Application.Json)
|
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 messageTimestamp = ULID.asTimestamp(messageId)
|
||||||
|
|
||||||
val db = Database(SqlStorage.driver)
|
val db = Database(SqlStorage.driver)
|
||||||
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
|
|
||||||
when (it.channelType) {
|
fun serverPrefix(serverId: String?): String? {
|
||||||
"DirectMessage" -> authorName
|
if (serverId == null) return null
|
||||||
"TextChannel" -> "#${it.name}"
|
return db.serverQueries.findById(serverId).executeAsOneOrNull()?.name
|
||||||
else -> it.name ?: authorName
|
}
|
||||||
|
|
||||||
|
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 {
|
} ?: runBlocking {
|
||||||
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
|
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
|
||||||
when (it.channelType) {
|
formatChannelName(
|
||||||
ChannelType.DirectMessage -> authorName
|
it.channelType?.value ?: "",
|
||||||
ChannelType.TextChannel -> "#${it.name}"
|
it.name,
|
||||||
else -> it.name ?: authorName
|
it.server
|
||||||
}
|
)
|
||||||
} ?: authorName
|
} ?: 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.login.MfaScreenViewModel
|
||||||
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
|
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
|
||||||
import chat.stoat.screens.settings.DebugSettingsScreenViewModel
|
import chat.stoat.screens.settings.DebugSettingsScreenViewModel
|
||||||
|
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
|
||||||
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
|
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
|
||||||
import chat.stoat.screens.settings.SettingsScreenViewModel
|
import chat.stoat.screens.settings.SettingsScreenViewModel
|
||||||
import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel
|
import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel
|
||||||
|
|
@ -26,6 +27,7 @@ val viewModelModule = module {
|
||||||
viewModel { MfaScreenViewModel(get()) }
|
viewModel { MfaScreenViewModel(get()) }
|
||||||
viewModel { SettingsScreenViewModel(get()) }
|
viewModel { SettingsScreenViewModel(get()) }
|
||||||
viewModel { DebugSettingsScreenViewModel(get()) }
|
viewModel { DebugSettingsScreenViewModel(get()) }
|
||||||
|
viewModel { NotificationsSettingsScreenViewModel(get(), androidContext()) }
|
||||||
viewModel { LoginViewModel(get()) }
|
viewModel { LoginViewModel(get()) }
|
||||||
viewModel { ProfileSettingsScreenViewModel(androidApplication()) }
|
viewModel { ProfileSettingsScreenViewModel(androidApplication()) }
|
||||||
viewModel { AppearanceSettingsScreenViewModel(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")
|
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 {
|
ListHeader {
|
||||||
Text(stringResource(R.string.settings_category_miscellaneous))
|
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_key_unknown">%1$s (unknown)</string>
|
||||||
<string name="settings_chat_hint_poorly_formed_settings_keys_reset">Reset %1$s settings</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">Feedback</string>
|
||||||
<string name="settings_feedback_description">Join the Stoat server to give feedback or suggestions and report bugs.</string>
|
<string name="settings_feedback_description">Join the Stoat server to give feedback or suggestions and report bugs.</string>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,9 @@ FROM Server;
|
||||||
delete:
|
delete:
|
||||||
DELETE
|
DELETE
|
||||||
FROM Server
|
FROM Server
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
findById:
|
||||||
|
SELECT *
|
||||||
|
FROM Server
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
Loading…
Reference in New Issue