[Architecture] split all feature modules to api/impl
This commit is contained in:
parent
84bfb14bd9
commit
bc9f3b69cc
214 changed files with 626 additions and 1090 deletions
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.LoginEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<LoginFlowNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
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.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
||||
import io.element.android.features.login.impl.root.LoginRootNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object ChangeServer : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : LoginRootNode.Callback {
|
||||
override fun onChangeHomeServer() {
|
||||
backstack.push(NavTarget.ChangeServer)
|
||||
}
|
||||
}
|
||||
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
// Animate transition to change server screen
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.changeserver
|
||||
|
||||
sealed interface ChangeServerEvents {
|
||||
data class SetServer(val server: String) : ChangeServerEvents
|
||||
object Submit : ChangeServerEvents
|
||||
object ClearError : ChangeServerEvents
|
||||
}
|
||||
|
|
@ -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.changeserver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ChangeServerNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: ChangeServerPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private fun onSuccess() {
|
||||
navigateUp()
|
||||
}
|
||||
|
||||
private fun openLearnMorePage(context: Context) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
|
||||
tryOrNull { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val context = LocalContext.current
|
||||
ChangeServerView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onChangeServerSuccess = this::onSuccess,
|
||||
onBackPressed = { navigateUp() },
|
||||
onLearnMoreClicked = { openLearnMorePage(context) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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.changeserver
|
||||
|
||||
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 androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.execute
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ChangeServerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
}
|
||||
val changeServerAction: MutableState<Async<Unit>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: ChangeServerEvents) {
|
||||
when (event) {
|
||||
is ChangeServerEvents.SetServer -> homeserver.value = event.server
|
||||
ChangeServerEvents.Submit -> {
|
||||
localCoroutineScope.submit(homeserver, changeServerAction)
|
||||
}
|
||||
ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return ChangeServerState(
|
||||
homeserver = homeserver.value,
|
||||
changeServerAction = changeServerAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
|
||||
authenticationService.setHomeserver(domain)
|
||||
homeserverUrl.value = domain
|
||||
}.execute(changeServerAction)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.changeserver
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
data class ChangeServerState(
|
||||
val homeserver: String,
|
||||
val changeServerAction: Async<Unit>,
|
||||
val eventSink: (ChangeServerEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.changeserver
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
|
||||
override val values: Sequence<ChangeServerState>
|
||||
get() = sequenceOf(
|
||||
aChangeServerState(),
|
||||
aChangeServerState().copy(homeserver = "matrix.org"),
|
||||
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Loading()),
|
||||
aChangeServerState().copy(homeserver = "invalid.org", changeServerAction = Async.Failure(Throwable("An error"))),
|
||||
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Success(Unit)),
|
||||
)
|
||||
}
|
||||
|
||||
fun aChangeServerState() = ChangeServerState(
|
||||
homeserver = "",
|
||||
changeServerAction = Async.Uninitialized,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.changeserver
|
||||
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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 io.element.android.features.login.impl.R
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||
import io.element.android.libraries.designsystem.theme.components.BackButton
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import org.matrix.rustcomponents.sdk.AuthenticationException
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangeServerView(
|
||||
state: ChangeServerState,
|
||||
onLearnMoreClicked: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServerSuccess: () -> Unit = {},
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
val scrollState = rememberScrollState()
|
||||
val interactionEnabled by remember(state.changeServerAction) {
|
||||
derivedStateOf {
|
||||
state.changeServerAction !is Async.Loading
|
||||
}
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(padding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(
|
||||
state = scrollState,
|
||||
)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(42.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 70.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.background(
|
||||
color = LocalColors.current.quinary,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(width = 32.dp, height = 32.dp),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
resourceId = R.drawable.ic_homeserver,
|
||||
contentDescription = "",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = StringR.string.ftue_auth_choose_server_title),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTextStyles.Bold.title2,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = StringR.string.ex_choose_server_subtitle),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTextStyles.Regular.subheadline,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(StringR.string.hs_url),
|
||||
style = ElementTextStyles.Regular.formHeader,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
|
||||
TextField(
|
||||
value = homeserverFieldState,
|
||||
readOnly = !interactionEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.changeServerServer)
|
||||
.onTabOrEnterKeyFocusNext(focusManager),
|
||||
onValueChange = {
|
||||
homeserverFieldState = it
|
||||
eventSink(ChangeServerEvents.SetServer(it))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { eventSink(ChangeServerEvents.Submit) }
|
||||
),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
trailingIcon = if (homeserverFieldState.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = {
|
||||
homeserverFieldState = ""
|
||||
}, enabled = interactionEnabled) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
if (state.changeServerAction is Async.Failure) {
|
||||
if (state.changeServerAction.error is AuthenticationException.SlidingSyncNotAvailable) {
|
||||
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
|
||||
onLearnMoreClicked()
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
}, onDismiss = {
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
})
|
||||
} else {
|
||||
ChangeServerErrorDialog(
|
||||
error = state.changeServerAction.error,
|
||||
onDismiss = {
|
||||
eventSink(ChangeServerEvents.ClearError)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(StringR.string.server_selection_server_footer),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
textAlign = TextAlign.Start,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = { eventSink(ChangeServerEvents.Submit) },
|
||||
enabled = interactionEnabled && state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.changeServerContinue)
|
||||
) {
|
||||
Text(text = stringResource(id = StringR.string.login_continue), style = ElementTextStyles.Button)
|
||||
}
|
||||
if (state.changeServerAction is Async.Success) {
|
||||
onChangeServerSuccess()
|
||||
}
|
||||
}
|
||||
if (state.changeServerAction is Async.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChangeServerErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||
ErrorDialog(
|
||||
content = error.localizedMessage ?: stringResource(id = StringR.string.unknown_error),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SlidingSyncNotSupportedDialog(onLearnMoreClicked: () -> Unit, onDismiss: () -> Unit) {
|
||||
ConfirmationDialog(
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(StringR.string.action_learn_more),
|
||||
onSubmitClicked = onLearnMoreClicked,
|
||||
title = stringResource(StringR.string.server_selection_sliding_sync_alert_title),
|
||||
content = stringResource(StringR.string.server_selection_sliding_sync_alert_message),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: ChangeServerState) {
|
||||
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.login.impl.root.LoginFormState
|
||||
import io.element.android.libraries.core.uri.isValidUrl
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@Composable
|
||||
fun loginError(
|
||||
data: LoginFormState,
|
||||
throwable: Throwable?
|
||||
): String {
|
||||
return when {
|
||||
data.login.isEmpty() -> "Please enter a login"
|
||||
data.password.isEmpty() -> "Please enter a password"
|
||||
throwable != null -> stringResource(id = StringR.string.auth_invalid_login_param)
|
||||
else -> "No error provided"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun changeServerError(
|
||||
data: String,
|
||||
throwable: Throwable?
|
||||
): String {
|
||||
return when {
|
||||
data.isEmpty() -> "Please enter a server URL"
|
||||
!data.isValidUrl() -> stringResource(id = StringR.string.login_error_invalid_home_server)
|
||||
throwable != null -> "That server doesn’t seem right. Please check the address."
|
||||
else -> "No error provided"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
sealed interface LoginRootEvents {
|
||||
data class SetLogin(val login: String) : LoginRootEvents
|
||||
data class SetPassword(val password: String) : LoginRootEvents
|
||||
object Submit : LoginRootEvents
|
||||
object ClearError : LoginRootEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LoginRootPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeHomeServer()
|
||||
}
|
||||
|
||||
private fun onChangeHomeServer() {
|
||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
LoginRootScreen(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onChangeServer = this::onChangeHomeServer,
|
||||
onBackPressed = this::navigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
||||
|
||||
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
|
||||
|
||||
@Composable
|
||||
override fun present(): LoginRootState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
|
||||
val loggedInState: MutableState<LoggedInState> = remember {
|
||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||
}
|
||||
val formState = rememberSaveable {
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
}
|
||||
|
||||
fun handleEvents(event: LoginRootEvents) {
|
||||
when (event) {
|
||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||
copy(login = event.login)
|
||||
}
|
||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||
copy(password = event.password)
|
||||
}
|
||||
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
|
||||
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
return LoginRootState(
|
||||
homeserverDetails = homeserver,
|
||||
loggedInState = loggedInState.value,
|
||||
formState = formState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||
loggedInState.value = LoggedInState.LoggingIn
|
||||
//TODO rework the setHomeserver flow
|
||||
tryOrNull {
|
||||
authenticationService.setHomeserver(homeserver)
|
||||
}
|
||||
try {
|
||||
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
|
||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||
} catch (failure: Throwable) {
|
||||
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||
formState.value = updateLambda(formState.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.root
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
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 io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.BackButton
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginRootScreen(
|
||||
state: LoginRootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServer: () -> Unit = {},
|
||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
||||
onBackPressed: () -> Unit,
|
||||
) {
|
||||
val interactionEnabled by remember(state.loggedInState) {
|
||||
derivedStateOf {
|
||||
state.loggedInState != LoggedInState.LoggingIn
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) },
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(padding)
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(state = scrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Title
|
||||
Text(
|
||||
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
style = ElementTextStyles.Bold.title1,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
ChangeServerSection(
|
||||
interactionEnabled = interactionEnabled,
|
||||
homeserver = state.homeserverDetails.url,
|
||||
onChangeServer = onChangeServer
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
LoginForm(state = state, interactionEnabled = interactionEnabled)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
}
|
||||
when (val loggedInState = state.loggedInState) {
|
||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChangeServerSection(
|
||||
interactionEnabled: Boolean,
|
||||
homeserver: String,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(id = StringR.string.ftue_auth_sign_in_choose_server_header),
|
||||
style = ElementTextStyles.Regular.formHeader,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.testTag(TestTags.loginChangeServer)
|
||||
.clickable {
|
||||
if (interactionEnabled) {
|
||||
onChangeServer()
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = homeserver,
|
||||
style = ElementTextStyles.Bold.body,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = {
|
||||
if (interactionEnabled) {
|
||||
onChangeServer()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LoginForm(
|
||||
state: LoginRootState,
|
||||
interactionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||
var passwordFieldState by textFieldState(stateValue = state.formState.password)
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val eventSink = state.eventSink
|
||||
Column(modifier) {
|
||||
Text(
|
||||
text = stringResource(StringR.string.login_form_title),
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
style = ElementTextStyles.Regular.formHeader
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = loginFieldState,
|
||||
readOnly = !interactionEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginEmailUsername)
|
||||
.onTabOrEnterKeyFocusNext(focusManager),
|
||||
label = {
|
||||
Text(text = stringResource(StringR.string.ex_login_username_hint))
|
||||
},
|
||||
onValueChange = {
|
||||
loginFieldState = it
|
||||
eventSink(LoginRootEvents.SetLogin(it))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = {
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
}),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
trailingIcon = if (loginFieldState.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = {
|
||||
loginFieldState = ""
|
||||
}) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||
// Ensure password is hidden when user submits the form
|
||||
passwordVisible = false
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextField(
|
||||
value = passwordFieldState,
|
||||
readOnly = !interactionEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginPassword)
|
||||
.onTabOrEnterKeyFocusNext(focusManager),
|
||||
onValueChange = {
|
||||
passwordFieldState = it
|
||||
eventSink(LoginRootEvents.SetPassword(it))
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(StringR.string.login_signup_password_hint))
|
||||
},
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val image =
|
||||
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
val description =
|
||||
if (passwordVisible) stringResource(StringR.string.login_hide_password) else stringResource(StringR.string.login_show_password)
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { eventSink(LoginRootEvents.Submit) }
|
||||
),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
|
||||
LoginErrorDialog(error = state.loggedInState.failure, onDismiss = {
|
||||
eventSink(LoginRootEvents.ClearError)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Submit
|
||||
Button(
|
||||
onClick = { eventSink(LoginRootEvents.Submit) },
|
||||
enabled = interactionEnabled && state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginContinue)
|
||||
) {
|
||||
Text(text = stringResource(StringR.string.login_continue), style = ElementTextStyles.Button)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||
ErrorDialog(
|
||||
content = error.localizedMessage ?: stringResource(id = StringR.string.unknown_error),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: LoginRootState) {
|
||||
LoginRootScreen(
|
||||
state = state,
|
||||
onBackPressed = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class LoginRootState(
|
||||
val homeserverDetails: MatrixHomeServerDetails,
|
||||
val loggedInState: LoggedInState,
|
||||
val formState: LoginFormState,
|
||||
val eventSink: (LoginRootEvents) -> Unit
|
||||
) {
|
||||
val submitEnabled =
|
||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn
|
||||
}
|
||||
|
||||
sealed interface LoggedInState {
|
||||
object NotLoggedIn : LoggedInState
|
||||
object LoggingIn : LoggedInState
|
||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class LoginFormState(
|
||||
val login: String,
|
||||
val password: String
|
||||
) : Parcelable {
|
||||
|
||||
companion object {
|
||||
val Default = LoginFormState("", "")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
|
||||
override val values: Sequence<LoginRootState>
|
||||
get() = sequenceOf(
|
||||
aLoginRootState(),
|
||||
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("1234"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLoginRootState() = LoginRootState(
|
||||
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
|
||||
loggedInState = LoggedInState.NotLoggedIn,
|
||||
formState = LoginFormState.Default,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.util
|
||||
|
||||
object LoginConstants {
|
||||
|
||||
const val DEFAULT_HOMESERVER_URL = "matrix.org"
|
||||
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||
|
||||
}
|
||||
13
features/login/impl/src/main/res/drawable/ic_homeserver.xml
Normal file
13
features/login/impl/src/main/res/drawable/ic_homeserver.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="42dp"
|
||||
android:height="42dp"
|
||||
android:viewportWidth="42"
|
||||
android:viewportHeight="42">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h42v42h-42z"/>
|
||||
<path
|
||||
android:pathData="M33.25,22.75H8.75C6.825,22.75 5.25,24.325 5.25,26.25V33.25C5.25,35.175 6.825,36.75 8.75,36.75H33.25C35.175,36.75 36.75,35.175 36.75,33.25V26.25C36.75,24.325 35.175,22.75 33.25,22.75ZM12.25,33.25C10.325,33.25 8.75,31.675 8.75,29.75C8.75,27.825 10.325,26.25 12.25,26.25C14.175,26.25 15.75,27.825 15.75,29.75C15.75,31.675 14.175,33.25 12.25,33.25ZM33.25,5.25H8.75C6.825,5.25 5.25,6.825 5.25,8.75V15.75C5.25,17.675 6.825,19.25 8.75,19.25H33.25C35.175,19.25 36.75,17.675 36.75,15.75V8.75C36.75,6.825 35.175,5.25 33.25,5.25ZM12.25,15.75C10.325,15.75 8.75,14.175 8.75,12.25C8.75,10.325 10.325,8.75 12.25,8.75C14.175,8.75 15.75,10.325 15.75,12.25C15.75,14.175 14.175,15.75 12.25,15.75Z"
|
||||
android:fillColor="#737D8C"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
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.changeserver.ChangeServerEvents
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
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_HOMESERVER_URL_2
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ChangeServerPresenterTest {
|
||||
@Test
|
||||
fun `present - should start with default homeserver`() = runTest {
|
||||
val presenter = ChangeServerPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - authentication service can provide a homeserver`() = runTest {
|
||||
val presenter = ChangeServerPresenter(
|
||||
FakeAuthenticationService().apply {
|
||||
givenHomeserver(A_HOMESERVER.copy(url = A_HOMESERVER_URL_2))
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL_2)
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - disable if empty or not correct`() = runTest {
|
||||
val presenter = ChangeServerPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.SetServer(""))
|
||||
val emptyState = awaitItem()
|
||||
assertThat(emptyState.homeserver).isEqualTo("")
|
||||
assertThat(emptyState.submitEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit`() = runTest {
|
||||
val presenter = ChangeServerPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.Submit)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isFalse()
|
||||
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isTrue()
|
||||
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit parses URL`() = runTest {
|
||||
val presenter = ChangeServerPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val longUrl = "https://matrix.org/.well-known/"
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.SetServer(longUrl))
|
||||
awaitItem()
|
||||
initialState.eventSink.invoke(ChangeServerEvents.Submit)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isFalse()
|
||||
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
|
||||
awaitItem() // Skip changing the url to the parsed domain
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.submitEnabled).isTrue()
|
||||
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(successState.homeserver).isEqualTo("matrix.org")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit fails`() = runTest {
|
||||
val authServer = FakeAuthenticationService()
|
||||
val presenter = ChangeServerPresenter(authServer)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
authServer.givenChangeServerError(Throwable())
|
||||
initialState.eventSink.invoke(ChangeServerEvents.Submit)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.submitEnabled).isTrue()
|
||||
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - clear error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val presenter = ChangeServerPresenter(
|
||||
authenticationService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Submit will return an error
|
||||
authenticationService.givenChangeServerError(A_THROWABLE)
|
||||
initialState.eventSink(ChangeServerEvents.Submit)
|
||||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.changeServerAction).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(ChangeServerEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.changeServerAction).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.root
|
||||
|
||||
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.root.LoggedInState
|
||||
import io.element.android.features.login.impl.root.LoginFormState
|
||||
import io.element.android.features.login.impl.root.LoginRootEvents
|
||||
import io.element.android.features.login.impl.root.LoginRootPresenter
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class LoginRootPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
|
||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||
assertThat(initialState.submitEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - enter login and password`() = runTest {
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
|
||||
val loginState = awaitItem()
|
||||
assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = ""))
|
||||
assertThat(loginState.submitEnabled).isFalse()
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
|
||||
val loginAndPasswordState = awaitItem()
|
||||
assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD))
|
||||
assertThat(loginAndPasswordState.submitEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit`() = runTest {
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
|
||||
skipItems(1)
|
||||
val loginAndPasswordState = awaitItem()
|
||||
loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
|
||||
val submitState = awaitItem()
|
||||
assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||
val loggedInState = awaitItem()
|
||||
assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit with error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
|
||||
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
|
||||
skipItems(1)
|
||||
val loginAndPasswordState = awaitItem()
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
|
||||
val submitState = awaitItem()
|
||||
assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||
val loggedInState = awaitItem()
|
||||
assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - clear error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Submit will return an error
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
initialState.eventSink(LoginRootEvents.Submit)
|
||||
awaitItem() // Skip LoggingIn state
|
||||
|
||||
// Check an error was returned
|
||||
val submittedState = awaitItem()
|
||||
assertThat(submittedState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||
|
||||
// Assert the error is then cleared
|
||||
submittedState.eventSink(LoginRootEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue