@@ -46,6 +46,9 @@ import androidx.compose.ui.autofill.ContentType
4646import androidx.compose.ui.draw.clip
4747import androidx.compose.ui.focus.FocusRequester
4848import androidx.compose.ui.focus.focusProperties
49+ import androidx.compose.ui.layout.AlignmentLine
50+ import androidx.compose.ui.layout.FirstBaseline
51+ import androidx.compose.ui.layout.layout
4952import androidx.compose.ui.platform.LocalContext
5053import androidx.compose.ui.platform.LocalLayoutDirection
5154import androidx.compose.ui.platform.LocalResources
@@ -56,8 +59,10 @@ import androidx.compose.ui.semantics.contentType
5659import androidx.compose.ui.semantics.semantics
5760import androidx.compose.ui.text.AnnotatedString
5861import androidx.compose.ui.text.SpanStyle
62+ import androidx.compose.ui.text.font.FontFamily
5963import androidx.compose.ui.text.input.ImeAction
6064import androidx.compose.ui.text.input.KeyboardType
65+ import androidx.compose.ui.text.rememberTextMeasurer
6166import androidx.compose.ui.text.style.TextDecoration
6267import androidx.compose.ui.text.style.TextDirection
6368import androidx.compose.ui.text.style.TextOverflow
@@ -157,6 +162,7 @@ fun Login(
157162 message = resources.getString(R .string.error_occurred)
158163 )
159164 }
165+
160166 is ApiUnreachableInfoDialogResult .Success -> {
161167 when (it.arg.action) {
162168 LoginAction .LOGIN -> vm.login(state.accountNumberInput)
@@ -170,14 +176,19 @@ fun Login(
170176 when (it) {
171177 LoginUiSideEffect .NavigateToWelcome ->
172178 navigator.navigate(WelcomeNavKey , clearBackStack = true )
179+
173180 is LoginUiSideEffect .NavigateToConnect ->
174181 navigator.navigate(ConnectNavKey , clearBackStack = true )
182+
175183 is LoginUiSideEffect .TooManyDevices ->
176184 navigator.navigate(DeviceListNavKey (it.accountNumber))
185+
177186 LoginUiSideEffect .NavigateToOutOfTime ->
178187 navigator.navigate(OutOfTimeNavKey , clearBackStack = true )
188+
179189 LoginUiSideEffect .NavigateToCreateAccountConfirmation ->
180190 navigator.navigate(CreateAccountConfirmationNavKey )
191+
181192 LoginUiSideEffect .GenericError ->
182193 snackbarHostState.showSnackbarImmediately(
183194 message = resources.getString(R .string.error_occurred)
@@ -366,30 +377,21 @@ private fun ColumnScope.LoginInput(
366377 accountNumberVisualTransformation(showPassword, if (showLastChar) 1 else 0 ),
367378 enabled = state.loginState is LoginState .Idle ,
368379 colors = mullvadWhiteTextFieldColors(),
369- textStyle = MaterialTheme .typography.bodyLarge.copy(textDirection = TextDirection .Ltr ),
380+ textStyle =
381+ MaterialTheme .typography.bodyLarge.copy(
382+ textDirection = TextDirection .Ltr ,
383+ fontFamily = FontFamily .Monospace ,
384+ ),
370385 isError = state.loginState.isError(),
371386 )
372387
373388 AnimatedVisibility (
374389 visible = state.lastUsedAccount != null && state.loginState is LoginState .Idle
375390 ) {
376- val token = state.lastUsedAccount?.value.orEmpty()
377- val accountTransformation =
378- remember(showPassword) {
379- accountNumberVisualTransformation(
380- showPassword,
381- showLastX = ACCOUNT_NUMBER_CHUNK_SIZE ,
382- )
383- }
384- val transformedText =
385- remember(token, accountTransformation) {
386- accountTransformation.filter(AnnotatedString (token)).text
387- }
388-
389- // Since content is number we should always do Ltr
390391 CompositionLocalProvider (LocalLayoutDirection provides LayoutDirection .Ltr ) {
391392 AccountDropDownItem (
392- accountNumber = transformedText.toString(),
393+ accountNumber = state.lastUsedAccount?.value.orEmpty(),
394+ showPassword = showPassword,
393395 onClick = {
394396 state.lastUsedAccount?.let {
395397 onAccountNumberChange(it.value)
@@ -416,6 +418,7 @@ private fun LoginIcon(loginState: LoginState, modifier: Modifier = Modifier) {
416418 } else {
417419 // If view is Idle, we display empty box to keep the same size as other states
418420 }
421+
419422 is LoginState .Loading -> MullvadCircularProgressIndicatorLarge ()
420423 LoginState .Success ->
421424 Image (
@@ -436,8 +439,10 @@ private fun LoginState.title(): String =
436439 is LoginUiStateError .LoginError -> R .string.login_fail_title
437440 is LoginUiStateError .CreateAccountError ->
438441 R .string.create_account_fail_title
442+
439443 null -> R .string.log_in
440444 }
445+
441446 is LoginState .Loading -> R .string.logging_in_title
442447 LoginState .Success -> R .string.logged_in_title
443448 }
@@ -472,23 +477,28 @@ private fun LoginState.supportingText(
472477 (loginUiStateError is LoginUiStateError .LoginError .ApiUnreachable ||
473478 loginUiStateError is LoginUiStateError .CreateAccountError .ApiUnreachable )
474479 -> apiUnreachableText(loginUiStateError, onShowApiUnreachableDialog)
480+
475481 is LoginState .Idle -> {
476482 when (loginUiStateError) {
477483 LoginUiStateError .LoginError .InvalidCredentials -> R .string.login_fail_description
478484 is LoginUiStateError .LoginError .InvalidInput -> R .string.login_error_invalid_input
479485 LoginUiStateError .LoginError .NoInternetConnection ,
480486 LoginUiStateError .CreateAccountError .NoInternetConnection ->
481487 R .string.no_internet_connection
488+
482489 LoginUiStateError .LoginError .ApiUnreachable ,
483490 LoginUiStateError .CreateAccountError .ApiUnreachable -> R .string.api_unreachable
491+
484492 LoginUiStateError .LoginError .TooManyAttempts ,
485493 LoginUiStateError .CreateAccountError .TooManyAttempts ->
486494 R .string.login_error_too_many_attempts
495+
487496 is LoginUiStateError .LoginError .Unknown -> R .string.error_occurred
488497 LoginUiStateError .CreateAccountError .Unknown -> R .string.failed_to_create_account
489498 null -> null
490499 }?.toAnnotatedString()
491500 }
501+
492502 is LoginState .Loading .CreatingAccount -> R .string.creating_new_account.toAnnotatedString()
493503 is LoginState .Loading .LoggingIn -> R .string.logging_in_description.toAnnotatedString()
494504 LoginState .Success -> R .string.logged_in_description.toAnnotatedString()
@@ -516,11 +526,21 @@ private fun Int.toAnnotatedString(): AnnotatedString = AnnotatedString(stringRes
516526@Composable
517527private fun AccountDropDownItem (
518528 modifier : Modifier = Modifier ,
529+ showPassword : Boolean ,
519530 accountNumber : String ,
520531 enabled : Boolean ,
521532 onClick : () -> Unit ,
522533 onDeleteClick : () -> Unit ,
523534) {
535+ val accountTransformation =
536+ remember(showPassword) {
537+ accountNumberVisualTransformation(showPassword, showLastX = ACCOUNT_NUMBER_CHUNK_SIZE )
538+ }
539+ val transformedText =
540+ remember(accountNumber, accountTransformation) {
541+ accountTransformation.filter(AnnotatedString (accountNumber)).text
542+ }
543+
524544 Row (
525545 modifier =
526546 modifier
@@ -534,6 +554,19 @@ private fun AccountDropDownItem(
534554 .height(IntrinsicSize .Min ),
535555 verticalAlignment = Alignment .CenterVertically ,
536556 ) {
557+
558+ // Hack, our PASSWORD_UNICODE dot char changes the baseline height, so this workaround
559+ // ensures we always place it at the same baseline
560+ val textStyle = MaterialTheme .typography.bodyLarge.copy(fontFamily = FontFamily .Monospace )
561+ // Measure the digit baseline once to use as a fixed reference
562+ val textMeasurer = rememberTextMeasurer()
563+ val digitBaseline =
564+ remember(textStyle) {
565+ textMeasurer
566+ .measure(text = AnnotatedString (" 0" ), style = textStyle, maxLines = 1 )
567+ .firstBaseline
568+ }
569+
537570 Box (
538571 modifier =
539572 Modifier .clickable(enabled = enabled, onClick = onClick)
@@ -543,9 +576,24 @@ private fun AccountDropDownItem(
543576 contentAlignment = Alignment .CenterStart ,
544577 ) {
545578 Text (
546- text = accountNumber ,
579+ text = transformedText ,
547580 overflow = TextOverflow .Clip ,
548- style = MaterialTheme .typography.bodyLarge,
581+ style = textStyle,
582+ maxLines = 1 ,
583+ // Place text according to baseline so text does not jump as user hide/show password
584+ modifier =
585+ Modifier .layout { measurable, constraints ->
586+ val placeable = measurable.measure(constraints)
587+ val actualBaseline = placeable[FirstBaseline ]
588+ // Shift the text so its baseline aligns with the digit baseline
589+ val yOffset =
590+ if (actualBaseline != AlignmentLine .Unspecified ) {
591+ digitBaseline.toInt() - actualBaseline
592+ } else {
593+ 0
594+ }
595+ layout(placeable.width, placeable.height) { placeable.place(0 , yOffset) }
596+ },
549597 )
550598 }
551599 IconButton (
0 commit comments