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:
parent
ced183dd80
commit
33b88b8026
271 changed files with 1431 additions and 871 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- tapOn: "Change"
|
- tapOn:
|
||||||
|
id: "login-change_server"
|
||||||
- takeScreenshot: build/maestro/200-ChangeServer
|
- takeScreenshot: build/maestro/200-ChangeServer
|
||||||
- tapOn: "Continue"
|
- tapOn: "Continue"
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,14 @@ appId: ${APP_ID}
|
||||||
- takeScreenshot: build/maestro/100-SignIn
|
- takeScreenshot: build/maestro/100-SignIn
|
||||||
- runFlow: changeServer.yaml
|
- runFlow: changeServer.yaml
|
||||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||||
- tapOn: "Username or email"
|
- tapOn:
|
||||||
# ios
|
id: "login-email_username"
|
||||||
# - tapOn:
|
|
||||||
# id: "usernameTextField"
|
|
||||||
# index: 0
|
|
||||||
- inputText: ${USERNAME}
|
- inputText: ${USERNAME}
|
||||||
|
- pressKey: Enter
|
||||||
- tapOn: "Password"
|
- tapOn: "Password"
|
||||||
# iOS
|
- tapOn:
|
||||||
#- tapOn:
|
id: "login-password"
|
||||||
# id: "passwordTextField"
|
|
||||||
# index: 0
|
|
||||||
- inputText: ${PASSWORD}
|
- inputText: ${PASSWORD}
|
||||||
|
- pressKey: Enter
|
||||||
- tapOn: "Continue"
|
- tapOn: "Continue"
|
||||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ package io.element.android.x
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
@ -42,7 +44,7 @@ class MainActivity : NodeComponentActivity() {
|
||||||
setContent {
|
setContent {
|
||||||
ElementTheme {
|
ElementTheme {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||||
RootFlowNode(
|
RootFlowNode(
|
||||||
|
|
|
||||||
|
|
@ -208,11 +208,11 @@ koverMerged {
|
||||||
name = "Global minimum code coverage."
|
name = "Global minimum code coverage."
|
||||||
target = kotlinx.kover.api.VerificationTarget.ALL
|
target = kotlinx.kover.api.VerificationTarget.ALL
|
||||||
bound {
|
bound {
|
||||||
minValue = 50
|
minValue = 55
|
||||||
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
// 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
|
// 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.
|
// minValue to 35 and maxValue to 40.
|
||||||
maxValue = 55
|
maxValue = 60
|
||||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||||
}
|
}
|
||||||
|
|
@ -297,3 +297,15 @@ tasks.register("runQualityChecks") {
|
||||||
}
|
}
|
||||||
dependsOn(":app:knitCheck")
|
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 {
|
sealed interface ChangeServerEvents {
|
||||||
data class SetServer(val server: String) : ChangeServerEvents
|
data class SetServer(val server: String) : ChangeServerEvents
|
||||||
object Submit : ChangeServerEvents
|
object Submit : ChangeServerEvents
|
||||||
|
object ClearError : ChangeServerEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,20 @@
|
||||||
|
|
||||||
package io.element.android.features.login.changeserver
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
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
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
|
|
@ -32,18 +38,25 @@ class ChangeServerNode @AssistedInject constructor(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: ChangeServerPresenter,
|
private val presenter: ChangeServerPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
private fun onSuccess() {
|
private fun onSuccess() {
|
||||||
navigateUp()
|
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
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
val context = LocalContext.current
|
||||||
ChangeServerView(
|
ChangeServerView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onChangeServerSuccess = this::onSuccess,
|
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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.execute
|
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 io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URL
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
|
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
|
||||||
|
|
@ -35,8 +38,9 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ChangeServerState {
|
override fun present(): ChangeServerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val homeserver = rememberSaveable {
|
val homeserver = rememberSaveable {
|
||||||
mutableStateOf(authenticationService.getHomeserverOrDefault())
|
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
}
|
}
|
||||||
val changeServerAction: MutableState<Async<Unit>> = remember {
|
val changeServerAction: MutableState<Async<Unit>> = remember {
|
||||||
mutableStateOf(Async.Uninitialized)
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
|
@ -45,7 +49,10 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
|
||||||
fun handleEvents(event: ChangeServerEvents) {
|
fun handleEvents(event: ChangeServerEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChangeServerEvents.SetServer -> homeserver.value = event.server
|
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 {
|
suspend {
|
||||||
authenticationService.setHomeserver(homeserver)
|
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
|
||||||
|
authenticationService.setHomeserver(domain)
|
||||||
|
homeserverUrl.value = domain
|
||||||
}.execute(changeServerAction)
|
}.execute(changeServerAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,164 +19,240 @@ package io.element.android.features.login.changeserver
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
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.R
|
||||||
import io.element.android.features.login.error.changeServerError
|
|
||||||
import io.element.android.libraries.architecture.Async
|
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.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
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.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
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.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.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.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
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
|
@Composable
|
||||||
fun ChangeServerView(
|
fun ChangeServerView(
|
||||||
state: ChangeServerState,
|
state: ChangeServerState,
|
||||||
|
onLearnMoreClicked: () -> Unit,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServerSuccess: () -> Unit = {},
|
onChangeServerSuccess: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Box(
|
val interactionEnabled by remember(state.changeServerAction) {
|
||||||
modifier = modifier
|
derivedStateOf {
|
||||||
.fillMaxSize()
|
state.changeServerAction !is Async.Loading
|
||||||
.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 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
|
@Preview
|
||||||
|
|
@ -191,5 +267,5 @@ internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProv
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: ChangeServerState) {
|
private fun ContentToPreview(state: ChangeServerState) {
|
||||||
ChangeServerView(state = state)
|
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
package io.element.android.features.login.root
|
package io.element.android.features.login.root
|
||||||
|
|
||||||
sealed interface LoginRootEvents {
|
sealed interface LoginRootEvents {
|
||||||
object RefreshHomeServer : LoginRootEvents
|
|
||||||
data class SetLogin(val login: String) : LoginRootEvents
|
data class SetLogin(val login: String) : LoginRootEvents
|
||||||
data class SetPassword(val password: String) : LoginRootEvents
|
data class SetPassword(val password: String) : LoginRootEvents
|
||||||
object Submit : LoginRootEvents
|
object Submit : LoginRootEvents
|
||||||
|
object ClearError : LoginRootEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,11 @@ class LoginRootNode @AssistedInject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
OnLifecycleEvent { _, event ->
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LoginRootScreen(
|
LoginRootScreen(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onChangeServer = this::onChangeHomeServer,
|
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.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
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.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
||||||
|
|
||||||
|
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoginRootState {
|
override fun present(): LoginRootState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val homeserver = rememberSaveable {
|
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
|
||||||
mutableStateOf(authenticationService.getHomeserverOrDefault())
|
|
||||||
}
|
|
||||||
val loggedInState: MutableState<LoggedInState> = remember {
|
val loggedInState: MutableState<LoggedInState> = remember {
|
||||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||||
}
|
}
|
||||||
|
|
@ -46,19 +51,19 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
|
|
||||||
fun handleEvents(event: LoginRootEvents) {
|
fun handleEvents(event: LoginRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
|
|
||||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||||
copy(login = event.login)
|
copy(login = event.login)
|
||||||
}
|
}
|
||||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||||
copy(password = event.password)
|
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(
|
return LoginRootState(
|
||||||
homeserver = homeserver.value,
|
homeserverDetails = homeserver,
|
||||||
loggedInState = loggedInState.value,
|
loggedInState = loggedInState.value,
|
||||||
formState = formState.value,
|
formState = formState.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
|
|
@ -83,7 +88,4 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
formState.value = updateLambda(formState.value)
|
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
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
import io.element.android.features.login.error.loginError
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
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.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
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.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
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.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.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginRootScreen(
|
fun LoginRootScreen(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: () -> Unit = {},
|
onChangeServer: () -> Unit = {},
|
||||||
onLoginWithSuccess: (UserId) -> Unit = {},
|
onLoginWithSuccess: (SessionId) -> Unit = {},
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val eventSink = state.eventSink
|
val interactionEnabled by remember(state.loggedInState) {
|
||||||
Box(
|
derivedStateOf {
|
||||||
modifier = modifier
|
state.loggedInState != LoggedInState.LoggingIn
|
||||||
.fillMaxSize()
|
}
|
||||||
.systemBarsPadding()
|
}
|
||||||
.imePadding()
|
Scaffold(
|
||||||
) {
|
topBar = {
|
||||||
val scrollState = rememberScrollState()
|
TopAppBar(
|
||||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
title = {},
|
||||||
var passwordFieldState by textFieldState(stateValue = state.formState.password)
|
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) },
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
// Form
|
}
|
||||||
Column(
|
) { padding ->
|
||||||
// modifier = Modifier.weight(1f),
|
Box(
|
||||||
) {
|
modifier = modifier
|
||||||
Box(
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxWidth()
|
.imePadding()
|
||||||
) {
|
.padding(padding)
|
||||||
OutlinedTextField(
|
) {
|
||||||
value = state.homeserver,
|
val scrollState = rememberScrollState()
|
||||||
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"
|
|
||||||
|
|
||||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
Column(
|
||||||
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,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.verticalScroll(state = scrollState)
|
||||||
.testTag(TestTags.loginContinue)
|
.padding(horizontal = 16.dp),
|
||||||
.padding(vertical = 32.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) {
|
when (val loggedInState = state.loggedInState) {
|
||||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (state.loggedInState is LoggedInState.LoggingIn) {
|
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LoginRootScreenLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun ChangeServerSection(
|
||||||
|
interactionEnabled: Boolean,
|
||||||
@Preview
|
homeserver: String,
|
||||||
@Composable
|
onChangeServer: () -> Unit,
|
||||||
internal fun LoginRootScreenDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
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
|
@Composable
|
||||||
private fun ContentToPreview() {
|
internal fun LoginForm(
|
||||||
LoginRootScreen(
|
state: LoginRootState,
|
||||||
state = aLoginRootState().copy(
|
interactionEnabled: Boolean,
|
||||||
homeserver = "matrix.org",
|
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
|
package io.element.android.features.login.root
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
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.SessionId
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
data class LoginRootState(
|
data class LoginRootState(
|
||||||
val homeserver: String,
|
val homeserverDetails: MatrixHomeServerDetails,
|
||||||
val loggedInState: LoggedInState,
|
val loggedInState: LoggedInState,
|
||||||
val formState: LoginFormState,
|
val formState: LoginFormState,
|
||||||
val eventSink: (LoginRootEvents) -> Unit
|
val eventSink: (LoginRootEvents) -> Unit
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,24 @@
|
||||||
|
|
||||||
package io.element.android.features.login.root
|
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(
|
fun aLoginRootState() = LoginRootState(
|
||||||
homeserver = "",
|
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
|
||||||
loggedInState = LoggedInState.NotLoggedIn,
|
loggedInState = LoggedInState.NotLoggedIn,
|
||||||
formState = LoginFormState.Default,
|
formState = LoginFormState.Default,
|
||||||
eventSink = {}
|
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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.Async
|
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
|
||||||
|
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 io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -39,7 +42,23 @@ class ChangeServerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
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()
|
assertThat(initialState.submitEnabled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,4 +97,70 @@ class ChangeServerPresenterTest {
|
||||||
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
|
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 app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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
|
||||||
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_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
|
|
@ -43,7 +42,7 @@ class LoginRootPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
|
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
|
||||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||||
assertThat(initialState.submitEnabled).isFalse()
|
assertThat(initialState.submitEnabled).isFalse()
|
||||||
|
|
@ -115,7 +114,7 @@ class LoginRootPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - refresh server`() = runTest {
|
fun `present - clear error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
|
|
@ -124,11 +123,20 @@ class LoginRootPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
|
|
||||||
authenticationService.givenHomeserver(A_HOMESERVER_2)
|
// Submit will return an error
|
||||||
initialState.eventSink.invoke(LoginRootEvents.RefreshHomeServer)
|
authenticationService.givenLoginError(A_THROWABLE)
|
||||||
val refreshedState = awaitItem()
|
initialState.eventSink(LoginRootEvents.Submit)
|
||||||
assertThat(refreshedState.homeserver).isEqualTo(A_HOMESERVER_2)
|
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 SystemGrey6Light = Color(0xFFF2F2F7)
|
||||||
val SystemGrey6Dark = Color(0xFF1C1C1E)
|
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 Azure = Color(0xFF368BD6)
|
||||||
val Kiwi = Color(0xFF74D12C)
|
val Kiwi = Color(0xFF74D12C)
|
||||||
val Grape = Color(0xFFAC3BA8)
|
val Grape = Color(0xFFAC3BA8)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ import androidx.compose.ui.unit.sp
|
||||||
// TODO Remove
|
// TODO Remove
|
||||||
object ElementTextStyles {
|
object ElementTextStyles {
|
||||||
|
|
||||||
|
val Button = TextStyle(
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
fontStyle = FontStyle.Normal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
object Bold {
|
object Bold {
|
||||||
val largeTitle = TextStyle(
|
val largeTitle = TextStyle(
|
||||||
fontSize = 34.sp,
|
fontSize = 34.sp,
|
||||||
|
|
@ -180,6 +188,14 @@ object ElementTextStyles {
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val formHeader = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontStyle = FontStyle.Normal,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
textAlign = TextAlign.Start
|
||||||
|
)
|
||||||
|
|
||||||
val footnote = TextStyle(
|
val footnote = TextStyle(
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,12 @@
|
||||||
package io.element.android.libraries.designsystem.components.dialogs
|
package io.element.android.libraries.designsystem.components.dialogs
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AlertDialogDefaults
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
|
@ -67,43 +64,24 @@ fun ConfirmationDialog(
|
||||||
Text(content)
|
Text(content)
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Row(
|
if (thirdButtonText != null) {
|
||||||
modifier = Modifier.padding(all = 8.dp),
|
// If there is a 3rd item it should be at the end of the dialog
|
||||||
horizontalArrangement = Arrangement.Center
|
// Having this 3rd action is discouraged, see https://m3.material.io/components/dialogs/guidelines#e13b68f5-e367-4275-ad6f-c552ee8e358f
|
||||||
) {
|
TextButton(onClick = onThirdButtonClicked) {
|
||||||
Column {
|
Text(thirdButtonText)
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
onCancelClicked()
|
|
||||||
}) {
|
|
||||||
Text(cancelText)
|
|
||||||
}
|
|
||||||
if (thirdButtonText != null) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
onThirdButtonClicked()
|
|
||||||
}) {
|
|
||||||
Text(thirdButtonText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TextButton(onClick = onCancelClicked) {
|
||||||
|
Text(cancelText)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(
|
TextButton(
|
||||||
modifier = Modifier.padding(all = 8.dp),
|
onClick = {
|
||||||
horizontalArrangement = Arrangement.Center
|
onSubmitClicked()
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
onSubmitClicked()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(submitText)
|
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
Text(submitText)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
shape = shape,
|
shape = shape,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AlertDialogDefaults
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -55,24 +56,14 @@ fun ErrorDialog(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = {
|
||||||
Text(text = title)
|
Text(title)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(content)
|
Text(content)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(
|
TextButton(onClick = onDismiss) {
|
||||||
modifier = Modifier.padding(all = 8.dp),
|
Text(submitText)
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(submitText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
shape = shape,
|
shape = shape,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.preview
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ElementPreviewLight(
|
fun ElementPreviewLight(
|
||||||
|
|
@ -55,9 +52,8 @@ private fun ElementPreview(
|
||||||
) {
|
) {
|
||||||
ElementTheme(darkTheme = darkTheme) {
|
ElementTheme(darkTheme = darkTheme) {
|
||||||
if (showBackground) {
|
if (showBackground) {
|
||||||
Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
// If we have a proper contentColor applied we need a Surface instead of a Box
|
||||||
content()
|
Surface { content() }
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.Azure
|
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.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.SystemGrey5Dark
|
||||||
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
||||||
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
|
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
|
||||||
|
|
@ -30,6 +34,8 @@ fun elementColorsDark() = ElementColors(
|
||||||
messageFromMeBackground = SystemGrey5Dark,
|
messageFromMeBackground = SystemGrey5Dark,
|
||||||
messageFromOtherBackground = SystemGrey6Dark,
|
messageFromOtherBackground = SystemGrey6Dark,
|
||||||
messageHighlightedBackground = Azure,
|
messageHighlightedBackground = Azure,
|
||||||
|
quaternary = Gray_400,
|
||||||
|
quinary = Gray_450,
|
||||||
isLight = false,
|
isLight = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -48,12 +54,12 @@ val materialColorSchemeDark = darkColorScheme(
|
||||||
// TODO onTertiary = ColorDarkTokens.OnTertiary,
|
// TODO onTertiary = ColorDarkTokens.OnTertiary,
|
||||||
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
|
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
|
||||||
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
|
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
|
||||||
background = Color.Black,
|
background = Black_800,
|
||||||
onBackground = Color.White,
|
onBackground = Color.White,
|
||||||
surface = Color.Black,
|
surface = Black_800,
|
||||||
onSurface = Color.White,
|
onSurface = Color.White,
|
||||||
surfaceVariant = SystemGrey5Dark,
|
surfaceVariant = Black_950,
|
||||||
// TODO onSurfaceVariant = ColorDarkTokens.OnSurfaceVariant,
|
onSurfaceVariant = Color.White,
|
||||||
// TODO surfaceTint = primary,
|
// TODO surfaceTint = primary,
|
||||||
// TODO inverseSurface = ColorDarkTokens.InverseSurface,
|
// TODO inverseSurface = ColorDarkTokens.InverseSurface,
|
||||||
// TODO inverseOnSurface = ColorDarkTokens.InverseOnSurface,
|
// TODO inverseOnSurface = ColorDarkTokens.InverseOnSurface,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.Azure
|
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.SystemGrey5Light
|
||||||
import io.element.android.libraries.designsystem.SystemGrey6Light
|
import io.element.android.libraries.designsystem.SystemGrey6Light
|
||||||
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
|
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
|
||||||
|
|
@ -30,21 +35,23 @@ fun elementColorsLight() = ElementColors(
|
||||||
messageFromMeBackground = SystemGrey5Light,
|
messageFromMeBackground = SystemGrey5Light,
|
||||||
messageFromOtherBackground = SystemGrey6Light,
|
messageFromOtherBackground = SystemGrey6Light,
|
||||||
messageHighlightedBackground = Azure,
|
messageHighlightedBackground = Azure,
|
||||||
|
quaternary = Gray_100,
|
||||||
|
quinary = Gray_50,
|
||||||
isLight = true,
|
isLight = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO Lots of colors are missing
|
// TODO Lots of colors are missing
|
||||||
val materialColorSchemeLight = lightColorScheme(
|
val materialColorSchemeLight = lightColorScheme(
|
||||||
primary = Color.Black,
|
primary = Black_900,
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
// TODO primaryContainer = ColorLightTokens.PrimaryContainer,
|
// TODO primaryContainer = ColorLightTokens.PrimaryContainer,
|
||||||
// TODO onPrimaryContainer = ColorLightTokens.OnPrimaryContainer,
|
// TODO onPrimaryContainer = ColorLightTokens.OnPrimaryContainer,
|
||||||
// TODO inversePrimary = ColorLightTokens.InversePrimary,
|
// TODO inversePrimary = ColorLightTokens.InversePrimary,
|
||||||
secondary = LightGrey,
|
secondary = Gray_200,
|
||||||
// TODO onSecondary = ColorLightTokens.OnSecondary,
|
// TODO onSecondary = ColorLightTokens.OnSecondary,
|
||||||
// TODO secondaryContainer = ColorLightTokens.SecondaryContainer,
|
// TODO secondaryContainer = ColorLightTokens.SecondaryContainer,
|
||||||
// TODO onSecondaryContainer = ColorLightTokens.OnSecondaryContainer,
|
// TODO onSecondaryContainer = ColorLightTokens.OnSecondaryContainer,
|
||||||
tertiary = Color.Black,
|
tertiary = Gray_150,
|
||||||
// TODO onTertiary = ColorLightTokens.OnTertiary,
|
// TODO onTertiary = ColorLightTokens.OnTertiary,
|
||||||
// TODO tertiaryContainer = ColorLightTokens.TertiaryContainer,
|
// TODO tertiaryContainer = ColorLightTokens.TertiaryContainer,
|
||||||
// TODO onTertiaryContainer = ColorLightTokens.OnTertiaryContainer,
|
// TODO onTertiaryContainer = ColorLightTokens.OnTertiaryContainer,
|
||||||
|
|
@ -52,8 +59,8 @@ val materialColorSchemeLight = lightColorScheme(
|
||||||
onBackground = Color.Black,
|
onBackground = Color.Black,
|
||||||
surface = Color.White,
|
surface = Color.White,
|
||||||
onSurface = Color.Black,
|
onSurface = Color.Black,
|
||||||
surfaceVariant = SystemGrey5Light,
|
surfaceVariant = Gray_25,
|
||||||
onSurfaceVariant = Color.Black,
|
onSurfaceVariant = Gray_150,
|
||||||
// TODO surfaceTint = primary,
|
// TODO surfaceTint = primary,
|
||||||
// TODO inverseSurface = ColorLightTokens.InverseSurface,
|
// TODO inverseSurface = ColorLightTokens.InverseSurface,
|
||||||
// TODO inverseOnSurface = ColorLightTokens.InverseOnSurface,
|
// TODO inverseOnSurface = ColorLightTokens.InverseOnSurface,
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,19 @@
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.theme
|
package io.element.android.libraries.designsystem.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Stable
|
||||||
class ElementColors(
|
class ElementColors(
|
||||||
messageFromMeBackground: Color,
|
messageFromMeBackground: Color,
|
||||||
messageFromOtherBackground: Color,
|
messageFromOtherBackground: Color,
|
||||||
messageHighlightedBackground: Color,
|
messageHighlightedBackground: Color,
|
||||||
|
quaternary: Color,
|
||||||
|
quinary: Color,
|
||||||
isLight: Boolean,
|
isLight: Boolean,
|
||||||
) {
|
) {
|
||||||
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
|
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
|
||||||
|
|
@ -34,6 +38,12 @@ class ElementColors(
|
||||||
var messageHighlightedBackground by mutableStateOf(messageHighlightedBackground)
|
var messageHighlightedBackground by mutableStateOf(messageHighlightedBackground)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var quaternary by mutableStateOf(quaternary)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var quinary by mutableStateOf(quinary)
|
||||||
|
private set
|
||||||
|
|
||||||
var isLight by mutableStateOf(isLight)
|
var isLight by mutableStateOf(isLight)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -41,11 +51,15 @@ class ElementColors(
|
||||||
messageFromMeBackground: Color = this.messageFromMeBackground,
|
messageFromMeBackground: Color = this.messageFromMeBackground,
|
||||||
messageFromOtherBackground: Color = this.messageFromOtherBackground,
|
messageFromOtherBackground: Color = this.messageFromOtherBackground,
|
||||||
messageHighlightedBackground: Color = this.messageHighlightedBackground,
|
messageHighlightedBackground: Color = this.messageHighlightedBackground,
|
||||||
|
quaternary: Color = this.quaternary,
|
||||||
|
quinary: Color = this.quinary,
|
||||||
isLight: Boolean = this.isLight,
|
isLight: Boolean = this.isLight,
|
||||||
) = ElementColors(
|
) = ElementColors(
|
||||||
messageFromMeBackground = messageFromMeBackground,
|
messageFromMeBackground = messageFromMeBackground,
|
||||||
messageFromOtherBackground = messageFromOtherBackground,
|
messageFromOtherBackground = messageFromOtherBackground,
|
||||||
messageHighlightedBackground = messageHighlightedBackground,
|
messageHighlightedBackground = messageHighlightedBackground,
|
||||||
|
quaternary = quaternary,
|
||||||
|
quinary = quinary,
|
||||||
isLight = isLight,
|
isLight = isLight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,6 +67,8 @@ class ElementColors(
|
||||||
messageFromMeBackground = other.messageFromMeBackground
|
messageFromMeBackground = other.messageFromMeBackground
|
||||||
messageFromOtherBackground = other.messageFromOtherBackground
|
messageFromOtherBackground = other.messageFromOtherBackground
|
||||||
messageHighlightedBackground = other.messageHighlightedBackground
|
messageHighlightedBackground = other.messageHighlightedBackground
|
||||||
|
quaternary = other.quaternary
|
||||||
|
quinary = other.quinary
|
||||||
isLight = other.isLight
|
isLight = other.isLight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,16 @@ val LocalColors = staticCompositionLocalOf { elementColorsLight() }
|
||||||
fun ElementTheme(
|
fun ElementTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = false, /* true to enable MaterialYou */
|
dynamicColor: Boolean = false, /* true to enable MaterialYou */
|
||||||
lightColors: ElementColors = elementColorsLight(),
|
colors: ElementColors = if (darkTheme) elementColorsDark() else elementColorsLight(),
|
||||||
darkColors: ElementColors = elementColorsDark(),
|
|
||||||
materialLightColors: ColorScheme = materialColorSchemeLight,
|
materialLightColors: ColorScheme = materialColorSchemeLight,
|
||||||
materialDarkColors: ColorScheme = materialColorSchemeDark,
|
materialDarkColors: ColorScheme = materialColorSchemeDark,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val systemUiController = rememberSystemUiController()
|
val systemUiController = rememberSystemUiController()
|
||||||
val useDarkIcons = !darkTheme
|
val useDarkIcons = !darkTheme
|
||||||
val currentColor = remember { if (darkTheme) darkColors else lightColors }
|
val currentColor = remember(darkTheme) {
|
||||||
|
colors.copy()
|
||||||
|
}.apply { updateColorsFrom(colors) }
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -75,9 +76,8 @@ fun ElementTheme(
|
||||||
darkIcons = useDarkIcons
|
darkIcons = useDarkIcons
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val rememberedColors = remember { currentColor.copy() }.apply { updateColorsFrom(currentColor) }
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalColors provides rememberedColors,
|
LocalColors provides currentColor,
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
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.Modifier
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
|
||||||
|
|
@ -37,11 +38,11 @@ fun Button(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
shape: Shape = ButtonDefaults.shape,
|
shape: Shape = ElementButtonDefaults.shape,
|
||||||
colors: ButtonColors = ButtonDefaults.buttonColors(),
|
colors: ButtonColors = ElementButtonDefaults.buttonColors(),
|
||||||
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
|
elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(),
|
||||||
border: BorderStroke? = null,
|
border: BorderStroke? = null,
|
||||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
content: @Composable RowScope.() -> Unit
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ButtonsLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun ButtonsLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,17 @@ import androidx.compose.material3.TextFieldColors
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
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.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.TextStyle
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.allBooleans
|
||||||
import io.element.android.libraries.designsystem.utils.asInt
|
import io.element.android.libraries.designsystem.utils.asInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OutlinedTextField(
|
fun OutlinedTextField(
|
||||||
value: String,
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OutlinedTextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun OutlinedTextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
@Suppress("DSL_SCOPE_VIOLATION")
|
@Suppress("DSL_SCOPE_VIOLATION")
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-library")
|
||||||
|
id("kotlin-parcelize")
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
kotlin("plugin.serialization") version "1.8.10"
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface MatrixAuthenticationService {
|
interface MatrixAuthenticationService {
|
||||||
fun isLoggedIn(): Flow<Boolean>
|
fun isLoggedIn(): Flow<Boolean>
|
||||||
suspend fun getLatestSessionId(): SessionId?
|
suspend fun getLatestSessionId(): SessionId?
|
||||||
suspend fun restoreSession(sessionId: SessionId): MatrixClient?
|
suspend fun restoreSession(sessionId: SessionId): MatrixClient?
|
||||||
fun getHomeserver(): String?
|
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
||||||
fun getHomeserverOrDefault(): String
|
|
||||||
suspend fun setHomeserver(homeserver: String)
|
suspend fun setHomeserver(homeserver: String)
|
||||||
suspend fun login(username: String, password: String): SessionId
|
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 com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.AppScope
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
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.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
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 io.element.android.libraries.sessionstorage.SessionStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.AuthenticationService
|
import org.matrix.rustcomponents.sdk.AuthenticationService
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
|
|
@ -39,6 +43,7 @@ import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
class RustMatrixAuthenticationService @Inject constructor(
|
class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private val baseDirectory: File,
|
private val baseDirectory: File,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
|
|
@ -47,6 +52,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private val authService: AuthenticationService,
|
private val authService: AuthenticationService,
|
||||||
) : MatrixAuthenticationService {
|
) : MatrixAuthenticationService {
|
||||||
|
|
||||||
|
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
|
||||||
override fun isLoggedIn(): Flow<Boolean> {
|
override fun isLoggedIn(): Flow<Boolean> {
|
||||||
return sessionStore.isLoggedIn()
|
return sessionStore.isLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
@ -74,13 +81,15 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getHomeserver(): String? = authService.homeserverDetails()?.url()
|
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> = currentHomeserver
|
||||||
|
|
||||||
override fun getHomeserverOrDefault(): String = getHomeserver() ?: "matrix.org"
|
|
||||||
|
|
||||||
override suspend fun setHomeserver(homeserver: String) {
|
override suspend fun setHomeserver(homeserver: String) {
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
authService.configureHomeserver(homeserver)
|
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
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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 A_REPLY = "OK, I'll be there!"
|
||||||
const val ANOTHER_MESSAGE = "Hello universe!"
|
const val ANOTHER_MESSAGE = "Hello universe!"
|
||||||
|
|
||||||
const val A_HOMESERVER = "matrix.org"
|
const val A_HOMESERVER_URL = "matrix.org"
|
||||||
const val A_HOMESERVER_2 = "matrix-client.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"
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
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_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
class FakeAuthenticationService : MatrixAuthenticationService {
|
class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
private var homeserver: String = A_HOMESERVER
|
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
private var loginError: Throwable? = null
|
private var loginError: Throwable? = null
|
||||||
|
private var changeServerError: Throwable? = null
|
||||||
|
|
||||||
override fun isLoggedIn(): Flow<Boolean> {
|
override fun isLoggedIn(): Flow<Boolean> {
|
||||||
return flowOf(false)
|
return flowOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLatestSessionId(): UserId? {
|
override suspend fun getLatestSessionId(): SessionId? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun restoreSession(userId: UserId): MatrixClient? {
|
override suspend fun restoreSession(sessionId: SessionId): MatrixClient? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getHomeserver(): String? {
|
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> {
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun givenHomeserver(homeserver: String) {
|
|
||||||
this.homeserver = homeserver
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getHomeserverOrDefault(): String {
|
|
||||||
return homeserver
|
return homeserver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenHomeserver(homeserver: MatrixHomeServerDetails) {
|
||||||
|
this.homeserver.value = homeserver
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setHomeserver(homeserver: String) {
|
override suspend fun setHomeserver(homeserver: String) {
|
||||||
|
changeServerError?.let { throw it }
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(username: String, password: String): UserId {
|
override suspend fun login(username: String, password: String): SessionId {
|
||||||
delay(100)
|
delay(100)
|
||||||
loginError?.let { throw it }
|
loginError?.let { throw it }
|
||||||
return A_USER_ID
|
return A_USER_ID
|
||||||
|
|
@ -66,4 +67,8 @@ class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
fun givenLoginError(throwable: Throwable?) {
|
fun givenLoginError(throwable: Throwable?) {
|
||||||
loginError = throwable
|
loginError = throwable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenChangeServerError(throwable: Throwable?) {
|
||||||
|
changeServerError = throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,17 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Add new strings for Element X Android here -->
|
<!-- 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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginRootScreen(
|
LoginRootScreen(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
|
onBackPressed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4842e6ffb2cca53e163a1a91f7d536ff65b832a2cadd8a76dff73aab69d3b695
|
oid sha256:0c48397dc1a7ec94678c9423d3f4734422390679882d838c391913fe544042bc
|
||||||
size 26047
|
size 39873
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c434e7bb1316fdd1668c9d41675ff7fe1acc62dfdc6c64e96e485155a51befc4
|
oid sha256:be550cedf5e99a3c80c50d201e8cf6faaed9161a1e5007eeff11fd9b46d844d8
|
||||||
size 27698
|
size 43345
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2d90fc0189e3acdcadbf8bf5f346dfa72d7023888a37f1cd4dd2a11edfed20ca
|
oid sha256:fde980094076a921eee29d84663b6b5f09cf363c51681eb7702d15e8922d2950
|
||||||
size 27833
|
size 42663
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:11b2bffc585116f73f393908635c086eabf5a7bf3197f18c9110128e79ede540
|
oid sha256:625bcb0c020582e470474463458c4a8335c84e6048bb12c4f57dbed92f257f57
|
||||||
size 30989
|
size 43384
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c434e7bb1316fdd1668c9d41675ff7fe1acc62dfdc6c64e96e485155a51befc4
|
oid sha256:be550cedf5e99a3c80c50d201e8cf6faaed9161a1e5007eeff11fd9b46d844d8
|
||||||
size 27698
|
size 43345
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a7dbf6d3203fb9ee8c9d4d16cd98fea617ebf3ee1adb746e868da9cf589ed902
|
oid sha256:fc9385ba5a8f78b91146404e40328b378038962171da2c00483b91cffbaea74c
|
||||||
size 25342
|
size 36587
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:555b8b55dc2873ab2963ae65ac13e11913794d2e344ec01d53eebe48cbfbcb39
|
oid sha256:e6ecfecebd483538023eca4e3ccaae2c43758c68e93dc75db861eec480386ab1
|
||||||
size 27048
|
size 40491
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:18cde5fc76af83edc321223a51385629d845899efcb645c378c512b70a9976eb
|
oid sha256:8355435407ed01e3807c6ac397891262f6d3286956f8d8c181dc329ca899674e
|
||||||
size 27193
|
size 39292
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:18a7585bcfd2bb6424b88e295ec2a3f6f84c205fa1631f13e01eecb91be4b60a
|
oid sha256:1f4005736ea7e7b5986e7dbbecb6c221b9a518ad45ed9d11abc6b0d5fd22721f
|
||||||
size 30085
|
size 40529
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:555b8b55dc2873ab2963ae65ac13e11913794d2e344ec01d53eebe48cbfbcb39
|
oid sha256:e6ecfecebd483538023eca4e3ccaae2c43758c68e93dc75db861eec480386ab1
|
||||||
size 27048
|
size 40491
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b1e6b10468982283001c25a8d8a36e7b4b2558028dfc52b1b8574f50394f917d
|
|
||||||
size 25016
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5128c12c3186abdab9020891825731fe1207421f86e7142f15fd67b15d872efe
|
||||||
|
size 33314
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7b3c1d84c57ac35d57ddcadeffaa3efe3cfb35d8472cb499b113efeee27494f6
|
||||||
|
size 36118
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3460e8f37e746e0ae7a30c1a055a3b68cc013197c4f2567d19ad51c00f8d6385
|
||||||
|
size 34814
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f86089af6c02e8eb4811ae0092150c2d5e439731ae970e7954a66fca7f98fd11
|
||||||
|
size 34173
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3460e8f37e746e0ae7a30c1a055a3b68cc013197c4f2567d19ad51c00f8d6385
|
||||||
|
size 34814
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3460e8f37e746e0ae7a30c1a055a3b68cc013197c4f2567d19ad51c00f8d6385
|
||||||
|
size 34814
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:40bbb72871c1eb6f1a9bae19ef5cd63d66eb7e2479eb72733bfa88f912e11957
|
|
||||||
size 24353
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:556d19b2b10461d0bd7e78aaa12d13e1d6c65dd61fba1350eaf6bcb570eaadfe
|
||||||
|
size 31118
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d47db02362874b68abf7198b59fc18b6c6190308abe2836129d7cd60eb4cfe29
|
||||||
|
size 33806
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c9abadaf4be40c1079a6348a0cb2b6e29d6ec4deac05c924a5a5f0bfec0203f9
|
||||||
|
size 33003
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:97aac787891269fe3a5bdb46d299cf11ff7d2225a3bc8535d0e02005ccb1b400
|
||||||
|
size 32076
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c9abadaf4be40c1079a6348a0cb2b6e29d6ec4deac05c924a5a5f0bfec0203f9
|
||||||
|
size 33003
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c9abadaf4be40c1079a6348a0cb2b6e29d6ec4deac05c924a5a5f0bfec0203f9
|
||||||
|
size 33003
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:155e39fcadcc8bc039267a8b0dca338a92bcb3b4b23ab55a0cd9f3ef91c729fc
|
oid sha256:ef0c92421e87d1d100a1ad72f7643c99d42167414c5d21ac4a11391078733644
|
||||||
size 8888
|
size 9016
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:48045852c9c677d54db29baa1484eb281aa9678518c4a47ae05e43b4604a77c5
|
oid sha256:3c0411a2f6cdbad8d2115c734d54ad13c1f0c6b24dfc90d33a7781d751a60d54
|
||||||
size 8369
|
size 8622
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4a904cb92b66b66730f5ac1a81b46710668d440e702f5b51320ecf5a29823e47
|
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9
|
||||||
size 4471
|
size 4478
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4a904cb92b66b66730f5ac1a81b46710668d440e702f5b51320ecf5a29823e47
|
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9
|
||||||
size 4471
|
size 4478
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ce15df8f50bf352fa93f5682a23b255ba143c0665d29bc8b7d7ecd0f588011c9
|
oid sha256:49aafbdb693cccd3ab7ca94e61f7a1de437766a29967dd89c4aaf5134b55c004
|
||||||
size 14736
|
size 14860
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:37edc587d96587aab57013d09def27c807d247d78fa90a69cdcb8d7d9a0344ec
|
oid sha256:857fedd347931600aa5613565887384579b565efb86d5eeca6e93c13f5ff442c
|
||||||
size 13676
|
size 13994
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:834da45ed9a3107006daaef96a0cf95c814c845fd4651bd6c18b6f88638d579d
|
oid sha256:a48ccf1ff3570915b0673de7c0367d951b795c833d7896645b37000b8dc09c15
|
||||||
size 9649
|
size 9649
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cb147a7f07bc20bf7ba9052d39121afdd6cd6b0326b893941b8df75c76754206
|
oid sha256:945b360a62877d16ec7c25942158ae47e244df85118ca861482ff3bbf0aef55f
|
||||||
size 8869
|
size 9268
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:80a83189267c837b6c178ff3086d4bdcee809d3a88b6eb08e99c7a8b0261b822
|
oid sha256:5a1c7d44e491508deb7f85bf84afb5fd82dbfe30779a231957fb1117c4a9a555
|
||||||
size 8326
|
size 8374
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3a56b35783886433d788f2ce0c8c39e507c77272ecaeb3648e15f739942ed229
|
oid sha256:dd5d243ba8f4fc26b79f506fb0c1c36ebfccd07d8a49c6f2eac67ab23d3c1053
|
||||||
size 7848
|
size 8157
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7546994a2e6c1b79cf58e71c061805c0385904eb244f39f6dd7d38bb23237380
|
oid sha256:2e7b963daafe38fbf25c66d887c617820f2937e0f5467c04abee1458db722e29
|
||||||
size 264001
|
size 263224
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d82e862a35c6b7ab602cc0d42e658b30915d391d15e8d6b17c7233e161c87fc7
|
oid sha256:713bc925e6075a16b7a95d1083ead3b0230628f75c80cc911e3498544916edfd
|
||||||
size 262144
|
size 262092
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9cbabaea8df5961f1a59ee0517da0133d85a71cc1303d83b243166918c36890f
|
oid sha256:eb7b807522be4a1d634935e9d3ffe2aa75fdcd143ff2c43df0c129133cab21d9
|
||||||
size 335842
|
size 335599
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ef86e92ea3caad3687d66458e3b0832361808692783671db54dd321d61564792
|
oid sha256:1e90887d2e7d3ac6deb63e6877b9d7d173d094d005aa56850f15b0d0f7d8bef1
|
||||||
size 5836
|
size 5846
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:674265aa2728d2f1724abcf600a573307d69d8dd77212eaf269176e56dc82fd5
|
oid sha256:0cabb7173940f98c41aae4788ccbd810811bdcdb98e9f4f3ba9351ea54389580
|
||||||
size 5610
|
size 5682
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:76dc061b4702c0699a66f21283bc888b7d2d78d0afd7c2783898b83ed1efc28d
|
oid sha256:a9842fc45e320050bf95bdc292f8769c25a12f988f4100510b1596603e3b578d
|
||||||
size 10610
|
size 10730
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cd0a90cecd6cb93873c3bc670affef6b076209b684275bf1c1e6ab7191671279
|
oid sha256:84be926e67f25f098b25bc52a9eab1734e66c9e92b4bcddc750f337c9f79cca0
|
||||||
size 9870
|
size 10334
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e71fcdba69104b2d6fd38f3fac57be9ee814e89d5eefea970121195e0100ac07
|
oid sha256:06d2fc04aaf51576601032f41e5d8ff92b604cd03e15045bd42332fcc8db198f
|
||||||
size 6038
|
size 6054
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e71fcdba69104b2d6fd38f3fac57be9ee814e89d5eefea970121195e0100ac07
|
oid sha256:06d2fc04aaf51576601032f41e5d8ff92b604cd03e15045bd42332fcc8db198f
|
||||||
size 6038
|
size 6054
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:23b3887a959548539a24af273c5b5d0adc5f8a462d968c1c42735d29186c9144
|
oid sha256:61e4c27beae19cc5ce1c12f8321eaa4de3b78e3faf0e4adf0700366e49fe1652
|
||||||
size 6354
|
size 6427
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:23b3887a959548539a24af273c5b5d0adc5f8a462d968c1c42735d29186c9144
|
oid sha256:61e4c27beae19cc5ce1c12f8321eaa4de3b78e3faf0e4adf0700366e49fe1652
|
||||||
size 6354
|
size 6427
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fd317f2b833d0a6a30c2439df978e9f5dac47489544e3c450949c6f277b998c3
|
oid sha256:d43a623e695f97507c1bfce7e2034b407afbc790e0ba64b8ef5b016dedf2d21c
|
||||||
size 5636
|
size 5594
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fd317f2b833d0a6a30c2439df978e9f5dac47489544e3c450949c6f277b998c3
|
oid sha256:d43a623e695f97507c1bfce7e2034b407afbc790e0ba64b8ef5b016dedf2d21c
|
||||||
size 5636
|
size 5594
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dbc833eaedfe356238c96ef9b64b5eb4cafc742a7bbc61fdf5669a338343eeae
|
oid sha256:d106fa79f7bd95a730b7f8c8cdd16cb876728bc0cd290a2d7adbf122a2777e20
|
||||||
size 5810
|
size 5936
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dbc833eaedfe356238c96ef9b64b5eb4cafc742a7bbc61fdf5669a338343eeae
|
oid sha256:d106fa79f7bd95a730b7f8c8cdd16cb876728bc0cd290a2d7adbf122a2777e20
|
||||||
size 5810
|
size 5936
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:22619ddf9fe74cbe2dc8ef35c3d836976ce9de0d381f1b417c04c5daa8794a98
|
oid sha256:34e59f52df4d9cc21073fe5e130691754b01080e96b049f8e3d27352d5e67db0
|
||||||
size 6119
|
size 6236
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:22619ddf9fe74cbe2dc8ef35c3d836976ce9de0d381f1b417c04c5daa8794a98
|
oid sha256:34e59f52df4d9cc21073fe5e130691754b01080e96b049f8e3d27352d5e67db0
|
||||||
size 6119
|
size 6236
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6e9edc116b41f826850f80b4c317b6f66dc83891af29a1fe5b4753eea5147f2
|
oid sha256:870982b7b06cba6197a387f5f46e6b8707d52430bc88bc8ece14d90ba62c3a8b
|
||||||
size 5462
|
size 5539
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6e9edc116b41f826850f80b4c317b6f66dc83891af29a1fe5b4753eea5147f2
|
oid sha256:870982b7b06cba6197a387f5f46e6b8707d52430bc88bc8ece14d90ba62c3a8b
|
||||||
size 5462
|
size 5539
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a2a06418de0da9b4f0853cf746d0c1fe168d9a2fc0d639af166f4eeda3dfb1b4
|
oid sha256:e40769a91da1c77db701684425ee74e1b41daa6bbff978d7c46131433a6c9cd9
|
||||||
size 9661
|
size 9811
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4f5184619b88e0f8d631265a800db0265af9be2913d974c65f68ebd415ab9b4f
|
oid sha256:370f14cbc1a98766901a9ebd2731ade03dbdc4d52d7452b6fd4f67973eb542b1
|
||||||
size 9122
|
size 9519
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:51223efd8b6dafa3eec8f8c59cdef68d9cc40998acaa1299622768a4102280c5
|
oid sha256:3fdb4d4ff9808604226ed8d61c4b92baa996b891c1b2b7ded8d170a14da53d07
|
||||||
size 5552
|
size 5572
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:40535a40237d85a5ce26c07f55369651fb255fa354ef29f0b2a0d35d62febdc0
|
oid sha256:df2d45add15b1c8bd2b4dd57c7b2d445ccdef8d374591c6154ce2c32cc85ff3b
|
||||||
size 6388
|
size 6464
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:413dc7a95d136d7c4870f861083490fe374a584d5bb748fbffb52b8885ffd12d
|
oid sha256:5e2b529fe83808704bd2d054bc5b8695ef29534e80724aa9490f77a476746bb2
|
||||||
size 5427
|
size 5378
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a66e9b552fea34a9a70d23b6227ef37ec0659d8e356f2d45b117d3984e3aa72
|
oid sha256:468e18a6db15ab0ba082629fcfa80b4e975c3cd284acdc789bdfa551b7f61065
|
||||||
size 23185
|
size 23434
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b55a38258ef2d7101b07479d5858a175df368df874b67953ade1b2c4f332ec07
|
oid sha256:76623bcd15bba7ba56a377396ae5909fb11be2b823ad9acec3460bf303744b76
|
||||||
size 5189
|
size 5087
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue