Add inline autocomplete for the account provider field

This commit is contained in:
Jenna Vassar
2026-08-06 12:11:18 -07:00
parent c2b3b0fe0f
commit d04a3853b4
6 changed files with 119 additions and 3 deletions
@@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
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.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@AssistedInject
class ConfirmAccountProviderPresenter(
@@ -34,6 +36,7 @@ class ConfirmAccountProviderPresenter(
private val accountProviderDataSource: AccountProviderDataSource,
private val loginModePresenter: Presenter<LoginModeState>,
private val changeServerPresenter: Presenter<ChangeServerState>,
private val appPreferencesStore: AppPreferencesStore,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -49,11 +52,24 @@ class ConfirmAccountProviderPresenter(
val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.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.
var userInput by rememberSaveable { mutableStateOf<String?>(null) }
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.
LaunchedEffect(changeServerState.changeServerAction) {
@@ -61,7 +77,7 @@ class ConfirmAccountProviderPresenter(
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = latestAccountProviderInput.trim(),
homeserverUrl = latestAccountProviderToSubmit.trim(),
resolvedHomeserverUrl = null,
loginHint = null,
)
@@ -77,7 +93,7 @@ class ConfirmAccountProviderPresenter(
// 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.
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderInput.trim()))
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim()))
)
ConfirmAccountProviderEvents.ClearError -> {
loginModeState.eventSink(LoginModeEvent.ClearError)
@@ -88,6 +104,7 @@ class ConfirmAccountProviderPresenter(
return ConfirmAccountProviderState(
accountProviderInput = accountProviderInput,
accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = params.isAccountCreation,
loginModeState = loginModeState,
changeServerState = changeServerState,
@@ -14,6 +14,8 @@ import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
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 loginModeState: LoginModeState,
val changeServerState: ChangeServerState,
@@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
override val values: Sequence<ConfirmAccountProviderState>
get() = sequenceOf(
aConfirmAccountProviderState(),
aConfirmAccountProviderState(
accountProviderInput = "element",
accountProviderSuggestion = "element.io",
),
aConfirmAccountProviderState(
isAccountCreation = true,
),
@@ -32,12 +36,14 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
private fun aConfirmAccountProviderState(
accountProviderInput: String = "matrix.org",
accountProviderSuggestion: String? = null,
isAccountCreation: Boolean = false,
loginModeState: LoginModeState = aLoginModeState(),
changeServerState: ChangeServerState = aChangeServerState(),
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProviderInput = accountProviderInput,
accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = isAccountCreation,
loginModeState = loginModeState,
changeServerState = changeServerState,
@@ -16,15 +16,25 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
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.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.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.login.LoginModeEvent
@@ -89,6 +99,15 @@ fun ConfirmAccountProviderView(
) {
var input by textFieldState(stateValue = state.accountProviderInput)
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(
value = input,
onValueChange = {
@@ -101,6 +120,7 @@ fun ConfirmAccountProviderView(
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer),
label = stringResource(id = CommonStrings.screen_select_server_textfield_header),
placeholder = stringResource(id = CommonStrings.screen_select_server_textfield_placeholder),
supportingText = stringResource(
id = if (state.isAccountCreation) {
CommonStrings.screen_select_server_textfield_footer_register
@@ -108,6 +128,7 @@ fun ConfirmAccountProviderView(
CommonStrings.screen_select_server_textfield_footer_login
}
),
visualTransformation = ghostTransformation,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
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
@Composable
internal fun ConfirmAccountProviderViewPreview(
@@ -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.FakePermissionsPresenter
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.test
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
* account-provider validation states, and returns that state.
@@ -376,9 +420,11 @@ class ConfirmAccountProviderPresenterTest {
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
appPreferencesStore = appPreferencesStore,
loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow,
@@ -13,6 +13,7 @@
<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_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_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>