Add polish to usernames UX.

This commit is contained in:
Alex Hart
2024-01-25 10:10:21 -04:00
committed by Nicholas Tinsley
parent ec96b4e3aa
commit 38d5d3ad1b
19 changed files with 357 additions and 118 deletions

View File

@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
class AppSettingsFragment : DSLSettingsFragment(
titleId = R.string.text_secure_normal__menu_settings,
@@ -340,6 +341,7 @@ class AppSettingsFragment : DSLSettingsFragment(
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val qrButton: View = itemView.findViewById(R.id.qr_button)
private val usernameView: TextView = itemView.findViewById(R.id.username)
init {
aboutView.setOverflowText(" ")
@@ -352,6 +354,8 @@ class AppSettingsFragment : DSLSettingsFragment(
titleView.text = model.recipient.profileName.toString()
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
usernameView.text = model.recipient.username.orElse("")
usernameView.visible = model.recipient.username.isPresent
avatarView.setRecipient(Recipient.self())
badgeView.setBadgeFromRecipient(Recipient.self())

View File

@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color
enum class UsernameQrCodeColorScheme(
val borderColor: Color,
val foregroundColor: Color,
val backgroundColor: Color,
val textColor: Color = Color.White,
val outlineColor: Color = Color.Transparent,
private val key: String
@@ -15,11 +16,13 @@ enum class UsernameQrCodeColorScheme(
Blue(
borderColor = Color(0xFF506ECD),
foregroundColor = Color(0xFF2449C0),
backgroundColor = Color(0xFFEDF0FA),
key = "blue"
),
White(
borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF000000),
backgroundColor = Color(0xFFF5F5F5),
textColor = Color.Black,
outlineColor = Color(0xFFE9E9E9),
key = "white"
@@ -27,31 +30,37 @@ enum class UsernameQrCodeColorScheme(
Grey(
borderColor = Color(0xFF6A6C74),
foregroundColor = Color(0xFF464852),
backgroundColor = Color(0xFFF0F0F1),
key = "grey"
),
Tan(
borderColor = Color(0xFFBBB29A),
foregroundColor = Color(0xFF73694F),
backgroundColor = Color(0xFFF6F5F2),
key = "tan"
),
Green(
borderColor = Color(0xFF97AA89),
foregroundColor = Color(0xFF55733F),
backgroundColor = Color(0xFFF2F5F0),
key = "green"
),
Orange(
borderColor = Color(0xFFDE7134),
foregroundColor = Color(0xFFDA6C2E),
backgroundColor = Color(0xFFFCF1EB),
key = "orange"
),
Pink(
borderColor = Color(0xFFEA7B9D),
foregroundColor = Color(0xFFBB617B),
backgroundColor = Color(0xFFFCF1F5),
key = "pink"
),
Purple(
borderColor = Color(0xFF9E7BE9),
foregroundColor = Color(0xFF7651C5),
backgroundColor = Color(0xFFF5F3FA),
key = "purple"
);

View File

@@ -23,7 +23,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -56,6 +55,7 @@ import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.CoroutineScope
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Snackbars
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
@@ -89,7 +89,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val navController: NavController by remember { mutableStateOf(findNavController()) }
var showResetDialog: Boolean by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA) {
viewModel.onTabSelected(ActiveTab.Scan)
}
val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent
val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
@@ -101,7 +103,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
snackbarHost = { Snackbars.Host(snackbarHostState) },
topBar = {
TopAppBarContent(
activeTab = state.activeTab,
@@ -123,6 +125,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }),
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth })
) {
val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code)
UsernameLinkShareScreen(
state = state,
snackbarHostState = snackbarHostState,
@@ -130,7 +133,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
modifier = Modifier.padding(contentPadding),
navController = navController,
onShareBadge = {
shareQrBadge(viewModel.generateQrCodeImage())
shareQrBadge(viewModel.generateQrCodeImage(helpText))
},
onResetClicked = { showResetDialog = true },
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }

View File

@@ -10,6 +10,9 @@ import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -17,8 +20,10 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.withSave
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.graphics.withTranslation
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
@@ -122,7 +127,9 @@ class UsernameLinkSettingsViewModel : ViewModel() {
val components: Optional<UsernameLinkComponents> = when (result) {
is UsernameLinkResetResult.Success -> Optional.of(result.components)
is UsernameLinkResetResult.NetworkError -> Optional.empty()
else -> { usernameLink.value ?: Optional.empty() }
else -> {
usernameLink.value ?: Optional.empty()
}
}
_state.value = _state.value.copy(
@@ -200,7 +207,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
*
* I hate this as much as you do.
*/
fun generateQrCodeImage(): Bitmap? {
fun generateQrCodeImage(helpText: String): Bitmap? {
val state: UsernameLinkSettingsState = _state.value
if (state.qrCodeState !is QrCodeState.Present) {
@@ -210,12 +217,16 @@ class UsernameLinkSettingsViewModel : ViewModel() {
val qrCodeData: QrCodeData = state.qrCodeState.data
val width = 480
val height = 525
val qrSize = 300f
val qrPadding = 25f
val borderSizeX = 64f
val borderSizeY = 52f
val width = 424
val height = 576
val backgroundPadHorizontal = 64f
val backgroundPadVertical = 80f
val qrBorderWidth = width - (backgroundPadHorizontal * 2)
val qrBorderHeight = 324f
val qrSize = 184f
val qrPadding = 16f
val borderSizeX = 40f
val borderSizeY = 32f
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.TRANSPARENT)
@@ -225,56 +236,70 @@ class UsernameLinkSettingsViewModel : ViewModel() {
val composeCanvas = Canvas(androidCanvas)
val canvasDrawScope = CanvasDrawScope()
// Draw the background
androidCanvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 30f, 30f, Paint().apply { color = state.qrCodeColorScheme.borderColor.toArgb() })
androidCanvas.drawRoundRect(borderSizeX, borderSizeY, borderSizeX + qrSize + qrPadding * 2, borderSizeY + qrSize + qrPadding * 2, 15f, 15f, Paint().apply { color = Color.WHITE })
androidCanvas.drawRoundRect(
borderSizeX,
borderSizeY,
borderSizeX + qrSize + qrPadding * 2,
borderSizeY + qrSize + qrPadding * 2,
15f,
15f,
Paint().apply {
color = state.qrCodeColorScheme.outlineColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = 4f
}
)
// Background
androidCanvas.drawColor(state.qrCodeColorScheme.backgroundColor.toArgb())
// Draw the QR code
composeCanvas.translate((width / 2) - (qrSize / 2), 80f)
canvasDrawScope.draw(
density = object : Density {
override val density: Float = 1f
override val fontScale: Float = 1f
},
layoutDirection = LayoutDirection.Ltr,
canvas = composeCanvas,
size = Size(qrSize, qrSize)
) {
drawQr(
data = qrCodeData,
foregroundColor = state.qrCodeColorScheme.foregroundColor,
backgroundColor = state.qrCodeColorScheme.borderColor,
deadzonePercent = 0.35f,
logo = null
// QR Border
androidCanvas.withTranslation(x = backgroundPadHorizontal, y = backgroundPadVertical) {
drawRoundRect(0f, 0f, qrBorderWidth, qrBorderHeight, 30f, 30f, Paint().apply { color = state.qrCodeColorScheme.borderColor.toArgb() })
drawRoundRect(borderSizeX, borderSizeY, borderSizeX + qrSize + qrPadding * 2, borderSizeY + qrSize + qrPadding * 2, 15f, 15f, Paint().apply { color = Color.WHITE })
drawRoundRect(
borderSizeX,
borderSizeY,
borderSizeX + qrSize + qrPadding * 2,
borderSizeY + qrSize + qrPadding * 2,
15f,
15f,
Paint().apply {
color = state.qrCodeColorScheme.outlineColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = 4f
}
)
}
composeCanvas.translate(-90f, -80f)
// Draw the signal logo -- unfortunately can't have the normal QR code drawing handle it because it requires a composable ImageBitmap
BitmapFactory.decodeResource(ApplicationDependencies.getApplication().resources, R.drawable.qrcode_logo).also { logoBitmap ->
val tintedPaint = Paint().apply {
colorFilter = PorterDuffColorFilter(state.qrCodeColorScheme.foregroundColor.toArgb(), PorterDuff.Mode.SRC_IN)
// Draw the QR code
composeCanvas.translate((qrBorderWidth / 2) - (qrSize / 2), borderSizeY + qrPadding)
composeCanvas.withSave {
composeCanvas.scale(qrSize / 300f, qrSize / 300f)
canvasDrawScope.draw(
density = object : Density {
override val density: Float = 1f
override val fontScale: Float = 1f
},
layoutDirection = LayoutDirection.Ltr,
canvas = composeCanvas,
size = Size(300f, 300f)
) {
drawQr(
data = qrCodeData,
foregroundColor = state.qrCodeColorScheme.foregroundColor,
backgroundColor = state.qrCodeColorScheme.borderColor,
deadzonePercent = 0.35f,
logo = null
)
}
}
composeCanvas.translate(-90f, -80f)
// Draw the signal logo -- unfortunately can't have the normal QR code drawing handle it because it requires a composable ImageBitmap
BitmapFactory.decodeResource(ApplicationDependencies.getApplication().resources, R.drawable.qrcode_logo).also { logoBitmap ->
val tintedPaint = Paint().apply {
colorFilter = PorterDuffColorFilter(state.qrCodeColorScheme.foregroundColor.toArgb(), PorterDuff.Mode.SRC_IN)
}
val sourceRect = Rect(0, 0, logoBitmap.width, logoBitmap.height)
val destLeft = qrBorderWidth / 2f + qrPadding
val destTop = destLeft - 10f
val destRect = RectF(destLeft, destTop, destLeft + 36f, destTop + 36f)
drawBitmap(logoBitmap, sourceRect, destRect, tintedPaint)
}
val sourceRect = Rect(0, 0, logoBitmap.width, logoBitmap.height)
val destRect = RectF(210f, 200f, 270f, 260f)
androidCanvas.drawBitmap(logoBitmap, sourceRect, destRect, tintedPaint)
}
// Draw the text
val textPaint = Paint().apply {
// Draw the username
val usernamePaint = Paint().apply {
color = state.qrCodeColorScheme.textColor.toArgb()
textSize = 34f
typeface = if (Build.VERSION.SDK_INT < 26) {
@@ -286,10 +311,33 @@ class UsernameLinkSettingsViewModel : ViewModel() {
.build()
}
}
val textBounds = Rect()
textPaint.getTextBounds(state.username, 0, state.username.length, textBounds)
val usernameBounds = Rect()
usernamePaint.getTextBounds(state.username, 0, state.username.length, usernameBounds)
androidCanvas.drawText(state.username, (width / 2f) - (textBounds.width() / 2f), 465f, textPaint)
androidCanvas.drawText(state.username, (width / 2f) - (usernameBounds.width() / 2f), 348f + usernameBounds.height(), usernamePaint)
// Draw the help text
val helpTextPaint = TextPaint().apply {
isAntiAlias = true
color = 0xFF3C3C43.toInt()
textSize = 14f
typeface = if (Build.VERSION.SDK_INT < 26) {
Typeface.DEFAULT
} else {
Typeface.Builder("")
.setFallback("sans-serif")
.setWeight(400)
.build()
}
}
val helpTextHorizontalPad = 72
val maxWidth = width - helpTextHorizontalPad * 2
val helpTextLayout = StaticLayout(helpText, helpTextPaint, maxWidth, Layout.Alignment.ALIGN_CENTER, 1f, 0f, true)
androidCanvas.withTranslation(x = helpTextHorizontalPad.toFloat(), y = 444f) {
helpTextLayout.draw(androidCanvas)
}
return bitmap
}

View File

@@ -93,21 +93,18 @@ fun UsernameLinkShareScreen(
ButtonBar(
onShareClicked = onShareBadge,
onColorClicked = { navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) }
)
LinkRow(
linkState = state.usernameLinkState,
onClick = {
onColorClicked = { navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) },
onLinkClicked = {
navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet())
}
},
linkState = state.usernameLinkState
)
Text(
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 19.dp, start = 43.dp, end = 43.dp),
modifier = Modifier.padding(top = 42.dp, bottom = 19.dp, start = 43.dp, end = 43.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -127,11 +124,22 @@ fun UsernameLinkShareScreen(
}
@Composable
private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
private fun ButtonBar(
linkState: UsernameLinkState,
onLinkClicked: () -> Unit,
onShareClicked: () -> Unit,
onColorClicked: () -> Unit
) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 32.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth()
) {
Buttons.ActionButton(
enabled = linkState is UsernameLinkState.Present,
onClick = onLinkClicked,
iconResId = R.drawable.symbol_link_24,
labelResId = R.string.UsernameLinkSettings_link_button_label
)
Buttons.ActionButton(
onClick = onShareClicked,
iconResId = R.drawable.symbol_share_android_24,

View File

@@ -194,6 +194,8 @@ public class UsernameEditFragment extends LoggingFragment {
case DISCRIMINATOR_HAS_INVALID_CHARACTERS, DISCRIMINATOR_NOT_AVAILABLE -> getString(R.string.UsernameEditFragment__this_username_is_not_available_try_another_number);
case DISCRIMINATOR_TOO_LONG -> getString(R.string.UsernameEditFragment__invalid_username_enter_a_maximum_of_d_digits, UsernameUtil.MAX_DISCRIMINATOR_LENGTH);
case DISCRIMINATOR_TOO_SHORT -> getString(R.string.UsernameEditFragment__invalid_username_enter_a_minimum_of_d_digits, UsernameUtil.MIN_DISCRIMINATOR_LENGTH);
case DISCRIMINATOR_CANNOT_BE_00 -> getString(R.string.UsernameEditFragment__this_number_cant_be_00);
case DISCRIMINATOR_CANNOT_START_WITH_00 -> getString(R.string.UsernameEditFragment__this_number_cant_start_with_00);
};
int colorRes = error != null ? R.color.signal_colorError : R.color.signal_colorPrimary;

View File

@@ -355,7 +355,9 @@ internal class UsernameEditViewModel private constructor(private val mode: Usern
DISCRIMINATOR_NOT_AVAILABLE,
DISCRIMINATOR_TOO_SHORT,
DISCRIMINATOR_TOO_LONG,
DISCRIMINATOR_HAS_INVALID_CHARACTERS
DISCRIMINATOR_HAS_INVALID_CHARACTERS,
DISCRIMINATOR_CANNOT_BE_00,
DISCRIMINATOR_CANNOT_START_WITH_00
}
enum class ButtonState {
@@ -383,6 +385,7 @@ internal class UsernameEditViewModel private constructor(private val mode: Usern
InvalidReason.TOO_LONG -> UsernameStatus.TOO_LONG
InvalidReason.STARTS_WITH_NUMBER -> UsernameStatus.CANNOT_START_WITH_NUMBER
InvalidReason.INVALID_CHARACTERS -> UsernameStatus.INVALID_CHARACTERS
InvalidReason.INVALID_NUMBER, InvalidReason.INVALID_NUMBER_PREFIX -> error("Unexpected reason $invalidReason")
}
}
@@ -391,6 +394,8 @@ internal class UsernameEditViewModel private constructor(private val mode: Usern
InvalidReason.TOO_SHORT -> UsernameStatus.DISCRIMINATOR_TOO_SHORT
InvalidReason.TOO_LONG -> UsernameStatus.DISCRIMINATOR_TOO_LONG
InvalidReason.INVALID_CHARACTERS -> UsernameStatus.DISCRIMINATOR_HAS_INVALID_CHARACTERS
InvalidReason.INVALID_NUMBER -> UsernameStatus.DISCRIMINATOR_CANNOT_BE_00
InvalidReason.INVALID_NUMBER_PREFIX -> UsernameStatus.DISCRIMINATOR_CANNOT_START_WITH_00
else -> UsernameStatus.INVALID_GENERIC
}
}

View File

@@ -149,7 +149,7 @@ public final class SpanUtil {
public static void appendBottomImageSpan(@NonNull SpannableStringBuilder builder, @NonNull Drawable drawable, int width, int height) {
drawable.setBounds(0, 0, ViewUtil.dpToPx(width), ViewUtil.dpToPx(height));
builder.append(" ").append(SpanUtil.buildImageSpan(drawable, DynamicDrawableSpan.ALIGN_BOTTOM));
builder.append(SpanUtil.buildImageSpan(drawable, DynamicDrawableSpan.ALIGN_BOTTOM));
}
public static CharSequence learnMore(@NonNull Context context,

View File

@@ -64,6 +64,12 @@ object UsernameUtil {
value == null -> {
null
}
value == "00" -> {
InvalidReason.INVALID_NUMBER
}
value.startsWith("00") -> {
InvalidReason.INVALID_NUMBER_PREFIX
}
value.length < MIN_DISCRIMINATOR_LENGTH -> {
InvalidReason.TOO_SHORT
}
@@ -83,6 +89,8 @@ object UsernameUtil {
TOO_SHORT,
TOO_LONG,
INVALID_CHARACTERS,
STARTS_WITH_NUMBER
STARTS_WITH_NUMBER,
INVALID_NUMBER,
INVALID_NUMBER_PREFIX
}
}