Refine sign in flow to match designs and iOS flow (#100)

* Fix dark theme

* First attempt at replicating iOS' UI & flows.

* Try to fix Maestro tests

* Add error dialogs and tests

* Remove unused modifiers

* Try to fix detekt issues

* Tidy up maestro login flow a bit

* Add `CompoundColorPalette` with some needed colors

* Fixes to designs

* Fix detekt issues

* More design fixes

* Some other minor design fixes

* Add changelog

* Minor tweaks.

* Remove legacy dark material theme as it's no longer needed.

* Move sliding sync 'learn more' url to constants object

* Remove unused focusManager

* Change how the displayed homeserver works

* Keep user input as homeserver if it's valid

* Remove `CompoundColorPalette`, try to fix issue when toggling dark mode.

* Add `@Stable` to the theme, adjust how it toggles in dark mode

* Remove unused strings

* Update screenshots

* Re-organize components in LoginRootScreen

* Bump min coverage to 55, max to 60

* Always replace the snapshots contents when running `recordPaparazzi`

* Fix dark theme preview of components using content colors

* Add `BackButton` component

* Handle errors with dialogs in a generic way

* Align our Dialog components with the designs, use them were needed

* Use a `MatrixHomeserverDetails` data class instead of just an URL.

* `AuthenticationService.getHomeserverDetails()` now returns a `StateFlow`.

Also, try to fix coverage issues in tests.
This commit is contained in:
Jorge Martin Espinosa
2023-03-06 09:30:16 +01:00
committed by GitHub
parent ced183dd80
commit 33b88b8026
271 changed files with 1431 additions and 871 deletions

View File

@@ -1,5 +1,6 @@
appId: ${APP_ID}
---
- tapOn: "Change"
- tapOn:
id: "login-change_server"
- takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "Continue"

View File

@@ -5,17 +5,14 @@ appId: ${APP_ID}
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml
- tapOn: "Username or email"
# ios
# - tapOn:
# id: "usernameTextField"
# index: 0
- tapOn:
id: "login-email_username"
- inputText: ${USERNAME}
- pressKey: Enter
- tapOn: "Password"
# iOS
#- tapOn:
# id: "passwordTextField"
# index: 0
- tapOn:
id: "login-password"
- inputText: ${PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -18,8 +18,10 @@ package io.element.android.x
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
@@ -42,7 +44,7 @@ class MainActivity : NodeComponentActivity() {
setContent {
ElementTheme {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
RootFlowNode(

View File

@@ -208,11 +208,11 @@ koverMerged {
name = "Global minimum code coverage."
target = kotlinx.kover.api.VerificationTarget.ALL
bound {
minValue = 50
minValue = 55
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
// For instance if we have minValue = 25 and maxValue = 30, and current code coverage is now 37.32%, update
// minValue to 35 and maxValue to 40.
maxValue = 55
maxValue = 60
counter = kotlinx.kover.api.CounterType.INSTRUCTION
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
}
@@ -297,3 +297,15 @@ tasks.register("runQualityChecks") {
}
dependsOn(":app:knitCheck")
}
// Make sure to delete old screenshots before recording new ones
subprojects {
val snapshotsDir = File("${project.path}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() }
doFirst {
snapshotsDir.deleteRecursively()
}
}
tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask)
}

1
changelog.d/88.bugfix Normal file
View File

@@ -0,0 +1 @@
Fix designs in sign in and change server flows

View File

@@ -19,4 +19,5 @@ package io.element.android.features.login.changeserver
sealed interface ChangeServerEvents {
data class SetServer(val server: String) : ChangeServerEvents
object Submit : ChangeServerEvents
object ClearError : ChangeServerEvents
}

View File

@@ -16,14 +16,20 @@
package io.element.android.features.login.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.util.LoginConstants
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
@@ -32,18 +38,25 @@ class ChangeServerNode @AssistedInject constructor(
@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) },
)
}
}

View File

@@ -22,12 +22,15 @@ 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.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> {
@@ -35,8 +38,9 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
@Composable
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
@@ -45,7 +49,10 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
fun handleEvents(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction)
ChangeServerEvents.Submit -> {
localCoroutineScope.submit(homeserver, changeServerAction)
}
ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized
}
}
@@ -56,9 +63,11 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
)
}
private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState<Async<Unit>>) = launch {
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch {
suspend {
authenticationService.setHomeserver(homeserver)
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
authenticationService.setHomeserver(domain)
homeserverUrl.value = domain
}.execute(changeServerAction)
}
}

