Merge pull request #781 from vector-im/feature/bma/waitingListScreen

Implement waiting list screen (error IO_ELEMENT_X_WAIT_LIST)
This commit is contained in:
Benoit Marty 2023-07-06 18:24:41 +02:00 committed by GitHub
commit 77e2ff4953
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 873 additions and 43 deletions

View file

@ -42,6 +42,7 @@ import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService
@ -55,6 +56,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@ -74,6 +76,7 @@ class RootFlowNode @AssistedInject constructor(
private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val loginUserStory: LoginUserStory,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@ -90,8 +93,7 @@ class RootFlowNode @AssistedInject constructor(
}
private fun observeLoggedInState() {
authenticationService.isLoggedIn()
.distinctUntilChanged()
isUserLoggedInFlow()
.combine(
cacheService.cacheIndex().onEach {
Timber.v("cacheIndex=$it")
@ -114,6 +116,16 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
}
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
}
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}

View file

@ -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.api
import kotlinx.coroutines.flow.StateFlow
interface LoginUserStory {
val loginFlowIsDone: StateFlow<Boolean>
}

View file

@ -0,0 +1,35 @@
/*
* 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
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.LoginUserStory
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLoginUserStory @Inject constructor() : LoginUserStory {
// True by default, will be set to false when the login user story is started, and set to true again once it's done.
override val loginFlowIsDone: MutableStateFlow<Boolean> = MutableStateFlow(true)
fun setLoginFlowIsDone(value: Boolean) {
loginFlowIsDone.value = value
}
}

View file

@ -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
@ -58,6 +60,7 @@ class LoginFlowNode @AssistedInject constructor(
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfirmAccountProvider,
@ -75,6 +78,11 @@ class LoginFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs()
override fun onBuilt() {
super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false)
}
sealed interface NavTarget : Parcelable {
@Parcelize
object ConfirmAccountProvider : NavTarget
@ -88,6 +96,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 +155,28 @@ class LoginFlowNode @AssistedInject constructor(
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
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 -> {
val input = OidcNode.Inputs(navTarget.oidcDetails)
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))
}
}
}

View file

@ -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()
}

View file

@ -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<Callback>().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,
)
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
@ -36,6 +37,7 @@ import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter<LoginPasswordState> {
@Composable
@ -77,6 +79,8 @@ class LoginPasswordPresenter @Inject constructor(
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
// We will not navigate to the WaitList screen, so the login user story is done
defaultLoginUserStory.setLoginFlowIsDone(true)
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->

View file

@ -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 = {},
)
}

View file

@ -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.screens.waitlistscreen
sealed interface WaitListEvents {
object AttemptLogin : WaitListEvents
object ClearError : WaitListEvents
object Continue : WaitListEvents
}

View file

@ -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
)
}
}

View file

@ -0,0 +1,96 @@
/*
* 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.DefaultLoginUserStory
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,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : 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
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true)
}
}
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)
}
}
}

View file

@ -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
)

View file

@ -0,0 +1,44 @@
/*
* 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"))),
aWaitListState(loginAction = Async.Success(SessionId("@alice:element.io"))),
// Add other state here
)
}
fun aWaitListState(
appName: String = "Element X",
serverName: String = "server.org",
loginAction: Async<SessionId> = Async.Uninitialized,
) = WaitListState(
appName = appName,
serverName = serverName,
loginAction = loginAction,
eventSink = {}
)

View file

@ -0,0 +1,266 @@
/*
* 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.architecture.Async
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.Button
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)
) {
if (state.loginAction !is Async.Success) {
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))
val titleRes = when (state.loginAction) {
is Async.Success -> R.string.screen_waitlist_title_success
else -> R.string.screen_waitlist_title
}
Text(
text = withColoredPeriod(titleRes),
style = ElementTheme.typography.fontHeadingXlBold,
textAlign = TextAlign.Center,
color = Color.White,
)
Spacer(modifier = Modifier.height(8.dp))
val subtitle = when (state.loginAction) {
is Async.Success -> stringResource(
id = R.string.screen_waitlist_message_success,
state.appName,
)
else -> stringResource(
id = R.string.screen_waitlist_message,
state.appName,
state.serverName,
)
}
Text(
modifier = Modifier.widthIn(max = 360.dp),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = Color.White,
)
}
}
if (state.loginAction is Async.Success) {
Button(
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black,
disabledContainerColor = Color.White,
disabledContentColor = Color.Black,
),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp)
) {
Text(
text = stringResource(id = CommonStrings.action_continue),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
}
}
}
@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 = {},
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -33,6 +33,12 @@
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s"</string>
<string name="screen_waitlist_title">"Jste v pořadníku!"</string>
<string name="screen_waitlist_title_success">"Jdete do toho!"</string>
<string name="screen_change_server_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>

View file

@ -33,6 +33,12 @@
<string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
<string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string>
<string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string>
<string name="screen_waitlist_message">"Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut.
Vielen Dank für deine Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Du hast es fast geschafft!"</string>
<string name="screen_waitlist_title_success">"Du bist dabei."</string>
<string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string>

View file

@ -33,6 +33,12 @@
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"</string>
<string name="screen_waitlist_message_success">"Bun venit la %1$s"</string>
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
<string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</string>

View file

@ -33,6 +33,12 @@
<string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s"</string>
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
<string name="screen_waitlist_title_success">"Ste dnu!"</string>
<string name="screen_change_server_submit">"Pokračovať"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>

View file

@ -33,6 +33,12 @@
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"</string>
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
<string name="screen_waitlist_title">"Youre almost there."</string>
<string name="screen_waitlist_title_success">"You\'re in."</string>
<string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string>

View file

@ -20,6 +20,7 @@ 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.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.architecture.Async
@ -38,9 +39,11 @@ class LoginPasswordPresenterTest {
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -57,9 +60,11 @@ class LoginPasswordPresenterTest {
fun `present - enter login and password`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
@ -81,14 +86,17 @@ class LoginPasswordPresenterTest {
fun `present - submit`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
val initialState = awaitItem()
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
@ -99,6 +107,7 @@ class LoginPasswordPresenterTest {
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID))
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
}
}
@ -106,9 +115,11 @@ class LoginPasswordPresenterTest {
fun `present - submit with error`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
@ -132,9 +143,11 @@ class LoginPasswordPresenterTest {
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val accountProviderDataSource = AccountProviderDataSource()
val loginUserStory = DefaultLoginUserStory()
val presenter = LoginPasswordPresenter(
authenticationService,
accountProviderDataSource,
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {

View file

@ -0,0 +1,118 @@
/*
* 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.DefaultLoginUserStory
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 loginUserStory = DefaultLoginUserStory()
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(applicationName = "Application Name"),
authenticationService,
loginUserStory,
)
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 loginUserStory = DefaultLoginUserStory()
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(),
authenticationService,
loginUserStory,
)
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 loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
val presenter = WaitListPresenter(
LoginFormState.Default,
aBuildMeta(),
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
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))
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
successState.eventSink.invoke(WaitListEvents.Continue)
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
}
}
}

View file

@ -175,12 +175,6 @@
<string name="screen_share_open_osm_maps">"Otevřít v OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Sdílet tuto polohu"</string>
<string name="screen_view_location_title">"Poloha"</string>
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s"</string>
<string name="screen_waitlist_title">"Jste v pořadníku!"</string>
<string name="screen_waitlist_title_success">"Jdete do toho!"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Práh detekce"</string>
<string name="settings_title_general">"Obecné"</string>

View file

@ -174,12 +174,6 @@
<string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string>
<string name="screen_share_this_location_action">"Diesen Ort teilen"</string>
<string name="screen_view_location_title">"Standort"</string>
<string name="screen_waitlist_message">"Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut.
Vielen Dank für deine Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Du hast es fast geschafft!"</string>
<string name="screen_waitlist_title_success">"Du bist dabei."</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
<string name="settings_title_general">"Allgemein"</string>

View file

@ -170,12 +170,6 @@
<string name="screen_share_my_location_action">"Distribuiți locația mea"</string>
<string name="screen_share_this_location_action">"Distribuiți această locație"</string>
<string name="screen_view_location_title">"Locație"</string>
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"</string>
<string name="screen_waitlist_message_success">"Bun venit la %1$s"</string>
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Prag de detecție"</string>
<string name="settings_title_general">"General"</string>

View file

@ -175,12 +175,6 @@
<string name="screen_share_open_osm_maps">"Otvoriť v OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Zdieľajte túto polohu"</string>
<string name="screen_view_location_title">"Poloha"</string>
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s"</string>
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
<string name="screen_waitlist_title_success">"Ste dnu!"</string>
<string name="settings_rageshake">"Zúrivé potrasenie"</string>
<string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string>
<string name="settings_title_general">"Všeobecné"</string>

View file

@ -174,12 +174,6 @@
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_view_location_title">"Location"</string>
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"</string>
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
<string name="screen_waitlist_title">"Youre almost there."</string>
<string name="screen_waitlist_title_success">"You\'re in."</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordPresenter
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordView
@ -33,7 +34,8 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
val presenter = remember {
LoginPasswordPresenter(
authenticationService = authenticationService,
AccountProviderDataSource()
AccountProviderDataSource(),
DefaultLoginUserStory(),
)
}
@ -46,6 +48,7 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
state = state,
modifier = modifier,
onBackPressed = {},
onWaitListError = {},
)
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8
size 148762

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8edd6d72db9efaaed76ac64f9882a5b66ac30747355815955157a3f3fc98c2c
size 149344

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e099a30e3090ffd9c0f30e095131cb60d53b615623acd50565bde8fa28e7fe74
size 62810

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8
size 148762

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85222447b700315e6eea458fc72d61fb741018cb80c3a4530d4efc08ac9335ac
size 129373

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8
size 148762

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8edd6d72db9efaaed76ac64f9882a5b66ac30747355815955157a3f3fc98c2c
size 149344

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71289cbba79500c063ec80430fb46bc544ccbe6fa7035ac81c81322c9f1a300f
size 63631

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8
size 148762

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85222447b700315e6eea458fc72d61fb741018cb80c3a4530d4efc08ac9335ac
size 129373

View file

@ -76,7 +76,8 @@
"screen_server_confirmation_.*",
"screen_change_server_.*",
"screen_change_account_provider_.*",
"screen_account_provider_.*"
"screen_account_provider_.*",
"screen_waitlist_.*"
]
},
{