From 8c90e83cefea5f1d13343cd3e7f5e680a0374306 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Thu, 9 Jul 2026 14:42:55 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20LoginScreen=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B8=D0=B7=20AppScaffold=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=20(?= =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=202=20=D0=BF.10,=20=D1=87.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся только в LoginScreen. Первый безопасный слой разбора AppScaffold. --- .../forbion/f7cloud/mobile/ui/AppScaffold.kt | 256 --------------- .../forbion/f7cloud/mobile/ui/LoginScreen.kt | 295 ++++++++++++++++++ 2 files changed, 295 insertions(+), 256 deletions(-) create mode 100644 app/src/main/java/ru/forbion/f7cloud/mobile/ui/LoginScreen.kt diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt index 8ceec2a..64589e7 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt @@ -18,27 +18,12 @@ 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.verticalScroll import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -53,12 +38,9 @@ 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver 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.AuthStore 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.push.F7PushEvent import ru.forbion.f7cloud.core.push.F7PushEventHub import ru.forbion.f7cloud.core.push.F7PushRegistrar import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider 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.F7AutoHideBottomBar import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar 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.F7Theme import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets @@ -688,237 +666,3 @@ fun AppScaffold( } 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(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 = "" - }, - ) - } - } - } - } - } -} diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/LoginScreen.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/LoginScreen.kt new file mode 100644 index 0000000..a08ff30 --- /dev/null +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/LoginScreen.kt @@ -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(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 = "" + }, + ) + } + } + } + } + } +}