View File

@@ -19,164 +19,240 @@ package io.element.android.features.login.changeserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
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.systemBarsPadding
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.text.font.FontWeight
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 androidx.compose.ui.unit.sp
import io.element.android.features.login.R
import io.element.android.features.login.error.changeServerError
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.OutlinedTextField
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()
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp)
) {
val isError = state.changeServerAction is Async.Failure
Box(
modifier = Modifier
.padding(top = 99.dp)
.size(width = 81.dp, height = 73.dp)
.align(Alignment.CenterHorizontally)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
// TODO Update with design input
resourceId = R.drawable.ic_baseline_dataset_24,
contentDescription = "",
)
}
Text(
text = "Your server",
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 38.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "A server is a home for all your data.\n" +
"You choose your server and its easy to make one.", // TODO "Learn more.",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.padding(top = 200.dp),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(ChangeServerEvents.Submit) }
)
)
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
state.changeServerAction.error
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Button(
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
.padding(top = 44.dp)
) {
Text(text = "Continue")
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
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
@@ -191,5 +267,5 @@ internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProv
@Composable
private fun ContentToPreview(state: ChangeServerState) {
ChangeServerView(state = state)
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {})
}

View File

@@ -17,8 +17,8 @@
package io.element.android.features.login.root
sealed interface LoginRootEvents {
object RefreshHomeServer : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents
object ClearError : LoginRootEvents
}

View File

@@ -47,16 +47,11 @@ class LoginRootNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
else -> Unit
}
}
LoginRootScreen(
state = state,
modifier = modifier,
onChangeServer = this::onChangeHomeServer,
onBackPressed = this::navigateUp
)
}
}

View File

@@ -18,25 +18,30 @@ package io.element.android.features.login.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.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.flow.filterNotNull
import kotlinx.coroutines.flow.map
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 = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
}
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
@@ -46,19 +51,19 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
fun handleEvents(event: LoginRootEvents) {
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState)
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
}
}
return LoginRootState(
homeserver = homeserver.value,
homeserverDetails = homeserver,
loggedInState = loggedInState.value,
formState = formState.value,
eventSink = ::handleEvents
@@ -83,7 +88,4 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
formState.value = updateLambda(formState.value)
}
private fun refreshHomeServer(homeserver: MutableState<String>) {
homeserver.value = authenticationService.getHomeserverOrDefault()
}
}

View File

@@ -16,222 +16,332 @@
package io.element.android.features.login.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.systemBarsPadding
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.font.FontWeight
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 androidx.compose.ui.unit.sp
import io.element.android.features.login.error.loginError
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.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
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: (UserId) -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
onBackPressed: () -> Unit,
) {
val eventSink = state.eventSink
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 48.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
val interactionEnabled by remember(state.loggedInState) {
derivedStateOf {
state.loggedInState != LoggedInState.LoggingIn
}
}
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) },
)
// Form
Column(
// modifier = Modifier.weight(1f),
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = state.homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = { /* no op */ },
enabled = false,
label = {
Text(text = "Server")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
),
)
Button(
onClick = onChangeServer,
modifier = Modifier
.align(Alignment.CenterEnd)
.testTag(TestTags.loginChangeServer)
.padding(top = 8.dp, end = 8.dp),
content = {
Text(text = "Change")
}
)
}
OutlinedTextField(
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginEmailUsername)
.padding(top = 60.dp),
label = {
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
OutlinedTextField(
value = passwordFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginPassword)
.padding(top = 24.dp),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
},
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) "Hide password" else "Show password"
}
) { padding ->
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(padding)
) {
val scrollState = rememberScrollState()
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
}
// Submit
Button(
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
Column(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
.padding(vertical = 32.dp)
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
Text(text = "Continue")
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)
)
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
@Preview
@Composable
internal fun LoginRootScreenLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun LoginRootScreenDarkPreview() = ElementPreviewDark { ContentToPreview() }
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
private fun ContentToPreview() {
LoginRootScreen(
state = aLoginRootState().copy(
homeserver = "matrix.org",
),
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 = {}
)
}

View File

