feat: begin work on account settings
This commit is contained in:
parent
03f298d27f
commit
5e555317e2
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue