Browse Source

LoginPasswordNode

feature/fga/small_timeline_improvements
Benoit Marty 1 year ago committed by Benoit Marty
parent
commit
dc3ad323e5
  1. 22
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
  2. 15
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt
  3. 41
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt
  4. 11
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt
  5. 2
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt
  6. 37
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
  7. 24
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt
  8. 45
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt
  9. 84
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt
  10. 43
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt
  11. 37
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt
  12. 305
      features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt

22
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt

@ -38,6 +38,7 @@ import io.element.android.features.login.impl.changeaccountprovider.form.ChangeA @@ -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.changeserver.ChangeServerNode
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.customtab.CustomTabHandler
import io.element.android.features.login.impl.oidc.webview.OidcNode
@ -90,6 +91,9 @@ class LoginFlowNode @AssistedInject constructor( @@ -90,6 +91,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
object ChangeAccountProviderForm : NavTarget
@Parcelize
object LoginPasswordForm : NavTarget
// Not used anymore
@Parcelize
object ChangeServer : NavTarget
@ -129,9 +133,18 @@ class LoginFlowNode @AssistedInject constructor( @@ -129,9 +133,18 @@ class LoginFlowNode @AssistedInject constructor(
isAccountCreation = inputs.isAccountCreation
)
val callback = object : AccountProviderNode.Callback {
override fun onServerValidated() {
// TODO
TODO("Not yet implemented")
override fun onOidcDetails(oidcDetails: OidcDetails) {
if (customTabAvailabilityChecker.supportCustomTab()) {
// 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() {
@ -166,6 +179,9 @@ class LoginFlowNode @AssistedInject constructor( @@ -166,6 +179,9 @@ class LoginFlowNode @AssistedInject constructor(
createNode<ChangeAccountProviderFormNode>(buildContext, plugins = listOf(callback))
}
NavTarget.LoginPasswordForm -> {
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
}
}
}

15
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt

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

41
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt

@ -29,11 +29,13 @@ import dagger.assisted.AssistedFactory @@ -29,11 +29,13 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
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.MatrixHomeServerDetails
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL
@ -56,22 +58,29 @@ class AccountProviderPresenter @AssistedInject constructor( @@ -56,22 +58,29 @@ class AccountProviderPresenter @AssistedInject constructor(
@Composable
override fun present(): AccountProviderState {
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 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)
}
fun handleEvents(event: AccountProviderEvents) {
when (event) {
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( @@ -79,16 +88,30 @@ class AccountProviderPresenter @AssistedInject constructor(
homeserver = accountProvider.title,
isMatrix = accountProvider.isMatrixOrg,
isAccountCreation = params.isAccountCreation,
changeServerAction = changeServerAction.value,
loginFlow = loginFlowAction.value,
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 {
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
authenticationService.setHomeserver(domain).getOrThrow()
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)
}
}

11
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt

@ -17,15 +17,20 @@ @@ -17,15 +17,20 @@
package io.element.android.features.login.impl.accountprovider
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.
data class AccountProviderState(
val homeserver: String,
val isMatrix: Boolean,
val isAccountCreation: Boolean,
// TODO Rename
val changeServerAction: Async<Unit>,
val loginFlow: Async<LoginFlow>,
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
}

2
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt

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

37
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt

@ -31,18 +31,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameter @@ -31,18 +31,20 @@ 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.changeserver.ChangeServerError
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog
import io.element.android.libraries.architecture.Async
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.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.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@ -50,19 +52,19 @@ import io.element.android.libraries.testtags.testTag @@ -50,19 +52,19 @@ import io.element.android.libraries.testtags.testTag
fun AccountProviderView(
state: AccountProviderState,
modifier: Modifier = Modifier,
// TODO Rename
onChangeServerSuccess: () -> Unit = {},
onOidcDetails: (OidcDetails) -> Unit = {},
onLoginPasswordNeeded: () -> Unit = {},
onLearnMoreClicked: () -> Unit = {},
onChange: () -> Unit = {},
) {
val isLoading by remember(state.changeServerAction) {
val isLoading by remember(state.loginFlow) {
derivedStateOf {
state.changeServerAction is Async.Loading
state.loginFlow is Async.Loading
}
}
val eventSink = state.eventSink
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
val invalidHomeserverError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
val slidingSyncNotSupportedError = (state.loginFlow as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
HeaderFooterPage(
modifier = modifier,
@ -112,6 +114,24 @@ fun AccountProviderView( @@ -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) {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked()
@ -129,9 +149,6 @@ fun AccountProviderView( @@ -129,9 +149,6 @@ fun AccountProviderView(
)
}
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
@Preview

24
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordEvents.kt

@ -0,0 +1,24 @@ @@ -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
}

45
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordNode.kt

@ -0,0 +1,45 @@ @@ -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
)
}
}

84
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordPresenter.kt

@ -0,0 +1,84 @@ @@ -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)
}
}

43
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordState.kt

@ -0,0 +1,43 @@ @@ -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("", "")
}
}

37
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordStateProvider.kt

@ -0,0 +1,37 @@ @@ -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 = {}
)

305
features/login/impl/src/main/kotlin/io/element/android/features/login/impl/loginpassword/LoginPasswordView.kt

@ -0,0 +1,305 @@ @@ -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 = {}
)
}
Loading…
Cancel
Save