diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index a5ae08c0..325b3ffb 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/chat/stoat/api/routes/account/Fetch.kt b/app/src/main/java/chat/stoat/api/routes/account/Fetch.kt new file mode 100644 index 00000000..786c2553 --- /dev/null +++ b/app/src/main/java/chat/stoat/api/routes/account/Fetch.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/api/routes/account/Patch.kt b/app/src/main/java/chat/stoat/api/routes/account/Patch.kt new file mode 100644 index 00000000..1967568e --- /dev/null +++ b/app/src/main/java/chat/stoat/api/routes/account/Patch.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/di/ViewModelModule.kt b/app/src/main/java/chat/stoat/di/ViewModelModule.kt index c875d451..57d2c658 100644 --- a/app/src/main/java/chat/stoat/di/ViewModelModule.kt +++ b/app/src/main/java/chat/stoat/di/ViewModelModule.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt new file mode 100644 index 00000000..b7fb2225 --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/settings/AccountSettingsScreen.kt @@ -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(null) + var waitingForEmailChangeNetworkResponse by mutableStateOf(false) + var showEmailChangeSuccess by mutableStateOf(false) + + var editingPassword by mutableStateOf(false) + var passwordChangeError by mutableStateOf(null) + var waitingForPasswordChangeNetworkResponse by mutableStateOf(false) + var showSnackbarPasswordChanged by mutableIntStateOf(0) + + var mfaState by mutableStateOf(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 + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt new file mode 100644 index 00000000..c911be18 --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/settings/MfaSettingsScreen.kt @@ -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(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}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt index 994c881c..3d38fcaa 100644 --- a/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/res/drawable/ic_lock_24dp.xml b/app/src/main/res/drawable/ic_lock_24dp.xml index 11d93f2f..67e91830 100644 --- a/app/src/main/res/drawable/ic_lock_24dp.xml +++ b/app/src/main/res/drawable/ic_lock_24dp.xml @@ -6,5 +6,5 @@ android:tint="?attr/colorControlNormal"> + 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"/> diff --git a/app/src/main/res/drawable/ic_person_24dp.xml b/app/src/main/res/drawable/ic_person_24dp.xml new file mode 100644 index 00000000..4ac431fb --- /dev/null +++ b/app/src/main/res/drawable/ic_person_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c203672d..d87412cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,6 +692,40 @@ Miscellaneous Stoat v%1$s + Login & Security + Email + Failed to load email + Change email + Change your email + Your current email is %1$s. + Please enter your new email address and your current account password. + New email address + Current password + Change + Keep current + One more thing + 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. + Open mail app + Got it + Password + Change password + Change your password + Please enter your current password and your new password. + Good passwords are at least 10 characters long and include a mix of letters, numbers, and symbols. + New password + Current password + Change + Keep current + Your password has been changed. + Two-Factor Authentication + You don\'t have any 2FA methods set up. You should set up 2FA to add an extra layer of security to your account. + Authenticator App + Protect your account with an 6-digit code from apps like Google Authenticator or Authy. + Manage Authenticator App + + Two-Factor Authentication + Authenticator App + Profile Profile picture Custom background