@@ -17,11 +17,12 @@
package io.element.android.features.login.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 homeserver: String,
val homeserverDetails: MatrixHomeServerDetails,
val loggedInState: LoggedInState,
val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit

View File

@@ -16,8 +16,24 @@
package io.element.android.features.login.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(
homeserver = "",
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}

View File

@@ -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.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"
}

View File

@@ -1,26 +0,0 @@
<!--
~ 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.
-->
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM11,17H7v-4h4V17zM11,11H7V7h4V11zM17,17h-4v-4h4V17zM17,11h-4V7h4V11z" />
</vector>

View 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>

View File

@@ -24,6 +24,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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
@@ -39,7 +42,23 @@ class ChangeServerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
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()
}
}
@@ -78,4 +97,70 @@ class ChangeServerPresenterTest {
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)
}
}
}

View File

@@ -23,7 +23,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_2
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
@@ -43,7 +42,7 @@ class LoginRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
@@ -115,7 +114,7 @@ class LoginRootPresenterTest {
}
@Test
fun `present - refresh server`() = runTest {
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
@@ -124,11 +123,20 @@ class LoginRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
authenticationService.givenHomeserver(A_HOMESERVER_2)
initialState.eventSink.invoke(LoginRootEvents.RefreshHomeServer)
val refreshedState = awaitItem()
assertThat(refreshedState.homeserver).isEqualTo(A_HOMESERVER_2)
// 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)
}
}
}

View File

@@ -41,6 +41,22 @@ val SystemGrey5Dark = Color(0xFF2C2C2E)
val SystemGrey6Light = Color(0xFFF2F2F7)
val SystemGrey6Dark = Color(0xFF1C1C1E)
// For light themes
val Gray_25 = Color(0xFFF4F6FA)
val Gray_50 = Color(0xFFE3E8F0)
val Gray_100 = Color(0xFFC1C6CD)
val Gray_150 = Color(0xFF8D97A5)
val Gray_200 = Color(0xFF737D8C)
val Black_900 = Color(0xFF17191C)
// For dark themes
val Gray_250 = Color(0xFFA9B2BC)
val Gray_300 = Color(0xFF8E99A4)
val Gray_400 = Color(0xFF6F7882)
val Gray_450 = Color(0xFF394049)
val Black_800 = Color(0xFF15191E)
val Black_950 = Color(0xFF21262C)
val Azure = Color(0xFF368BD6)
val Kiwi = Color(0xFF74D12C)
val Grape = Color(0xFFAC3BA8)

View File

@@ -25,6 +25,14 @@ import androidx.compose.ui.unit.sp
// TODO Remove
object ElementTextStyles {
val Button = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
lineHeight = 22.sp,
fontStyle = FontStyle.Normal,
textAlign = TextAlign.Center,
)
object Bold {
val largeTitle = TextStyle(
fontSize = 34.sp,
@@ -180,6 +188,14 @@ object ElementTextStyles {
textAlign = TextAlign.Center
)
val formHeader = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
lineHeight = 20.sp,
textAlign = TextAlign.Start
)
val footnote = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Normal,

View File

@@ -17,13 +17,12 @@
package io.element.android.libraries.designsystem.components.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -31,10 +30,8 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.ui.strings.R as StringR
@@ -67,43 +64,24 @@ fun ConfirmationDialog(
Text(content)
},
dismissButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Column {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onCancelClicked()
}) {
Text(cancelText)
}
if (thirdButtonText != null) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onThirdButtonClicked()
}) {
Text(thirdButtonText)
}
if (thirdButtonText != null) {
// If there is a 3rd item it should be at the end of the dialog
// Having this 3rd action is discouraged, see https://m3.material.io/components/dialogs/guidelines#e13b68f5-e367-4275-ad6f-c552ee8e358f
TextButton(onClick = onThirdButtonClicked) {
Text(thirdButtonText)
}
}
TextButton(onClick = onCancelClicked) {
Text(cancelText)
}
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onSubmitClicked()
}
) {
Text(submitText)
TextButton(
onClick = {
onSubmitClicked()
}
) {
Text(submitText)
}
},
shape = shape,

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -55,24 +56,14 @@ fun ErrorDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Text(text = title)
Text(title)
},
text = {
Text(content)
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
}
) {
Text(submitText)
}
TextButton(onClick = onDismiss) {
Text(submitText)
}
},
shape = shape,

