Waitlist screen
This commit is contained in:
parent
b67ecb8843
commit
f5a2e2dd25
13 changed files with 662 additions and 9 deletions
|
|
@ -18,7 +18,6 @@ package io.element.android.features.login.impl
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
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.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
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.push
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dagger.assisted.Assisted
|
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.oidc.webview.OidcNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
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.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.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
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.BackstackNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
|
|
@ -88,6 +90,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object LoginPassword : NavTarget
|
object LoginPassword : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class WaitList(val loginFormState: LoginFormState) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -144,12 +149,28 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.LoginPassword -> {
|
NavTarget.LoginPassword -> {
|
||||||
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
|
val callback = object : LoginPasswordNode.Callback {
|
||||||
|
override fun onWaitListError(loginFormState: LoginFormState) {
|
||||||
|
backstack.newRoot(NavTarget.WaitList(loginFormState))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
is NavTarget.OidcView -> {
|
is NavTarget.OidcView -> {
|
||||||
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||||
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||||
}
|
}
|
||||||
|
is NavTarget.WaitList -> {
|
||||||
|
val inputs = WaitListNode.Inputs(
|
||||||
|
loginFormState = navTarget.loginFormState,
|
||||||
|
)
|
||||||
|
val callback = object : WaitListNode.Callback {
|
||||||
|
override fun onCancelClicked() {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -33,13 +34,22 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||||
private val presenter: LoginPasswordPresenter,
|
private val presenter: LoginPasswordPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onWaitListError(loginFormState: LoginFormState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWaitListError(loginFormState: LoginFormState) {
|
||||||
|
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginPasswordView(
|
LoginPasswordView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = ::navigateUp
|
onBackPressed = ::navigateUp,
|
||||||
|
onWaitListError = ::onWaitListError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.features.login.impl.error.isWaitListError
|
||||||
import io.element.android.features.login.impl.error.loginError
|
import io.element.android.features.login.impl.error.loginError
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
|
@ -82,8 +83,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginPasswordView(
|
fun LoginPasswordView(
|
||||||
state: LoginPasswordState,
|
state: LoginPasswordState,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
|
onWaitListError: (LoginFormState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loginAction) {
|
val isLoading by remember(state.loginAction) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
@ -133,7 +135,8 @@ fun LoginPasswordView(
|
||||||
subTitle = stringResource(id = R.string.screen_login_form_header)
|
subTitle = stringResource(id = R.string.screen_login_form_header)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
LoginForm(state = state,
|
LoginForm(
|
||||||
|
state = state,
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
onSubmit = ::submit
|
onSubmit = ::submit
|
||||||
)
|
)
|
||||||
|
|
@ -152,9 +155,16 @@ fun LoginPasswordView(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.loginAction is Async.Failure) {
|
if (state.loginAction is Async.Failure) {
|
||||||
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
when {
|
||||||
state.eventSink(LoginPasswordEvents.ClearError)
|
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
|
@Composable
|
||||||
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
|
title = stringResource(id = CommonStrings.dialog_title_error),
|
||||||
content = stringResource(loginError(error)),
|
content = stringResource(loginError(error)),
|
||||||
onDismiss = onDismiss
|
onDismiss = onDismiss
|
||||||
)
|
)
|
||||||
|
|
@ -288,6 +299,7 @@ internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStatePr
|
||||||
private fun ContentToPreview(state: LoginPasswordState) {
|
private fun ContentToPreview(state: LoginPasswordState) {
|
||||||
LoginPasswordView(
|
LoginPasswordView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = {}
|
onBackPressed = {},
|
||||||
|
onWaitListError = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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<Plugin>,
|
||||||
|
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<Callback>().forEach { it.onCancelClicked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
WaitListView(
|
||||||
|
state = state,
|
||||||
|
onCancelClicked = ::onCancelClicked,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<WaitListState> {
|
||||||
|
|
||||||
|
@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<Async<SessionId>> = remember {
|
||||||
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attemptNumber: MutableState<Int> = 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<Async<SessionId>>) = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SessionId>,
|
||||||
|
val eventSink: (WaitListEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -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<WaitListState> {
|
||||||
|
override val values: Sequence<WaitListState>
|
||||||
|
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<SessionId> = Async.Uninitialized,
|
||||||
|
) = WaitListState(
|
||||||
|
appName = appName,
|
||||||
|
serverName = serverName,
|
||||||
|
loginAction = loginAction,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
features/login/impl/src/main/res/drawable/light_dark.png
Normal file
BIN
features/login/impl/src/main/res/drawable/light_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -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<SessionId>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = {},
|
onBackPressed = {},
|
||||||
|
onWaitListError = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue