Benoit Marty
1 week ago
27 changed files with 4 additions and 570 deletions
@ -1,14 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
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() |
|
||||||
} |
|
@ -1,14 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
sealed interface WaitListEvents { |
|
||||||
data object AttemptLogin : WaitListEvents |
|
||||||
data object ClearError : WaitListEvents |
|
||||||
data object Continue : WaitListEvents |
|
||||||
} |
|
@ -1,52 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
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 onCancelClick() |
|
||||||
} |
|
||||||
|
|
||||||
private fun onCancelClick() { |
|
||||||
plugins<Callback>().forEach { it.onCancelClick() } |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
override fun View(modifier: Modifier) { |
|
||||||
val state = presenter.present() |
|
||||||
WaitListView( |
|
||||||
state = state, |
|
||||||
onCancelClick = ::onCancelClick, |
|
||||||
modifier = modifier |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
@ -1,87 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable |
|
||||||
import androidx.compose.runtime.MutableState |
|
||||||
import androidx.compose.runtime.mutableIntStateOf |
|
||||||
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.AsyncData |
|
||||||
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<AsyncData<SessionId>> = remember { |
|
||||||
mutableStateOf(AsyncData.Uninitialized) |
|
||||||
} |
|
||||||
|
|
||||||
val attemptNumber = remember { mutableIntStateOf(0) } |
|
||||||
|
|
||||||
fun handleEvents(event: WaitListEvents) { |
|
||||||
when (event) { |
|
||||||
WaitListEvents.AttemptLogin -> { |
|
||||||
// Do not attempt to login on first resume of the View. |
|
||||||
attemptNumber.intValue++ |
|
||||||
if (attemptNumber.intValue > 1) { |
|
||||||
coroutineScope.loginAttempt(formState, loginAction) |
|
||||||
} |
|
||||||
} |
|
||||||
WaitListEvents.ClearError -> loginAction.value = AsyncData.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<AsyncData<SessionId>>) = launch { |
|
||||||
Timber.w("Attempt to login...") |
|
||||||
loggedInState.value = AsyncData.Loading() |
|
||||||
authenticationService.login(formState.login.trim(), formState.password) |
|
||||||
.onSuccess { sessionId -> |
|
||||||
loggedInState.value = AsyncData.Success(sessionId) |
|
||||||
} |
|
||||||
.onFailure { failure -> |
|
||||||
loggedInState.value = AsyncData.Failure(failure) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,19 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncData |
|
||||||
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: AsyncData<SessionId>, |
|
||||||
val eventSink: (WaitListEvents) -> Unit |
|
||||||
) |
|
@ -1,35 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
|
||||||
import io.element.android.libraries.architecture.AsyncData |
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId |
|
||||||
|
|
||||||
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> { |
|
||||||
override val values: Sequence<WaitListState> |
|
||||||
get() = sequenceOf( |
|
||||||
aWaitListState(loginAction = AsyncData.Uninitialized), |
|
||||||
aWaitListState(loginAction = AsyncData.Loading()), |
|
||||||
aWaitListState(loginAction = AsyncData.Failure(Throwable("error"))), |
|
||||||
aWaitListState(loginAction = AsyncData.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))), |
|
||||||
aWaitListState(loginAction = AsyncData.Success(SessionId("@alice:element.io"))), |
|
||||||
// Add other state here |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
fun aWaitListState( |
|
||||||
appName: String = "Element X", |
|
||||||
serverName: String = "server.org", |
|
||||||
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized, |
|
||||||
) = WaitListState( |
|
||||||
appName = appName, |
|
||||||
serverName = serverName, |
|
||||||
loginAction = loginAction, |
|
||||||
eventSink = {} |
|
||||||
) |
|
@ -1,143 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box |
|
||||||
import androidx.compose.foundation.layout.fillMaxSize |
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth |
|
||||||
import androidx.compose.foundation.layout.padding |
|
||||||
import androidx.compose.material3.LocalContentColor |
|
||||||
import androidx.compose.runtime.Composable |
|
||||||
import androidx.compose.runtime.CompositionLocalProvider |
|
||||||
import androidx.compose.ui.Alignment |
|
||||||
import androidx.compose.ui.Modifier |
|
||||||
import androidx.compose.ui.res.stringResource |
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
|
||||||
import androidx.compose.ui.unit.dp |
|
||||||
import androidx.lifecycle.Lifecycle |
|
||||||
import io.element.android.compound.theme.ElementTheme |
|
||||||
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.AsyncData |
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage |
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog |
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button |
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton |
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent |
|
||||||
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, |
|
||||||
onCancelClick: () -> Unit, |
|
||||||
modifier: Modifier = Modifier, |
|
||||||
) { |
|
||||||
OnLifecycleEvent { _, event -> |
|
||||||
when (event) { |
|
||||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin) |
|
||||||
else -> Unit |
|
||||||
} |
|
||||||
} |
|
||||||
WaitListContent(state, onCancelClick, modifier) |
|
||||||
} |
|
||||||
|
|
||||||
@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 WaitListContent( |
|
||||||
state: WaitListState, |
|
||||||
onCancelClick: () -> Unit, |
|
||||||
modifier: Modifier = Modifier, |
|
||||||
) { |
|
||||||
Box( |
|
||||||
modifier = modifier.fillMaxSize(), |
|
||||||
) { |
|
||||||
val title = stringResource( |
|
||||||
when (state.loginAction) { |
|
||||||
is AsyncData.Success -> R.string.screen_waitlist_title_success |
|
||||||
else -> R.string.screen_waitlist_title |
|
||||||
} |
|
||||||
) |
|
||||||
val subtitle = when (state.loginAction) { |
|
||||||
is AsyncData.Success -> stringResource( |
|
||||||
id = R.string.screen_waitlist_message_success, |
|
||||||
state.appName, |
|
||||||
) |
|
||||||
else -> stringResource( |
|
||||||
id = R.string.screen_waitlist_message, |
|
||||||
state.appName, |
|
||||||
state.serverName, |
|
||||||
) |
|
||||||
} |
|
||||||
SunsetPage( |
|
||||||
isLoading = state.loginAction.isLoading(), |
|
||||||
title = title, |
|
||||||
subtitle = subtitle, |
|
||||||
) { |
|
||||||
OverallContent(state, onCancelClick) |
|
||||||
} |
|
||||||
WaitListError(state) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
private fun OverallContent( |
|
||||||
state: WaitListState, |
|
||||||
onCancelClick: () -> Unit, |
|
||||||
) { |
|
||||||
Box(modifier = Modifier.fillMaxSize()) { |
|
||||||
if (state.loginAction !is AsyncData.Success) { |
|
||||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) { |
|
||||||
TextButton( |
|
||||||
text = stringResource(CommonStrings.action_cancel), |
|
||||||
onClick = onCancelClick, |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
if (state.loginAction is AsyncData.Success) { |
|
||||||
Button( |
|
||||||
text = stringResource(id = CommonStrings.action_continue), |
|
||||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) }, |
|
||||||
modifier = Modifier |
|
||||||
.fillMaxWidth() |
|
||||||
.align(Alignment.BottomCenter) |
|
||||||
.padding(bottom = 8.dp), |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@PreviewsDayNight |
|
||||||
@Composable |
|
||||||
internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview { |
|
||||||
WaitListView( |
|
||||||
state = state, |
|
||||||
onCancelClick = {}, |
|
||||||
) |
|
||||||
} |
|
@ -1,114 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2023, 2024 New Vector Ltd. |
|
||||||
* |
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only |
|
||||||
* Please see LICENSE in the repository root for full details. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen |
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode |
|
||||||
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.AsyncData |
|
||||||
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.FakeMatrixAuthenticationService |
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta |
|
||||||
import io.element.android.tests.testutils.WarmUpRule |
|
||||||
import kotlinx.coroutines.test.runTest |
|
||||||
import org.junit.Rule |
|
||||||
import org.junit.Test |
|
||||||
|
|
||||||
class WaitListPresenterTest { |
|
||||||
@get:Rule |
|
||||||
val warmUpRule = WarmUpRule() |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `present - initial state`() = runTest { |
|
||||||
val authenticationService = FakeMatrixAuthenticationService().apply { |
|
||||||
givenHomeserver(A_HOMESERVER) |
|
||||||
} |
|
||||||
val loginUserStory = DefaultLoginUserStory() |
|
||||||
val presenter = WaitListPresenter( |
|
||||||
LoginFormState.Default, |
|
||||||
aBuildMeta(applicationName = "Application Name"), |
|
||||||
authenticationService, |
|
||||||
loginUserStory, |
|
||||||
) |
|
||||||
moleculeFlow(RecompositionMode.Immediate) { |
|
||||||
presenter.present() |
|
||||||
}.test { |
|
||||||
val initialState = awaitItem() |
|
||||||
assertThat(initialState.appName).isEqualTo("Application Name") |
|
||||||
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL) |
|
||||||
assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `present - attempt login with error`() = runTest { |
|
||||||
val authenticationService = FakeMatrixAuthenticationService().apply { |
|
||||||
givenLoginError(A_THROWABLE) |
|
||||||
} |
|
||||||
val loginUserStory = DefaultLoginUserStory() |
|
||||||
val presenter = WaitListPresenter( |
|
||||||
LoginFormState.Default, |
|
||||||
aBuildMeta(), |
|
||||||
authenticationService, |
|
||||||
loginUserStory, |
|
||||||
) |
|
||||||
moleculeFlow(RecompositionMode.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(AsyncData.Loading::class.java) |
|
||||||
val errorState = awaitItem() |
|
||||||
assertThat(errorState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE)) |
|
||||||
// Assert the error can be cleared |
|
||||||
errorState.eventSink(WaitListEvents.ClearError) |
|
||||||
val clearedState = awaitItem() |
|
||||||
assertThat(clearedState.loginAction).isEqualTo(AsyncData.Uninitialized) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@Test |
|
||||||
fun `present - attempt login with success`() = runTest { |
|
||||||
val authenticationService = FakeMatrixAuthenticationService() |
|
||||||
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } |
|
||||||
val presenter = WaitListPresenter( |
|
||||||
LoginFormState.Default, |
|
||||||
aBuildMeta(), |
|
||||||
authenticationService, |
|
||||||
loginUserStory, |
|
||||||
) |
|
||||||
moleculeFlow(RecompositionMode.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(AsyncData.Loading::class.java) |
|
||||||
val successState = awaitItem() |
|
||||||
assertThat(successState.loginAction).isEqualTo(AsyncData.Success(A_USER_ID)) |
|
||||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse() |
|
||||||
successState.eventSink.invoke(WaitListEvents.Continue) |
|
||||||
assertThat(loginUserStory.loginFlowIsDone.value).isTrue() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c |
|
||||||
size 140926 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:8524bdd37ac6eb784d82041b572d9cf3bb69cf3de318ba1d8abc45e9a239dad1 |
|
||||||
size 141726 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:a4fd2455de9a763e582e4977e36686c714dfb380737fc6193b4eab9ef8b64de9 |
|
||||||
size 58641 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c |
|
||||||
size 140926 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:f7880a67dd818c44b519be634a4a33a4a0efc842f9ad5e4879f16acb7c8b1f5f |
|
||||||
size 122622 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf |
|
||||||
size 138058 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:ef852707489db29300b4bf8259a5d71d3c82ce26bcea3ddb00d037b1ba379423 |
|
||||||
size 138901 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:5946208785bcc7eff5e76048cd467e9bde6f4ff5874ef2dd2562d5bbb0844f93 |
|
||||||
size 59177 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf |
|
||||||
size 138058 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:3b4fe8418f426d9953189908053407550ad6d83d45a548253d75507321f686c9 |
|
||||||
size 120721 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:3d81ad9f5a5983653d9266a2500b2c71dc7ca231d52a62d1c1f1b091849ede32 |
|
||||||
size 165501 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:1cb4f2cbdc2b7fca643a9debff50206d4aa9321894a86149d6c815fd4db7b593 |
|
||||||
size 166084 |
|
@ -1,3 +0,0 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
|
||||||
oid sha256:3b942ce79745a26aa2d4227c35aaad442b35c2f768915e3d9fabf45a04cd1d82 |
|
||||||
size 61267 |
|
Loading…
Reference in new issue