mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Add inline autocomplete for the account provider field
This commit is contained in:
+20
-3
@@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
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.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -27,6 +28,7 @@ import io.element.android.features.login.impl.login.LoginModeEvent
|
|||||||
import io.element.android.features.login.impl.login.LoginModeState
|
import io.element.android.features.login.impl.login.LoginModeState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class ConfirmAccountProviderPresenter(
|
class ConfirmAccountProviderPresenter(
|
||||||
@@ -34,6 +36,7 @@ class ConfirmAccountProviderPresenter(
|
|||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
private val loginModePresenter: Presenter<LoginModeState>,
|
private val loginModePresenter: Presenter<LoginModeState>,
|
||||||
private val changeServerPresenter: Presenter<ChangeServerState>,
|
private val changeServerPresenter: Presenter<ChangeServerState>,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
) : Presenter<ConfirmAccountProviderState> {
|
) : Presenter<ConfirmAccountProviderState> {
|
||||||
data class Params(
|
data class Params(
|
||||||
val isAccountCreation: Boolean,
|
val isAccountCreation: Boolean,
|
||||||
@@ -49,11 +52,24 @@ class ConfirmAccountProviderPresenter(
|
|||||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||||
val loginModeState = loginModePresenter.present()
|
val loginModeState = loginModePresenter.present()
|
||||||
val changeServerState = changeServerPresenter.present()
|
val changeServerState = changeServerPresenter.present()
|
||||||
|
val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
|
||||||
|
|
||||||
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
|
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
|
||||||
var userInput by rememberSaveable { mutableStateOf<String?>(null) }
|
var userInput by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val accountProviderInput = userInput ?: accountProvider.url
|
val accountProviderInput = userInput ?: accountProvider.url
|
||||||
val latestAccountProviderInput by rememberUpdatedState(accountProviderInput)
|
|
||||||
|
// Offer the most recent previously-used account provider that the current input is a prefix of.
|
||||||
|
val accountProviderSuggestion = remember(accountProviderInput, homeserverHistory) {
|
||||||
|
val input = accountProviderInput.trim()
|
||||||
|
input.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { prefix ->
|
||||||
|
homeserverHistory.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue accepts the suggestion when one is offered, otherwise the raw input.
|
||||||
|
val accountProviderToSubmit = accountProviderSuggestion ?: accountProviderInput
|
||||||
|
val latestAccountProviderToSubmit by rememberUpdatedState(accountProviderToSubmit)
|
||||||
|
|
||||||
// Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
|
// Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
|
||||||
LaunchedEffect(changeServerState.changeServerAction) {
|
LaunchedEffect(changeServerState.changeServerAction) {
|
||||||
@@ -61,7 +77,7 @@ class ConfirmAccountProviderPresenter(
|
|||||||
loginModeState.eventSink(
|
loginModeState.eventSink(
|
||||||
LoginModeEvent.Submit(
|
LoginModeEvent.Submit(
|
||||||
isAccountCreation = params.isAccountCreation,
|
isAccountCreation = params.isAccountCreation,
|
||||||
homeserverUrl = latestAccountProviderInput.trim(),
|
homeserverUrl = latestAccountProviderToSubmit.trim(),
|
||||||
resolvedHomeserverUrl = null,
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
)
|
)
|
||||||
@@ -77,7 +93,7 @@ class ConfirmAccountProviderPresenter(
|
|||||||
// Validate (and persist) the chosen account provider before proceeding. This also enforces the
|
// Validate (and persist) the chosen account provider before proceeding. This also enforces the
|
||||||
// enterprise account-provider access control, which the login submit does not do on its own.
|
// enterprise account-provider access control, which the login submit does not do on its own.
|
||||||
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink(
|
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink(
|
||||||
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderInput.trim()))
|
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim()))
|
||||||
)
|
)
|
||||||
ConfirmAccountProviderEvents.ClearError -> {
|
ConfirmAccountProviderEvents.ClearError -> {
|
||||||
loginModeState.eventSink(LoginModeEvent.ClearError)
|
loginModeState.eventSink(LoginModeEvent.ClearError)
|
||||||
@@ -88,6 +104,7 @@ class ConfirmAccountProviderPresenter(
|
|||||||
|
|
||||||
return ConfirmAccountProviderState(
|
return ConfirmAccountProviderState(
|
||||||
accountProviderInput = accountProviderInput,
|
accountProviderInput = accountProviderInput,
|
||||||
|
accountProviderSuggestion = accountProviderSuggestion,
|
||||||
isAccountCreation = params.isAccountCreation,
|
isAccountCreation = params.isAccountCreation,
|
||||||
loginModeState = loginModeState,
|
loginModeState = loginModeState,
|
||||||
changeServerState = changeServerState,
|
changeServerState = changeServerState,
|
||||||
|
|||||||
+2
@@ -14,6 +14,8 @@ import io.element.android.libraries.architecture.AsyncData
|
|||||||
|
|
||||||
data class ConfirmAccountProviderState(
|
data class ConfirmAccountProviderState(
|
||||||
val accountProviderInput: String,
|
val accountProviderInput: String,
|
||||||
|
// The full account provider from history that the current input is a prefix of, offered as inline autocomplete.
|
||||||
|
val accountProviderSuggestion: String?,
|
||||||
val isAccountCreation: Boolean,
|
val isAccountCreation: Boolean,
|
||||||
val loginModeState: LoginModeState,
|
val loginModeState: LoginModeState,
|
||||||
val changeServerState: ChangeServerState,
|
val changeServerState: ChangeServerState,
|
||||||
|
|||||||
+6
@@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
|
|||||||
override val values: Sequence<ConfirmAccountProviderState>
|
override val values: Sequence<ConfirmAccountProviderState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aConfirmAccountProviderState(),
|
aConfirmAccountProviderState(),
|
||||||
|
aConfirmAccountProviderState(
|
||||||
|
accountProviderInput = "element",
|
||||||
|
accountProviderSuggestion = "element.io",
|
||||||
|
),
|
||||||
aConfirmAccountProviderState(
|
aConfirmAccountProviderState(
|
||||||
isAccountCreation = true,
|
isAccountCreation = true,
|
||||||
),
|
),
|
||||||
@@ -32,12 +36,14 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
|
|||||||
|
|
||||||
private fun aConfirmAccountProviderState(
|
private fun aConfirmAccountProviderState(
|
||||||
accountProviderInput: String = "matrix.org",
|
accountProviderInput: String = "matrix.org",
|
||||||
|
accountProviderSuggestion: String? = null,
|
||||||
isAccountCreation: Boolean = false,
|
isAccountCreation: Boolean = false,
|
||||||
loginModeState: LoginModeState = aLoginModeState(),
|
loginModeState: LoginModeState = aLoginModeState(),
|
||||||
changeServerState: ChangeServerState = aChangeServerState(),
|
changeServerState: ChangeServerState = aChangeServerState(),
|
||||||
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
|
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
|
||||||
) = ConfirmAccountProviderState(
|
) = ConfirmAccountProviderState(
|
||||||
accountProviderInput = accountProviderInput,
|
accountProviderInput = accountProviderInput,
|
||||||
|
accountProviderSuggestion = accountProviderSuggestion,
|
||||||
isAccountCreation = isAccountCreation,
|
isAccountCreation = isAccountCreation,
|
||||||
loginModeState = loginModeState,
|
loginModeState = loginModeState,
|
||||||
changeServerState = changeServerState,
|
changeServerState = changeServerState,
|
||||||
|
|||||||
+44
@@ -16,15 +16,25 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
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.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
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 io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerView
|
import io.element.android.features.login.impl.changeserver.ChangeServerView
|
||||||
import io.element.android.features.login.impl.login.LoginModeEvent
|
import io.element.android.features.login.impl.login.LoginModeEvent
|
||||||
@@ -89,6 +99,15 @@ fun ConfirmAccountProviderView(
|
|||||||
) {
|
) {
|
||||||
var input by textFieldState(stateValue = state.accountProviderInput)
|
var input by textFieldState(stateValue = state.accountProviderInput)
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
// Inline autocomplete: render the un-typed remainder of the suggested account provider in grey.
|
||||||
|
val suggestionSuffix = state.accountProviderSuggestion
|
||||||
|
?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
|
||||||
|
?.substring(input.length)
|
||||||
|
.orEmpty()
|
||||||
|
val ghostColor = ElementTheme.colors.textSecondary
|
||||||
|
val ghostTransformation = remember(suggestionSuffix, ghostColor) {
|
||||||
|
GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
|
||||||
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = input,
|
value = input,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@@ -101,6 +120,7 @@ fun ConfirmAccountProviderView(
|
|||||||
.onTabOrEnterKeyFocusNext(focusManager)
|
.onTabOrEnterKeyFocusNext(focusManager)
|
||||||
.testTag(TestTags.changeServerServer),
|
.testTag(TestTags.changeServerServer),
|
||||||
label = stringResource(id = CommonStrings.screen_select_server_textfield_header),
|
label = stringResource(id = CommonStrings.screen_select_server_textfield_header),
|
||||||
|
placeholder = stringResource(id = CommonStrings.screen_select_server_textfield_placeholder),
|
||||||
supportingText = stringResource(
|
supportingText = stringResource(
|
||||||
id = if (state.isAccountCreation) {
|
id = if (state.isAccountCreation) {
|
||||||
CommonStrings.screen_select_server_textfield_footer_register
|
CommonStrings.screen_select_server_textfield_footer_register
|
||||||
@@ -108,6 +128,7 @@ fun ConfirmAccountProviderView(
|
|||||||
CommonStrings.screen_select_server_textfield_footer_login
|
CommonStrings.screen_select_server_textfield_footer_login
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
visualTransformation = ghostTransformation,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
@@ -167,6 +188,29 @@ fun ConfirmAccountProviderView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint.
|
||||||
|
* The underlying field value stays limited to what the user actually typed, so the cursor can never
|
||||||
|
* enter the suggested region.
|
||||||
|
*/
|
||||||
|
private class GhostSuffixVisualTransformation(
|
||||||
|
private val suffix: String,
|
||||||
|
private val color: Color,
|
||||||
|
) : VisualTransformation {
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
|
if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity)
|
||||||
|
val transformed = buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
withStyle(SpanStyle(color = color)) { append(suffix) }
|
||||||
|
}
|
||||||
|
val offsetMapping = object : OffsetMapping {
|
||||||
|
override fun originalToTransformed(offset: Int) = offset
|
||||||
|
override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length)
|
||||||
|
}
|
||||||
|
return TransformedText(transformed, offsetMapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ConfirmAccountProviderViewPreview(
|
internal fun ConfirmAccountProviderViewPreview(
|
||||||
|
|||||||
+46
@@ -34,6 +34,8 @@ import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
|
|||||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -353,6 +355,48 @@ class ConfirmAccountProviderPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - offers an autocomplete suggestion from the account provider history`() = runTest {
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
appPreferencesStore = InMemoryAppPreferencesStore(
|
||||||
|
homeserverHistory = listOf("https://randomcommunity.org"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.accountProviderSuggestion).isNull()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random"))
|
||||||
|
val suggestionState = awaitState { it.accountProviderInput == "https://random" }
|
||||||
|
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("https://randomcommunity.org")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest {
|
||||||
|
val submittedUrls = mutableListOf<String>()
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setHomeserverResult = { url ->
|
||||||
|
submittedUrls.add(url)
|
||||||
|
Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
appPreferencesStore = InMemoryAppPreferencesStore(
|
||||||
|
homeserverHistory = listOf("https://randomcommunity.org"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random"))
|
||||||
|
val suggestionState = awaitState { it.accountProviderSuggestion != null }
|
||||||
|
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
awaitLoginMode { it is AsyncData.Success }
|
||||||
|
assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Awaits until the emitted state's login mode matches [predicate], skipping the intermediate
|
* Awaits until the emitted state's login mode matches [predicate], skipping the intermediate
|
||||||
* account-provider validation states, and returns that state.
|
* account-provider validation states, and returns that state.
|
||||||
@@ -376,9 +420,11 @@ class ConfirmAccountProviderPresenterTest {
|
|||||||
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
|
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
|
||||||
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
||||||
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
) = ConfirmAccountProviderPresenter(
|
) = ConfirmAccountProviderPresenter(
|
||||||
params = params,
|
params = params,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
loginModePresenter = createLoginModePresenter(
|
loginModePresenter = createLoginModePresenter(
|
||||||
authenticationService = matrixAuthenticationService,
|
authenticationService = matrixAuthenticationService,
|
||||||
oAuthActionFlow = defaultOAuthActionFlow,
|
oAuthActionFlow = defaultOAuthActionFlow,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<string name="screen_select_server_title_login">"Enter your account provider"</string>
|
<string name="screen_select_server_title_login">"Enter your account provider"</string>
|
||||||
<string name="screen_select_server_title_register">"Choose an account provider"</string>
|
<string name="screen_select_server_title_register">"Choose an account provider"</string>
|
||||||
<string name="screen_select_server_textfield_header">"Account provider"</string>
|
<string name="screen_select_server_textfield_header">"Account provider"</string>
|
||||||
|
<string name="screen_select_server_textfield_placeholder">"example.com"</string>
|
||||||
<string name="screen_select_server_textfield_footer_login">"If your account is provided by your company or a community, then change the account provider (example: companyname.com)."</string>
|
<string name="screen_select_server_textfield_footer_login">"If your account is provided by your company or a community, then change the account provider (example: companyname.com)."</string>
|
||||||
<string name="screen_select_server_textfield_footer_register">"Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."</string>
|
<string name="screen_select_server_textfield_footer_register">"Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user