View File

@@ -16,12 +16,9 @@
package io.element.android.libraries.designsystem.preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
@Composable
fun ElementPreviewLight(
@@ -55,9 +52,8 @@ private fun ElementPreview(
) {
ElementTheme(darkTheme = darkTheme) {
if (showBackground) {
Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
content()
}
// If we have a proper contentColor applied we need a Surface instead of a Box
Surface { content() }
} else {
content()
}

View File

@@ -21,7 +21,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.Black_800
import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Gray_450
import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
@@ -30,6 +34,8 @@ fun elementColorsDark() = ElementColors(
messageFromMeBackground = SystemGrey5Dark,
messageFromOtherBackground = SystemGrey6Dark,
messageHighlightedBackground = Azure,
quaternary = Gray_400,
quinary = Gray_450,
isLight = false,
)
@@ -48,12 +54,12 @@ val materialColorSchemeDark = darkColorScheme(
// TODO onTertiary = ColorDarkTokens.OnTertiary,
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
background = Color.Black,
background = Black_800,
onBackground = Color.White,
surface = Color.Black,
surface = Black_800,
onSurface = Color.White,
surfaceVariant = SystemGrey5Dark,
// TODO onSurfaceVariant = ColorDarkTokens.OnSurfaceVariant,
surfaceVariant = Black_950,
onSurfaceVariant = Color.White,
// TODO surfaceTint = primary,
// TODO inverseSurface = ColorDarkTokens.InverseSurface,
// TODO inverseOnSurface = ColorDarkTokens.InverseOnSurface,

View File

@@ -21,7 +21,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.LightGrey
import io.element.android.libraries.designsystem.Black_900
import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
@@ -30,21 +35,23 @@ fun elementColorsLight() = ElementColors(
messageFromMeBackground = SystemGrey5Light,
messageFromOtherBackground = SystemGrey6Light,
messageHighlightedBackground = Azure,
quaternary = Gray_100,
quinary = Gray_50,
isLight = true,
)
// TODO Lots of colors are missing
val materialColorSchemeLight = lightColorScheme(
primary = Color.Black,
primary = Black_900,
onPrimary = Color.White,
// TODO primaryContainer = ColorLightTokens.PrimaryContainer,
// TODO onPrimaryContainer = ColorLightTokens.OnPrimaryContainer,
// TODO inversePrimary = ColorLightTokens.InversePrimary,
secondary = LightGrey,
secondary = Gray_200,
// TODO onSecondary = ColorLightTokens.OnSecondary,
// TODO secondaryContainer = ColorLightTokens.SecondaryContainer,
// TODO onSecondaryContainer = ColorLightTokens.OnSecondaryContainer,
tertiary = Color.Black,
tertiary = Gray_150,
// TODO onTertiary = ColorLightTokens.OnTertiary,
// TODO tertiaryContainer = ColorLightTokens.TertiaryContainer,
// TODO onTertiaryContainer = ColorLightTokens.OnTertiaryContainer,
@@ -52,8 +59,8 @@ val materialColorSchemeLight = lightColorScheme(
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
surfaceVariant = SystemGrey5Light,
onSurfaceVariant = Color.Black,
surfaceVariant = Gray_25,
onSurfaceVariant = Gray_150,
// TODO surfaceTint = primary,
// TODO inverseSurface = ColorLightTokens.InverseSurface,
// TODO inverseOnSurface = ColorLightTokens.InverseOnSurface,

View File

@@ -16,15 +16,19 @@
package io.element.android.libraries.designsystem.theme
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
@Stable
class ElementColors(
messageFromMeBackground: Color,
messageFromOtherBackground: Color,
messageHighlightedBackground: Color,
quaternary: Color,
quinary: Color,
isLight: Boolean,
) {
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
@@ -34,6 +38,12 @@ class ElementColors(
var messageHighlightedBackground by mutableStateOf(messageHighlightedBackground)
private set
var quaternary by mutableStateOf(quaternary)
private set
var quinary by mutableStateOf(quinary)
private set
var isLight by mutableStateOf(isLight)
private set
@@ -41,11 +51,15 @@ class ElementColors(
messageFromMeBackground: Color = this.messageFromMeBackground,
messageFromOtherBackground: Color = this.messageFromOtherBackground,
messageHighlightedBackground: Color = this.messageHighlightedBackground,
quaternary: Color = this.quaternary,
quinary: Color = this.quinary,
isLight: Boolean = this.isLight,
) = ElementColors(
messageFromMeBackground = messageFromMeBackground,
messageFromOtherBackground = messageFromOtherBackground,
messageHighlightedBackground = messageHighlightedBackground,
quaternary = quaternary,
quinary = quinary,
isLight = isLight,
)
@@ -53,6 +67,8 @@ class ElementColors(
messageFromMeBackground = other.messageFromMeBackground
messageFromOtherBackground = other.messageFromOtherBackground
messageHighlightedBackground = other.messageHighlightedBackground
quaternary = other.quaternary
quinary = other.quinary
isLight = other.isLight
}
}

View File

@@ -49,15 +49,16 @@ val LocalColors = staticCompositionLocalOf { elementColorsLight() }
fun ElementTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, /* true to enable MaterialYou */
lightColors: ElementColors = elementColorsLight(),
darkColors: ElementColors = elementColorsDark(),
colors: ElementColors = if (darkTheme) elementColorsDark() else elementColorsLight(),
materialLightColors: ColorScheme = materialColorSchemeLight,
materialDarkColors: ColorScheme = materialColorSchemeDark,
content: @Composable () -> Unit,
) {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !darkTheme
val currentColor = remember { if (darkTheme) darkColors else lightColors }
val currentColor = remember(darkTheme) {
colors.copy()
}.apply { updateColorsFrom(colors) }
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
@@ -75,9 +76,8 @@ fun ElementTheme(
darkIcons = useDarkIcons
)
}
val rememberedColors = remember { currentColor.copy() }.apply { updateColorsFrom(currentColor) }
CompositionLocalProvider(
LocalColors provides rememberedColors,
LocalColors provides currentColor,
) {
MaterialTheme(
colorScheme = colorScheme,

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R.string as StringR
@Composable
fun BackButton(
action: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector = Icons.Default.ArrowBack,
contentDescription: String = stringResource(StringR.action_back),
enabled: Boolean = true
) {
IconButton(
modifier = modifier,
onClick = action,
enabled = enabled,
) {
Icon(imageVector = icon, contentDescription = contentDescription)
}
}
@Preview
@Composable
internal fun BackButtonPreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun BackButtonPreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
BackButton(action = { }, enabled = true, contentDescription = "Back")
BackButton(action = { }, enabled = false, contentDescription = "Back")
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@@ -37,11 +38,11 @@ fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.shape,
colors: ButtonColors = ButtonDefaults.buttonColors(),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
shape: Shape = ElementButtonDefaults.shape,
colors: ButtonColors = ElementButtonDefaults.buttonColors(),
elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
@@ -59,6 +60,17 @@ fun Button(
)
}
object ElementButtonDefaults {
val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
val shape: Shape @Composable get() = ButtonDefaults.shape
@Composable
fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation()
@Composable
fun buttonColors(): ButtonColors = ButtonDefaults.buttonColors()
}
@Preview
@Composable
internal fun ButtonsLightPreview() = ElementPreviewLight { ContentToPreview() }

View File

@@ -29,8 +29,17 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
@@ -40,7 +49,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun OutlinedTextField(
value: String,
@@ -88,6 +97,16 @@ fun OutlinedTextField(
)
}
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && (event.key == Key.Tab || event.key == Key.Enter)) {
focusManager.moveFocus(FocusDirection.Down)
true
} else {
false
}
}
@Preview
@Composable
internal fun OutlinedTextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }

View File

@@ -18,6 +18,7 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
alias(libs.plugins.anvil)
kotlin("plugin.serialization") version "1.8.10"
}

View File

@@ -19,13 +19,13 @@ package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MatrixAuthenticationService {
fun isLoggedIn(): Flow<Boolean>
suspend fun getLatestSessionId(): SessionId?
suspend fun restoreSession(sessionId: SessionId): MatrixClient?
fun getHomeserver(): String?
fun getHomeserverOrDefault(): String
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
suspend fun setHomeserver(homeserver: String)
suspend fun login(username: String, password: String): SessionId
}

View File

@@ -0,0 +1,34 @@
/*
* 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.libraries.matrix.api.auth
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
@Parcelize
data class MatrixHomeServerDetails(
val url: String,
val supportsPasswordLogin: Boolean,
val authenticationIssuer: String?
): Parcelable {
constructor(homeserverLoginDetails: HomeserverLoginDetails) : this(
homeserverLoginDetails.url(),
homeserverLoginDetails.supportsPasswordLogin(),
homeserverLoginDetails.authenticationIssuer()
)
}

View File

@@ -19,8 +19,10 @@ package io.element.android.libraries.matrix.impl.auth
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.impl.RustMatrixClient
@@ -29,6 +31,8 @@ import io.element.android.libraries.matrix.session.SessionData
import io.element.android.libraries.sessionstorage.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client
@@ -39,6 +43,7 @@ import java.io.File
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class RustMatrixAuthenticationService @Inject constructor(
private val baseDirectory: File,
private val coroutineScope: CoroutineScope,
@@ -47,6 +52,8 @@ class RustMatrixAuthenticationService @Inject constructor(
private val authService: AuthenticationService,
) : MatrixAuthenticationService {
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
override fun isLoggedIn(): Flow<Boolean> {
return sessionStore.isLoggedIn()
}
@@ -74,13 +81,15 @@ class RustMatrixAuthenticationService @Inject constructor(
}
}
override fun getHomeserver(): String? = authService.homeserverDetails()?.url()
override fun getHomeserverOrDefault(): String = getHomeserver() ?: "matrix.org"
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> = currentHomeserver
override suspend fun setHomeserver(homeserver: String) {
withContext(coroutineDispatchers.io) {
authService.configureHomeserver(homeserver)
val homeServerDetails = authService.homeserverDetails()?.use { MatrixHomeServerDetails(it) }
if (homeServerDetails != null) {
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
}
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -34,8 +35,10 @@ const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!"
const val A_HOMESERVER = "matrix.org"
const val A_HOMESERVER_2 = "matrix-client.org"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
const val AN_AVATAR_URL = "mxc://data"

View File

@@ -18,46 +18,47 @@ package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
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_USER_ID
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver: String = A_HOMESERVER
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var loginError: Throwable? = null
private var changeServerError: Throwable? = null
override fun isLoggedIn(): Flow<Boolean> {
return flowOf(false)
}
override suspend fun getLatestSessionId(): UserId? {
override suspend fun getLatestSessionId(): SessionId? {
return null
}
override suspend fun restoreSession(userId: UserId): MatrixClient? {
override suspend fun restoreSession(sessionId: SessionId): MatrixClient? {
return null
}
override fun getHomeserver(): String? {
return null
}
fun givenHomeserver(homeserver: String) {
this.homeserver = homeserver
}
override fun getHomeserverOrDefault(): String {
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> {
return homeserver
}
fun givenHomeserver(homeserver: MatrixHomeServerDetails) {
this.homeserver.value = homeserver
}
override suspend fun setHomeserver(homeserver: String) {
changeServerError?.let { throw it }
delay(100)
}
override suspend fun login(username: String, password: String): UserId {
override suspend fun login(username: String, password: String): SessionId {
delay(100)
loginError?.let { throw it }
return A_USER_ID
@@ -66,4 +67,8 @@ class FakeAuthenticationService : MatrixAuthenticationService {
fun givenLoginError(throwable: Throwable?) {
loginError = throwable
}
fun givenChangeServerError(throwable: Throwable?) {
changeServerError = throwable
}
}

View File

@@ -2,5 +2,17 @@
<resources>
<!-- Add new strings for Element X Android here -->
<string name="action_back">Back</string>
<string name="action_clear">Clear</string>
<string name="login_form_title">Enter your details</string>
<string name="ex_login_username_hint">Email or username</string>
<string name="login_show_password">Show password</string>
<string name="login_hide_password">Hide password</string>
<string name="ex_choose_server_subtitle">What is the address of your server?</string>
<string name="server_selection_server_footer">You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it.</string>
<string name="server_selection_sliding_sync_alert_title">Server not supported</string>
<string name="server_selection_sliding_sync_alert_message">This server currently doesn\'t support sliding sync.</string>
</resources>

View File

@@ -33,7 +33,8 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
val state = presenter.present()
LoginRootScreen(
state = state,
modifier = modifier
modifier = modifier,
onBackPressed = {},
)
}
}

Some files were not shown because too many files have changed in this diff Show More