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:
committed by
GitHub
parent
ced183dd80
commit
33b88b8026
@@ -1,5 +1,6 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "Change"
|
||||
- tapOn:
|
||||
id: "login-change_server"
|
||||
- takeScreenshot: build/maestro/200-ChangeServer
|
||||
- tapOn: "Continue"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
1
changelog.d/88.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix designs in sign in and change server flows
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 it’s 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 = {})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
13
features/login/src/main/res/drawable/ic_homeserver.xml
Normal file
13
features/login/src/main/res/drawable/ic_homeserver.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="42dp"
|
||||
android:height="42dp"
|
||||
android:viewportWidth="42"
|
||||
android:viewportHeight="42">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h42v42h-42z"/>
|
||||
<path
|
||||
android:pathData="M33.25,22.75H8.75C6.825,22.75 5.25,24.325 5.25,26.25V33.25C5.25,35.175 6.825,36.75 8.75,36.75H33.25C35.175,36.75 36.75,35.175 36.75,33.25V26.25C36.75,24.325 35.175,22.75 33.25,22.75ZM12.25,33.25C10.325,33.25 8.75,31.675 8.75,29.75C8.75,27.825 10.325,26.25 12.25,26.25C14.175,26.25 15.75,27.825 15.75,29.75C15.75,31.675 14.175,33.25 12.25,33.25ZM33.25,5.25H8.75C6.825,5.25 5.25,6.825 5.25,8.75V15.75C5.25,17.675 6.825,19.25 8.75,19.25H33.25C35.175,19.25 36.75,17.675 36.75,15.75V8.75C36.75,6.825 35.175,5.25 33.25,5.25ZM12.25,15.75C10.325,15.75 8.75,14.175 8.75,12.25C8.75,10.325 10.325,8.75 12.25,8.75C14.175,8.75 15.75,10.325 15.75,12.25C15.75,14.175 14.175,15.75 12.25,15.75Z"
|
||||
android:fillColor="#737D8C"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,7 +33,8 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||
val state = presenter.present()
|
||||
LoginRootScreen(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user