diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 2d85ec7677..75681099b1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -28,6 +28,7 @@ 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 com.bumble.appyx.navmodel.backstack.operation.singleTop import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode @@ -36,11 +37,11 @@ import io.element.android.features.login.impl.changeaccountprovider.ChangeAccoun import io.element.android.features.login.impl.changeaccountprovider.form.ChangeAccountProviderFormNode import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem import io.element.android.features.login.impl.changeserver.ChangeServerNode +import io.element.android.features.login.impl.datasource.AccountProviderDataSource import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker 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.root.LoginRootNode -import io.element.android.features.login.impl.util.LoginConstants import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -57,6 +58,7 @@ class LoginFlowNode @AssistedInject constructor( @Assisted plugins: List, private val customTabAvailabilityChecker: CustomTabAvailabilityChecker, private val customTabHandler: CustomTabHandler, + private val accountProviderDataSource: AccountProviderDataSource, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.AccountProvider, // NavTarget.Root, @@ -124,12 +126,11 @@ class LoginFlowNode @AssistedInject constructor( } NavTarget.AccountProvider -> { val inputs = AccountProviderNode.Inputs( - homeserver = LoginConstants.DEFAULT_HOMESERVER_URL, - isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == "matrix.org", isAccountCreation = inputs.isAccountCreation ) val callback = object : AccountProviderNode.Callback { - override fun onContinue() { + override fun onServerValidated() { + // TODO TODO("Not yet implemented") } @@ -142,7 +143,9 @@ class LoginFlowNode @AssistedInject constructor( NavTarget.ChangeAccountProvider -> { val callback = object : ChangeAccountProviderNode.Callback { override fun onAccountProviderItemClicked(data: AccountProviderItem) { - TODO("Not yet implemented") + accountProviderDataSource.userSelection(data) + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.AccountProvider) } override fun onOtherClicked() { @@ -155,7 +158,9 @@ class LoginFlowNode @AssistedInject constructor( NavTarget.ChangeAccountProviderForm -> { val callback = object : ChangeAccountProviderFormNode.Callback { override fun onAccountProviderItemClicked(data: AccountProviderItem) { - TODO("Not yet implemented") + accountProviderDataSource.userSelection(data) + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.AccountProvider) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderEvents.kt index 7a4cbf0366..ec3a879c89 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderEvents.kt @@ -16,7 +16,7 @@ package io.element.android.features.login.impl.accountprovider -// TODO Add your events or remove the file completely if no events sealed interface AccountProviderEvents { - object MyEvent : AccountProviderEvents + object Continue : AccountProviderEvents + object ClearError : AccountProviderEvents } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt index bd1dbf24c2..3331080f23 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderNode.kt @@ -16,8 +16,12 @@ package io.element.android.features.login.impl.accountprovider +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 @@ -25,8 +29,10 @@ 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.util.LoginConstants import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.AppScope @ContributesNode(AppScope::class) @@ -37,41 +43,44 @@ class AccountProviderNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { data class Inputs( - val homeserver: String, - val isMatrixOrg: Boolean, val isAccountCreation: Boolean, ) : NodeInputs private val inputs: Inputs = inputs() private val presenter = presenterFactory.create( AccountProviderPresenterParams( - homeserver = inputs.homeserver, - isMatrixOrg = inputs.isMatrixOrg, isAccountCreation = inputs.isAccountCreation, ) ) interface Callback : Plugin { - fun onContinue() + fun onServerValidated() fun onChangeAccountProvider() } - private fun onContinue() { - plugins().forEach { it.onContinue() } + private fun onServerValidated() { + plugins().forEach { it.onServerValidated() } } private fun onChangeAccountProvider() { plugins().forEach { it.onChangeAccountProvider() } } + 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 AccountProviderView( state = state, modifier = modifier, - onContinue = ::onContinue, + onChangeServerSuccess = ::onServerValidated, onChange = ::onChangeAccountProvider, + onLearnMoreClicked = { openLearnMorePage(context) }, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt index 577d22ec7c..3561e2f19a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderPresenter.kt @@ -17,19 +17,35 @@ package io.element.android.features.login.impl.accountprovider import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.features.login.impl.changeserver.ChangeServerError +import io.element.android.features.login.impl.datasource.AccountProviderDataSource +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 data class AccountProviderPresenterParams( - val homeserver: String, - val isMatrixOrg: Boolean, val isAccountCreation: Boolean, ) class AccountProviderPresenter @AssistedInject constructor( @Assisted private val params: AccountProviderPresenterParams, + private val accountProviderDataSource: AccountProviderDataSource, + private val authenticationService: MatrixAuthenticationService ) : Presenter { @AssistedFactory @@ -39,18 +55,40 @@ class AccountProviderPresenter @AssistedInject constructor( @Composable override fun present(): AccountProviderState { + val accountProvider by accountProviderDataSource.flow().collectAsState() + + val localCoroutineScope = rememberCoroutineScope() + + val homeserver = rememberSaveable { + mutableStateOf(accountProvider.title /* TODO There is a mix of data and UI here, which is not nice */) + } + val changeServerAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } fun handleEvents(event: AccountProviderEvents) { when (event) { - AccountProviderEvents.MyEvent -> Unit + AccountProviderEvents.Continue -> { + localCoroutineScope.submit(homeserver, changeServerAction) + } + AccountProviderEvents.ClearError -> changeServerAction.value = Async.Uninitialized } } return AccountProviderState( - homeserver = params.homeserver, - isMatrix = params.isMatrixOrg, + homeserver = accountProvider.title, + isMatrix = accountProvider.isMatrixOrg, isAccountCreation = params.isAccountCreation, + changeServerAction = changeServerAction.value, eventSink = ::handleEvents ) } + + private fun CoroutineScope.submit(homeserverUrl: MutableState, changeServerAction: MutableState>) = launch { + suspend { + val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value + authenticationService.setHomeserver(domain).getOrThrow() + homeserverUrl.value = domain + }.execute(changeServerAction, errorMapping = ChangeServerError::from) + } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt index 1bee64e88d..d7462895d4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderState.kt @@ -16,10 +16,16 @@ package io.element.android.features.login.impl.accountprovider +import io.element.android.libraries.architecture.Async + // Do not use default value, so no member get forgotten in the presenters. data class AccountProviderState( val homeserver: String, val isMatrix: Boolean, val isAccountCreation: Boolean, + // TODO Rename + val changeServerAction: Async, val eventSink: (AccountProviderEvents) -> Unit -) +) { + val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt index 35452cd256..908888f2a1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.login.impl.accountprovider import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async open class AccountProviderStateProvider : PreviewParameterProvider { override val values: Sequence @@ -30,5 +31,6 @@ fun aAccountProviderState() = AccountProviderState( homeserver = "matrix.org", isMatrix = true, isAccountCreation = false, + changeServerAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt index 73f03a75e4..5989dfa8bd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -21,28 +21,49 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.changeserver.ChangeServerError +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog +import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog 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.Text import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag @Composable fun AccountProviderView( state: AccountProviderState, modifier: Modifier = Modifier, - onContinue: () -> Unit = {}, + // TODO Rename + onChangeServerSuccess: () -> Unit = {}, + onLearnMoreClicked: () -> Unit = {}, onChange: () -> Unit = {}, ) { + val isLoading by remember(state.changeServerAction) { + derivedStateOf { + state.changeServerAction is Async.Loading + } + } + val eventSink = state.eventSink + val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage + val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert + HeaderFooterPage( modifier = modifier, header = { @@ -69,16 +90,15 @@ fun AccountProviderView( }, footer = { ButtonColumnMolecule { - Button( - onClick = { - onContinue() - }, - enabled = true, + ButtonWithProgress( + text = stringResource(id = R.string.screen_account_provider_continue), + showProgress = isLoading, + onClick = { eventSink.invoke(AccountProviderEvents.Continue) }, + enabled = state.submitEnabled, modifier = Modifier .fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.screen_account_provider_continue)) - } + .testTag(TestTags.changeServerContinue) + ) TextButton( onClick = { onChange() @@ -92,7 +112,25 @@ fun AccountProviderView( } } ) { - // No content + if (slidingSyncNotSupportedError != null) { + SlidingSyncNotSupportedDialog(onLearnMoreClicked = { + onLearnMoreClicked() + eventSink(AccountProviderEvents.ClearError) + }, onDismiss = { + eventSink(AccountProviderEvents.ClearError) + }) + } + if (invalidHomeserverError != null) { + ErrorDialog( + content = invalidHomeserverError.message(), + onDismiss = { + eventSink.invoke(AccountProviderEvents.ClearError) + } + ) + } + } + if (state.changeServerAction is Async.Success) { + onChangeServerSuccess() } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/datasource/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/datasource/AccountProviderDataSource.kt new file mode 100644 index 0000000000..9a548f7aab --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/datasource/AccountProviderDataSource.kt @@ -0,0 +1,42 @@ +/* + * 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.datasource + +import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem +import io.element.android.features.login.impl.util.defaultAccountProviderItem +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +class AccountProviderDataSource @Inject constructor( +) { + private val accountProvider: MutableStateFlow = MutableStateFlow( + defaultAccountProviderItem + ) + + fun flow(): StateFlow { + return accountProvider.asStateFlow() + } + + fun userSelection(data: AccountProviderItem) { + accountProvider.tryEmit(data) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt index cb01f8095a..06ffcb94f4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -16,8 +16,18 @@ package io.element.android.features.login.impl.util +import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem + object LoginConstants { + const val MATRIX_ORG_URL = "matrix.org" const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" } + +val defaultAccountProviderItem = AccountProviderItem( + title = LoginConstants.DEFAULT_HOMESERVER_URL, + subtitle = null, + isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, + isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, +)