feat: begin work on account settings

This commit is contained in:
infi 2026-06-02 03:28:54 +02:00
parent 03f298d27f
commit 5e555317e2
10 changed files with 686 additions and 1 deletions

View File

@ -112,11 +112,13 @@ import chat.stoat.screens.register.RegisterDetailsScreen
import chat.stoat.screens.register.RegisterGreetingScreen
import chat.stoat.screens.register.RegisterVerifyScreen
import chat.stoat.screens.services.DiscoverScreen
import chat.stoat.screens.settings.AccountSettingsScreen
import chat.stoat.screens.settings.AppearanceSettingsScreen
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.MfaSettingsScreen
import chat.stoat.screens.settings.NotificationsSettingsScreen
import chat.stoat.screens.settings.ProfileSettingsScreen
import chat.stoat.screens.settings.SessionSettingsScreen
@ -712,6 +714,8 @@ fun AppEntrypoint(
composable("discover") { DiscoverScreen(navController) }
composable("settings") { SettingsScreen(navController) }
composable("settings/account") { AccountSettingsScreen(navController) }
composable("settings/account/mfa") { MfaSettingsScreen(navController) }
composable("settings/profile") { ProfileSettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }

View File

@ -0,0 +1,39 @@
package chat.stoat.api.routes.account
import chat.stoat.api.StoatHttp
import chat.stoat.api.StoatJson
import chat.stoat.api.api
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AccountResponse(
@SerialName("_id") val id: String,
val email: String,
)
suspend fun fetchAccount(): AccountResponse {
val response = StoatHttp.get("/auth/account".api())
.bodyAsText()
return StoatJson.decodeFromString(response)
}
@Serializable
data class MfaSettings(
@SerialName("email_otp") val emailOtp: Boolean? = null,
@SerialName("trusted_handover") val trustedHandover: Boolean? = null,
@SerialName("email_mfa") val emailMfa: Boolean? = null,
@SerialName("totp_mfa") val totpMfa: Boolean? = null,
@SerialName("security_key_mfa") val securityKeyMfa: Boolean? = null,
@SerialName("recovery_active") val recoveryActive: Boolean? = null,
)
suspend fun fetchMfaSettings(): MfaSettings {
val response = StoatHttp.get("/auth/mfa".api())
.bodyAsText()
return StoatJson.decodeFromString(response)
}

View File

@ -0,0 +1,54 @@
package chat.stoat.api.routes.account
import chat.stoat.api.StoatAPIError
import chat.stoat.api.StoatHttp
import chat.stoat.api.StoatJson
import chat.stoat.api.api
import io.ktor.client.request.patch
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ChangeEmailBody(
val email: String,
@SerialName("current_password") val currentPassword: String
)
suspend fun changeEmail(newEmail: String, currentPassword: String) {
val res = StoatHttp.patch("/auth/account/change/email".api()) {
setBody(ChangeEmailBody(newEmail, currentPassword))
contentType(ContentType.Application.Json)
}
if (!res.status.isSuccess()) {
runCatching { StoatJson.decodeFromString(StoatAPIError.serializer(), res.bodyAsText()) }
.onSuccess { throw Exception(it.type) }
val errorResponse = res.bodyAsText()
throw Exception("Failed to change email: $errorResponse")
}
}
@Serializable
data class ChangePasswordBody(
val password: String,
@SerialName("current_password") val currentPassword: String
)
suspend fun changePassword(newPassword: String, currentPassword: String) {
val res = StoatHttp.patch("/auth/account/change/password".api()) {
setBody(ChangePasswordBody(newPassword, currentPassword))
contentType(ContentType.Application.Json)
}
if (!res.status.isSuccess()) {
runCatching { StoatJson.decodeFromString(StoatAPIError.serializer(), res.bodyAsText()) }
.onSuccess { throw Exception(it.type) }
val errorResponse = res.bodyAsText()
throw Exception("Failed to change password: $errorResponse")
}
}

View File

@ -6,6 +6,7 @@ import chat.stoat.screens.chat.ChatRouterViewModel
import chat.stoat.screens.chat.views.channel.ChannelScreenViewModel
import chat.stoat.screens.login.LoginViewModel
import chat.stoat.screens.login.MfaScreenViewModel
import chat.stoat.screens.settings.AccountSettingsScreenViewModel
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
import chat.stoat.screens.settings.DebugSettingsScreenViewModel
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
@ -32,4 +33,5 @@ val viewModelModule = module {
viewModel { ProfileSettingsScreenViewModel(androidApplication()) }
viewModel { AppearanceSettingsScreenViewModel(androidApplication()) }
viewModel { ChannelSettingsOverviewViewModel(androidApplication()) }
viewModel { AccountSettingsScreenViewModel(androidApplication()) }
}

View File

@ -0,0 +1,447 @@
package chat.stoat.screens.settings
import android.app.Application
import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecureTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.autofill.ContentType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.account.MfaSettings
import chat.stoat.api.routes.account.changeEmail
import chat.stoat.api.routes.account.changePassword
import chat.stoat.api.routes.account.fetchAccount
import chat.stoat.api.routes.account.fetchMfaSettings
import chat.stoat.settings.dsl.SettingsPage
import chat.stoat.ui.theme.FragmentMono
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
class AccountSettingsScreenViewModel(val context: Application) : ViewModel() {
var accountEmail by mutableStateOf("")
private set
var accountEmailMasked by mutableStateOf("")
private set
var accountEmailLoaded by mutableStateOf(false)
private set
var mfaStateLoaded by mutableStateOf(false)
private set
var editingEmail by mutableStateOf(false)
var emailChangeError by mutableStateOf<String?>(null)
var waitingForEmailChangeNetworkResponse by mutableStateOf(false)
var showEmailChangeSuccess by mutableStateOf(false)
var editingPassword by mutableStateOf(false)
var passwordChangeError by mutableStateOf<String?>(null)
var waitingForPasswordChangeNetworkResponse by mutableStateOf(false)
var showSnackbarPasswordChanged by mutableIntStateOf(0)
var mfaState by mutableStateOf<MfaSettings?>(null)
private set
suspend fun loadDetails() {
runCatching { fetchAccount() }
.onSuccess { account ->
accountEmail = account.email
val parts = account.email.split("@")
if (parts.size == 2) {
val domainPart = parts[1]
val bullets = "\u2022".repeat(5)
accountEmailMasked = "$bullets@$domainPart"
} else {
accountEmailMasked = account.email
}
}
.onFailure {
emailChangeError = context.getString(R.string.settings_account_email_loading_error)
}
.also { accountEmailLoaded = true }
runCatching { fetchMfaSettings() }
.onSuccess { mfaState = it }
.also { mfaStateLoaded = true }
}
fun requestChangeEmail(newEmail: CharSequence, password: CharSequence) {
viewModelScope.launch {
waitingForEmailChangeNetworkResponse = true
runCatching { changeEmail(newEmail.toString(), password.toString()) }
.onSuccess {
editingEmail = false
loadDetails()
emailChangeError = null
showEmailChangeSuccess = true
}
.onFailure { e ->
editingEmail = false
passwordChangeError = null
emailChangeError = e.message ?: "Unknown error"
}
.also { waitingForEmailChangeNetworkResponse = false }
}
}
fun requestChangePassword(newPassword: CharSequence, currentPassword: CharSequence) {
viewModelScope.launch {
waitingForPasswordChangeNetworkResponse = true
runCatching { changePassword(newPassword.toString(), currentPassword.toString()) }
.onSuccess {
editingPassword = false
passwordChangeError = null
showSnackbarPasswordChanged++
}
.onFailure { e ->
editingPassword = false
emailChangeError = null
passwordChangeError = e.message ?: "Unknown error"
}
.also { waitingForPasswordChangeNetworkResponse = false }
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AccountSettingsScreen(
navController: NavController,
viewModel: AccountSettingsScreenViewModel = koinViewModel()
) {
val context = LocalContext.current
val resources = LocalResources.current
LaunchedEffect(Unit) {
viewModel.loadDetails()
}
if (viewModel.showEmailChangeSuccess) {
AlertDialog(
onDismissRequest = { viewModel.showEmailChangeSuccess = false },
title = { Text(stringResource(R.string.settings_account_email_confirm_email)) },
text = { Text(stringResource(R.string.settings_account_email_confirm_email_message)) },
confirmButton = {
TextButton(onClick = {
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_APP_EMAIL)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
viewModel.showEmailChangeSuccess = false
}) {
Text(stringResource(R.string.settings_account_email_confirm_email_open_mail_app))
}
},
dismissButton = {
TextButton(onClick = { viewModel.showEmailChangeSuccess = false }) {
Text(stringResource(R.string.settings_account_email_confirm_email_dismiss))
}
}
)
}
if (viewModel.editingEmail) {
val newEmailFieldState = rememberTextFieldState()
val confirmationPasswordFieldState = rememberTextFieldState()
AlertDialog(
onDismissRequest = {
if (!viewModel.waitingForEmailChangeNetworkResponse) viewModel.editingEmail = false
},
title = { Text(stringResource(R.string.settings_account_email_edit_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.settings_account_email_edit_current,
viewModel.accountEmail
)
)
Text(stringResource(R.string.settings_account_email_edit_message))
TextField(
state = newEmailFieldState,
label = { Text(stringResource(R.string.settings_account_email_edit_new_email)) },
modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.EmailAddress }
)
SecureTextField(
state = confirmationPasswordFieldState,
label = { Text(stringResource(R.string.settings_account_email_edit_password)) },
modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.Password }
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.requestChangeEmail(
newEmailFieldState.text,
confirmationPasswordFieldState.text
)
},
enabled = !viewModel.waitingForEmailChangeNetworkResponse &&
newEmailFieldState.text.isNotBlank() &&
confirmationPasswordFieldState.text.isNotBlank()
) {
Text(stringResource(R.string.settings_account_email_edit_confirm))
}
},
dismissButton = {
TextButton(onClick = { viewModel.editingEmail = false }) {
Text(stringResource(R.string.settings_account_email_edit_cancel))
}
}
)
}
if (viewModel.editingPassword) {
val confirmationPasswordFieldState = rememberTextFieldState()
val newPasswordFieldState = rememberTextFieldState()
AlertDialog(
onDismissRequest = {
if (!viewModel.waitingForPasswordChangeNetworkResponse) viewModel.editingPassword =
false
},
title = { Text(stringResource(R.string.settings_account_password_edit_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.settings_account_password_edit_message))
Text(stringResource(R.string.settings_account_password_edit_message_hint))
SecureTextField(
state = confirmationPasswordFieldState,
label = { Text(stringResource(R.string.settings_account_password_edit_current_password)) },
modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.Password }
)
SecureTextField(
state = newPasswordFieldState,
label = { Text(stringResource(R.string.settings_account_password_edit_new_password)) },
modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword }
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.requestChangePassword(
newPasswordFieldState.text,
confirmationPasswordFieldState.text
)
},
enabled = !viewModel.waitingForPasswordChangeNetworkResponse &&
newPasswordFieldState.text.isNotBlank() &&
confirmationPasswordFieldState.text.isNotBlank() &&
newPasswordFieldState.text != confirmationPasswordFieldState.text &&
newPasswordFieldState.text.length >= 8
) {
Text(stringResource(R.string.settings_account_password_edit_confirm))
}
},
dismissButton = {
TextButton(onClick = { viewModel.editingPassword = false }) {
Text(stringResource(R.string.settings_account_password_edit_cancel))
}
}
)
}
SettingsPage(
navController,
title = { Text(stringResource(R.string.settings_account)) }
) {
LaunchedEffect(viewModel.showSnackbarPasswordChanged) {
if (viewModel.showSnackbarPasswordChanged > 0) {
showSnackbar(resources.getString(R.string.settings_account_password_change_success))
}
}
AnimatedContent(
targetState = viewModel.accountEmailLoaded
) { loaded ->
if (!loaded) {
Box(
Modifier
.height(200.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
LoadingIndicator(
modifier = Modifier.size(72.dp)
)
}
} else {
Column {
AnimatedVisibility(
viewModel.emailChangeError != null
|| viewModel.passwordChangeError != null
) {
Text(
text = viewModel.emailChangeError ?: viewModel.passwordChangeError
?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
Subcategory(
title = { Text(stringResource(R.string.settings_account_email)) }
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.clickable { viewModel.editingEmail = true }
.padding(horizontal = 22.dp, vertical = 18.dp)
) {
Text(
text = viewModel.accountEmailMasked,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = stringResource(R.string.settings_account_email_edit_alt)
)
}
}
Subcategory(
title = { Text(stringResource(R.string.settings_account_password)) }
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.clickable { viewModel.editingPassword = true }
.padding(horizontal = 22.dp, vertical = 18.dp)
) {
Text(
text = remember { "\u2022".repeat(11) },
style = MaterialTheme.typography.bodyLarge,
fontFamily = FragmentMono,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = stringResource(R.string.settings_account_password_edit_alt)
)
}
}
Subcategory(
title = { Text(stringResource(R.string.settings_account_mfa)) }
) {
AnimatedVisibility(
viewModel.mfaStateLoaded && viewModel.mfaState?.totpMfa == false
) {
Text(
text = stringResource(R.string.settings_account_mfa_warning),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp)
)
}
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.clickable {
navController.navigate("settings/account/mfa")
}
.padding(horizontal = 22.dp, vertical = 18.dp)
) {
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_account_mfa_totp),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.settings_account_mfa_totp_upsell),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = viewModel.mfaState?.totpMfa == true,
onCheckedChange = null
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,74 @@
package chat.stoat.screens.settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.account.MfaSettings
import chat.stoat.api.routes.account.fetchMfaSettings
import chat.stoat.settings.dsl.SettingsPage
class MfaSettingsScreenViewModel() : ViewModel() {
var mfaStateLoaded by mutableStateOf(false)
private set
var mfaState by mutableStateOf<MfaSettings?>(null)
private set
suspend fun loadDetails() {
runCatching { fetchMfaSettings() }
.onSuccess { mfaState = it }
.also { mfaStateLoaded = true }
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MfaSettingsScreen(
navController: NavController,
viewModel: MfaSettingsScreenViewModel = viewModel()
) {
LaunchedEffect(Unit) {
viewModel.loadDetails()
}
SettingsPage(
navController,
title = { Text(stringResource(R.string.settings_mfa)) }
) {
AnimatedContent(
targetState = viewModel.mfaStateLoaded,
) { loaded ->
if (!loaded) {
Box(
Modifier
.height(200.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
LoadingIndicator(
modifier = Modifier.size(72.dp)
)
}
} else {
Text("MFA Settings: ${viewModel.mfaState}")
}
}
}
}

View File

@ -106,6 +106,27 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_category_account))
}
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.settings_account)
)
},
leadingContent = {
SettingsIcon {
Icon(
painter = painterResource(R.drawable.ic_lock_24dp),
contentDescription = null,
)
}
},
modifier = Modifier
.testTag("settings_view_account")
.clickable {
navController.navigate("settings/account")
}
)
ListItem(
headlineContent = {
Text(

View File

@ -6,5 +6,5 @@
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z"/>
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680Q513,680 536.5,656.5ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z"/>
</vector>

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="M367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480Q414,480 367,433ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400Q513,400 536.5,376.5ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720Z"/>
</vector>

View File

@ -692,6 +692,40 @@
<string name="settings_category_miscellaneous">Miscellaneous</string>
<string name="settings_category_last" translatable="false">Stoat v%1$s</string>
<string name="settings_account">Login &amp; Security</string>
<string name="settings_account_email">Email</string>
<string name="settings_account_email_loading_error">Failed to load email</string>
<string name="settings_account_email_edit_alt">Change email</string>
<string name="settings_account_email_edit_title">Change your email</string>
<string name="settings_account_email_edit_current">Your current email is %1$s.</string>
<string name="settings_account_email_edit_message">Please enter your new email address and your current account password.</string>
<string name="settings_account_email_edit_new_email">New email address</string>
<string name="settings_account_email_edit_password">Current password</string>
<string name="settings_account_email_edit_confirm">Change</string>
<string name="settings_account_email_edit_cancel">Keep current</string>
<string name="settings_account_email_confirm_email">One more thing</string>
<string name="settings_account_email_confirm_email_message">We just emailed you to confirm your new email address. Please click the link in that email to complete the change, and then you\'ll be all set.</string>
<string name="settings_account_email_confirm_email_open_mail_app">Open mail app</string>
<string name="settings_account_email_confirm_email_dismiss">Got it</string>
<string name="settings_account_password">Password</string>
<string name="settings_account_password_edit_alt">Change password</string>
<string name="settings_account_password_edit_title">Change your password</string>
<string name="settings_account_password_edit_message">Please enter your current password and your new password.</string>
<string name="settings_account_password_edit_message_hint">Good passwords are at least 10 characters long and include a mix of letters, numbers, and symbols.</string>
<string name="settings_account_password_edit_new_password">New password</string>
<string name="settings_account_password_edit_current_password">Current password</string>
<string name="settings_account_password_edit_confirm">Change</string>
<string name="settings_account_password_edit_cancel">Keep current</string>
<string name="settings_account_password_change_success">Your password has been changed.</string>
<string name="settings_account_mfa">Two-Factor Authentication</string>
<string name="settings_account_mfa_warning">You don\'t have any 2FA methods set up. You should set up 2FA to add an extra layer of security to your account.</string>
<string name="settings_account_mfa_totp">Authenticator App</string>
<string name="settings_account_mfa_totp_upsell">Protect your account with an 6-digit code from apps like Google Authenticator or Authy.</string>
<string name="settings_account_mfa_totp_manage_alt">Manage Authenticator App</string>
<string name="settings_mfa">Two-Factor Authentication</string>
<string name="settings_mfa_totp">Authenticator App</string>
<string name="settings_profile">Profile</string>
<string name="settings_profile_profile_picture">Profile picture</string>
<string name="settings_profile_custom_background">Custom background</string>