refactor: LoginScreen вынесен из AppScaffold в свой файл (Этап 2 п.10, ч.1)

LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в
LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не
менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся
только в LoginScreen. Первый безопасный слой разбора AppScaffold.
This commit is contained in:
b-dev-mobile
2026-07-09 14:42:55 +00:00
parent a674721353
commit 8c90e83cef
2 changed files with 295 additions and 256 deletions
@@ -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 = ""
},
)
}
}
}
}
}
}