improve login screen UI and logic & fixes for blur

This commit is contained in:
2025-03-29 22:03:37 +03:00
parent 157c0c71fe
commit d46c72f7e6
21 changed files with 610 additions and 795 deletions
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
@@ -26,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
@@ -42,6 +42,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.profileScreen
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
@@ -58,7 +59,7 @@ fun MainScreen(
onMessageClicked: (userId: Int) -> Unit = {},
onCreateChatClicked: () -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val theme = LocalThemeConfig.current
val hazeState = remember { HazeState() }
val navController = rememberNavController()
@@ -75,18 +76,12 @@ fun MainScreen(
bottomBar = {
NavigationBar(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.enableBlur) 0f else 1f
)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
),
containerColor = Color.Transparent
) {
navigationItems.forEachIndexed { index, item ->
NavigationBarItem(
@@ -145,11 +140,10 @@ fun MainScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding.calculateBottomPadding())
) {
CompositionLocalProvider(
LocalHazeState provides hazeState,
// LocalBottomPadding provides if (currentTheme.enableBlur) padding.calculateBottomPadding() else 0.dp
LocalBottomPadding provides padding.calculateBottomPadding()
) {
NavHost(
navController = navController,
@@ -1,135 +0,0 @@
package dev.meloda.fast.ui.basic
import android.os.Build
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import kotlin.math.roundToInt
fun Modifier.connectNode(handler: AutoFillHandler): Modifier {
return with(handler) { fillBounds() }
}
fun Modifier.defaultFocusChangeAutoFill(handler: AutoFillHandler): Modifier {
return this.then(
Modifier.onFocusChanged {
if (it.isFocused) {
handler.request()
} else {
handler.cancel()
}
}
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun autoFillRequestHandler(
autofillTypes: List<AutofillType> = listOf(),
onFill: (String) -> Unit,
): AutoFillHandler {
val view = LocalView.current
val context = LocalContext.current
var isFillRecently = remember { false }
val autoFillNode = remember {
AutofillNode(
autofillTypes = autofillTypes,
onFill = {
isFillRecently = true
onFill(it)
}
)
}
val autofill = LocalAutofill.current
LocalAutofillTree.current += autoFillNode
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return EmptyAutoFillHandler
return remember {
@RequiresApi(Build.VERSION_CODES.O)
object : AutoFillHandler {
val autofillManager = context.getSystemService(AutofillManager::class.java)
override fun requestManual() {
autofillManager.requestAutofill(
view,
autoFillNode.id,
autoFillNode.boundingBox?.toAndroidRect() ?: error("BoundingBox is not provided yet")
)
}
override fun requestVerifyManual() {
if (isFillRecently) {
isFillRecently = false
requestManual()
}
}
override val autoFill: Autofill?
get() = autofill
override val autoFillNode: AutofillNode
get() = autoFillNode
override fun request() {
autofill?.requestAutofillForNode(autofillNode = autoFillNode)
}
override fun cancel() {
autofill?.cancelAutofillForNode(autofillNode = autoFillNode)
}
override fun Modifier.fillBounds(): Modifier {
return this.then(
Modifier.onGloballyPositioned {
autoFillNode.boundingBox = it.boundsInWindow()
})
}
}
}
}
fun Rect.toAndroidRect(): android.graphics.Rect {
return android.graphics.Rect(
left.roundToInt(),
top.roundToInt(),
right.roundToInt(),
bottom.roundToInt()
)
}
@OptIn(ExperimentalComposeUiApi::class)
interface AutoFillHandler {
val autoFill: Autofill?
val autoFillNode: AutofillNode?
fun requestVerifyManual()
fun requestManual()
fun request()
fun cancel()
fun Modifier.fillBounds(): Modifier
}
@ExperimentalComposeUiApi
data object EmptyAutoFillHandler : AutoFillHandler {
override val autoFill: Autofill? = null
override val autoFillNode: AutofillNode? = null
override fun requestVerifyManual() {}
override fun requestManual() {}
override fun request() {}
override fun cancel() {}
override fun Modifier.fillBounds(): Modifier = this.then(Modifier)
}
@@ -6,19 +6,26 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R as UiR
@Composable
fun ErrorView(
modifier: Modifier = Modifier,
iconResId: Int? = UiR.drawable.round_error_24,
text: String,
buttonText: String? = null,
onButtonClick: (() -> Unit)? = null,
@@ -30,6 +37,16 @@ fun ErrorView(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
iconResId?.let {
Icon(
modifier = Modifier.size(120.dp),
painter = painterResource(iconResId),
contentDescription = null,
tint = MaterialTheme.colorScheme.primaryContainer
)
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
@@ -37,9 +54,10 @@ fun ErrorView(
)
buttonText?.let {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { onButtonClick?.invoke() }
onClick = { onButtonClick?.invoke() },
shape = RoundedCornerShape(6.dp)
) {
Text(text = buttonText)
}
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,13c-0.55,0 -1,-0.45 -1,-1L11,8c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v4c0,0.55 -0.45,1 -1,1zM13,17h-2v-2h2v2z" />
</vector>
+7
View File
@@ -46,6 +46,13 @@ androidComponents {
}
}
// TODO: 29-Mar-25, Danil Nikolaev: remove when autofill changes will be in release
configurations.all {
resolutionStrategy {
force(libs.compose.ui)
}
}
android {
namespace = "dev.meloda.fast.auth"
@@ -6,9 +6,8 @@ import androidx.navigation.navigation
import dev.meloda.fast.auth.captcha.navigation.captchaScreen
import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha
import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult
import dev.meloda.fast.auth.login.navigation.Logo
import dev.meloda.fast.auth.login.navigation.Login
import dev.meloda.fast.auth.login.navigation.loginScreen
import dev.meloda.fast.auth.login.navigation.navigateToLogin
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned
import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute
@@ -26,9 +25,7 @@ fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
navigation<AuthGraph>(startDestination = Login) {
loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
@@ -57,7 +54,6 @@ fun NavGraphBuilder.authNavGraph(
)
)
},
onNavigateToCredentials = navController::navigateToLogin,
navController = navController
)
@@ -1,10 +1,11 @@
package dev.meloda.fast.auth.login
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -36,7 +37,7 @@ import kotlinx.coroutines.launch
interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?>
val loginDialog: StateFlow<LoginDialog?>
val validationCode: StateFlow<String?>
val validationArguments: StateFlow<LoginValidationArguments?>
@@ -44,7 +45,11 @@ interface LoginViewModel {
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
val isNeedToShowFastSignInAlert: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked()
@@ -53,8 +58,6 @@ interface LoginViewModel {
fun onSignInButtonClicked()
fun onErrorDialogDismissed()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
@@ -64,9 +67,6 @@ interface LoginViewModel {
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
fun onFastLogInAlertDismissed()
fun onFastLogInAlertConfirmClicked(token: String)
}
class LoginViewModelImpl(
@@ -78,7 +78,7 @@ class LoginViewModelImpl(
) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginError = MutableStateFlow<LoginError?>(null)
override val loginDialog = MutableStateFlow<LoginDialog?>(null)
override val validationCode = MutableStateFlow<String?>(null)
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
@@ -86,39 +86,63 @@ class LoginViewModelImpl(
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
override val isNeedToShowFastSignInAlert = MutableStateFlow(false)
private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate)
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty))
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is LoginDialog.Error -> Unit
LoginDialog.FastAuth -> {
val token = bundle.getString("token")?.trim() ?: return
fastAuth(token)
}
}
}
override fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null }
}
override fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) }
}
override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
}
override fun onLoginInputChanged(newLogin: String) {
val newState = screenState.value.copy(
login = newLogin.trim(),
loginError = false
)
screenState.setValue { newState }
screenState.setValue { old ->
old.copy(
login = newLogin.trim(),
loginError = false
)
}
}
override fun onPasswordInputChanged(newPassword: String) {
val newState = screenState.value.copy(
password = newPassword.trim(),
passwordError = false
)
screenState.setValue { newState }
screenState.setValue { old ->
old.copy(
password = newPassword.trim(),
passwordError = false
)
}
}
override fun onSignInButtonClicked() {
if (screenState.value.isLoading) return
login()
}
override fun onErrorDialogDismissed() {
loginError.update { null }
if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) }
return
}
login()
}
override fun onNavigatedToMain() {
@@ -150,14 +174,10 @@ class LoginViewModelImpl(
}
override fun onLogoLongClicked() {
isNeedToShowFastSignInAlert.update { true }
loginDialog.setValue { LoginDialog.FastAuth }
}
override fun onFastLogInAlertDismissed() {
isNeedToShowFastSignInAlert.update { false }
}
override fun onFastLogInAlertConfirmClicked(token: String) {
private fun fastAuth(token: String) {
var currentAccount = AccountEntity(
userId = -1,
accessToken = token,
@@ -177,12 +197,12 @@ class LoginViewModelImpl(
nomCase = null
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
error = {
UserConfig.currentUserId = -1
UserConfig.userId = -1
UserConfig.accessToken = ""
// TODO: 19/07/2024, Danil Nikolaev: show error?
loginDialog.setValue { LoginDialog.Error() }
},
success = { response ->
val actualUserId = requireNotNull(response).id
@@ -241,7 +261,7 @@ class LoginViewModelImpl(
val accessToken = response.accessToken
if (userId == null || accessToken == null) {
loginError.update { LoginError.Unknown }
loginDialog.setValue { LoginDialog.Error() }
return@processState
}
@@ -312,7 +332,9 @@ class LoginViewModelImpl(
}
OAuthErrorDomain.InvalidCredentialsError -> {
loginError.update { LoginError.WrongCredentials }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.")
}
}
is OAuthErrorDomain.UserBannedError -> {
@@ -326,19 +348,25 @@ class LoginViewModelImpl(
}
OAuthErrorDomain.WrongValidationCode -> {
loginError.update { LoginError.WrongValidationCode }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.")
}
}
OAuthErrorDomain.WrongValidationCodeFormat -> {
loginError.update { LoginError.WrongValidationCodeFormat }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.")
}
}
OAuthErrorDomain.TooManyTriesError -> {
loginError.update { LoginError.TooManyTries }
loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
}
}
OAuthErrorDomain.UnknownError -> {
loginError.update { LoginError.Unknown }
loginDialog.setValue { LoginDialog.Error() }
}
}
@@ -346,9 +374,9 @@ class LoginViewModelImpl(
}
is State.Error.TestError -> {
val message = stateError.message
val error = LoginError.SimpleError(message = message)
loginError.update { error }
loginDialog.setValue {
LoginDialog.Error(errorText = stateError.message)
}
true
}
@@ -0,0 +1,14 @@
package dev.meloda.fast.auth.login.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class LoginDialog {
data object FastAuth : LoginDialog()
data class Error(
val errorText: String? = null,
val errorTextResId: Int? = null
) : LoginDialog()
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
@Immutable
data class LoginScreenState(
val showLogo: Boolean,
val login: String,
val password: String,
val isLoading: Boolean,
@@ -14,6 +15,7 @@ data class LoginScreenState(
companion object {
val EMPTY = LoginScreenState(
showLogo = true,
login = "",
password = "",
isLoading = false,
@@ -10,22 +10,17 @@ import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.auth.login.presentation.LoginRoute
import dev.meloda.fast.auth.login.presentation.LogoRoute
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
object Login
@Serializable
object Logo
fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
navController: NavController
) {
composable<Login> { backStackEntry ->
@@ -45,17 +40,6 @@ fun NavGraphBuilder.loginScreen(
viewModel = viewModel
)
}
composable<Logo> {
LogoRoute(
onNavigateToMain = onNavigateToMain,
onGoNextButtonClicked = onNavigateToCredentials
)
}
}
fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
fun NavBackStackEntry.getValidationResult(): String? {
@@ -1,6 +1,10 @@
package dev.meloda.fast.auth.login.presentation
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
@@ -28,13 +32,10 @@ import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -42,22 +43,23 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
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.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.ui.basic.autoFillRequestHandler
import dev.meloda.fast.ui.basic.connectNode
import dev.meloda.fast.ui.basic.defaultFocusChangeAutoFill
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig
@@ -74,50 +76,52 @@ fun LoginRoute(
onNavigateToValidation: (LoginValidationArguments) -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenMain) {
BackHandler(
enabled = !screenState.showLogo,
onBack = viewModel::onBackPressed
)
LaunchedEffect(
isNeedToOpenMain,
userBannedArguments,
captchaArguments,
validationArguments,
validationCode,
captchaCode
) {
if (isNeedToOpenMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
}
LaunchedEffect(userBannedArguments) {
userBannedArguments?.let { arguments ->
viewModel.onNavigatedToUserBanned()
onNavigateToUserBanned(arguments)
}
}
LaunchedEffect(captchaArguments) {
captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments)
}
}
LaunchedEffect(validationArguments) {
validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation()
onNavigateToValidation(arguments)
}
}
LaunchedEffect(validationCode) {
if (validationCode != null) {
viewModel.onValidationCodeReceived(validationCode)
}
}
LaunchedEffect(captchaCode) {
if (captchaCode != null) {
viewModel.onCaptchaCodeReceived(captchaCode)
}
@@ -125,247 +129,220 @@ fun LoginRoute(
LoginScreen(
screenState = screenState,
onLoginAutoFilled = viewModel::onLoginInputChanged,
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
onLoginInputChanged = viewModel::onLoginInputChanged,
onPasswordInputChanged = viewModel::onPasswordInputChanged,
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoLongClicked = viewModel::onLogoLongClicked
)
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
HandleDialogs(
loginDialog = loginDialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
screenState: LoginScreenState = LoginScreenState.EMPTY,
onLoginAutoFilled: (String) -> Unit = {},
onPasswordAutoFilled: (String) -> Unit = {},
onLoginInputChanged: (String) -> Unit = {},
onPasswordInputChanged: (String) -> Unit = {},
onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {}
onSignInButtonClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) {
val currentSize = LocalSizeConfig.current
val size = LocalSizeConfig.current
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError
val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp)
val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val autoFillEmailHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.EmailAddress),
onFill = { value ->
loginText = TextFieldValue(text = value, selection = TextRange(value.length))
onLoginAutoFilled(value)
}
)
var passwordText by remember { mutableStateOf(TextFieldValue(screenState.password)) }
val showPasswordError = screenState.passwordError
val autoFillPasswordHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.Password),
onFill = { value ->
passwordText = TextFieldValue(text = value, selection = TextRange(value.length))
onPasswordAutoFilled(value)
}
)
val titleStyle = if (currentSize.isWidthSmall) {
MaterialTheme.typography.displayMedium
} else {
MaterialTheme.typography.displayMedium
}
val titleSpacerSize = if (currentSize.isHeightSmall) {
24.dp
} else {
58.dp
}
val bottomPadding = if (currentSize.isHeightSmall) {
10.dp
} else {
30.dp
}
val (loginFocusable, passwordFocusable) =
FocusRequester.createRefs()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomPadding)
.fillMaxSize()
) {
Column(
AnimatedVisibility(
visible = screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) {
Logo(onLogoLongClicked = onLogoLongClicked)
}
AnimatedVisibility(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
.align(Alignment.Center),
visible = !screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = titleStyle
)
Spacer(modifier = Modifier.height(titleSpacerSize))
TextField(
Column(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
passwordFocusable.requestFocus()
true
}
.handleTabKey {
passwordFocusable.requestFocus()
true
}
.focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler)
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
value = loginText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillEmailHandler.requestVerifyManual()
}
.align(Alignment.Center)
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium
)
loginText = newText
onLoginInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (showLoginError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
Spacer(modifier = Modifier.height(titleSpacerSize))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
passwordFocusable.requestFocus()
true
}
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = showLoginError,
singleLine = true
)
AnimatedVisibility(visible = showLoginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
.connectNode(handler = autoFillPasswordHandler)
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler),
value = passwordText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillPasswordHandler.requestVerifyManual()
}
passwordText = newText
onPasswordInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (showPasswordError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
.handleTabKey {
passwordFocusable.requestFocus()
true
}
)
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
.focusRequester(loginFocusable)
.semantics {
contentType = ContentType.Username + ContentType.EmailAddress
},
value = screenState.login,
onValueChange = onLoginInputChanged,
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
else "Password invisible icon"
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (screenState.loginError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
onPasswordFieldGoAction()
}
),
isError = showPasswordError,
visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = true
)
AnimatedVisibility(visible = showPasswordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = screenState.loginError,
singleLine = true
)
AnimatedVisibility(visible = screenState.loginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
.semantics { contentType = ContentType.Password },
value = screenState.password,
onValueChange = onPasswordInputChanged,
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (screenState.passwordError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
else "Password invisible icon"
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
onPasswordFieldGoAction()
}
),
isError = screenState.passwordError,
visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = true
)
AnimatedVisibility(visible = screenState.passwordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
}
}
Box(
modifier = Modifier.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
FloatingActionButton(
onClick = {
if (!screenState.isLoading) {
focusManager.clearFocus()
onSignInButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("sing_in_fab")
) {
FloatingActionButton(
onClick = {
if (!screenState.isLoading) {
focusManager.clearFocus()
onSignInButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("sing_in_fab")
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator()
}
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
@@ -374,18 +351,40 @@ fun LoginScreen(
)
}
}
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
fun HandleDialogs(
loginDialog: LoginDialog?,
onConfirmed: (LoginDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (LoginDialog) -> Unit = {},
) {
when (loginDialog) {
null -> Unit
is LoginDialog.Error -> {
MaterialDialog(
onDismissRequest = { onDismissed(loginDialog) },
title = stringResource(UiR.string.title_error),
text = loginDialog.errorTextResId?.let { stringResource(it) }
?: loginDialog.errorText.orEmpty(),
confirmText = stringResource(id = UiR.string.ok)
)
}
LoginDialog.FastAuth -> {
SignInAlert(
onDismissRequest = { onDismissed(loginDialog) },
onConfirmClick = { onConfirmed(loginDialog, bundleOf("token" to it)) }
)
}
}
}
@Composable
fun HandleError(
onDismiss: () -> Unit,
@@ -0,0 +1,83 @@
package dev.meloda.fast.auth.login.presentation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalSizeConfig
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Logo(
modifier: Modifier = Modifier,
onLogoLongClicked: () -> Unit = {}
) {
val size = LocalSizeConfig.current
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 40 else 40)
val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
Box(
modifier = modifier
.fillMaxSize()
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomAdditionalPadding)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = R.drawable.ic_logo_big),
contentDescription = "Application Logo",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.width(iconWidth)
.combinedClickable(
interactionSource = null,
indication = null,
onLongClick = onLogoLongClicked,
onClick = {}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = R.string.fast_messenger),
style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp),
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
@Preview
@Composable
private fun LogoPreview() {
Logo()
}
@@ -1,229 +0,0 @@
package dev.meloda.fast.auth.login.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
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.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.theme.LocalSizeConfig
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable
fun LogoRoute(
onNavigateToMain: () -> Unit,
onGoNextButtonClicked: () -> Unit,
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val isNeedToShowSignInAlert by viewModel.isNeedToShowFastSignInAlert.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenMain) {
if (isNeedToOpenMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
}
LogoScreen(
isLoading = screenState.isLoading,
onLogoLongClicked = viewModel::onLogoLongClicked,
onGoNextButtonClicked = onGoNextButtonClicked
)
if (isNeedToShowSignInAlert) {
SignInAlert(
onDismissRequest = viewModel::onFastLogInAlertDismissed,
onConfirmClick = viewModel::onFastLogInAlertConfirmClicked,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
isLoading: Boolean = false,
onLogoLongClicked: () -> Unit = {},
onGoNextButtonClicked: () -> Unit = {}
) {
val currentSize = LocalSizeConfig.current
Scaffold { padding ->
val topPadding by animateDpAsState(
targetValue = padding.calculateTopPadding(),
label = "topPaddingAnimation"
)
val bottomPadding by animateDpAsState(
targetValue = padding.calculateBottomPadding(),
label = "bottomPaddingAnimation"
)
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr),
label = "endPaddingAnimation"
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr),
label = "startPaddingAnimation"
)
val iconWidth = if (currentSize.isWidthSmall) {
110.dp
} else {
134.dp
}
val appNameTextStyle = if (currentSize.isWidthSmall) {
MaterialTheme.typography.displayMedium.copy(fontSize = 40.sp)
} else {
MaterialTheme.typography.displayMedium
}
val bottomAdditionalPadding = if (currentSize.isHeightSmall) {
10.dp
} else {
30.dp
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = startPadding,
top = topPadding,
end = endPadding,
bottom = bottomPadding
)
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomAdditionalPadding)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_logo_big),
contentDescription = "Application Logo",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.width(iconWidth)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = onLogoLongClicked,
onClick = {}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = UiR.string.fast_messenger),
style = appNameTextStyle,
color = MaterialTheme.colorScheme.onBackground
)
}
AnimatedVisibility(
visible = !isLoading,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
FloatingActionButton(
onClick = {
if (!isLoading) {
onGoNextButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("go_next_fab")
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Go button",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
AnimatedVisibility(
visible = isLoading,
modifier = Modifier.align(Alignment.BottomCenter)
) {
CircularProgressIndicator()
}
}
}
}
@Composable
fun SignInAlert(
onDismissRequest: () -> Unit,
onConfirmClick: (token: String) -> Unit
) {
var tokenText by rememberSaveable {
mutableStateOf(BuildConfig.debugToken)
}
val maxWidthModifier = Modifier.fillMaxWidth()
MaterialDialog(
onDismissRequest = onDismissRequest,
title = "Fast authorization",
confirmText = stringResource(id = UiR.string.action_authorize),
confirmAction = { onConfirmClick(tokenText) },
cancelText = stringResource(id = UiR.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
) {
Column(modifier = maxWidthModifier) {
OutlinedTextField(
modifier = maxWidthModifier.padding(horizontal = 16.dp),
value = tokenText,
onValueChange = { tokenText = it },
placeholder = { Text(text = "Access token") },
label = { Text(text = "Access token") }
)
}
}
}
@@ -0,0 +1,51 @@
package dev.meloda.fast.auth.login.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R as UiR
@Composable
fun SignInAlert(
onDismissRequest: () -> Unit = {},
onConfirmClick: (token: String) -> Unit = {}
) {
var tokenText by rememberSaveable {
mutableStateOf(BuildConfig.debugToken)
}
val maxWidthModifier = Modifier.fillMaxWidth()
MaterialDialog(
onDismissRequest = onDismissRequest,
title = "Fast authorization",
confirmText = stringResource(id = UiR.string.action_authorize),
confirmAction = { onConfirmClick(tokenText) },
cancelText = stringResource(id = UiR.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
) {
Column(modifier = maxWidthModifier) {
OutlinedTextField(
modifier = maxWidthModifier.padding(horizontal = 16.dp),
value = tokenText,
onValueChange = { tokenText = it },
placeholder = { Text(text = "Access token") },
label = { Text(text = "Access token") }
)
}
}
}
@@ -27,6 +27,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -52,6 +53,7 @@ import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -118,9 +120,10 @@ fun ChatMaterialsScreen(
)
val topBarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !canScrollBackward) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
targetValue = if (currentTheme.enableBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
@@ -204,113 +207,115 @@ fun ChatMaterialsScreen(
}
}
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
when (index) {
0 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.PHOTO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
CompositionLocalProvider(LocalHazeState provides hazeState) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
when (index) {
0 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.PHOTO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
PhotoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onPhotoClicked = onPhotoClicked
)
PhotoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onPhotoClicked = onPhotoClicked
)
}
1 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.VIDEO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
VideoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
2 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.AUDIO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
AudioMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
3 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.FILE))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
FileMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
4 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.LINK))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LinkMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
else -> Unit
}
1 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.VIDEO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
VideoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
2 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.AUDIO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
AudioMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
3 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.FILE))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
FileMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
4 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.LINK))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LinkMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
else -> Unit
}
}
}
@@ -26,18 +26,19 @@ import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsList(
modifier: Modifier = Modifier,
onConversationsClick: (Int) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues
) {
@@ -116,6 +117,7 @@ fun ConversationsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -98,6 +99,7 @@ fun FriendsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -71,7 +71,6 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean>
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
fun onDialogCancelled(dialog: MessageDialog)
fun onDialogDismissed(dialog: MessageDialog)
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
@@ -151,7 +150,7 @@ class MessagesHistoryViewModelImpl(
}
override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) {
messageDialog.setValue { null }
onDialogDismissed(dialog)
when (dialog) {
is MessageDialog.MessageOptions -> Unit
@@ -223,10 +222,6 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onDialogCancelled(dialog: MessageDialog) {
messageDialog.setValue { null }
}
override fun onDialogDismissed(dialog: MessageDialog) {
messageDialog.setValue { null }
}
@@ -1,7 +1,9 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
sealed class MessageDialog {
data class MessageOptions(val message: VkMessage) : MessageDialog()
data class MessagePin(val messageId: Int) : MessageDialog()
@@ -170,7 +170,6 @@ fun MessagesHistoryRoute(
screenState = screenState,
messageDialog = messageDialog,
onConfirmed = viewModel::onDialogConfirmed,
onCancelled = viewModel::onDialogCancelled,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
@@ -181,7 +180,6 @@ fun HandleDialogs(
screenState: MessagesHistoryScreenState,
messageDialog: MessageDialog?,
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
onCancelled: (MessageDialog) -> Unit = {},
onDismissed: (MessageDialog) -> Unit = {},
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
) {
+1 -13
View File
@@ -30,12 +30,8 @@ retrofit = "2.11.0"
room = "2.6.1"
preference-ktx = "1.2.1"
nanokt = "1.2.0"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
androidx-navigation = "2.8.9"
serialization = "1.8.0"
rebugger = "1.0.0-rc03"
moduleGraph = "2.8.0"
[libraries]
@@ -69,18 +65,13 @@ preference-ktx = { module = "androidx.preference:preference-ktx", version.ref =
nanokt = { module = "com.conena.nanokt:nanokt", version.ref = "nanokt" }
nanokt-android = { module = "com.conena.nanokt:nanokt-android", version.ref = "nanokt" }
nanokt-jvm = { module = "com.conena.nanokt:nanokt-jvm", version.ref = "nanokt" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
rebugger = { module = "io.github.theapache64:rebugger-android", version.ref = "rebugger" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui = { module = "androidx.compose.ui:ui", version = "1.8.0-rc02" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" }
@@ -92,10 +83,7 @@ compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-core-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-android-test = { module = "io.insert-koin:koin-android-test", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin" }