diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index f8d965ec6d..0ed5848240 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -18,7 +18,6 @@ package io.element.android.features.login.impl import android.app.Activity import android.os.Parcelable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier @@ -28,6 +27,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.singleTop import dagger.assisted.Assisted @@ -39,8 +39,10 @@ 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.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode +import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -88,6 +90,9 @@ class LoginFlowNode @AssistedInject constructor( @Parcelize object LoginPassword : NavTarget + @Parcelize + data class WaitList(val loginFormState: LoginFormState) : NavTarget + @Parcelize data class OidcView(val oidcDetails: OidcDetails) : NavTarget } @@ -144,12 +149,28 @@ class LoginFlowNode @AssistedInject constructor( createNode(buildContext, plugins = listOf(callback)) } NavTarget.LoginPassword -> { - createNode(buildContext, plugins = listOf()) + val callback = object : LoginPasswordNode.Callback { + override fun onWaitListError(loginFormState: LoginFormState) { + backstack.newRoot(NavTarget.WaitList(loginFormState)) + } + } + createNode(buildContext, plugins = listOf(callback)) } is NavTarget.OidcView -> { val input = OidcNode.Inputs(navTarget.oidcDetails) createNode(buildContext, plugins = listOf(input)) } + is NavTarget.WaitList -> { + val inputs = WaitListNode.Inputs( + loginFormState = navTarget.loginFormState, + ) + val callback = object : WaitListNode.Callback { + override fun onCancelClicked() { + navigateUp() + } + } + createNode(buildContext, plugins = listOf(callback, inputs)) + } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt new file mode 100644 index 0000000000..99060f3464 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt @@ -0,0 +1,23 @@ +/* + * 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.error + +import io.element.android.libraries.core.bool.orFalse + +fun Throwable.isWaitListError(): Boolean { + return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt index 630b08570c..cb5542d2eb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -21,6 +21,7 @@ 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 com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode @@ -33,13 +34,22 @@ class LoginPasswordNode @AssistedInject constructor( private val presenter: LoginPasswordPresenter, ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onWaitListError(loginFormState: LoginFormState) + } + + private fun onWaitListError(loginFormState: LoginFormState) { + plugins().forEach { it.onWaitListError(loginFormState) } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() LoginPasswordView( state = state, modifier = modifier, - onBackPressed = ::navigateUp + onBackPressed = ::navigateUp, + onWaitListError = ::onWaitListError, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 9d6fe7d1ea..e209a9af55 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -56,6 +56,7 @@ 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.isWaitListError import io.element.android.features.login.impl.error.loginError import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.ElementTextStyles @@ -82,8 +83,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LoginPasswordView( state: LoginPasswordState, - modifier: Modifier = Modifier, onBackPressed: () -> Unit, + onWaitListError: (LoginFormState) -> Unit, + modifier: Modifier = Modifier, ) { val isLoading by remember(state.loginAction) { derivedStateOf { @@ -133,7 +135,8 @@ fun LoginPasswordView( subTitle = stringResource(id = R.string.screen_login_form_header) ) Spacer(Modifier.height(32.dp)) - LoginForm(state = state, + LoginForm( + state = state, isLoading = isLoading, onSubmit = ::submit ) @@ -152,9 +155,16 @@ fun LoginPasswordView( } if (state.loginAction is Async.Failure) { - LoginErrorDialog(error = state.loginAction.error, onDismiss = { - state.eventSink(LoginPasswordEvents.ClearError) - }) + when { + state.loginAction.error.isWaitListError() -> { + onWaitListError(state.formState) + } + else -> { + LoginErrorDialog(error = state.loginAction.error, onDismiss = { + state.eventSink(LoginPasswordEvents.ClearError) + }) + } + } } } } @@ -269,6 +279,7 @@ internal fun LoginForm( @Composable internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { ErrorDialog( + title = stringResource(id = CommonStrings.dialog_title_error), content = stringResource(loginError(error)), onDismiss = onDismiss ) @@ -288,6 +299,7 @@ internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStatePr private fun ContentToPreview(state: LoginPasswordState) { LoginPasswordView( state = state, - onBackPressed = {} + onBackPressed = {}, + onWaitListError = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt new file mode 100644 index 0000000000..5604789f55 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt @@ -0,0 +1,22 @@ +/* + * 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.screens.waitlistscreen + +sealed interface WaitListEvents { + object AttemptLogin : WaitListEvents + object ClearError : WaitListEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt new file mode 100644 index 0000000000..24b5f271a0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt @@ -0,0 +1,62 @@ +/* + * 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.screens.waitlistscreen + +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 com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class WaitListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: WaitListPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs(val loginFormState: LoginFormState) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.loginFormState) + + interface Callback : Plugin { + fun onCancelClicked() + } + + private fun onCancelClicked() { + plugins().forEach { it.onCancelClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + WaitListView( + state = state, + onCancelClicked = ::onCancelClicked, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt new file mode 100644 index 0000000000..061872839b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt @@ -0,0 +1,93 @@ +/* + * 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.screens.waitlistscreen + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +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 timber.log.Timber + +class WaitListPresenter @AssistedInject constructor( + @Assisted private val formState: LoginFormState, + private val buildMeta: BuildMeta, + private val authenticationService: MatrixAuthenticationService, +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(loginFormState: LoginFormState): WaitListPresenter + } + + @Composable + override fun present(): WaitListState { + val coroutineScope = rememberCoroutineScope() + val homeserverUrl = remember { + authenticationService.getHomeserverDetails().value?.url ?: "server" + } + + val loginAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + val attemptNumber: MutableState = remember { mutableStateOf(0) } + + fun handleEvents(event: WaitListEvents) { + when (event) { + WaitListEvents.AttemptLogin -> { + // Do not attempt to login on first resume of the View. + attemptNumber.value++ + if (attemptNumber.value > 1) { + coroutineScope.loginAttempt(formState, loginAction) + } + } + WaitListEvents.ClearError -> loginAction.value = Async.Uninitialized + } + } + + return WaitListState( + appName = buildMeta.applicationName, + serverName = homeserverUrl, + loginAction = loginAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState>) = launch { + Timber.w("Attempt to login...") + 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) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt new file mode 100644 index 0000000000..f50de7e194 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt @@ -0,0 +1,28 @@ +/* + * 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.screens.waitlistscreen + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId + +// Do not use default value, so no member get forgotten in the presenters. +data class WaitListState( + val appName: String, + val serverName: String, + val loginAction: Async, + val eventSink: (WaitListEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt new file mode 100644 index 0000000000..f7cd26209c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt @@ -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.screens.waitlistscreen + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId + +open class WaitListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aWaitListState(loginAction = Async.Uninitialized), + aWaitListState(loginAction = Async.Loading()), + aWaitListState(loginAction = Async.Failure(Throwable())), + aWaitListState(loginAction = Async.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))), + // Add other state here + ) +} + +fun aWaitListState( + appName: String = "Element", + serverName: String = "server.org", + loginAction: Async = Async.Uninitialized, +) = WaitListState( + appName = appName, + serverName = serverName, + loginAction = loginAction, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt new file mode 100644 index 0000000000..f611c5ef20 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -0,0 +1,231 @@ +/* + * 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.screens.waitlistscreen + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAbsoluteAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.error.isWaitListError +import io.element.android.features.login.impl.error.loginError +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +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 +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425 +// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode. +@Composable +fun WaitListView( + state: WaitListState, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin) + else -> Unit + } + } + + Box(modifier = modifier) { + WaitListBackground() + WaitListContent(state, onCancelClicked) + WaitListError(state) + } +} + +@Composable +private fun WaitListError(state: WaitListState) { + // Display a dialog for error other than the waitlist error + state.loginAction.errorOrNull()?.let { error -> + if (error.isWaitListError().not()) { + RetryDialog( + content = stringResource(id = loginError(error)), + onRetry = { + state.eventSink.invoke(WaitListEvents.AttemptLogin) + }, + onDismiss = { + state.eventSink.invoke(WaitListEvents.ClearError) + } + ) + } + } +} + +@Composable +private fun WaitListBackground( + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.3f) + .background(Color.White) + ) + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(id = R.drawable.light_dark), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.7f) + .background(Color(0xFF121418)) + ) + } +} + +@Composable +private fun WaitListContent( + state: WaitListState, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + TextButton( + onClick = onCancelClicked, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + disabledContainerColor = Color.White, + disabledContentColor = Color.Black, + ), + ) { + Text( + text = stringResource(CommonStrings.action_cancel), + style = ElementTheme.typography.fontBodyLgMedium, + ) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAbsoluteAlignment( + horizontalBias = 0f, + verticalBias = -0.05f + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.loginAction.isLoading()) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } else { + Spacer(modifier = Modifier.height(24.dp)) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = withColoredPeriod(R.string.screen_waitlist_title), + style = ElementTheme.typography.fontHeadingXlBold, + textAlign = TextAlign.Center, + color = Color.White, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.widthIn(max = 360.dp), + text = stringResource( + id = R.string.screen_waitlist_message, + state.appName, + state.serverName, + ), + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + color = Color.White, + ) + } + } + } +} + +@Composable +private fun withColoredPeriod( + @StringRes textRes: Int, +) = buildAnnotatedString { + val text = stringResource(textRes) + append(text) + if (text.endsWith(".")) { + addStyle( + style = SpanStyle( + // Light.colorGreen700 + color = Color(0xff0bc491), + ), + start = text.length - 1, + end = text.length, + ) + } +} + +@Preview +@Composable +internal fun WaitListViewLightPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun WaitListViewDarkPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: WaitListState) { + WaitListView( + state = state, + onCancelClicked = {}, + ) +} diff --git a/features/login/impl/src/main/res/drawable/light_dark.png b/features/login/impl/src/main/res/drawable/light_dark.png new file mode 100644 index 0000000000..8572310364 Binary files /dev/null and b/features/login/impl/src/main/res/drawable/light_dark.png differ diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt new file mode 100644 index 0000000000..f997399e04 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt @@ -0,0 +1,107 @@ +/* + * 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.screens.waitlistscreen + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class WaitListPresenterTest { + @Test + fun `present - initial state`() = runTest { + val authenticationService = FakeAuthenticationService().apply { + givenHomeserver(A_HOMESERVER) + } + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(applicationName = "Application Name"), + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo("Application Name") + assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL) + assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - attempt login with error`() = runTest { + val authenticationService = FakeAuthenticationService().apply { + givenLoginError(A_THROWABLE) + } + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(), + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // First usage of AttemptLogin, nothing should happen + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + expectNoEvents() + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.loginAction).isEqualTo(Async.Failure(A_THROWABLE)) + // Assert the error can be cleared + errorState.eventSink(WaitListEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - attempt login with success`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(), + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // First usage of AttemptLogin, nothing should happen + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + expectNoEvents() + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.loginAction).isEqualTo(Async.Success(A_USER_ID)) + } + } +} diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt index 54bef4652b..663e92fa04 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt @@ -46,6 +46,7 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService state = state, modifier = modifier, onBackPressed = {}, + onWaitListError = {}, ) } }