refactor: LoginScreen вынесен из AppScaffold в свой файл (Этап 2 п.10, ч.1)
LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся только в LoginScreen. Первый безопасный слой разбора AppScaffold.
This commit is contained in:
@@ -18,27 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -53,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -74,22 +56,18 @@ import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
|||||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
|
||||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
|
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
|
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
|
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
|
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||||
@@ -688,237 +666,3 @@ fun AppScaffold(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
||||||
@Composable
|
|
||||||
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
|
||||||
F7SecureScreen() // ввод пароля — не в скриншотах/recents
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val serverFocus = remember { FocusRequester() }
|
|
||||||
val usernameFocus = remember { FocusRequester() }
|
|
||||||
val passwordFocus = remember { FocusRequester() }
|
|
||||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
|
||||||
var username by rememberSaveable { mutableStateOf("") }
|
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
|
||||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var loading by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val qrLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
|
||||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
|
||||||
?: return@rememberLauncherForActivityResult
|
|
||||||
scope.launch {
|
|
||||||
loading = true
|
|
||||||
error = null
|
|
||||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
|
||||||
if (loginResult == null) {
|
|
||||||
error = if (qrData.contains("/login/v2/flow/")) {
|
|
||||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
|
||||||
} else {
|
|
||||||
"Не удалось распознать QR-код"
|
|
||||||
}
|
|
||||||
loading = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val newSession = AuthSession(
|
|
||||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
|
||||||
username = loginResult.username,
|
|
||||||
appPassword = loginResult.appPassword,
|
|
||||||
trustAllCerts = trustAllCerts,
|
|
||||||
)
|
|
||||||
AuthVerifier.verify(newSession)
|
|
||||||
.onSuccess { verified -> onLogin(verified) }
|
|
||||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchQrScan() {
|
|
||||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
android.Manifest.permission.CAMERA,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
|
||||||
} else {
|
|
||||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submitLogin() {
|
|
||||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val newSession = AuthSession(
|
|
||||||
serverUrl = normalizeServerUrl(serverUrl),
|
|
||||||
username = username.trim(),
|
|
||||||
appPassword = password,
|
|
||||||
trustAllCerts = trustAllCerts,
|
|
||||||
)
|
|
||||||
scope.launch {
|
|
||||||
loading = true
|
|
||||||
error = null
|
|
||||||
AuthVerifier.verify(newSession)
|
|
||||||
.onSuccess { verified -> onLogin(verified) }
|
|
||||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val logoUrl = remember(serverUrl) {
|
|
||||||
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.f7SafeTopInsets()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 440.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = logoUrl,
|
|
||||||
contentDescription = "Forbion",
|
|
||||||
modifier = Modifier
|
|
||||||
.width(300.dp)
|
|
||||||
.height(70.dp)
|
|
||||||
.padding(bottom = 48.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = F7Colors.Background,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Вход",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
color = F7Colors.TextPrimary,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
|
||||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = serverUrl,
|
|
||||||
onValueChange = { serverUrl = it },
|
|
||||||
label = "Адрес сервера",
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(serverFocus)
|
|
||||||
.focusProperties { next = usernameFocus },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Uri,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { usernameFocus.requestFocus() },
|
|
||||||
),
|
|
||||||
onEnter = { usernameFocus.requestFocus() },
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = username,
|
|
||||||
onValueChange = { username = it },
|
|
||||||
label = "Имя пользователя",
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(usernameFocus)
|
|
||||||
.focusProperties { next = passwordFocus },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { passwordFocus.requestFocus() },
|
|
||||||
),
|
|
||||||
onEnter = { passwordFocus.requestFocus() },
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = { password = it },
|
|
||||||
label = "Пароль",
|
|
||||||
modifier = Modifier.focusRequester(passwordFocus),
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
onGo = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onEnter = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
F7PrimaryButton(
|
|
||||||
text = if (loading) "…" else "Войти",
|
|
||||||
onClick = { submitLogin() },
|
|
||||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7TextButton(
|
|
||||||
text = "Сканировать QR для входа",
|
|
||||||
onClick = { launchQrScan() },
|
|
||||||
enabled = !loading,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
if (loading) {
|
|
||||||
CircularProgressIndicator(color = F7Colors.Primary)
|
|
||||||
}
|
|
||||||
if (error != null) {
|
|
||||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
F7TextButton(
|
|
||||||
text = "Очистить",
|
|
||||||
onClick = {
|
|
||||||
error = null
|
|
||||||
password = ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||||
|
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||||
|
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
|
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||||
|
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||||
|
F7SecureScreen() // ввод пароля — не в скриншотах/recents
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val serverFocus = remember { FocusRequester() }
|
||||||
|
val usernameFocus = remember { FocusRequester() }
|
||||||
|
val passwordFocus = remember { FocusRequester() }
|
||||||
|
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var loading by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val qrLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||||
|
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||||
|
?: return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||||
|
if (loginResult == null) {
|
||||||
|
error = if (qrData.contains("/login/v2/flow/")) {
|
||||||
|
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||||
|
} else {
|
||||||
|
"Не удалось распознать QR-код"
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val newSession = AuthSession(
|
||||||
|
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||||
|
username = loginResult.username,
|
||||||
|
appPassword = loginResult.appPassword,
|
||||||
|
trustAllCerts = trustAllCerts,
|
||||||
|
)
|
||||||
|
AuthVerifier.verify(newSession)
|
||||||
|
.onSuccess { verified -> onLogin(verified) }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchQrScan() {
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.CAMERA,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
} else {
|
||||||
|
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitLogin() {
|
||||||
|
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val newSession = AuthSession(
|
||||||
|
serverUrl = normalizeServerUrl(serverUrl),
|
||||||
|
username = username.trim(),
|
||||||
|
appPassword = password,
|
||||||
|
trustAllCerts = trustAllCerts,
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
AuthVerifier.verify(newSession)
|
||||||
|
.onSuccess { verified -> onLogin(verified) }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val logoUrl = remember(serverUrl) {
|
||||||
|
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.f7SafeTopInsets()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 440.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoUrl,
|
||||||
|
contentDescription = "Forbion",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
.height(70.dp)
|
||||||
|
.padding(bottom = 48.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = F7Colors.Background,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Вход",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||||
|
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = { serverUrl = it },
|
||||||
|
label = "Адрес сервера",
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(serverFocus)
|
||||||
|
.focusProperties { next = usernameFocus },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { usernameFocus.requestFocus() },
|
||||||
|
),
|
||||||
|
onEnter = { usernameFocus.requestFocus() },
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = "Имя пользователя",
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(usernameFocus)
|
||||||
|
.focusProperties { next = passwordFocus },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocus.requestFocus() },
|
||||||
|
),
|
||||||
|
onEnter = { passwordFocus.requestFocus() },
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = "Пароль",
|
||||||
|
modifier = Modifier.focusRequester(passwordFocus),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
onGo = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onEnter = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = if (loading) "…" else "Войти",
|
||||||
|
onClick = { submitLogin() },
|
||||||
|
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
F7TextButton(
|
||||||
|
text = "Сканировать QR для входа",
|
||||||
|
onClick = { launchQrScan() },
|
||||||
|
enabled = !loading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
F7TextButton(
|
||||||
|
text = "Очистить",
|
||||||
|
onClick = {
|
||||||
|
error = null
|
||||||
|
password = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user