LoginPasswordNode

This commit is contained in:
Benoit Marty 2023-06-06 18:16:35 +02:00 committed by Benoit Marty
parent 22ed3b7bfd
commit 32f953aa83
12 changed files with 636 additions and 30 deletions

View file

@ -38,6 +38,7 @@ import io.element.android.features.login.impl.changeaccountprovider.form.ChangeA
import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
import io.element.android.features.login.impl.changeserver.ChangeServerNode import io.element.android.features.login.impl.changeserver.ChangeServerNode
import io.element.android.features.login.impl.datasource.AccountProviderDataSource import io.element.android.features.login.impl.datasource.AccountProviderDataSource
import io.element.android.features.login.impl.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
import io.element.android.features.login.impl.oidc.webview.OidcNode import io.element.android.features.login.impl.oidc.webview.OidcNode
@ -90,6 +91,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object ChangeAccountProviderForm : NavTarget object ChangeAccountProviderForm : NavTarget
@Parcelize
object LoginPasswordForm : NavTarget
// Not used anymore // Not used anymore
@Parcelize @Parcelize
object ChangeServer : NavTarget object ChangeServer : NavTarget
@ -129,9 +133,18 @@ class LoginFlowNode @AssistedInject constructor(
isAccountCreation = inputs.isAccountCreation isAccountCreation = inputs.isAccountCreation
) )
val callback = object : AccountProviderNode.Callback { val callback = object : AccountProviderNode.Callback {
override fun onServerValidated() { override fun onOidcDetails(oidcDetails: OidcDetails) {
// TODO if (customTabAvailabilityChecker.supportCustomTab()) {
TODO("Not yet implemented") // In this case open a Chrome Custom tab
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPasswordForm)
} }
override fun onChangeAccountProvider() { override fun onChangeAccountProvider() {
@ -166,6 +179,9 @@ class LoginFlowNode @AssistedInject constructor(
createNode<ChangeAccountProviderFormNode>(buildContext, plugins = listOf(callback)) createNode<ChangeAccountProviderFormNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.LoginPasswordForm -> {
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
}
} }
} }

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class AccountProviderNode @AssistedInject constructor( class AccountProviderNode @AssistedInject constructor(
@ -54,12 +55,17 @@ class AccountProviderNode @AssistedInject constructor(
) )
interface Callback : Plugin { interface Callback : Plugin {
fun onServerValidated() fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onChangeAccountProvider() fun onChangeAccountProvider()
} }
private fun onServerValidated() { private fun onOidcDetails(data: OidcDetails) {
plugins<Callback>().forEach { it.onServerValidated() } plugins<Callback>().forEach { it.onOidcDetails(data) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
} }
private fun onChangeAccountProvider() { private fun onChangeAccountProvider() {
@ -78,7 +84,8 @@ class AccountProviderNode @AssistedInject constructor(
AccountProviderView( AccountProviderView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onChangeServerSuccess = ::onServerValidated, onOidcDetails = ::onOidcDetails,
onLoginPasswordNeeded = ::onLoginPasswordNeeded,
onChange = ::onChangeAccountProvider, onChange = ::onChangeAccountProvider,
onLearnMoreClicked = { openLearnMorePage(context) }, onLearnMoreClicked = { openLearnMorePage(context) },
) )

View file

@ -29,11 +29,13 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.changeserver.ChangeServerError import io.element.android.features.login.impl.changeserver.ChangeServerError
import io.element.android.features.login.impl.datasource.AccountProviderDataSource import io.element.android.features.login.impl.datasource.AccountProviderDataSource
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URL import java.net.URL
@ -56,22 +58,29 @@ class AccountProviderPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): AccountProviderState { override fun present(): AccountProviderState {
val accountProvider by accountProviderDataSource.flow().collectAsState() val accountProvider by accountProviderDataSource.flow().collectAsState()
val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
if (currentHomeServerDetails != null) {
mutableStateOf(Async.Success(currentHomeServerDetails))
} else {
mutableStateOf(Async.Uninitialized)
}
}
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable { val homeserver = rememberSaveable {
mutableStateOf(accountProvider.title /* TODO There is a mix of data and UI here, which is not nice */) mutableStateOf(currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
} }
val changeServerAction: MutableState<Async<Unit>> = remember { val loginFlowAction: MutableState<Async<LoginFlow>> = remember {
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
fun handleEvents(event: AccountProviderEvents) { fun handleEvents(event: AccountProviderEvents) {
when (event) { when (event) {
AccountProviderEvents.Continue -> { AccountProviderEvents.Continue -> {
localCoroutineScope.submit(homeserver, changeServerAction) localCoroutineScope.submit(homeserver, loginFlowAction)
} }
AccountProviderEvents.ClearError -> changeServerAction.value = Async.Uninitialized AccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
} }
} }
@ -79,16 +88,30 @@ class AccountProviderPresenter @AssistedInject constructor(
homeserver = accountProvider.title, homeserver = accountProvider.title,
isMatrix = accountProvider.isMatrixOrg, isMatrix = accountProvider.isMatrixOrg,
isAccountCreation = params.isAccountCreation, isAccountCreation = params.isAccountCreation,
changeServerAction = changeServerAction.value, loginFlow = loginFlowAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.submit(
homeserverUrl: MutableState<String>,
loginFlowAction: MutableState<Async<LoginFlow>>,
) = launch {
suspend { suspend {
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
authenticationService.setHomeserver(domain).getOrThrow()
homeserverUrl.value = domain homeserverUrl.value = domain
}.execute(changeServerAction, errorMapping = ChangeServerError::from) authenticationService.setHomeserver(domain).map {
authenticationService.getHomeserverDetails().value!!
}.map {
if (it.supportsOidcLogin) {
// Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
} else if (it.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
throw IllegalStateException("Unsupported login flow")
}
}.getOrThrow()
}.execute(loginFlowAction, errorMapping = ChangeServerError::from)
} }
} }

View file

@ -17,15 +17,20 @@
package io.element.android.features.login.impl.accountprovider package io.element.android.features.login.impl.accountprovider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
// Do not use default value, so no member get forgotten in the presenters. // Do not use default value, so no member get forgotten in the presenters.
data class AccountProviderState( data class AccountProviderState(
val homeserver: String, val homeserver: String,
val isMatrix: Boolean, val isMatrix: Boolean,
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
// TODO Rename val loginFlow: Async<LoginFlow>,
val changeServerAction: Async<Unit>,
val eventSink: (AccountProviderEvents) -> Unit val eventSink: (AccountProviderEvents) -> Unit
) { ) {
val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading) val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
}
sealed interface LoginFlow {
object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
} }

View file

@ -31,6 +31,6 @@ fun aAccountProviderState() = AccountProviderState(
homeserver = "matrix.org", homeserver = "matrix.org",
isMatrix = true, isMatrix = true,
isAccountCreation = false, isAccountCreation = false,
changeServerAction = Async.Uninitialized, loginFlow = Async.Uninitialized,
eventSink = {} eventSink = {}
) )

View file

@ -31,18 +31,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.changeserver.ChangeServerError import io.element.android.features.login.impl.changeserver.ChangeServerError
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
@ -50,19 +52,19 @@ import io.element.android.libraries.testtags.testTag
fun AccountProviderView( fun AccountProviderView(
state: AccountProviderState, state: AccountProviderState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// TODO Rename onOidcDetails: (OidcDetails) -> Unit = {},
onChangeServerSuccess: () -> Unit = {}, onLoginPasswordNeeded: () -> Unit = {},
onLearnMoreClicked: () -> Unit = {}, onLearnMoreClicked: () -> Unit = {},
onChange: () -> Unit = {}, onChange: () -> Unit = {},
) { ) {
val isLoading by remember(state.changeServerAction) { val isLoading by remember(state.loginFlow) {
derivedStateOf { derivedStateOf {
state.changeServerAction is Async.Loading state.loginFlow is Async.Loading
} }
} }
val eventSink = state.eventSink val eventSink = state.eventSink
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage val invalidHomeserverError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert val slidingSyncNotSupportedError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
@ -112,6 +114,24 @@ fun AccountProviderView(
} }
} }
) { ) {
when (state.loginFlow) {
is Async.Failure -> {
AsyncFailure(
throwable = state.loginFlow.error,
onRetry = {
state.eventSink.invoke(AccountProviderEvents.Continue)
}
)
}
is Async.Loading -> AsyncLoading()
is Async.Success -> {
when (val loginFlowState = state.loginFlow.state) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
}
}
Async.Uninitialized -> Unit
}
if (slidingSyncNotSupportedError != null) { if (slidingSyncNotSupportedError != null) {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = { SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked() onLearnMoreClicked()
@ -129,9 +149,6 @@ fun AccountProviderView(
) )
} }
} }
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
} }
@Preview @Preview

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
sealed interface LoginPasswordEvents {
data class SetLogin(val login: String) : LoginPasswordEvents
data class SetPassword(val password: String) : LoginPasswordEvents
object Submit : LoginPasswordEvents
object ClearError : LoginPasswordEvents
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class LoginPasswordNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LoginPasswordPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LoginPasswordView(
state = state,
modifier = modifier,
onBackPressed = ::navigateUp
)
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
) : Presenter<LoginPasswordState> {
@Composable
override fun present(): LoginPasswordState {
val localCoroutineScope = rememberCoroutineScope()
val loginAction: MutableState<Async<SessionId>> = remember {
mutableStateOf(Async.Uninitialized)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
fun handleEvents(event: LoginPasswordEvents) {
when (event) {
is LoginPasswordEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginPasswordEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginPasswordEvents.Submit -> {
localCoroutineScope.submit(formState.value, loginAction)
}
LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized
}
}
return LoginPasswordState(
formState = formState.value,
loginAction = loginAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch {
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->
loggedInState.value = Async.Failure(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
import android.os.Parcelable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
data class LoginPasswordState(
val formState: LoginFormState,
val loginAction: Async<SessionId>,
val eventSink: (LoginPasswordEvents) -> Unit
) {
val submitEnabled: Boolean
get() = loginAction !is Async.Failure &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
}
@Parcelize
data class LoginFormState(
val login: String,
val password: String
) : Parcelable {
companion object {
val Default = LoginFormState("", "")
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class LoginPasswordStateProvider : PreviewParameterProvider<LoginPasswordState> {
override val values: Sequence<LoginPasswordState>
get() = sequenceOf(
aLoginPasswordState(),
// Loading
aLoginPasswordState().copy(loginAction = Async.Loading()),
// Error
aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))),
)
}
fun aLoginPasswordState() = LoginPasswordState(
formState = LoginFormState.Default,
loginAction = Async.Uninitialized,
eventSink = {}
)

View file

@ -0,0 +1,305 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.loginpassword
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
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.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun LoginPasswordView(
state: LoginPasswordState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
) {
val isLoading by remember(state.loginAction) {
derivedStateOf {
state.loginAction is Async.Loading
}
}
val focusManager = LocalFocusManager.current
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
state.eventSink(LoginPasswordEvents.Submit)
}
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) },
)
}
) { padding ->
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
Spacer(Modifier.height(16.dp))
// Title
Text(
text = stringResource(id = R.string.screen_login_title),
modifier = Modifier
.fillMaxWidth(),
style = ElementTextStyles.Bold.title1,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(32.dp))
ServerDetailForm(
state = state,
isLoading = isLoading,
submit = ::submit
)
}
}
}
if (state.loginAction is Async.Failure) {
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
state.eventSink(LoginPasswordEvents.ClearError)
})
}
}
@Composable
fun ServerDetailForm(
state: LoginPasswordState,
isLoading: Boolean,
submit: () -> Unit,
modifier: Modifier = Modifier,
) {
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
Spacer(Modifier.height(28.dp))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
state: LoginPasswordState,
isLoading: Boolean,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
) {
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
val focusManager = LocalFocusManager.current
val eventSink = state.eventSink
Column(modifier) {
Text(
text = stringResource(R.string.screen_login_form_header),
modifier = Modifier.padding(start = 16.dp),
style = ElementTextStyles.Regular.formHeader
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = loginFieldState,
readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it
eventSink(LoginPasswordEvents.SetLogin(it))
}),
label = {
Text(text = stringResource(R.string.screen_login_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginPasswordEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = {
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
maxLines = 1,
trailingIcon = if (loginFieldState.isNotEmpty()) {
{
IconButton(onClick = {
loginFieldState = ""
}) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
}
}
} else null,
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loginAction is Async.Loading) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
Spacer(Modifier.height(20.dp))
TextField(
value = passwordFieldState,
readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it
eventSink(LoginPasswordEvents.SetPassword(it))
}),
onValueChange = {
passwordFieldState = it
eventSink(LoginPasswordEvents.SetPassword(it))
},
label = {
Text(text = stringResource(R.string.screen_login_password_hint))
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) stringResource(StringR.string.a11y_hide_password) else stringResource(StringR.string.a11y_show_password)
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmit() }
),
singleLine = true,
maxLines = 1,
)
}
}
@Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
content = stringResource(loginError(error)),
onDismiss = onDismiss
)
}
@Preview
@Composable
internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LoginPasswordState) {
LoginPasswordView(
state = state,
onBackPressed = {}
)
}