diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt index d3f8738dc7..5aa5071876 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt @@ -17,6 +17,7 @@ package io.element.android.features.login.impl.root sealed interface LoginRootEvents { + object RetryFetchServerInfo : LoginRootEvents data class SetLogin(val login: String) : LoginRootEvents data class SetPassword(val password: String) : LoginRootEvents object Submit : LoginRootEvents diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt index fd3d293870..0d1b8de7fd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt @@ -17,6 +17,7 @@ package io.element.android.features.login.impl.root import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -24,25 +25,38 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable 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.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject -class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter { - - private val defaultHomeserver = MatrixHomeServerDetails( - url = LoginConstants.DEFAULT_HOMESERVER_URL, - supportsPasswordLogin = true, - supportsOidc = false, - ) +class LoginRootPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, +) : Presenter { @Composable override fun present(): LoginRootState { val localCoroutineScope = rememberCoroutineScope() - val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver + val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value + val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL + val getHomeServerDetailsAction: MutableState> = remember { + if (currentHomeServerDetails != null) { + mutableStateOf(Async.Success(currentHomeServerDetails)) + } else { + mutableStateOf(Async.Uninitialized) + } + } + + LaunchedEffect(Unit) { + if (currentHomeServerDetails == null) { + getHomeServerDetails(homeserver, getHomeServerDetailsAction) + } + } + val loggedInState: MutableState = remember { mutableStateOf(LoggedInState.NotLoggedIn) } @@ -52,6 +66,7 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: fun handleEvents(event: LoginRootEvents) { when (event) { + LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction) is LoginRootEvents.SetLogin -> updateFormState(formState) { copy(login = event.login) } @@ -59,9 +74,10 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: copy(password = event.password) } LoginRootEvents.Submit -> { + val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return when { - homeserver.supportsOidc -> localCoroutineScope.submitOidc(homeserver.url, loggedInState) - homeserver.supportsPasswordLogin -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState) + homeServerDetails.supportsOidc -> localCoroutineScope.submitOidc(loggedInState) + homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState) } } LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn @@ -69,17 +85,27 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: } return LoginRootState( - homeserverDetails = homeserver, + homeserverUrl = homeserver, + homeserverDetails = getHomeServerDetailsAction.value, loggedInState = loggedInState.value, formState = formState.value, eventSink = ::handleEvents ) } - private fun CoroutineScope.submitOidc(homeserver: String, loggedInState: MutableState) = launch { + private fun CoroutineScope.getHomeServerDetails( + homeserver: String, + state: MutableState>, + ) = launch { + state.value = Async.Loading() + suspend { + authenticationService.setHomeserver(homeserver) + authenticationService.getHomeserverDetails().value!! + }.execute(state) + } + + private fun CoroutineScope.submitOidc(loggedInState: MutableState) = launch { loggedInState.value = LoggedInState.LoggingIn - // TODO rework the setHomeserver flow - authenticationService.setHomeserver(homeserver) authenticationService.getOidcUrl() .onSuccess { loggedInState.value = LoggedInState.OidcStarted(it) @@ -89,10 +115,8 @@ class LoginRootPresenter @Inject constructor(private val authenticationService: } } - private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState) = launch { + private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState) = launch { loggedInState.value = LoggedInState.LoggingIn - // TODO rework the setHomeserver flow - authenticationService.setHomeserver(homeserver) authenticationService.login(formState.login.trim(), formState.password) .onSuccess { sessionId -> loggedInState.value = LoggedInState.LoggedIn(sessionId) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt index 884a3037df..ccf7523b1b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt @@ -17,19 +17,22 @@ package io.element.android.features.login.impl.root import android.os.Parcelable +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize data class LoginRootState( - val homeserverDetails: MatrixHomeServerDetails, + val homeserverUrl: String, + val homeserverDetails: Async, val loggedInState: LoggedInState, val formState: LoginFormState, val eventSink: (LoginRootEvents) -> Unit ) { - val supportPasswordLogin = homeserverDetails.supportsPasswordLogin - val supportOidcLogin = homeserverDetails.supportsOidc + val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse() + val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidc.orFalse() val submitEnabled: Boolean get() = loggedInState !is LoggedInState.ErrorLoggingIn && ((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt index cdb92bcfaf..cffa395ea0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.login.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.core.SessionId @@ -24,20 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aLoginRootState(), - aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", supportsPasswordLogin = true, supportsOidc = false)), + aLoginRootState().copy( + homeserverDetails = Async.Success( + MatrixHomeServerDetails( + "some-custom-server.com", + supportsPasswordLogin = true, + supportsOidc = false + ) + ) + ), aLoginRootState().copy(formState = LoginFormState("user", "pass")), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))), // Oidc - aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("server-with-oidc.org", supportsPasswordLogin = false, supportsOidc = true)), + aLoginRootState().copy( + homeserverUrl = "server-with-oidc.org", + homeserverDetails = Async.Success( + MatrixHomeServerDetails( + "server-with-oidc.org", + supportsPasswordLogin = false, + supportsOidc = true + ) + ) + ), // No password, no oidc support - aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("wrong.org", supportsPasswordLogin = false, supportsOidc = false)), + aLoginRootState().copy( + homeserverUrl = "wrong.org", + homeserverDetails = Async.Success( + MatrixHomeServerDetails( + "wrong.org", + supportsPasswordLogin = false, + supportsOidc = false + ) + ) + ), + // Loading + aLoginRootState().copy(homeserverDetails = Async.Loading()), + //Error + aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))), ) } fun aLoginRootState() = LoginRootState( - homeserverDetails = MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false), + homeserverUrl = "matrix.org", + homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false)), loggedInState = LoggedInState.NotLoggedIn, formState = LoginFormState.Default, eventSink = {} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt index c8c85ed50f..2444418725 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt @@ -68,7 +68,10 @@ 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.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading 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 @@ -146,39 +149,22 @@ fun LoginRootView( ChangeServerSection( interactionEnabled = !isLoading, - homeserver = state.homeserverDetails.url, + homeserver = state.homeserverUrl, onChangeServer = onChangeServer ) Spacer(Modifier.height(32.dp)) - when { - state.supportOidcLogin -> { - // Oidc, in this case, just display a Spacer and the submit button - Spacer(Modifier.height(28.dp)) - } - state.supportPasswordLogin -> { - LoginForm(state = state, isLoading = isLoading, onSubmit = ::submit) - } - else -> { - Text(text = "No supported login flow") - } - } - - Spacer(Modifier.height(28.dp)) - - if (state.supportOidcLogin || state.supportPasswordLogin) { - // Submit - ButtonWithProgress( - text = stringResource(R.string.screen_login_submit), - showProgress = isLoading, - onClick = ::submit, - enabled = state.submitEnabled, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.loginContinue) + when (state.homeserverDetails) { + Async.Uninitialized, + is Async.Loading -> AsyncLoading() + is Async.Failure -> AsyncFailure( + throwable = state.homeserverDetails.error, + onRetry = { + state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo) + } ) - Spacer(modifier = Modifier.height(32.dp)) + is Async.Success -> ServerDetailForm(state, isLoading, ::submit) } } when (val loggedInState = state.loggedInState) { @@ -195,6 +181,43 @@ fun LoginRootView( } } +@Composable +fun ServerDetailForm( + state: LoginRootState, + isLoading: Boolean, + submit: () -> Unit, + modifier: Modifier = Modifier, +) { + when { + state.supportOidcLogin -> { + // Oidc, in this case, just display a Spacer and the submit button + Spacer(modifier.height(28.dp)) + } + state.supportPasswordLogin -> { + LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier) + } + else -> { + Text(modifier = modifier, text = "No supported login flow") + } + } + + Spacer(Modifier.height(28.dp)) + + if (state.supportOidcLogin || state.supportPasswordLogin) { + // 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)) + } +} + @Composable internal fun ChangeServerSection( interactionEnabled: Boolean, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt new file mode 100644 index 0000000000..5863f4c80c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt @@ -0,0 +1,70 @@ +/* + * 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.libraries.designsystem.components.async + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun AsyncFailure( + throwable: Throwable, + onRetry: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = throwable.message ?: "An error occurred") + if (onRetry != null) { + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text(text = "Retry") + } + } + } +} + +@Preview +@Composable +internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AsyncFailure( + throwable = IllegalStateException("An error occurred"), + onRetry = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt new file mode 100644 index 0000000000..f63d34fd9b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt @@ -0,0 +1,54 @@ +/* + * 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.libraries.designsystem.components.async + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator + +@Composable +fun AsyncLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AsyncLoading() +} diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dea5ca7bfc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee096c4234d5f983e84113058518af756ff03696ed5a733ec7540a842ff59ba4 +size 11435 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8356990393 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e2f7614737130cb1a23105faab7e46b39580eadcd05b6d7f811ddef525bef11 +size 10640 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..54cbdbcbed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:226b5056efcf7371c76729caf4105d94f1d945f40f1d6715d538ed19154ea0f0 +size 4971 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..29817c3dd2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59580eab9625ce67761ab8674f2adb0d9e4f0d2b27bbc132f60ae021828fd558 +size 4644