From 4694d9d2fbe622217aa9c2fd5924a043e2978ca1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 11:19:06 -0300 Subject: [PATCH 01/20] fix: align profile create and edit with figma Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bitkit/ui/components/ProfileEditForm.kt | 347 ++++++++++-------- .../java/to/bitkit/ui/components/TextInput.kt | 9 +- .../ui/screens/profile/CreateProfileScreen.kt | 67 +--- .../ui/screens/profile/EditProfileScreen.kt | 6 +- changelog.d/next/1332.fixed.md | 1 + 5 files changed, 222 insertions(+), 208 deletions(-) create mode 100644 changelog.d/next/1332.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index e84f9268d3..6e6e8ed2bb 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -1,8 +1,7 @@ package to.bitkit.ui.components -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -26,18 +26,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardCapitalization +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.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R -import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface @@ -78,106 +88,77 @@ fun ProfileEditForm( val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current - Column( + val hazeState = rememberHazeState() + val density = LocalDensity.current + var footerHeight by remember { mutableStateOf(0.dp) } + + Box( modifier = modifier .fillMaxSize() .imePadding() ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .weight(1f) + .fillMaxSize() + .hazeSource(hazeState) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { - VerticalSpacer(16.dp) - avatarContent() - VerticalSpacer(12.dp) - - TextInput( - value = name, - onValueChange = onNameChange, - placeholder = stringResource(R.string.profile__edit_name_placeholder), - singleLine = true, - textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center), - colors = AppTextFieldDefaults.transparent, - modifier = Modifier - .fillMaxWidth() - .testTag("ProfileEditName") - ) - HorizontalDivider() - VerticalSpacer(12.dp) - - Text13Up( - text = resolvedPublicKeyLabel, - color = Colors.White64, + ProfileEditHeader( + name = name, + onNameChange = onNameChange, + publicKey = publicKey, + publicKeyLabel = resolvedPublicKeyLabel, + avatarContent = avatarContent, + nameTestTag = "ProfileEditName", ) - VerticalSpacer(4.dp) - BodyMSB( - text = publicKey, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) - VerticalSpacer(16.dp) - Text13Up( - text = stringResource(R.string.profile__edit_bio), - color = Colors.White64, + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(8.dp) - TextInput( - value = bio, - onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, - placeholder = resolvedBioPlaceholder, - minLines = 2, - maxLines = 4, - modifier = Modifier - .fillMaxWidth() - .testTag("ProfileEditBio") - ) + ) { + HorizontalDivider() + FieldLabel(text = stringResource(R.string.profile__edit_bio)) { + TextInput( + value = bio, + onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, + placeholder = resolvedBioPlaceholder, + minLines = 2, + maxLines = 4, + modifier = Modifier + .fillMaxWidth() + .testTag("ProfileEditBio") + ) + } + HorizontalDivider() - VerticalSpacer(16.dp) - links.forEachIndexed { index, link -> - HorizontalDivider(color = Colors.White10) - VerticalSpacer(8.dp) - Text13Up( - text = link.label, - color = Colors.White64, - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(8.dp) - TextInput( - value = link.url, - onValueChange = { onLinkUrlChange(index, it) }, - placeholder = stringResource(R.string.profile__add_link_url_placeholder), - singleLine = true, - trailingIcon = { - IconButton( - onClick = { onRemoveLink(index) }, - modifier = Modifier.testTag("ProfileEditLinkRemove_$index") - ) { - Icon( - painter = painterResource(R.drawable.ic_trash), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(16.dp) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = Colors.White10, - shape = AppShapes.small, + links.forEachIndexed { index, link -> + FieldLabel(text = link.label) { + TextInput( + value = link.url, + onValueChange = { onLinkUrlChange(index, it) }, + placeholder = stringResource(R.string.profile__add_link_url_placeholder), + singleLine = true, + trailingIcon = { + IconButton( + onClick = { onRemoveLink(index) }, + modifier = Modifier.testTag("ProfileEditLinkRemove_$index") + ) { + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp) + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .testTag("ProfileEditLink_$index") ) - .testTag("ProfileEditLink_$index") - ) - VerticalSpacer(8.dp) - } - Row(modifier = Modifier.fillMaxWidth()) { + } + } PrimaryButton( text = stringResource(R.string.profile__add_link), onClick = { @@ -196,79 +177,61 @@ fun ProfileEditForm( }, modifier = Modifier.testTag("ProfileEditAddLink") ) - } + HorizontalDivider() - VerticalSpacer(16.dp) - Text13Up( - text = stringResource(R.string.profile__edit_tags), - color = Colors.White64, - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(8.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - tags.forEachIndexed { index, tag -> - TagButton( - text = tag, - onClick = { onRemoveTag(index) }, - displayIconClose = true, + FieldLabel(text = stringResource(R.string.profile__edit_tags)) { + if (tags.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + tags.forEachIndexed { index, tag -> + TagButton( + text = tag, + onClick = { onRemoveTag(index) }, + displayIconClose = true, + ) + } + } + } + PrimaryButton( + text = stringResource(R.string.profile__add_tag), + onClick = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onAddTag() + }, + size = ButtonSize.Small, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("ProfileEditAddTag") ) } - } - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { - PrimaryButton( - text = stringResource(R.string.profile__add_tag), - onClick = { - focusManager.clearFocus(force = true) - keyboardController?.hide() - onAddTag() - }, - size = ButtonSize.Small, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.testTag("ProfileEditAddTag") - ) - } - VerticalSpacer(16.dp) - if (showFooterNote) { - HorizontalDivider(color = Colors.White10) - VerticalSpacer(16.dp) - BodyS( - text = resolvedFooterNote, - color = Colors.White64, - ) - } - - if (onDelete != null) { - Column { - VerticalSpacer(16.dp) + if (showFooterNote) { HorizontalDivider() - VerticalSpacer(16.dp) - Text13Up( - text = stringResource(R.string.profile__edit_delete_section), + BodyS( + text = resolvedFooterNote, color = Colors.White64, modifier = Modifier.fillMaxWidth() ) - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { + } + + if (onDelete != null) { + HorizontalDivider() + FieldLabel(text = stringResource(R.string.profile__edit_delete_section)) { PrimaryButton( text = deleteLabel, onClick = onDelete, size = ButtonSize.Small, fullWidth = false, - color = Colors.White10, - enableGradient = false, contentColor = Colors.Brand, icon = { Icon( @@ -284,18 +247,20 @@ fun ProfileEditForm( } } - VerticalSpacer(32.dp) + VerticalSpacer(footerHeight + 16.dp) } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black), - ) - ) + .onSizeChanged { footerHeight = with(density) { it.height.toDp() } } + .hazeEffect(state = hazeState) { + backgroundColor = Colors.Black + mask = FooterBlurMask + tints = listOf(HazeTint(Colors.Black50)) + } .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp) ) { SecondaryButton( @@ -317,6 +282,78 @@ fun ProfileEditForm( } } +@Composable +fun ProfileEditHeader( + name: String, + onNameChange: (String) -> Unit, + publicKey: String, + nameTestTag: String, + modifier: Modifier = Modifier, + publicKeyLabel: String = stringResource(R.string.profile__your_pubky), + avatarContent: @Composable () -> Unit = {}, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + VerticalSpacer(16.dp) + avatarContent() + VerticalSpacer(16.dp) + TextInput( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.profile__edit_name_placeholder), + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + autoCorrectEnabled = false, + ), + visualTransformation = UppercaseTransformation, + textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center), + colors = AppTextFieldDefaults.transparent, + placeholderColor = Colors.White32, + modifier = Modifier + .fillMaxWidth() + .testTag(nameTestTag) + ) + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(32.dp) + Text13Up( + text = publicKeyLabel, + color = Colors.White64, + ) + VerticalSpacer(8.dp) + BodyMSB( + text = publicKey, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun FieldLabel( + text: String, + content: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text13Up(text = text, color = Colors.White64) + content() + } +} + +private val UppercaseTransformation = VisualTransformation { + TransformedText(AnnotatedString(it.text.map(Char::uppercaseChar).joinToString("")), OffsetMapping.Identity) +} + +private val FooterBlurMask = Brush.verticalGradient(0f to Color.Transparent, 0.4f to Color.Black) + data class ProfileEditLink(val label: String, val url: String) @Preview(showSystemUi = true) diff --git a/app/src/main/java/to/bitkit/ui/components/TextInput.kt b/app/src/main/java/to/bitkit/ui/components/TextInput.kt index 494dd5581b..8aedae27ac 100644 --- a/app/src/main/java/to/bitkit/ui/components/TextInput.kt +++ b/app/src/main/java/to/bitkit/ui/components/TextInput.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf 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.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue @@ -48,6 +49,7 @@ fun TextInput( visualTransformation: VisualTransformation = VisualTransformation.None, textStyle: TextStyle = AppTextStyles.BodySSB, colors: TextFieldColors = AppTextFieldDefaults.semiTransparent, + placeholderColor: Color = Colors.White64, inputTransform: (String) -> String = { it }, ) { var textFieldValue by remember { @@ -72,7 +74,12 @@ fun TextInput( TextField( placeholder = { placeholder?.let { - Text(placeholder, color = Colors.White64, style = textStyle) + Text( + text = placeholder, + color = placeholderColor, + style = textStyle, + modifier = Modifier.fillMaxWidth() + ) } }, isError = isError, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index a1721cb827..ede7cb53e0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -10,13 +10,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,25 +26,20 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import to.bitkit.R import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.Text13Up -import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.ProfileEditHeader import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppTextFieldDefaults -import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -116,47 +109,23 @@ private fun Content( LoadingState(text = stringResource(R.string.profile__deriving_keys)) } else { Column( - horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { - VerticalSpacer(32.dp) - - AvatarPickerButton( - avatarUri = uiState.avatarUri, - onClick = launchPhotoPicker, - modifier = Modifier.testTag("CreateProfileAvatar"), - ) - - VerticalSpacer(32.dp) - - TextInput( - value = uiState.name, - onValueChange = onNameChange, - placeholder = stringResource(R.string.profile__edit_name_placeholder), - singleLine = true, - textStyle = AppTextStyles.Display.copy(textAlign = TextAlign.Center), - colors = AppTextFieldDefaults.transparent, - modifier = Modifier - .fillMaxWidth() - .testTag("CreateProfileUsername"), - ) - - VerticalSpacer(16.dp) - HorizontalDivider() - VerticalSpacer(16.dp) - - Text13Up( - text = stringResource(R.string.profile__your_pubky), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - BodyMSB( - text = uiState.derivedPublicKey ?: "...", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + ProfileEditHeader( + name = uiState.name, + onNameChange = onNameChange, + publicKey = uiState.derivedPublicKey ?: "...", + nameTestTag = "CreateProfileUsername", + avatarContent = { + AvatarPickerButton( + avatarUri = uiState.avatarUri, + onClick = launchPhotoPicker, + modifier = Modifier.testTag("CreateProfileAvatar"), + ) + }, ) FillHeight() @@ -167,7 +136,7 @@ private fun Content( onClick = onSave, enabled = uiState.name.isNotBlank() && !uiState.isSaving, isLoading = uiState.isSaving, - modifier = Modifier.testTag("CreateProfileSave"), + modifier = Modifier.testTag("CreateProfileSave") ) VerticalSpacer(16.dp) } @@ -186,7 +155,7 @@ private fun AvatarPickerButton( modifier = modifier .size(96.dp) .clip(CircleShape) - .background(Colors.Gray5) + .background(Colors.Gray6) .clickable(onClick = onClick), ) { if (avatarUri != null) { @@ -198,10 +167,10 @@ private fun AvatarPickerButton( ) } else { Icon( - painter = painterResource(R.drawable.ic_user_square), + painter = painterResource(R.drawable.ic_image_square), contentDescription = null, tint = Colors.White32, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(32.dp), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt index 0d0c5a5e69..a8148f1f62 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt @@ -224,7 +224,7 @@ private fun AvatarSection( modifier = Modifier .size(96.dp) .clip(CircleShape) - .background(Colors.Gray5) + .background(Colors.Gray6) .testTag("EditProfileAvatar") .clickable(onClick = onClick) ) { @@ -237,10 +237,10 @@ private fun AvatarSection( ) imageUrl != null -> PubkyImage(uri = imageUrl, size = 96.dp) else -> Icon( - painter = painterResource(R.drawable.ic_user_square), + painter = painterResource(R.drawable.ic_image_square), contentDescription = null, tint = Colors.White32, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(32.dp) ) } } diff --git a/changelog.d/next/1332.fixed.md b/changelog.d/next/1332.fixed.md new file mode 100644 index 0000000000..c2bb2be610 --- /dev/null +++ b/changelog.d/next/1332.fixed.md @@ -0,0 +1 @@ +The Create Profile and Edit Profile screens now match the design, and the edit form scrolls under a blurred Cancel and Save bar. From bad792dca4f6d4c8048c2458ba7d431665d761a8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 11:26:40 -0300 Subject: [PATCH 02/20] fix: blur only the cancel button in profile edit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/to/bitkit/ui/components/ProfileEditForm.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index 6e6e8ed2bb..67cddaddca 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -41,8 +42,6 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList @@ -256,16 +255,13 @@ fun ProfileEditForm( .align(Alignment.BottomCenter) .fillMaxWidth() .onSizeChanged { footerHeight = with(density) { it.height.toDp() } } - .hazeEffect(state = hazeState) { - backgroundColor = Colors.Black - mask = FooterBlurMask - tints = listOf(HazeTint(Colors.Black50)) - } + .background(FooterGradient) .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp) ) { SecondaryButton( text = stringResource(R.string.common__cancel), onClick = onCancel, + hazeState = hazeState, modifier = Modifier .weight(1f) .testTag("ProfileEditCancel") @@ -352,7 +348,7 @@ private val UppercaseTransformation = VisualTransformation { TransformedText(AnnotatedString(it.text.map(Char::uppercaseChar).joinToString("")), OffsetMapping.Identity) } -private val FooterBlurMask = Brush.verticalGradient(0f to Color.Transparent, 0.4f to Color.Black) +private val FooterGradient = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) data class ProfileEditLink(val label: String, val url: String) From 03becd1d6217744e605dbdc1b4ff03f14699fd92 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 11:55:29 -0300 Subject: [PATCH 03/20] fix: remove tags section from own profile screen Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/screens/profile/ProfileScreen.kt | 61 -------- .../ui/screens/profile/ProfileViewModel.kt | 65 +------- .../screens/profile/ProfileViewModelTest.kt | 147 +----------------- 3 files changed, 4 insertions(+), 269 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 38e0e65c9e..1ff53ad792 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -4,8 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -23,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -32,7 +29,6 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.ui.components.ActionButton -import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.CenteredProfileHeader @@ -41,8 +37,6 @@ import to.bitkit.ui.components.LinkRow import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.TagButton -import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar @@ -81,10 +75,6 @@ fun ProfileScreen( onDismissSignOutDialog = { viewModel.dismissSignOutDialog() }, onConfirmSignOut = { viewModel.signOut() }, onClickRetry = { viewModel.loadProfile() }, - onClickAddTag = { viewModel.showAddTagSheet() }, - onRemoveTag = { viewModel.removeTag(it) }, - onDismissAddTagSheet = { viewModel.dismissAddTagSheet() }, - onSaveTag = { viewModel.addTag(it) }, ) } @@ -99,10 +89,6 @@ private fun Content( onDismissSignOutDialog: () -> Unit, onConfirmSignOut: () -> Unit, onClickRetry: () -> Unit, - onClickAddTag: () -> Unit, - onRemoveTag: (String) -> Unit, - onDismissAddTagSheet: () -> Unit, - onSaveTag: (String) -> Unit, ) { val currentProfile = uiState.profile @@ -120,8 +106,6 @@ private fun Content( onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickShare = onClickShare, - onClickAddTag = onClickAddTag, - onRemoveTag = onRemoveTag, ) else -> EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) } @@ -136,13 +120,6 @@ private fun Content( onDismiss = onDismissSignOutDialog, ) } - - if (uiState.showAddTagSheet) { - AddTagSheet( - onDismiss = onDismissAddTagSheet, - onSave = onSaveTag, - ) - } } @Composable @@ -151,8 +128,6 @@ private fun ProfileBody( onClickEdit: () -> Unit, onClickCopy: () -> Unit, onClickShare: () -> Unit, - onClickAddTag: () -> Unit, - onRemoveTag: (String) -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -231,38 +206,6 @@ private fun ProfileBody( } } - VerticalSpacer(16.dp) - Text13Up( - text = stringResource(R.string.profile__edit_tags), - color = Colors.White64, - modifier = Modifier - .fillMaxWidth() - .testTag("ProfileViewTagsHeader") - ) - VerticalSpacer(8.dp) - @OptIn(ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - profile.tags.forEach { tag -> - TagButton( - text = tag, - onClick = { onRemoveTag(tag) }, - accessibilityLabel = stringResource(R.string.common__remove_tag, tag), - displayIconClose = true, - ) - } - TagButton( - text = stringResource(R.string.profile__add_tag), - onClick = onClickAddTag, - icon = painterResource(R.drawable.ic_tag), - displayIconClose = true, - modifier = Modifier.testTag("ProfileAddTag") - ) - } - VerticalSpacer(16.dp) } } @@ -330,10 +273,6 @@ private fun Preview() { onDismissSignOutDialog = {}, onConfirmSignOut = {}, onClickRetry = {}, - onClickAddTag = {}, - onRemoveTag = {}, - onDismissAddTagSheet = {}, - onSaveTag = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 280edad106..d0143906dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -15,8 +15,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile @@ -24,7 +22,6 @@ import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -39,14 +36,11 @@ class ProfileViewModel @Inject constructor( private val _showSignOutDialog = MutableStateFlow(false) private val _isSigningOut = MutableStateFlow(false) - private val _showAddTagSheet = MutableStateFlow(false) - private val tagUpdateMutex = Mutex() private val controls = combine( _showSignOutDialog, _isSigningOut, - _showAddTagSheet, - ) { showSignOutDialog, isSigningOut, showAddTagSheet -> - ProfileControls(showSignOutDialog, isSigningOut, showAddTagSheet) + ) { showSignOutDialog, isSigningOut -> + ProfileControls(showSignOutDialog, isSigningOut) } val uiState: StateFlow = combine( @@ -61,7 +55,6 @@ class ProfileViewModel @Inject constructor( isLoading = isLoading, showSignOutDialog = controls.showSignOutDialog, isSigningOut = controls.isSigningOut, - showAddTagSheet = controls.showAddTagSheet, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState()) @@ -84,25 +77,6 @@ class ProfileViewModel @Inject constructor( _showSignOutDialog.update { false } } - fun showAddTagSheet() { - _showAddTagSheet.update { true } - } - - fun dismissAddTagSheet() { - _showAddTagSheet.update { false } - } - - fun addTag(tag: String) { - updateTags( - transform = { (it + tag).distinct() }, - onSuccess = { _showAddTagSheet.update { false } }, - ) - } - - fun removeTag(tag: String) { - updateTags(transform = { tags -> tags.filterNot { it == tag } }) - } - fun signOut() { viewModelScope.launch { _isSigningOut.update { true } @@ -148,39 +122,6 @@ class ProfileViewModel @Inject constructor( ) } } - - private fun updateTags( - transform: (List) -> List, - onSuccess: () -> Unit = {}, - ) { - viewModelScope.launch { - tagUpdateMutex.withLock { - val profile = pubkyRepo.profile.value ?: return@withLock - val tags = transform(profile.tags) - if (tags == profile.tags) { - onSuccess() - return@withLock - } - - pubkyRepo.saveProfile( - name = profile.name, - bio = profile.bio, - links = profile.links, - tags = tags, - imageUrl = profile.imageUrl, - ).onSuccess { - onSuccess() - }.onFailure { - Logger.error("Failed to update profile tags", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__edit_save_error), - description = it.message, - ) - } - } - } - } } @Stable @@ -190,13 +131,11 @@ data class ProfileUiState( val isLoading: Boolean = false, val showSignOutDialog: Boolean = false, val isSigningOut: Boolean = false, - val showAddTagSheet: Boolean = false, ) private data class ProfileControls( val showSignOutDialog: Boolean, val isSigningOut: Boolean, - val showAddTagSheet: Boolean, ) sealed interface ProfileEffect { diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 16fb6886d5..d574049939 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -7,23 +7,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.models.PubkyProfile -import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest : BaseUnitTest() { @@ -77,140 +71,13 @@ class ProfileViewModelTest : BaseUnitTest() { verify(privatePaykitRepo, never()).closeAndClear() } - @Test - fun `addTag saves the updated profile and closes the sheet`() = test { - val sut = createSut(createProfile()) - advanceUntilIdle() - - sut.uiState.test { - var state = awaitItem() - while (state.profile == null) state = awaitItem() - sut.showAddTagSheet() - assertTrue(awaitItem().showAddTagSheet) - - sut.addTag("Bitcoin") - assertFalse(awaitItem().showAddTagSheet) - } - advanceUntilIdle() - verify(pubkyRepo).saveProfile( - name = "Alice", - bio = "Builder", - links = listOf(PubkyProfileLink("Website", "https://example.com")), - tags = listOf("Founder", "Bitcoin"), - imageUrl = "https://example.com/avatar.png", - ) - } - - @Test - fun `adding an existing tag closes the sheet without saving`() = test { - val sut = createSut(createProfile()) - advanceUntilIdle() - - sut.uiState.test { - var state = awaitItem() - while (state.profile == null) state = awaitItem() - sut.showAddTagSheet() - assertTrue(awaitItem().showAddTagSheet) - - sut.addTag("Founder") - assertFalse(awaitItem().showAddTagSheet) - } - verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) - } - - @Test - fun `removeTag saves the profile without the selected tag`() = test { - val sut = createSut(createProfile(tags = listOf("Founder", "Bitcoin"))) - advanceUntilIdle() - - sut.removeTag("Founder") - advanceUntilIdle() - - verify(pubkyRepo).saveProfile( - name = eq("Alice"), - bio = eq("Builder"), - links = any(), - tags = eq(listOf("Bitcoin")), - imageUrl = eq("https://example.com/avatar.png"), - ) - } - - @Test - fun `rapid tag removals are serialized against the latest profile`() = test { - val profileFlow = MutableStateFlow( - createProfile(tags = listOf("Founder", "Bitcoin")), - ) - val sut = createSut(profileFlow = profileFlow) - whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { - val tags = it.getArgument>(3) - profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) - Result.success(Unit) - } - advanceUntilIdle() - - sut.removeTag("Founder") - sut.removeTag("Bitcoin") - advanceUntilIdle() - - inOrder(pubkyRepo).apply { - verify(pubkyRepo).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) - verify(pubkyRepo).saveProfile(any(), any(), any(), eq(emptyList()), any()) - } - assertEquals(emptyList(), profileFlow.value?.tags) - } - - @Test - fun `double tap removal does not remove a neighboring tag`() = test { - val profileFlow = MutableStateFlow( - createProfile(tags = listOf("Founder", "Bitcoin")), - ) - val sut = createSut(profileFlow = profileFlow) - whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { - val tags = it.getArgument>(3) - profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) - Result.success(Unit) - } - advanceUntilIdle() - - sut.removeTag("Founder") - sut.removeTag("Founder") - advanceUntilIdle() - - verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) - assertEquals(listOf("Bitcoin"), profileFlow.value?.tags) - } - - @Test - fun `failed tag save keeps the add sheet open for retry`() = test { - val sut = createSut(createProfile()) - whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())) - .thenReturn(Result.failure(ProfileTestAppError("save failed"))) - advanceUntilIdle() - - sut.uiState.test { - var state = awaitItem() - while (state.profile == null) state = awaitItem() - sut.showAddTagSheet() - assertTrue(awaitItem().showAddTagSheet) - - sut.addTag("Bitcoin") - advanceUntilIdle() - - assertTrue(sut.uiState.value.showAddTagSheet) - } - } - - private fun createSut( - profile: PubkyProfile? = null, - profileFlow: MutableStateFlow = MutableStateFlow(profile), - ): ProfileViewModel { + private fun createSut(): ProfileViewModel { whenever(context.getString(any())).thenReturn("") - whenever(pubkyRepo.profile).thenReturn(profileFlow) + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) whenever { pubkyRepo.signOut() }.thenReturn(Result.success(Unit)) - whenever { pubkyRepo.saveProfile(any(), any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -221,16 +88,6 @@ class ProfileViewModelTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, ) } - - private fun createProfile(tags: List = listOf("Founder")) = PubkyProfile( - publicKey = "pubkyalice", - name = "Alice", - bio = "Builder", - imageUrl = "https://example.com/avatar.png", - links = listOf(PubkyProfileLink("Website", "https://example.com")), - tags = tags, - status = null, - ) } private class ProfileTestAppError(message: String) : AppError(message) From b592ef43dbdc27b086fec63f3015689ef932e29d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 11:57:09 -0300 Subject: [PATCH 04/20] chore: update changelog fragment Co-Authored-By: Claude Opus 5.5 (1M context) --- changelog.d/next/1332.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1332.fixed.md b/changelog.d/next/1332.fixed.md index c2bb2be610..f4e028a5f1 100644 --- a/changelog.d/next/1332.fixed.md +++ b/changelog.d/next/1332.fixed.md @@ -1 +1 @@ -The Create Profile and Edit Profile screens now match the design, and the edit form scrolls under a blurred Cancel and Save bar. +The Create Profile, Edit Profile and Profile screens now match the design, with the edit form scrolling under the Cancel and Save buttons and tags managed from Edit Profile. From e5c91081bcb41f8e76f427a71baf0a64b91f2bc8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 14:31:45 -0300 Subject: [PATCH 05/20] fix: address profile design review divergences Co-Authored-By: Claude Opus 5.5 (1M context) --- .../to/bitkit/ui/components/AddLinkSheet.kt | 18 +++++++-- .../ui/components/CenteredProfileHeader.kt | 2 +- .../bitkit/ui/components/ProfileEditForm.kt | 20 +++++++--- .../ui/screens/profile/CreateProfileScreen.kt | 1 + .../ui/screens/profile/PayContactsScreen.kt | 38 +++++++++++-------- .../ui/screens/profile/ProfileScreen.kt | 6 +-- .../bitkit/ui/screens/wallets/HomeScreen.kt | 14 +++---- 7 files changed, 63 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt index b73a9bffcd..a88dabaa12 100644 --- a/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt @@ -165,17 +165,27 @@ internal fun SuggestionsContent( .sheetHeight(SheetSize.COMPACT, isModal = true) .gradientBackground() .navigationBarsPadding() - .padding(horizontal = 16.dp), ) { - SheetTopBar(titleText = title, onBack = onBack) + SheetTopBar( + titleText = title, + onBack = onBack, + modifier = Modifier.padding(horizontal = 4.dp) + ) VerticalSpacer(16.dp) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) ) { suggestions.forEach { suggestion -> - TagButton(text = suggestion, onClick = { onSelect(suggestion) }) + PrimaryButton( + text = suggestion, + onClick = { onSelect(suggestion) }, + size = ButtonSize.Small, + fullWidth = false, + ) } } VerticalSpacer(24.dp) diff --git a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt index 4b86c8b1df..6094181de5 100644 --- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt +++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt @@ -73,7 +73,7 @@ fun CenteredProfileHeader( ) if (bio.isNotEmpty()) { - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) BodyM( text = bio, color = Colors.White64, diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index 67cddaddca..fcb358ab7a 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -19,12 +19,15 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged @@ -91,14 +94,11 @@ fun ProfileEditForm( val density = LocalDensity.current var footerHeight by remember { mutableStateOf(0.dp) } - Box( - modifier = modifier - .fillMaxSize() - .imePadding() - ) { + Box(modifier = modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() + .imePadding() .hazeSource(hazeState) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) @@ -123,7 +123,7 @@ fun ProfileEditForm( value = bio, onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, placeholder = resolvedBioPlaceholder, - minLines = 2, + minLines = 3, maxLines = 4, modifier = Modifier .fillMaxWidth() @@ -286,8 +286,15 @@ fun ProfileEditHeader( nameTestTag: String, modifier: Modifier = Modifier, publicKeyLabel: String = stringResource(R.string.profile__your_pubky), + autoFocusName: Boolean = false, avatarContent: @Composable () -> Unit = {}, ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(autoFocusName) { + if (autoFocusName) focusRequester.requestFocus() + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier @@ -312,6 +319,7 @@ fun ProfileEditHeader( placeholderColor = Colors.White32, modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .testTag(nameTestTag) ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index ede7cb53e0..bafda4a1e5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -119,6 +119,7 @@ private fun Content( onNameChange = onNameChange, publicKey = uiState.derivedPublicKey ?: "...", nameTestTag = "CreateProfileUsername", + autoFocusName = true, avatarContent = { AvatarPickerButton( avatarUri = uiState.avatarUri, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt index ee6baa0de5..6095a516cf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt @@ -1,12 +1,15 @@ package to.bitkit.ui.screens.profile import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -62,36 +65,41 @@ private fun Content( actions = { DrawerNavIcon() }, ) - Column( - modifier = Modifier.padding(horizontal = 32.dp) + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .fillMaxWidth() + .weight(1f) ) { Image( painter = painterResource(R.drawable.coin_stack), contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .weight(1f) + modifier = Modifier.size(279.dp) ) - + } + VerticalSpacer(16.dp) + Column(modifier = Modifier.padding(horizontal = 32.dp)) { Display( text = stringResource(R.string.profile__pay_contacts_headline) .withAccent(accentColor = Colors.PubkyGreen), color = Colors.White, ) - VerticalSpacer(16.dp) + VerticalSpacer(8.dp) BodyM( text = stringResource(R.string.profile__pay_contacts_description), color = Colors.White64, ) - VerticalSpacer(32.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinue, - enabled = !uiState.isLoading, - modifier = Modifier.testTag("PayContactsContinue") - ) - VerticalSpacer(16.dp) } + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onContinue, + enabled = !uiState.isLoading, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PayContactsContinue") + ) + VerticalSpacer(16.dp) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 1ff53ad792..77006685c6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -147,7 +147,7 @@ private fun ProfileBody( notesTestTag = "ProfileViewNotes", ) - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) Box( contentAlignment = Alignment.Center, @@ -175,7 +175,7 @@ private fun ProfileBody( } } - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), @@ -183,7 +183,7 @@ private fun ProfileBody( ) { ActionButton( onClick = onClickEdit, - iconRes = R.drawable.ic_edit, + iconRes = R.drawable.ic_pencil_simple, modifier = Modifier.testTag("ProfileEdit") ) ActionButton( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 6e6e8f41e3..0873bad062 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -136,6 +136,7 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -1420,6 +1421,7 @@ private fun ProfileButton( displayImageUri: String?, onClick: () -> Unit, ) { + val name = displayName ?: stringResource(R.string.profile__your_name) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -1438,19 +1440,17 @@ private fun ProfileButton( modifier = Modifier .size(32.dp) .clip(CircleShape) - .background(Colors.Gray4) + .background(Colors.Gray6) ) { - Icon( - painter = painterResource(R.drawable.ic_user_square), - contentDescription = null, - tint = Colors.White32, - modifier = Modifier.size(16.dp) + CaptionB( + text = name.firstOrNull()?.uppercase().orEmpty(), + color = Colors.White, ) } } Title( - text = displayName ?: stringResource(R.string.profile__your_name), + text = name, maxLines = 1, ) } From 53ce4258f1ca7342443d721d02984e5b007fec45 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 14:46:06 -0300 Subject: [PATCH 06/20] fix: position pubky choice background as in figma Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/screens/profile/PubkyChoiceScreen.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index 854541911d..4a640a921b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -51,12 +53,13 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" -private const val BG_IMAGE_WIDTH_FRACTION = 0.83f -private const val TAG_OFFSET_X = -0.179f -private const val TAG_OFFSET_Y = 0.13f -private const val KEYRING_OFFSET_X = 0.341f -private const val KEYRING_OFFSET_Y = 0.06f -private const val TAG_ALPHA = 0.6f +/** Figma "Profile Create" geometry as fractions of the 375pt frame width, anchored to the screen bottom. */ +private const val TAG_WIDTH_FRACTION = 0.736f +private const val TAG_OFFSET_X = -0.197f +private const val TAG_OFFSET_Y = 0.067f +private const val KEYRING_WIDTH_FRACTION = 0.83f +private const val KEYRING_OFFSET_X = 0.335f +private const val KEYRING_OFFSET_Y = -0.109f private const val KEYRING_ALPHA = 0.9f @Composable @@ -119,7 +122,7 @@ private fun Content( ) { Box( modifier = Modifier - .screen() + .screen(insets = WindowInsets.statusBars) .clipToBounds() ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { @@ -128,10 +131,9 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) - .align(Alignment.Center) - .offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y) - .alpha(TAG_ALPHA) + .fillMaxWidth(TAG_WIDTH_FRACTION) + .align(Alignment.BottomStart) + .offset(x = maxWidth * TAG_OFFSET_X, y = maxWidth * TAG_OFFSET_Y) ) Image( @@ -139,9 +141,9 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) - .align(Alignment.Center) - .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y) + .fillMaxWidth(KEYRING_WIDTH_FRACTION) + .align(Alignment.BottomStart) + .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxWidth * KEYRING_OFFSET_Y) .alpha(KEYRING_ALPHA) ) } From 6ed6d83196f8f120607ed2ba20ff1e4ac2761006 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 14:46:49 -0300 Subject: [PATCH 07/20] chore: fix detekt spacing Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index 4a640a921b..50216285c2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -53,6 +53,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" + /** Figma "Profile Create" geometry as fractions of the 375pt frame width, anchored to the screen bottom. */ private const val TAG_WIDTH_FRACTION = 0.736f private const val TAG_OFFSET_X = -0.197f From 540232ae3d8b0a2eee3736d075272ec45cf69245 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 07:52:30 -0300 Subject: [PATCH 08/20] fix: focus profile name only when empty Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index bafda4a1e5..df88a63246 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -108,6 +109,7 @@ private fun Content( if (uiState.isLoading) { LoadingState(text = stringResource(R.string.profile__deriving_keys)) } else { + val isNameInitiallyEmpty = remember { uiState.name.isEmpty() } Column( modifier = Modifier .fillMaxSize() @@ -119,7 +121,7 @@ private fun Content( onNameChange = onNameChange, publicKey = uiState.derivedPublicKey ?: "...", nameTestTag = "CreateProfileUsername", - autoFocusName = true, + autoFocusName = isNameInitiallyEmpty, avatarContent = { AvatarPickerButton( avatarUri = uiState.avatarUri, From 2f05ea3e59874412cbe4d1af779090c55119ec6c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:18:48 -0300 Subject: [PATCH 09/20] fix: match profile icons and pubky choice copy to figma Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/screens/profile/ProfileScreen.kt | 4 +-- app/src/main/res/drawable/ic_copy_simple.xml | 22 ++++++++++++++++ app/src/main/res/drawable/ic_pencil.xml | 26 +++++++++++++++++++ app/src/main/res/values/strings.xml | 4 +-- 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/drawable/ic_copy_simple.xml create mode 100644 app/src/main/res/drawable/ic_pencil.xml diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 77006685c6..787f6ee937 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -183,12 +183,12 @@ private fun ProfileBody( ) { ActionButton( onClick = onClickEdit, - iconRes = R.drawable.ic_pencil_simple, + iconRes = R.drawable.ic_pencil, modifier = Modifier.testTag("ProfileEdit") ) ActionButton( onClick = onClickCopy, - iconRes = R.drawable.ic_copy, + iconRes = R.drawable.ic_copy_simple, modifier = Modifier.testTag("ProfileCopy") ) ActionButton( diff --git a/app/src/main/res/drawable/ic_copy_simple.xml b/app/src/main/res/drawable/ic_copy_simple.xml new file mode 100644 index 0000000000..c5781d59f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_simple.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_pencil.xml b/app/src/main/res/drawable/ic_pencil.xml new file mode 100644 index 0000000000..f9f8eb67be --- /dev/null +++ b/app/src/main/res/drawable/ic_pencil.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7af882dd27..4d739129d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -659,10 +659,10 @@ This Bitkit claim is not supported. Failed to read selected image Create profile with Bitkit - Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring. + Create a new pubky and profile in Bitkit. Import with Pubky Ring Loading your profile… - Join the\n<accent>pubky web</accent> + Enter the\n<accent>freedom web</accent> Waiting for Pubky Ring… Failed to create profile Create Profile From 5c2403eca57985e330e3bf84c4eccae1f6074579 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:32:16 -0300 Subject: [PATCH 10/20] fix: size profile qr to figma insets Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 787f6ee937..c92e7a0f48 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -153,11 +153,12 @@ private fun ProfileBody( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() + .padding(horizontal = 32.dp) .clickableAlpha(onClick = onClickCopy) ) { QrCodeImage( content = profile.publicKey, - modifier = Modifier.size(279.dp), + modifier = Modifier.fillMaxWidth(), testTag = "ProfileQRCode", ) if (profile.imageUrl != null) { From 0d351a53c9b6eca935c9ca72043f882b45b74f87 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:36:40 -0300 Subject: [PATCH 11/20] fix: remove links from own profile screen Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/to/bitkit/ui/screens/profile/ProfileScreen.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index c92e7a0f48..2e3b672358 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -33,7 +33,6 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.CenteredProfileHeader import to.bitkit.ui.components.GradientCircularProgressIndicator -import to.bitkit.ui.components.LinkRow import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.components.SecondaryButton @@ -199,14 +198,6 @@ private fun ProfileBody( ) } - VerticalSpacer(32.dp) - - if (profile.links.isNotEmpty()) { - profile.links.forEachIndexed { index, link -> - LinkRow(label = link.label, value = link.url, linkIndex = index) - } - } - VerticalSpacer(16.dp) } } From a0180fb1dc93f18b37ca196a78eccb0159f9807c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:52:40 -0300 Subject: [PATCH 12/20] fix: match contact add tag and action icons to figma Co-Authored-By: Claude Opus 5.5 (1M context) --- .../screens/contacts/ContactDetailScreen.kt | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 0f217ac5d9..71f33aaf09 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.CenteredProfileHeader import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight @@ -327,7 +328,7 @@ private fun ContactBody( ) ActionButton( onClick = onClickCopy, - iconRes = R.drawable.ic_copy, + iconRes = R.drawable.ic_copy_simple, modifier = Modifier.testTag("ContactCopy") ) ActionButton( @@ -344,7 +345,7 @@ private fun ContactBody( } else { ActionButton( onClick = onClickEdit, - iconRes = R.drawable.ic_edit, + iconRes = R.drawable.ic_pencil, modifier = Modifier.testTag("ContactEdit") ) } @@ -367,27 +368,36 @@ private fun ContactBody( .testTag("ContactViewTagsHeader") ) VerticalSpacer(8.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - tags.forEach { tag -> - TagButton( - text = tag, - onClick = { onRemoveTag(tag) }, - accessibilityLabel = stringResource(R.string.common__remove_tag, tag), - displayIconClose = true, - ) + if (tags.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + tags.forEach { tag -> + TagButton( + text = tag, + onClick = { onRemoveTag(tag) }, + accessibilityLabel = stringResource(R.string.common__remove_tag, tag), + displayIconClose = true, + ) + } } + VerticalSpacer(8.dp) } - VerticalSpacer(8.dp) Row(modifier = Modifier.fillMaxWidth()) { - TagButton( + PrimaryButton( text = stringResource(R.string.profile__add_tag), onClick = onAddTag, - icon = painterResource(R.drawable.ic_tag), - displayIconClose = true, + size = ButtonSize.Small, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, modifier = Modifier.testTag("ContactAddTag") ) } From c2f6c83d239112c936c4573842ed9c5cc0e7b521 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:03:30 -0300 Subject: [PATCH 13/20] feat: open valid contact links Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/to/bitkit/ui/components/LinkRow.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index fa2b23f8a5..44c755748a 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -1,13 +1,24 @@ package to.bitkit.ui.components +import android.content.Intent +import android.net.Uri +import android.util.Patterns import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.isValidEmail +import to.bitkit.utils.Logger + +private const val TAG = "LinkRow" @Composable fun LinkRow( @@ -16,6 +27,9 @@ fun LinkRow( linkIndex: Int, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val uri = remember(value) { value.toLinkUri() } + Column(modifier = modifier.fillMaxWidth()) { VerticalSpacer(16.dp) Text13Up( @@ -26,9 +40,26 @@ fun LinkRow( VerticalSpacer(8.dp) BodySSB( text = value, - modifier = Modifier.testTag("ProfileLinkValue_$linkIndex"), + modifier = Modifier + .clickableAlpha( + onClick = uri?.let { linkUri -> + { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, linkUri)) } + .onFailure { Logger.warn("Failed to open link '$linkUri'", it, context = TAG) } + } + } + ) + .testTag("ProfileLinkValue_$linkIndex"), ) VerticalSpacer(16.dp) HorizontalDivider() } } + +private fun String.toLinkUri(): Uri? { + val trimmed = trim() + if (trimmed.isValidEmail()) return "mailto:$trimmed".toUri() + if (!Patterns.WEB_URL.matcher(trimmed).matches()) return null + val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" + return withScheme.toUri().takeIf { it.scheme == "http" || it.scheme == "https" } +} From 9fa83f6cedfa249ea4a01ebe7c28f15eba23c5ca Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:26:48 -0300 Subject: [PATCH 14/20] fix: accept mixed-case link schemes Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/java/to/bitkit/ui/components/LinkRow.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index 44c755748a..3277ea5217 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -61,5 +61,7 @@ private fun String.toLinkUri(): Uri? { if (trimmed.isValidEmail()) return "mailto:$trimmed".toUri() if (!Patterns.WEB_URL.matcher(trimmed).matches()) return null val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" - return withScheme.toUri().takeIf { it.scheme == "http" || it.scheme == "https" } + return withScheme.toUri().takeIf { + it.scheme.equals("http", ignoreCase = true) || it.scheme.equals("https", ignoreCase = true) + } } From 1742e66ed9677b159f0e3660f4ae5264f1efffe8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:29:03 -0300 Subject: [PATCH 15/20] fix: open phone and explicit mailto/tel contact links Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/java/to/bitkit/ui/components/LinkRow.kt | 11 +++++++++++ .../to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index 3277ea5217..a59a8c7bc9 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -56,9 +56,20 @@ fun LinkRow( } } +private val PHONE_REGEX = Regex("""^\+?[0-9 ()-]{7,}$""") +private const val MIN_PHONE_DIGITS = 7 + private fun String.toLinkUri(): Uri? { val trimmed = trim() + if (trimmed.isEmpty()) return null if (trimmed.isValidEmail()) return "mailto:$trimmed".toUri() + if (trimmed.matches(PHONE_REGEX) && trimmed.count(Char::isDigit) >= MIN_PHONE_DIGITS) { + return "tel:${trimmed.filter { it.isDigit() || it == '+' }}".toUri() + } + if (trimmed.contains(' ')) return null + + val scheme = trimmed.toUri().scheme?.lowercase() + if (scheme == "mailto" || scheme == "tel") return trimmed.toUri() if (!Patterns.WEB_URL.matcher(trimmed).matches()) return null val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" return withScheme.toUri().takeIf { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index 50216285c2..04c84e6a2c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -54,7 +54,6 @@ import to.bitkit.ui.utils.withAccent private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" -/** Figma "Profile Create" geometry as fractions of the 375pt frame width, anchored to the screen bottom. */ private const val TAG_WIDTH_FRACTION = 0.736f private const val TAG_OFFSET_X = -0.197f private const val TAG_OFFSET_Y = 0.067f From 5ea5e21014b24df20a42e51cba71c5b8757e6a9d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:30:32 -0300 Subject: [PATCH 16/20] refactor: simplify contact link parsing Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/to/bitkit/ui/components/LinkRow.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index a59a8c7bc9..c9f157e181 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -61,17 +61,22 @@ private const val MIN_PHONE_DIGITS = 7 private fun String.toLinkUri(): Uri? { val trimmed = trim() - if (trimmed.isEmpty()) return null - if (trimmed.isValidEmail()) return "mailto:$trimmed".toUri() - if (trimmed.matches(PHONE_REGEX) && trimmed.count(Char::isDigit) >= MIN_PHONE_DIGITS) { - return "tel:${trimmed.filter { it.isDigit() || it == '+' }}".toUri() + val scheme = trimmed.toUri().scheme?.lowercase() + return when { + trimmed.isEmpty() -> null + trimmed.isValidEmail() -> "mailto:$trimmed".toUri() + trimmed.isPhoneNumber() -> "tel:${trimmed.filter { it.isDigit() || it == '+' }}".toUri() + trimmed.contains(' ') -> null + scheme == "mailto" || scheme == "tel" -> trimmed.toUri() + Patterns.WEB_URL.matcher(trimmed).matches() -> trimmed.toWebUri() + else -> null } - if (trimmed.contains(' ')) return null +} - val scheme = trimmed.toUri().scheme?.lowercase() - if (scheme == "mailto" || scheme == "tel") return trimmed.toUri() - if (!Patterns.WEB_URL.matcher(trimmed).matches()) return null - val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed" +private fun String.isPhoneNumber() = matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS + +private fun String.toWebUri(): Uri? { + val withScheme = if (contains("://")) this else "https://$this" return withScheme.toUri().takeIf { it.scheme.equals("http", ignoreCase = true) || it.scheme.equals("https", ignoreCase = true) } From 7d7b2e912b3f467078e4b6ed51d2ee3d77a9508e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:44:21 -0300 Subject: [PATCH 17/20] fix: open formatted tel links and dotted phone numbers Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/java/to/bitkit/ui/components/LinkRow.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index c9f157e181..a718ac7fe8 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -56,7 +56,7 @@ fun LinkRow( } } -private val PHONE_REGEX = Regex("""^\+?[0-9 ()-]{7,}$""") +private val PHONE_REGEX = Regex("""^\+?[0-9 ().-]{7,}$""") private const val MIN_PHONE_DIGITS = 7 private fun String.toLinkUri(): Uri? { @@ -65,9 +65,10 @@ private fun String.toLinkUri(): Uri? { return when { trimmed.isEmpty() -> null trimmed.isValidEmail() -> "mailto:$trimmed".toUri() - trimmed.isPhoneNumber() -> "tel:${trimmed.filter { it.isDigit() || it == '+' }}".toUri() + trimmed.isPhoneNumber() -> trimmed.toTelUri() + scheme == "tel" -> trimmed.substringAfter(':').trim().takeIf { it.isPhoneNumber() }?.toTelUri() trimmed.contains(' ') -> null - scheme == "mailto" || scheme == "tel" -> trimmed.toUri() + scheme == "mailto" -> trimmed.toUri() Patterns.WEB_URL.matcher(trimmed).matches() -> trimmed.toWebUri() else -> null } @@ -75,6 +76,8 @@ private fun String.toLinkUri(): Uri? { private fun String.isPhoneNumber() = matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS +private fun String.toTelUri() = "tel:${filter { it.isDigit() || it == '+' }}".toUri() + private fun String.toWebUri(): Uri? { val withScheme = if (contains("://")) this else "https://$this" return withScheme.toUri().takeIf { From 942de3e133cbafe486c5735093fdb1e789db5ac7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 10:15:45 -0300 Subject: [PATCH 18/20] fix: keep ip addresses out of phone link detection Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/java/to/bitkit/ui/components/LinkRow.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index a718ac7fe8..858dac0be4 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -74,7 +74,8 @@ private fun String.toLinkUri(): Uri? { } } -private fun String.isPhoneNumber() = matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS +private fun String.isPhoneNumber() = + matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS && !Patterns.IP_ADDRESS.matcher(this).matches() private fun String.toTelUri() = "tel:${filter { it.isDigit() || it == '+' }}".toUri() From aea0987400683d96be29fbca275298a193a3ebec Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 13:48:08 -0300 Subject: [PATCH 19/20] fix: restore profile links and tags, add copy popup and transparent header Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bitkit/ui/components/ProfileEditForm.kt | 10 + .../ui/screens/contacts/EditContactScreen.kt | 16 +- .../ui/screens/profile/EditProfileScreen.kt | 7 +- .../ui/screens/profile/ProfileScreen.kt | 175 +++++++++++++++++- .../ui/screens/profile/ProfileViewModel.kt | 89 ++++++++- app/src/main/res/values/strings.xml | 1 + .../screens/profile/ProfileViewModelTest.kt | 147 ++++++++++++++- 7 files changed, 426 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index fcb358ab7a..1be5d64513 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -54,6 +54,7 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.TopBarGradient private const val BIO_MAX_LENGTH = 160 @@ -75,6 +76,7 @@ fun ProfileEditForm( onSave: () -> Unit, onCancel: () -> Unit, isSaveEnabled: Boolean, + topBar: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, avatarContent: @Composable () -> Unit = {}, publicKeyLabel: String? = null, @@ -103,6 +105,7 @@ fun ProfileEditForm( .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { + TopBarSpacer() ProfileEditHeader( name = name, onNameChange = onNameChange, @@ -249,6 +252,12 @@ fun ProfileEditForm( VerticalSpacer(footerHeight + 16.dp) } + topBar( + Modifier + .align(Alignment.TopCenter) + .background(TopBarGradient) + ) + Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier @@ -382,6 +391,7 @@ private fun Preview() { onSave = {}, onCancel = {}, isSaveEnabled = true, + topBar = {}, onDelete = {}, deleteLabel = "Delete Profile", ) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt index 70820c97d2..f4ec34d266 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt @@ -97,16 +97,25 @@ private fun Content( onDismissAddTagSheet: () -> Unit, onSaveTag: (String) -> Unit, ) { - ScreenColumn { + val topBar = @Composable { modifier: Modifier -> AppTopBar( titleText = stringResource(R.string.contacts__edit_contact_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = modifier, ) + } + ScreenColumn { when { - uiState.isLoading -> LoadingState() - uiState.isMissing -> EmptyState(onRetryClick = onRetryClick) + uiState.isLoading -> { + topBar(Modifier) + LoadingState() + } + uiState.isMissing -> { + topBar(Modifier) + EmptyState(onRetryClick = onRetryClick) + } else -> ProfileEditForm( name = uiState.name, onNameChange = onNameChange, @@ -123,6 +132,7 @@ private fun Content( onSave = onSave, onCancel = onBackClick, isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving, + topBar = topBar, avatarContent = { ContactEditAvatar(imageUrl = uiState.imageUrl) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt index a8148f1f62..1f20eecbb4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt @@ -128,14 +128,18 @@ private fun Content( } } - ScreenColumn { + val topBar = @Composable { modifier: Modifier -> AppTopBar( titleText = stringResource(R.string.profile__edit_nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = modifier, ) + } + ScreenColumn { if (uiState.isLoading) { + topBar(Modifier) Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() @@ -159,6 +163,7 @@ private fun Content( onSave = onSave, onCancel = onBackClick, isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving, + topBar = topBar, avatarContent = { AvatarSection( imageUrl = uiState.imageUrl, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 2e3b672358..c30ee8967c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -1,9 +1,16 @@ package to.bitkit.ui.screens.profile +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -12,16 +19,26 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,13 +46,21 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.ui.components.ActionButton +import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.CenteredProfileHeader import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.LinkRow +import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar @@ -43,9 +68,11 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.rememberDebouncedClick +import to.bitkit.ui.shared.util.screen import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.TopBarGradient @Composable fun ProfileScreen( @@ -74,6 +101,11 @@ fun ProfileScreen( onDismissSignOutDialog = { viewModel.dismissSignOutDialog() }, onConfirmSignOut = { viewModel.signOut() }, onClickRetry = { viewModel.loadProfile() }, + onClickAddTag = { viewModel.showAddTagSheet() }, + onRemoveTag = { viewModel.removeTag(it) }, + onDismissAddTagSheet = { viewModel.dismissAddTagSheet() }, + onSaveTag = { viewModel.addTag(it) }, + onDismissCopiedPopup = { viewModel.dismissCopiedPopup() }, ) } @@ -88,25 +120,47 @@ private fun Content( onDismissSignOutDialog: () -> Unit, onConfirmSignOut: () -> Unit, onClickRetry: () -> Unit, + onClickAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onDismissAddTagSheet: () -> Unit, + onSaveTag: (String) -> Unit, + onDismissCopiedPopup: () -> Unit, ) { val currentProfile = uiState.profile - - ScreenColumn { + val topBar = @Composable { modifier: Modifier -> AppTopBar( titleText = stringResource(R.string.profile__nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = modifier, ) + } - when { - uiState.isLoading && currentProfile == null -> LoadingState() - currentProfile != null -> ProfileBody( + if (currentProfile == null) { + ScreenColumn { + topBar(Modifier) + if (uiState.isLoading) { + LoadingState() + } else { + EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) + } + } + } else { + Box(modifier = Modifier.screen()) { + ProfileBody( profile = currentProfile, onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickShare = onClickShare, + onClickAddTag = onClickAddTag, + onRemoveTag = onRemoveTag, + ) + topBar(Modifier.background(TopBarGradient)) + CopiedPopup( + publicKey = uiState.copiedPublicKey, + onClick = onDismissCopiedPopup, + modifier = Modifier.align(Alignment.Center) ) - else -> EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) } } @@ -119,6 +173,13 @@ private fun Content( onDismiss = onDismissSignOutDialog, ) } + + if (uiState.showAddTagSheet) { + AddTagSheet( + onDismiss = onDismissAddTagSheet, + onSave = onSaveTag, + ) + } } @Composable @@ -127,6 +188,8 @@ private fun ProfileBody( onClickEdit: () -> Unit, onClickCopy: () -> Unit, onClickShare: () -> Unit, + onClickAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -135,6 +198,7 @@ private fun ProfileBody( .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { + TopBarSpacer() VerticalSpacer(24.dp) CenteredProfileHeader( @@ -198,10 +262,104 @@ private fun ProfileBody( ) } + VerticalSpacer(32.dp) + HorizontalDivider() + + profile.links.forEachIndexed { index, link -> + LinkRow(label = link.label, value = link.url, linkIndex = index) + } + + VerticalSpacer(16.dp) + Text13Up( + text = stringResource(R.string.profile__edit_tags), + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .testTag("ProfileViewTagsHeader") + ) + VerticalSpacer(8.dp) + if (profile.tags.isNotEmpty()) { + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + profile.tags.forEach { tag -> + TagButton( + text = tag, + onClick = { onRemoveTag(tag) }, + accessibilityLabel = stringResource(R.string.common__remove_tag, tag), + displayIconClose = true, + ) + } + } + VerticalSpacer(8.dp) + } + Row(modifier = Modifier.fillMaxWidth()) { + PrimaryButton( + text = stringResource(R.string.profile__add_tag), + onClick = onClickAddTag, + size = ButtonSize.Small, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("ProfileAddTag") + ) + } + VerticalSpacer(16.dp) } } +@Composable +private fun CopiedPopup( + publicKey: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var lastPublicKey by remember { mutableStateOf("") } + LaunchedEffect(publicKey) { + if (publicKey != null) lastPublicKey = publicKey + } + + AnimatedVisibility( + visible = publicKey != null, + enter = fadeIn() + scaleIn(initialScale = 0.95f), + exit = fadeOut() + scaleOut(targetScale = 0.95f), + modifier = modifier + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(horizontal = 32.dp) + .shadow(elevation = 25.dp, shape = MaterialTheme.shapes.medium, spotColor = Colors.Black) + .clip(MaterialTheme.shapes.medium) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(32.dp) + .testTag("ProfilePubkyCopiedToast") + ) { + BodyMSB( + text = stringResource(R.string.profile__pubky_copied), + color = Colors.Brand, + textAlign = TextAlign.Center, + ) + BodyS( + text = lastPublicKey, + color = Colors.White, + textAlign = TextAlign.Center, + ) + } + } +} + @Composable private fun LoadingState() { Box( @@ -265,6 +423,11 @@ private fun Preview() { onDismissSignOutDialog = {}, onConfirmSignOut = {}, onClickRetry = {}, + onClickAddTag = {}, + onRemoveTag = {}, + onDismissAddTagSheet = {}, + onSaveTag = {}, + onDismissCopiedPopup = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index d0143906dc..2140ed08d2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -15,6 +17,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile @@ -22,7 +26,9 @@ import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class ProfileViewModel @Inject constructor( @@ -32,15 +38,22 @@ class ProfileViewModel @Inject constructor( ) : ViewModel() { companion object { private const val TAG = "ProfileViewModel" + private val COPIED_POPUP_DURATION = 3.seconds } private val _showSignOutDialog = MutableStateFlow(false) private val _isSigningOut = MutableStateFlow(false) + private val _showAddTagSheet = MutableStateFlow(false) + private val _copiedPublicKey = MutableStateFlow(null) + private val tagUpdateMutex = Mutex() + private var hideCopiedPopupJob: Job? = null private val controls = combine( _showSignOutDialog, _isSigningOut, - ) { showSignOutDialog, isSigningOut -> - ProfileControls(showSignOutDialog, isSigningOut) + _showAddTagSheet, + _copiedPublicKey, + ) { showSignOutDialog, isSigningOut, showAddTagSheet, copiedPublicKey -> + ProfileControls(showSignOutDialog, isSigningOut, showAddTagSheet, copiedPublicKey) } val uiState: StateFlow = combine( @@ -55,6 +68,8 @@ class ProfileViewModel @Inject constructor( isLoading = isLoading, showSignOutDialog = controls.showSignOutDialog, isSigningOut = controls.isSigningOut, + showAddTagSheet = controls.showAddTagSheet, + copiedPublicKey = controls.copiedPublicKey, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState()) @@ -77,6 +92,25 @@ class ProfileViewModel @Inject constructor( _showSignOutDialog.update { false } } + fun showAddTagSheet() { + _showAddTagSheet.update { true } + } + + fun dismissAddTagSheet() { + _showAddTagSheet.update { false } + } + + fun addTag(tag: String) { + updateTags( + transform = { (it + tag).distinct() }, + onSuccess = { _showAddTagSheet.update { false } }, + ) + } + + fun removeTag(tag: String) { + updateTags(transform = { tags -> tags.filterNot { it == tag } }) + } + fun signOut() { viewModelScope.launch { _isSigningOut.update { true } @@ -114,12 +148,49 @@ class ProfileViewModel @Inject constructor( fun copyPublicKey() { val pk = pubkyRepo.publicKey.value ?: return context.setClipboardText(pk, context.getString(R.string.profile__public_key)) + _copiedPublicKey.update { pk } + hideCopiedPopupJob?.cancel() + hideCopiedPopupJob = viewModelScope.launch { + delay(COPIED_POPUP_DURATION) + _copiedPublicKey.update { null } + } + } + + fun dismissCopiedPopup() { + hideCopiedPopupJob?.cancel() + _copiedPublicKey.update { null } + } + + private fun updateTags( + transform: (List) -> List, + onSuccess: () -> Unit = {}, + ) { viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - testTag = "ProfilePubkyCopiedToast", - ) + tagUpdateMutex.withLock { + val profile = pubkyRepo.profile.value ?: return@withLock + val tags = transform(profile.tags) + if (tags == profile.tags) { + onSuccess() + return@withLock + } + + pubkyRepo.saveProfile( + name = profile.name, + bio = profile.bio, + links = profile.links, + tags = tags, + imageUrl = profile.imageUrl, + ).onSuccess { + onSuccess() + }.onFailure { + Logger.error("Failed to update profile tags", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__edit_save_error), + description = it.message, + ) + } + } } } } @@ -131,11 +202,15 @@ data class ProfileUiState( val isLoading: Boolean = false, val showSignOutDialog: Boolean = false, val isSigningOut: Boolean = false, + val showAddTagSheet: Boolean = false, + val copiedPublicKey: String? = null, ) private data class ProfileControls( val showSignOutDialog: Boolean, val isSigningOut: Boolean, + val showAddTagSheet: Boolean, + val copiedPublicKey: String?, ) sealed interface ProfileEffect { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d739129d4..eaeefe3282 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,6 +696,7 @@ Wallet is still starting. Try again in a moment. Let your\ncontacts\n<accent>pay you</accent> Pay Contacts + pubky copied to clipboard Public Key Scan to add {name} Restore Profile diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index d574049939..16fb6886d5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -7,17 +7,23 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest : BaseUnitTest() { @@ -71,13 +77,140 @@ class ProfileViewModelTest : BaseUnitTest() { verify(privatePaykitRepo, never()).closeAndClear() } - private fun createSut(): ProfileViewModel { + @Test + fun `addTag saves the updated profile and closes the sheet`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Bitcoin") + assertFalse(awaitItem().showAddTagSheet) + } + advanceUntilIdle() + verify(pubkyRepo).saveProfile( + name = "Alice", + bio = "Builder", + links = listOf(PubkyProfileLink("Website", "https://example.com")), + tags = listOf("Founder", "Bitcoin"), + imageUrl = "https://example.com/avatar.png", + ) + } + + @Test + fun `adding an existing tag closes the sheet without saving`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Founder") + assertFalse(awaitItem().showAddTagSheet) + } + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + } + + @Test + fun `removeTag saves the profile without the selected tag`() = test { + val sut = createSut(createProfile(tags = listOf("Founder", "Bitcoin"))) + advanceUntilIdle() + + sut.removeTag("Founder") + advanceUntilIdle() + + verify(pubkyRepo).saveProfile( + name = eq("Alice"), + bio = eq("Builder"), + links = any(), + tags = eq(listOf("Bitcoin")), + imageUrl = eq("https://example.com/avatar.png"), + ) + } + + @Test + fun `rapid tag removals are serialized against the latest profile`() = test { + val profileFlow = MutableStateFlow( + createProfile(tags = listOf("Founder", "Bitcoin")), + ) + val sut = createSut(profileFlow = profileFlow) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { + val tags = it.getArgument>(3) + profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) + Result.success(Unit) + } + advanceUntilIdle() + + sut.removeTag("Founder") + sut.removeTag("Bitcoin") + advanceUntilIdle() + + inOrder(pubkyRepo).apply { + verify(pubkyRepo).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) + verify(pubkyRepo).saveProfile(any(), any(), any(), eq(emptyList()), any()) + } + assertEquals(emptyList(), profileFlow.value?.tags) + } + + @Test + fun `double tap removal does not remove a neighboring tag`() = test { + val profileFlow = MutableStateFlow( + createProfile(tags = listOf("Founder", "Bitcoin")), + ) + val sut = createSut(profileFlow = profileFlow) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { + val tags = it.getArgument>(3) + profileFlow.value = requireNotNull(profileFlow.value).copy(tags = tags) + Result.success(Unit) + } + advanceUntilIdle() + + sut.removeTag("Founder") + sut.removeTag("Founder") + advanceUntilIdle() + + verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), eq(listOf("Bitcoin")), any()) + assertEquals(listOf("Bitcoin"), profileFlow.value?.tags) + } + + @Test + fun `failed tag save keeps the add sheet open for retry`() = test { + val sut = createSut(createProfile()) + whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())) + .thenReturn(Result.failure(ProfileTestAppError("save failed"))) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + while (state.profile == null) state = awaitItem() + sut.showAddTagSheet() + assertTrue(awaitItem().showAddTagSheet) + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertTrue(sut.uiState.value.showAddTagSheet) + } + } + + private fun createSut( + profile: PubkyProfile? = null, + profileFlow: MutableStateFlow = MutableStateFlow(profile), + ): ProfileViewModel { whenever(context.getString(any())).thenReturn("") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.profile).thenReturn(profileFlow) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) whenever { pubkyRepo.signOut() }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.saveProfile(any(), any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -88,6 +221,16 @@ class ProfileViewModelTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, ) } + + private fun createProfile(tags: List = listOf("Founder")) = PubkyProfile( + publicKey = "pubkyalice", + name = "Alice", + bio = "Builder", + imageUrl = "https://example.com/avatar.png", + links = listOf(PubkyProfileLink("Website", "https://example.com")), + tags = tags, + status = null, + ) } private class ProfileTestAppError(message: String) : AppError(message) From 817cf58cc5ea2dbe8eb49a9a93a1f3d7adc3cdbb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 14:00:23 -0300 Subject: [PATCH 20/20] fix: require plus or phone label for bare phone links Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/to/bitkit/ui/components/LinkRow.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt index 858dac0be4..625a79116f 100644 --- a/app/src/main/java/to/bitkit/ui/components/LinkRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/LinkRow.kt @@ -28,7 +28,7 @@ fun LinkRow( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val uri = remember(value) { value.toLinkUri() } + val uri = remember(label, value) { value.toLinkUri(label) } Column(modifier = modifier.fillMaxWidth()) { VerticalSpacer(16.dp) @@ -58,14 +58,16 @@ fun LinkRow( private val PHONE_REGEX = Regex("""^\+?[0-9 ().-]{7,}$""") private const val MIN_PHONE_DIGITS = 7 +private val PHONE_LABELS = setOf("phone", "tel", "telephone", "mobile", "cell") -private fun String.toLinkUri(): Uri? { +private fun String.toLinkUri(label: String): Uri? { val trimmed = trim() val scheme = trimmed.toUri().scheme?.lowercase() return when { trimmed.isEmpty() -> null trimmed.isValidEmail() -> "mailto:$trimmed".toUri() - trimmed.isPhoneNumber() -> trimmed.toTelUri() + (trimmed.startsWith('+') || label.trim().lowercase() in PHONE_LABELS) && trimmed.isPhoneNumber() -> + trimmed.toTelUri() scheme == "tel" -> trimmed.substringAfter(':').trim().takeIf { it.isPhoneNumber() }?.toTelUri() trimmed.contains(' ') -> null scheme == "mailto" -> trimmed.toUri() @@ -74,14 +76,14 @@ private fun String.toLinkUri(): Uri? { } } -private fun String.isPhoneNumber() = - matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS && !Patterns.IP_ADDRESS.matcher(this).matches() +private fun String.isPhoneNumber() = matches(PHONE_REGEX) && count(Char::isDigit) >= MIN_PHONE_DIGITS private fun String.toTelUri() = "tel:${filter { it.isDigit() || it == '+' }}".toUri() private fun String.toWebUri(): Uri? { val withScheme = if (contains("://")) this else "https://$this" return withScheme.toUri().takeIf { - it.scheme.equals("http", ignoreCase = true) || it.scheme.equals("https", ignoreCase = true) + val isHttp = it.scheme.equals("http", ignoreCase = true) || it.scheme.equals("https", ignoreCase = true) + isHttp && it.host?.substringAfterLast('.').orEmpty().any(Char::isLetter) } }