mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +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.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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+44
@@ -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(
|
||||
|
||||
+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.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>
|
||||
|
||||
Reference in New Issue
Block a user