feat: «глазик» в поле пароля при входе и смена своего пароля (сайт — своя карточка, приложение — «Настройки»); Android 1.11.0
This commit is contained in:
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ru.checklists.app"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 16
|
||||
versionName = "1.10.0"
|
||||
versionCode = 17
|
||||
versionName = "1.11.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -103,6 +103,12 @@ class Repository(
|
||||
/** Сообщение о смене режима (показывается один раз). */
|
||||
val modeChanged = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Сменить свой пароль: другие сессии завершаются, это устройство получает новые токены. */
|
||||
suspend fun changePassword(old: String, new: String) {
|
||||
val t = client.api().changePassword(ChangePasswordReq(old, new))
|
||||
prefs.access = t.accessToken; prefs.refresh = t.refreshToken
|
||||
}
|
||||
|
||||
/** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */
|
||||
suspend fun leaveDemo() {
|
||||
val t = client.api().demoLeave()
|
||||
|
||||
@@ -28,6 +28,9 @@ interface Api {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body b: LoginReq): TokenRes
|
||||
|
||||
@POST("auth/change-password")
|
||||
suspend fun changePassword(@Body b: ChangePasswordReq): TokenRes
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Body b: RefreshReq)
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class LoginReq(val username: String, val password: String, val device: String = "android")
|
||||
|
||||
@Serializable
|
||||
data class ChangePasswordReq(@SerialName("old_password") val oldPassword: String, @SerialName("new_password") val newPassword: String)
|
||||
|
||||
@Serializable
|
||||
data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android")
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
@@ -79,8 +78,7 @@ fun LoginScreen(onDone: () -> Unit) {
|
||||
OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||
OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
PasswordField(pass, { pass = it }, "Пароль")
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package ru.checklists.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.checklists.app.data.api.humanMessage
|
||||
import ru.checklists.app.repo
|
||||
|
||||
private const val PASSWORD_MIN = 10 // как на сервере (app/schemas.py)
|
||||
|
||||
/** Поле пароля с «глазиком»: показать или скрыть введённое. */
|
||||
@Composable
|
||||
fun PasswordField(value: String, onChange: (String) -> Unit, label: String, modifier: Modifier = Modifier.fillMaxWidth(), supporting: String? = null) {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(value, onChange, label = { Text(label) }, singleLine = true, modifier = modifier,
|
||||
visualTransformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
supportingText = supporting?.let { { Text(it) } },
|
||||
trailingIcon = {
|
||||
val hint = if (show) "Скрыть пароль" else "Показать пароль"
|
||||
IconButton({ show = !show }, Modifier.semantics { contentDescription = hint }) { Text(if (show) "🙈" else "👁") }
|
||||
})
|
||||
}
|
||||
|
||||
/** Смена своего пароля: текущий, новый и повтор. Другие сессии завершаются, это устройство остаётся в системе. */
|
||||
@Composable
|
||||
fun ChangePasswordDialog(onDismiss: () -> Unit, onDone: () -> Unit) {
|
||||
val repo = LocalContext.current.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
var old by remember { mutableStateOf("") }
|
||||
var new by remember { mutableStateOf("") }
|
||||
var repeat by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
fun validate(): String? = when {
|
||||
old.isEmpty() -> "Введите текущий пароль"
|
||||
new.length < PASSWORD_MIN -> "Новый пароль — не короче $PASSWORD_MIN символов"
|
||||
new != repeat -> "Новые пароли не совпадают"
|
||||
new == old -> "Новый пароль совпадает с текущим"
|
||||
else -> null
|
||||
}
|
||||
|
||||
AlertDialog(onDismissRequest = { if (!busy) onDismiss() }, title = { Text("Смена пароля") }, text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PasswordField(old, { old = it }, "Текущий пароль")
|
||||
PasswordField(new, { new = it }, "Новый пароль", supporting = "Не короче $PASSWORD_MIN символов")
|
||||
PasswordField(repeat, { repeat = it }, "Новый пароль ещё раз")
|
||||
Text("После смены на других устройствах и на сайте нужно будет войти заново.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = {
|
||||
TextButton({
|
||||
error = validate()
|
||||
if (error == null) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
try { repo.changePassword(old, new); onDone() }
|
||||
catch (e: Throwable) { error = e.humanMessage() }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
}, enabled = !busy) { Text("Сменить") }
|
||||
}, dismissButton = { TextButton(onDismiss, enabled = !busy) { Text("Отмена") } })
|
||||
}
|
||||
@@ -45,6 +45,8 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
|
||||
val error by repo.lastError.collectAsState()
|
||||
var confirmLogout by remember { mutableStateOf(false) }
|
||||
var unsynced by remember { mutableStateOf(0) }
|
||||
var changePass by remember { mutableStateOf(false) }
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Scaffold(topBar = {
|
||||
ScreenBar(title = { Text("Настройки") })
|
||||
@@ -55,6 +57,7 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
|
||||
me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") }
|
||||
if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор")
|
||||
if (me?.isStorekeeper == true) Text("Администратор склада")
|
||||
if (me?.demoMode != true) OutlinedButton({ changePass = true }, Modifier.fillMaxWidth()) { Text("🔑 Сменить пароль") }
|
||||
|
||||
Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp))
|
||||
Text(repo.prefs.baseUrl)
|
||||
@@ -77,6 +80,11 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
if (changePass) ChangePasswordDialog(onDismiss = { changePass = false }) {
|
||||
changePass = false
|
||||
android.widget.Toast.makeText(ctx, "Пароль изменён", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
if (confirmLogout) AlertDialog(
|
||||
onDismissRequest = { confirmLogout = false },
|
||||
title = { Text("Выйти из аккаунта?") },
|
||||
|
||||
Reference in New Issue
Block a user