feat: «глазик» в поле пароля при входе и смена своего пароля (сайт — своя карточка, приложение — «Настройки»); Android 1.11.0

This commit is contained in:
darkorion
2026-10-16 16:41:52 +03:00
parent 4f2aed1770
commit 4cebf300aa
17 changed files with 184 additions and 12 deletions
+2 -2
View File
@@ -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("Выйти из аккаунта?") },