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.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost 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.model.BottomNavigationItem
import dev.meloda.fast.navigation.MainGraph import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.profileScreen 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.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser import dev.meloda.fast.ui.theme.LocalUser
@@ -58,7 +59,7 @@ fun MainScreen(
onMessageClicked: (userId: Int) -> Unit = {}, onMessageClicked: (userId: Int) -> Unit = {},
onCreateChatClicked: () -> Unit = {} onCreateChatClicked: () -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val navController = rememberNavController() val navController = rememberNavController()
@@ -75,18 +76,12 @@ fun MainScreen(
bottomBar = { bottomBar = {
NavigationBar( NavigationBar(
modifier = Modifier modifier = Modifier
.then( .fillMaxWidth()
if (currentTheme.enableBlur) { .hazeEffect(
Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.thick()
) ),
} else Modifier containerColor = Color.Transparent
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.enableBlur) 0f else 1f
)
) { ) {
navigationItems.forEachIndexed { index, item -> navigationItems.forEachIndexed { index, item ->
NavigationBarItem( NavigationBarItem(
@@ -145,11 +140,10 @@ fun MainScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = padding.calculateBottomPadding())
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalHazeState provides hazeState, LocalHazeState provides hazeState,
// LocalBottomPadding provides if (currentTheme.enableBlur) padding.calculateBottomPadding() else 0.dp LocalBottomPadding provides padding.calculateBottomPadding()
) { ) {
NavHost( NavHost(
navController = navController, 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.fillMaxSize
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.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun ErrorView( fun ErrorView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconResId: Int? = UiR.drawable.round_error_24,
text: String, text: String,
buttonText: String? = null, buttonText: String? = null,
onButtonClick: (() -> Unit)? = null, onButtonClick: (() -> Unit)? = null,
@@ -30,6 +37,16 @@ fun ErrorView(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally 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 = text, text = text,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -37,9 +54,10 @@ fun ErrorView(
) )
buttonText?.let { buttonText?.let {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onButtonClick?.invoke() } onClick = { onButtonClick?.invoke() },
shape = RoundedCornerShape(6.dp)
) { ) {
Text(text = buttonText) 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 { android {
namespace = "dev.meloda.fast.auth" 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.captchaScreen
import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha
import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult 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.loginScreen
import dev.meloda.fast.auth.login.navigation.navigateToLogin
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned
import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute
@@ -26,9 +25,7 @@ fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
navController: NavController navController: NavController
) { ) {
navigation<AuthGraph>( navigation<AuthGraph>(startDestination = Login) {
startDestination = Logo
) {
loginScreen( loginScreen(
onNavigateToCaptcha = { arguments -> onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha( navController.navigateToCaptcha(
@@ -57,7 +54,6 @@ fun NavGraphBuilder.authNavGraph(
) )
) )
}, },
onNavigateToCredentials = navController::navigateToLogin,
navController = navController navController = navController
) )
@@ -1,10 +1,11 @@
package dev.meloda.fast.auth.login package dev.meloda.fast.auth.login
import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments 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.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -36,7 +37,7 @@ import kotlinx.coroutines.launch
interface LoginViewModel { interface LoginViewModel {
val screenState: StateFlow<LoginScreenState> val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?> val loginDialog: StateFlow<LoginDialog?>
val validationCode: StateFlow<String?> val validationCode: StateFlow<String?>
val validationArguments: StateFlow<LoginValidationArguments?> val validationArguments: StateFlow<LoginValidationArguments?>
@@ -44,7 +45,11 @@ interface LoginViewModel {
val captchaArguments: StateFlow<CaptchaArguments?> val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?> val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean> val isNeedToOpenMain: StateFlow<Boolean>
val isNeedToShowFastSignInAlert: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked() fun onPasswordVisibilityButtonClicked()
@@ -53,8 +58,6 @@ interface LoginViewModel {
fun onSignInButtonClicked() fun onSignInButtonClicked()
fun onErrorDialogDismissed()
fun onNavigatedToMain() fun onNavigatedToMain()
fun onNavigatedToUserBanned() fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha() fun onNavigatedToCaptcha()
@@ -64,9 +67,6 @@ interface LoginViewModel {
fun onCaptchaCodeReceived(code: String) fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked() fun onLogoLongClicked()
fun onFastLogInAlertDismissed()
fun onFastLogInAlertConfirmClicked(token: String)
} }
class LoginViewModelImpl( class LoginViewModelImpl(
@@ -78,7 +78,7 @@ class LoginViewModelImpl(
) : ViewModel(), LoginViewModel { ) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY) 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 validationCode = MutableStateFlow<String?>(null)
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null) override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
@@ -86,39 +86,63 @@ class LoginViewModelImpl(
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null) override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null) override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false) override val isNeedToOpenMain = MutableStateFlow(false)
override val isNeedToShowFastSignInAlert = MutableStateFlow(false)
private val validationState: StateFlow<List<LoginValidationResult>> = private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate) screenState.map(loginValidator::validate)
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty)) .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() { override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) } screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
} }
override fun onLoginInputChanged(newLogin: String) { override fun onLoginInputChanged(newLogin: String) {
val newState = screenState.value.copy( screenState.setValue { old ->
old.copy(
login = newLogin.trim(), login = newLogin.trim(),
loginError = false loginError = false
) )
screenState.setValue { newState } }
} }
override fun onPasswordInputChanged(newPassword: String) { override fun onPasswordInputChanged(newPassword: String) {
val newState = screenState.value.copy( screenState.setValue { old ->
old.copy(
password = newPassword.trim(), password = newPassword.trim(),
passwordError = false passwordError = false
) )
screenState.setValue { newState } }
} }
override fun onSignInButtonClicked() { override fun onSignInButtonClicked() {
if (screenState.value.isLoading) return if (screenState.value.isLoading) return
login()
if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) }
return
} }
override fun onErrorDialogDismissed() { login()
loginError.update { null }
} }
override fun onNavigatedToMain() { override fun onNavigatedToMain() {
@@ -150,14 +174,10 @@ class LoginViewModelImpl(
} }
override fun onLogoLongClicked() { override fun onLogoLongClicked() {
isNeedToShowFastSignInAlert.update { true } loginDialog.setValue { LoginDialog.FastAuth }
} }
override fun onFastLogInAlertDismissed() { private fun fastAuth(token: String) {
isNeedToShowFastSignInAlert.update { false }
}
override fun onFastLogInAlertConfirmClicked(token: String) {
var currentAccount = AccountEntity( var currentAccount = AccountEntity(
userId = -1, userId = -1,
accessToken = token, accessToken = token,
@@ -177,12 +197,12 @@ class LoginViewModelImpl(
nomCase = null nomCase = null
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = {
UserConfig.currentUserId = -1 UserConfig.currentUserId = -1
UserConfig.userId = -1 UserConfig.userId = -1
UserConfig.accessToken = "" UserConfig.accessToken = ""
// TODO: 19/07/2024, Danil Nikolaev: show error? loginDialog.setValue { LoginDialog.Error() }
}, },
success = { response -> success = { response ->
val actualUserId = requireNotNull(response).id val actualUserId = requireNotNull(response).id
@@ -241,7 +261,7 @@ class LoginViewModelImpl(
val accessToken = response.accessToken val accessToken = response.accessToken
if (userId == null || accessToken == null) { if (userId == null || accessToken == null) {
loginError.update { LoginError.Unknown } loginDialog.setValue { LoginDialog.Error() }
return@processState return@processState
} }
@@ -312,7 +332,9 @@ class LoginViewModelImpl(
} }
OAuthErrorDomain.InvalidCredentialsError -> { OAuthErrorDomain.InvalidCredentialsError -> {
loginError.update { LoginError.WrongCredentials } loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.")
}
} }
is OAuthErrorDomain.UserBannedError -> { is OAuthErrorDomain.UserBannedError -> {
@@ -326,19 +348,25 @@ class LoginViewModelImpl(
} }
OAuthErrorDomain.WrongValidationCode -> { OAuthErrorDomain.WrongValidationCode -> {
loginError.update { LoginError.WrongValidationCode } loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.")
}
} }
OAuthErrorDomain.WrongValidationCodeFormat -> { OAuthErrorDomain.WrongValidationCodeFormat -> {
loginError.update { LoginError.WrongValidationCodeFormat } loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.")
}
} }
OAuthErrorDomain.TooManyTriesError -> { OAuthErrorDomain.TooManyTriesError -> {
loginError.update { LoginError.TooManyTries } loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
}
} }
OAuthErrorDomain.UnknownError -> { OAuthErrorDomain.UnknownError -> {
loginError.update { LoginError.Unknown } loginDialog.setValue { LoginDialog.Error() }
} }
} }
@@ -346,9 +374,9 @@ class LoginViewModelImpl(
} }
is State.Error.TestError -> { is State.Error.TestError -> {
val message = stateError.message loginDialog.setValue {
val error = LoginError.SimpleError(message = message) LoginDialog.Error(errorText = stateError.message)
loginError.update { error } }
true 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 @Immutable
data class LoginScreenState( data class LoginScreenState(
val showLogo: Boolean,
val login: String, val login: String,
val password: String, val password: String,
val isLoading: Boolean, val isLoading: Boolean,
@@ -14,6 +15,7 @@ data class LoginScreenState(
companion object { companion object {
val EMPTY = LoginScreenState( val EMPTY = LoginScreenState(
showLogo = true,
login = "", login = "",
password = "", password = "",
isLoading = false, 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.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.auth.login.presentation.LoginRoute import dev.meloda.fast.auth.login.presentation.LoginRoute
import dev.meloda.fast.auth.login.presentation.LogoRoute
import dev.meloda.fast.ui.extensions.sharedViewModel import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
object Login object Login
@Serializable
object Logo
fun NavGraphBuilder.loginScreen( fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
navController: NavController navController: NavController
) { ) {
composable<Login> { backStackEntry -> composable<Login> { backStackEntry ->
@@ -45,17 +40,6 @@ fun NavGraphBuilder.loginScreen(
viewModel = viewModel viewModel = viewModel
) )
} }
composable<Logo> {
LogoRoute(
onNavigateToMain = onNavigateToMain,
onGoNextButtonClicked = onNavigateToCredentials
)
}
}
fun NavController.navigateToLogin() {
this.navigate(route = Login)
} }
fun NavBackStackEntry.getValidationResult(): String? { fun NavBackStackEntry.getValidationResult(): String? {
@@ -1,6 +1,10 @@
package dev.meloda.fast.auth.login.presentation 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.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -28,13 +32,10 @@ import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginError import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments 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.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig import dev.meloda.fast.ui.theme.LocalSizeConfig
@@ -74,50 +76,52 @@ fun LoginRoute(
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
validationCode: String?, validationCode: String?,
captchaCode: 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 screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle() val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle() val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.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) { if (isNeedToOpenMain) {
viewModel.onNavigatedToMain() viewModel.onNavigatedToMain()
onNavigateToMain() onNavigateToMain()
} }
}
LaunchedEffect(userBannedArguments) {
userBannedArguments?.let { arguments -> userBannedArguments?.let { arguments ->
viewModel.onNavigatedToUserBanned() viewModel.onNavigatedToUserBanned()
onNavigateToUserBanned(arguments) onNavigateToUserBanned(arguments)
} }
}
LaunchedEffect(captchaArguments) {
captchaArguments?.let { arguments -> captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha() viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments) onNavigateToCaptcha(arguments)
} }
}
LaunchedEffect(validationArguments) {
validationArguments?.let { arguments -> validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation() viewModel.onNavigatedToValidation()
onNavigateToValidation(arguments) onNavigateToValidation(arguments)
} }
}
LaunchedEffect(validationCode) {
if (validationCode != null) { if (validationCode != null) {
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
}
LaunchedEffect(captchaCode) {
if (captchaCode != null) { if (captchaCode != null) {
viewModel.onCaptchaCodeReceived(captchaCode) viewModel.onCaptchaCodeReceived(captchaCode)
} }
@@ -125,89 +129,68 @@ fun LoginRoute(
LoginScreen( LoginScreen(
screenState = screenState, screenState = screenState,
onLoginAutoFilled = viewModel::onLoginInputChanged,
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
onLoginInputChanged = viewModel::onLoginInputChanged, onLoginInputChanged = viewModel::onLoginInputChanged,
onPasswordInputChanged = viewModel::onPasswordInputChanged, onPasswordInputChanged = viewModel::onPasswordInputChanged,
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoLongClicked = viewModel::onLogoLongClicked
) )
HandleError( HandleDialogs(
onDismiss = viewModel::onErrorDialogDismissed, loginDialog = loginDialog,
error = loginError onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed
) )
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
screenState: LoginScreenState = LoginScreenState.EMPTY, screenState: LoginScreenState = LoginScreenState.EMPTY,
onLoginAutoFilled: (String) -> Unit = {},
onPasswordAutoFilled: (String) -> Unit = {},
onLoginInputChanged: (String) -> Unit = {}, onLoginInputChanged: (String) -> Unit = {},
onPasswordInputChanged: (String) -> Unit = {}, onPasswordInputChanged: (String) -> Unit = {},
onPasswordFieldEnterKeyClicked: () -> Unit = {}, onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {}, onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {}, onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {} onSignInButtonClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) { ) {
val currentSize = LocalSizeConfig.current val size = LocalSizeConfig.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) } val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp)
val showLoginError = screenState.loginError val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val autoFillEmailHandler = autoFillRequestHandler( val (loginFocusable, passwordFocusable) =
autofillTypes = listOf(AutofillType.EmailAddress), FocusRequester.createRefs()
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
}
Scaffold( Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize()
.padding(padding) .padding(padding)
.padding(top = 30.dp) .padding(top = 30.dp)
.padding(horizontal = 30.dp) .padding(horizontal = 30.dp)
.padding(bottom = bottomPadding) .padding(bottom = bottomPadding)
.fillMaxSize()
) {
AnimatedVisibility(
visible = screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) {
Logo(onLogoLongClicked = onLogoLongClicked)
}
AnimatedVisibility(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
visible = !screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -217,7 +200,7 @@ fun LoginScreen(
Text( Text(
text = stringResource(id = UiR.string.sign_in_to_vk), text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
style = titleStyle style = MaterialTheme.typography.displayMedium
) )
Spacer(modifier = Modifier.height(titleSpacerSize)) Spacer(modifier = Modifier.height(titleSpacerSize))
@@ -236,25 +219,18 @@ fun LoginScreen(
true true
} }
.focusRequester(loginFocusable) .focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler) .semantics {
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler), contentType = ContentType.Username + ContentType.EmailAddress
value = loginText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillEmailHandler.requestVerifyManual()
}
loginText = newText
onLoginInputChanged(text)
}, },
value = screenState.login,
onValueChange = onLoginInputChanged,
label = { Text(text = stringResource(id = UiR.string.login_hint)) }, label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) }, placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_round_person_24), painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon", contentDescription = "Login icon",
tint = if (showLoginError) { tint = if (screenState.loginError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
} else { } else {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@@ -266,10 +242,10 @@ fun LoginScreen(
keyboardType = KeyboardType.Email keyboardType = KeyboardType.Email
), ),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }), keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = showLoginError, isError = screenState.loginError,
singleLine = true singleLine = true
) )
AnimatedVisibility(visible = showLoginError) { AnimatedVisibility(visible = screenState.loginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
} }
@@ -286,25 +262,16 @@ fun LoginScreen(
true true
} }
.focusRequester(passwordFocusable) .focusRequester(passwordFocusable)
.connectNode(handler = autoFillPasswordHandler) .semantics { contentType = ContentType.Password },
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler), value = screenState.password,
value = passwordText, onValueChange = onPasswordInputChanged,
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)) }, label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24), painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon", contentDescription = "Password icon",
tint = if (showPasswordError) { tint = if (screenState.passwordError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
} else { } else {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@@ -335,7 +302,7 @@ fun LoginScreen(
onPasswordFieldGoAction() onPasswordFieldGoAction()
} }
), ),
isError = showPasswordError, isError = screenState.passwordError,
visualTransformation = if (screenState.passwordVisible) { visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None VisualTransformation.None
} else { } else {
@@ -343,19 +310,16 @@ fun LoginScreen(
}, },
singleLine = true singleLine = true
) )
AnimatedVisibility(visible = showPasswordError) { AnimatedVisibility(visible = screenState.passwordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
} }
} }
}
Box( Box(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -367,13 +331,6 @@ fun LoginScreen(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("sing_in_fab") modifier = Modifier.testTag("sing_in_fab")
) { ) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Sign in icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
AnimatedVisibility( AnimatedVisibility(
visible = screenState.isLoading, visible = screenState.isLoading,
enter = fadeIn(), enter = fadeIn(),
@@ -381,10 +338,52 @@ fun LoginScreen(
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Sign in icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
} }
} }
} }
} }
}
}
@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 @Composable
fun HandleError( fun HandleError(
@@ -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.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -118,9 +120,10 @@ fun ChatMaterialsScreen(
) )
val topBarContainerColor by animateColorAsState( val topBarContainerColor by animateColorAsState(
targetValue = targetValue = if (currentTheme.enableBlur || !canScrollBackward)
if (currentTheme.enableBlur || !canScrollBackward) MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
animationSpec = tween( animationSpec = tween(
durationMillis = 200, durationMillis = 200,
@@ -204,6 +207,7 @@ fun ChatMaterialsScreen(
} }
} }
) { padding -> ) { padding ->
CompositionLocalProvider(LocalHazeState provides hazeState) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -315,3 +319,4 @@ fun ChatMaterialsScreen(
} }
} }
} }
}
@@ -26,18 +26,19 @@ import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun ConversationsList( fun ConversationsList(
modifier: Modifier = Modifier,
onConversationsClick: (Int) -> Unit, onConversationsClick: (Int) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit, onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState, screenState: ConversationsScreenState,
state: LazyListState, state: LazyListState,
maxLines: Int, maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit, onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues padding: PaddingValues
) { ) {
@@ -116,6 +117,7 @@ fun ConversationsList(
} }
Spacer(modifier = Modifier.height(8.dp)) 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 androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.ui.model.api.UiFriend import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -98,6 +99,7 @@ fun FriendsList(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
} }
} }
} }
@@ -71,7 +71,6 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
fun onDialogCancelled(dialog: MessageDialog)
fun onDialogDismissed(dialog: MessageDialog) fun onDialogDismissed(dialog: MessageDialog)
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
@@ -151,7 +150,7 @@ class MessagesHistoryViewModelImpl(
} }
override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) { override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) {
messageDialog.setValue { null } onDialogDismissed(dialog)
when (dialog) { when (dialog) {
is MessageDialog.MessageOptions -> Unit is MessageDialog.MessageOptions -> Unit
@@ -223,10 +222,6 @@ class MessagesHistoryViewModelImpl(
} }
} }
override fun onDialogCancelled(dialog: MessageDialog) {
messageDialog.setValue { null }
}
override fun onDialogDismissed(dialog: MessageDialog) { override fun onDialogDismissed(dialog: MessageDialog) {
messageDialog.setValue { null } messageDialog.setValue { null }
} }
@@ -1,7 +1,9 @@
package dev.meloda.fast.messageshistory.model package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
sealed class MessageDialog { sealed class MessageDialog {
data class MessageOptions(val message: VkMessage) : MessageDialog() data class MessageOptions(val message: VkMessage) : MessageDialog()
data class MessagePin(val messageId: Int) : MessageDialog() data class MessagePin(val messageId: Int) : MessageDialog()
@@ -170,7 +170,6 @@ fun MessagesHistoryRoute(
screenState = screenState, screenState = screenState,
messageDialog = messageDialog, messageDialog = messageDialog,
onConfirmed = viewModel::onDialogConfirmed, onConfirmed = viewModel::onDialogConfirmed,
onCancelled = viewModel::onDialogCancelled,
onDismissed = viewModel::onDialogDismissed, onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked onItemPicked = viewModel::onDialogItemPicked
) )
@@ -181,7 +180,6 @@ fun HandleDialogs(
screenState: MessagesHistoryScreenState, screenState: MessagesHistoryScreenState,
messageDialog: MessageDialog?, messageDialog: MessageDialog?,
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> }, onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
onCancelled: (MessageDialog) -> Unit = {},
onDismissed: (MessageDialog) -> Unit = {}, onDismissed: (MessageDialog) -> Unit = {},
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> } onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
+1 -13
View File
@@ -30,12 +30,8 @@ retrofit = "2.11.0"
room = "2.6.1" room = "2.6.1"
preference-ktx = "1.2.1" preference-ktx = "1.2.1"
nanokt = "1.2.0" nanokt = "1.2.0"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
androidx-navigation = "2.8.9" androidx-navigation = "2.8.9"
serialization = "1.8.0" serialization = "1.8.0"
rebugger = "1.0.0-rc03"
moduleGraph = "2.8.0" moduleGraph = "2.8.0"
[libraries] [libraries]
@@ -69,18 +65,13 @@ preference-ktx = { module = "androidx.preference:preference-ktx", version.ref =
nanokt = { module = "com.conena.nanokt:nanokt", version.ref = "nanokt" } nanokt = { module = "com.conena.nanokt:nanokt", version.ref = "nanokt" }
nanokt-android = { module = "com.conena.nanokt:nanokt-android", 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" } 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" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } 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-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-material3 = { module = "androidx.compose.material3:material3" } 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-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" } 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" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 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 = { 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 = { 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" } koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koin" }