Benoit Marty
1 year ago
committed by
Benoit Marty
12 changed files with 636 additions and 30 deletions
@ -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 |
||||
} |
@ -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 |
||||
) |
||||
} |
||||
} |
@ -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) |
||||
} |
||||
} |
@ -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("", "") |
||||
} |
||||
} |
@ -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 = {} |
||||
) |
@ -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…
Reference in new issue