From 0d3412682a9ce304ec787108871b08a8c80162fe Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 17:28:27 +0200 Subject: [PATCH 1/3] fix: align pubky and paykit ui --- .../main/java/to/bitkit/data/SettingsStore.kt | 10 +- .../data/serializers/SettingsSerializer.kt | 4 +- app/src/main/java/to/bitkit/ext/String.kt | 5 + .../java/to/bitkit/models/PubkyProfile.kt | 3 +- .../ContactPaymentSettingsRepo.kt | 184 +++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 38 +- .../to/bitkit/ui/components/AddLinkSheet.kt | 4 +- .../to/bitkit/ui/components/AddTagSheet.kt | 2 +- .../java/to/bitkit/ui/components/Button.kt | 4 +- .../ui/components/CenteredProfileHeader.kt | 10 +- .../bitkit/ui/components/ProfileEditForm.kt | 352 +++++++++--------- .../java/to/bitkit/ui/components/SheetHost.kt | 4 +- .../ui/screens/contacts/AddContactScreen.kt | 110 +++--- .../screens/contacts/AddContactViewModel.kt | 9 +- .../screens/contacts/ContactActivityScreen.kt | 2 +- .../screens/contacts/ContactDetailScreen.kt | 63 +++- .../contacts/ContactDetailViewModel.kt | 29 ++ .../contacts/ContactImportOverviewScreen.kt | 86 ++++- .../contacts/ContactImportSelectScreen.kt | 37 +- .../screens/contacts/ContactsIntroScreen.kt | 2 +- .../ui/screens/contacts/ContactsScreen.kt | 52 ++- .../ui/screens/contacts/EditContactScreen.kt | 37 +- .../ui/screens/profile/CreateProfileScreen.kt | 10 +- .../ui/screens/profile/EditProfileScreen.kt | 6 +- .../ui/screens/profile/PayContactsScreen.kt | 35 -- .../screens/profile/PayContactsViewModel.kt | 211 +---------- .../ui/screens/profile/ProfileIntroScreen.kt | 2 +- .../ui/screens/profile/ProfileScreen.kt | 52 +-- .../ui/screens/scanner/QrScanningScreen.kt | 35 +- .../to/bitkit/ui/settings/SettingsScreen.kt | 32 +- .../PaymentPreferenceScreen.kt | 145 -------- .../PaymentPreferenceViewModel.kt | 326 ---------------- .../bitkit/ui/shared/modifiers/SheetHeight.kt | 1 + .../to/bitkit/ui/sheets/QrScanningSheet.kt | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 +- .../to/bitkit/viewmodels/SettingsViewModel.kt | 45 ++- app/src/main/res/values/strings.xml | 34 +- .../serializers/SettingsSerializerTest.kt | 26 ++ .../ContactPaymentSettingsRepoTest.kt | 167 +++++++++ .../contacts/AddContactViewModelTest.kt | 18 + .../contacts/ContactDetailViewModelTest.kt | 85 +++++ .../profile/PayContactsViewModelTest.kt | 289 +------------- .../PaymentPreferenceViewModelTest.kt | 337 ----------------- .../viewmodels/SettingsViewModelTest.kt | 18 + changelog.d/next/970.changed.md | 1 + 45 files changed, 1207 insertions(+), 1731 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt delete mode 100644 app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt create mode 100644 app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt delete mode 100644 app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt create mode 100644 changelog.d/next/970.changed.md diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index ab21f8f496..04889377a7 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -47,7 +47,7 @@ class SettingsStore @Inject constructor( suspend fun restoreFromBackup(payload: SettingsBackupV1) = runCatching { - val data = payload.settings.resetPin() + val data = payload.settings.resetPin().withDefaultPaykitPaymentMethods() store.updateData { data } val monitored = data.addressTypesToMonitor @@ -164,6 +164,14 @@ fun SettingsData.resetPin() = this.copy( isBiometricEnabled = false, ) +fun SettingsData.areContactPaymentsEnabled(): Boolean = + sharesPublicPaykitEndpoints || sharesPrivatePaykitEndpoints + +fun SettingsData.withDefaultPaykitPaymentMethods() = copy( + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, +) + fun SettingsData.hasPublicPaykitPublicationState(): Boolean = hasConfirmedPublicPaykitEndpoints || sharesPublicPaykitEndpoints || diff --git a/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt index 3c112311a1..4c4ca67c78 100644 --- a/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt +++ b/app/src/main/java/to/bitkit/data/serializers/SettingsSerializer.kt @@ -3,6 +3,7 @@ package to.bitkit.data.serializers import androidx.datastore.core.Serializer import kotlinx.serialization.SerializationException import to.bitkit.data.SettingsData +import to.bitkit.data.withDefaultPaykitPaymentMethods import to.bitkit.di.json import to.bitkit.utils.Logger import java.io.InputStream @@ -13,7 +14,8 @@ object SettingsSerializer : Serializer { override suspend fun readFrom(input: InputStream): SettingsData { return try { - json.decodeFromString(input.readBytes().decodeToString()) + json.decodeFromString(input.readBytes().decodeToString()) + .withDefaultPaykitPaymentMethods() } catch (e: SerializationException) { Logger.error("Failed to deserialize: $e") defaultValue diff --git a/app/src/main/java/to/bitkit/ext/String.kt b/app/src/main/java/to/bitkit/ext/String.kt index 045279bca2..a2aa35a8a1 100644 --- a/app/src/main/java/to/bitkit/ext/String.kt +++ b/app/src/main/java/to/bitkit/ext/String.kt @@ -15,6 +15,11 @@ fun String.ellipsisMiddle(totalLength: Int): String { } } +fun String.pubkyDisplayPublicKey(): String { + val rawKey = removePrefix("pubky") + return if (rawKey.length > 8) "${rawKey.take(4)}...${rawKey.takeLast(4)}" else rawKey +} + fun String.truncate(length: Int): String { return if (this.length > length) { "${this.substring(0, length - 3)}..." diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index 468c86285a..bdbfbf4e40 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.pubkyDisplayPublicKey import com.synonym.paykit.PaykitProfile as SdkPaykitProfile import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @@ -67,7 +68,7 @@ data class PubkyProfile( } val truncatedPublicKey: String - get() = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH) + get() = publicKey.pubkyDisplayPublicKey() fun withNameFallback(fallbackName: String?): PubkyProfile { return if (name.isBlank() && !fallbackName.isNullOrBlank()) copy(name = fallbackName) else this diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt new file mode 100644 index 0000000000..c6ffe0d185 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -0,0 +1,184 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.areContactPaymentsEnabled +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContactPaymentSettingsRepo @Inject constructor( + private val settingsStore: SettingsStore, + private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, + private val pubkyRepo: PubkyRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + val isEnabled: Flow = settingsStore.data.map { it.areContactPaymentsEnabled() } + + suspend fun setEnabled(isEnabled: Boolean): Result = withContext(ioDispatcher) { + val contacts = pubkyRepo.contacts.value.map { it.publicKey } + if (isEnabled) enable(contacts) else disable(contacts) + } + + private suspend fun enable(contacts: List): Result { + val previous = settingsStore.data.first() + publicPaykitRepo.syncPublishedEndpoints(publish = true) + .onFailure { + rollbackEnabled(previous, contacts, it) + return Result.failure(it) + } + + val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() + if (canUsePrivateContactPayments) { + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { + rollbackEnabled(previous, contacts, it) + return Result.failure(it) + } + } + + runSuspendCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = canUsePrivateContactPayments, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, + ) + } + }.onFailure { + rollbackEnabled(previous, contacts, it) + return Result.failure(it) + } + + if (canUsePrivateContactPayments) { + privatePaykitRepo.prepareSavedContacts(contacts) + } + + return Result.success(Unit) + } + + private suspend fun rollbackEnabled( + previous: SettingsData, + contacts: List, + error: Throwable, + ) { + runSuspendCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints, + sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints, + sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints, + ) + } + }.onFailure(error::addSuppressed) + publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + .onFailure { + error.addSuppressed(it) + markPublicPaykitRetry(error) + } + if (!previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure(error::addSuppressed) + } + } + + private suspend fun disable(contacts: List): Result { + val previous = settingsStore.data.first() + runSuspendCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = false, + sharesPrivatePaykitEndpoints = false, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, + ) + } + }.onFailure { + return Result.failure(it) + } + + var publicCleanupError: Throwable? = null + var privateCleanupError: Throwable? = null + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onFailure { publicCleanupError = it } + + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { privateCleanupError = it } + + publicCleanupError?.let { error -> + runSuspendCatching { + settingsStore.update { settings -> + settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) + } + }.onFailure(error::addSuppressed) + publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) + .onFailure { + error.addSuppressed(it) + markPublicPaykitRetry(error) + } + } + privateCleanupError?.let { error -> + if (previous.sharesPrivatePaykitEndpoints) { + restorePrivate(contacts, error) + } else { + updatePrivatePreference(isEnabled = false, error = error) + } + } + + val cleanupError = publicCleanupError ?: privateCleanupError + publicCleanupError?.let { publicError -> + privateCleanupError?.let { publicError.addSuppressed(it) } + } + cleanupError?.let { return Result.failure(it) } + + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { return Result.failure(it) } + + return Result.success(Unit) + } + + private suspend fun restorePrivate( + contacts: List, + error: Throwable, + ) { + if (!updatePrivatePreference(isEnabled = true, error = error)) return + + privatePaykitRepo.prepareSavedContacts( + publicKeys = contacts, + requireImmediatePublication = true, + ).exceptionOrNull()?.let { + error.addSuppressed(it) + updatePrivatePreference(isEnabled = false, error = error) + return + } + + privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { + error.addSuppressed(it) + updatePrivatePreference(isEnabled = false, error = error) + } + } + + private suspend fun markPublicPaykitRetry(error: Throwable) { + runSuspendCatching { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + }.onFailure(error::addSuppressed) + } + + private suspend fun updatePrivatePreference( + isEnabled: Boolean, + error: Throwable, + ): Boolean = runSuspendCatching { + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) } + }.onFailure(error::addSuppressed).isSuccess +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 505bacbe16..fafb9d44bc 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -174,7 +174,6 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel -import to.bitkit.ui.settings.paymentPreference.PaymentPreferenceScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen @@ -499,7 +498,7 @@ fun ContentView( is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) is Sheet.Gift -> GiftSheet(sheet, appViewModel) - Sheet.QrScanner -> QrScanningSheet(appViewModel) + is Sheet.QrScanner -> QrScanningSheet(sheet, appViewModel) is Sheet.PubkyAuth -> PubkyAuthApprovalSheet( authUrl = sheet.authUrl, viewModel = hiltViewModel(), @@ -1167,7 +1166,7 @@ private fun NavGraphBuilder.contacts( onClickContact = { navController.navigateTo(Routes.ContactDetail(it)) }, onAddContact = { navController.navigateTo(Routes.AddContact(it)) }, onScanQr = { - appViewModel.showScannerSheet { scannedData -> + appViewModel.showScannerSheet(isPubkyScan = true) { scannedData -> navController.navigateTo(Routes.AddContact(scannedData)) } }, @@ -1195,8 +1194,9 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() ContactDetailScreen( viewModel = viewModel, @@ -1205,6 +1205,10 @@ private fun NavGraphBuilder.contacts( appViewModel.openContactPayment(paymentRequest, publicKey) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, + showDeleteAction = route.showDeleteAction, + onContactDeleted = { + navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } + }, onEditContact = { navController.navigateTo(Routes.EditContact(it)) }, ) } @@ -1225,7 +1229,13 @@ private fun NavGraphBuilder.contacts( AddContactScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onContactSaved = { navController.popBackStack() }, + onContactSaved = { publicKey -> + navController.navigateTo( + Routes.ContactDetail(publicKey, showDeleteAction = true) + ) { + popUpTo(Routes.AddContact(publicKey)) { inclusive = true } + } + }, onPayContact = { paymentRequest, publicKey -> navController.popBackStack() appViewModel.openContactPayment(paymentRequest, publicKey) @@ -1431,14 +1441,6 @@ private fun NavGraphBuilder.generalSettingsSubScreens( onBack = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { - PaykitRouteGuard(settingsViewModel, navController) { - PaymentPreferenceScreen( - onBack = { navController.popBackStack() }, - ) - } - } - composableWithDefaultTransitions { val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() @@ -1873,8 +1875,6 @@ fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogD fun NavController.navigateToTransactionSpeedSettings() = navigateTo(Routes.TransactionSpeedSettings) -fun NavController.navigateToPaymentPreferenceSettings() = navigateTo(Routes.PaymentPreferenceSettings) - fun NavController.navigateToCustomFeeSettings() = navigateTo(Routes.CustomFeeSettings) fun NavController.navigateToWidgetsSettings() = navigateTo(Routes.WidgetsSettings) @@ -1911,9 +1911,6 @@ sealed interface Routes { @Serializable data object TransactionSpeedSettings : Routes - @Serializable - data object PaymentPreferenceSettings : Routes - @Serializable data object WidgetsSettings : Routes @@ -2130,7 +2127,10 @@ sealed interface Routes { data object ContactsIntro : Routes @Serializable - data class ContactDetail(val publicKey: String) : Routes + data class ContactDetail( + val publicKey: String, + val showDeleteAction: Boolean = false, + ) : Routes @Serializable data class ContactActivity(val publicKey: String) : Routes 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 ebc29c18c4..b73a9bffcd 100644 --- a/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/AddLinkSheet.kt @@ -105,7 +105,7 @@ private fun LinkFormContent( ) { Column( modifier = Modifier - .sheetHeight(isModal = true) + .sheetHeight(SheetSize.COMPACT, isModal = true) .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp), @@ -162,7 +162,7 @@ internal fun SuggestionsContent( ) { Column( modifier = Modifier - .sheetHeight(isModal = true) + .sheetHeight(SheetSize.COMPACT, isModal = true) .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp), diff --git a/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt index 20a05a27e9..cf782138b7 100644 --- a/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/AddTagSheet.kt @@ -95,7 +95,7 @@ private fun TagFormContent( ) { Column( modifier = Modifier - .sheetHeight(isModal = true) + .sheetHeight(SheetSize.COMPACT, isModal = true) .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp), diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index 6887ed3a32..cda8c1eaf4 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -101,6 +101,7 @@ fun PrimaryButton( fullWidth: Boolean = true, color: Color? = null, enableGradient: Boolean = true, + contentColor: Color = Colors.White, ) { val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val buttonShape = MaterialTheme.shapes.extraLarge @@ -110,7 +111,8 @@ fun PrimaryButton( enabled = enabled && !isLoading, colors = AppButtonDefaults.primaryColors.copy( containerColor = Color.Transparent, - disabledContainerColor = Color.Transparent + disabledContainerColor = Color.Transparent, + contentColor = contentColor, ), contentPadding = contentPadding, shape = buttonShape, 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 93956881a2..6bcd545bc2 100644 --- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt +++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt @@ -17,12 +17,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.pubkyDisplayPublicKey import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -private const val TRUNCATED_PK_LENGTH = 11 - @Composable fun CenteredProfileHeader( publicKey: String, @@ -38,7 +36,7 @@ fun CenteredProfileHeader( modifier = modifier ) { Text13Up( - text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH), + text = publicKey.pubkyDisplayPublicKey(), color = Colors.White64, textAlign = TextAlign.Center, ) @@ -46,12 +44,12 @@ fun CenteredProfileHeader( VerticalSpacer(16.dp) if (imageUrl != null) { - PubkyImage(uri = imageUrl, size = 100.dp) + PubkyImage(uri = imageUrl, size = 96.dp) } else { Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(100.dp) + .size(96.dp) .clip(CircleShape) .background(Colors.Gray5) ) { 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 84f76d96fc..e84f9268d3 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.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -23,6 +24,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -76,87 +79,81 @@ fun ProfileEditForm( val keyboardController = LocalSoftwareKeyboardController.current Column( - horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() .imePadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.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, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() - .testTag("ProfileEditName") - ) - HorizontalDivider() - VerticalSpacer(12.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + VerticalSpacer(16.dp) + avatarContent() + VerticalSpacer(12.dp) - Text13Up( - text = resolvedPublicKeyLabel, - color = Colors.White64, - ) - VerticalSpacer(4.dp) - BodyS( - text = publicKey, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(modifier = Modifier.padding(top = 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) - VerticalSpacer(16.dp) - Text13Up( - text = stringResource(R.string.profile__edit_bio), - color = Colors.White64, - 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") - ) + Text13Up( + text = resolvedPublicKeyLabel, + color = Colors.White64, + ) + VerticalSpacer(4.dp) + BodyMSB( + text = publicKey, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) - VerticalSpacer(16.dp) - links.forEachIndexed { index, link -> - HorizontalDivider(color = Colors.White10) - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) Text13Up( - text = link.label, + text = stringResource(R.string.profile__edit_bio), 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 = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(16.dp) - ) + value = bio, + onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, + placeholder = resolvedBioPlaceholder, + minLines = 2, + maxLines = 4, + modifier = Modifier + .fillMaxWidth() + .testTag("ProfileEditBio") + ) + + 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") @@ -168,128 +165,138 @@ fun ProfileEditForm( modifier = Modifier.size(16.dp) ) } - } - }, - modifier = Modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = Colors.White10, - shape = AppShapes.small, - ) - .testTag("ProfileEditLink_$index") - ) - VerticalSpacer(8.dp) - } - Row(modifier = Modifier.fillMaxWidth()) { - PrimaryButton( - text = stringResource(R.string.profile__add_link), - onClick = { - focusManager.clearFocus(force = true) - keyboardController?.hide() - onAddLink() - }, - size = ButtonSize.Small, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.testTag("ProfileEditAddLink") - ) - } - - 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, + }, + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = Colors.White10, + shape = AppShapes.small, + ) + .testTag("ProfileEditLink_$index") + ) + VerticalSpacer(8.dp) + } + Row(modifier = Modifier.fillMaxWidth()) { + PrimaryButton( + text = stringResource(R.string.profile__add_link), + onClick = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onAddLink() + }, + size = ButtonSize.Small, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("ProfileEditAddLink") ) } - } - 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, + 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, + ) + } + } + 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") + ) + } - if (onDelete != null) { - Column { - VerticalSpacer(16.dp) - HorizontalDivider() + VerticalSpacer(16.dp) + if (showFooterNote) { + HorizontalDivider(color = Colors.White10) 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()) { - PrimaryButton( - text = deleteLabel, - onClick = onDelete, - size = ButtonSize.Small, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_trash), - contentDescription = null, - tint = Colors.Red, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.testTag("ProfileEditDelete") + } + + if (onDelete != null) { + Column { + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(16.dp) + Text13Up( + text = stringResource(R.string.profile__edit_delete_section), + color = Colors.White64, + modifier = Modifier.fillMaxWidth() ) + VerticalSpacer(8.dp) + Row(modifier = Modifier.fillMaxWidth()) { + PrimaryButton( + text = deleteLabel, + onClick = onDelete, + size = ButtonSize.Small, + fullWidth = false, + color = Colors.White10, + enableGradient = false, + contentColor = Colors.Brand, + icon = { + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("ProfileEditDelete") + ) + } } } + + VerticalSpacer(32.dp) } - FillHeight() - VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + ) + ) + .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp) ) { SecondaryButton( text = stringResource(R.string.common__cancel), @@ -307,7 +314,6 @@ fun ProfileEditForm( .testTag("ProfileEditSave") ) } - VerticalSpacer(16.dp) } } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 4f46aa5b59..d1b6c8d835 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -41,7 +41,7 @@ import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.Colors -enum class SheetSize { LARGE, MEDIUM, SMALL, CALENDAR; } +enum class SheetSize { LARGE, MEDIUM, COMPACT, SMALL, CALENDAR; } val DefaultSheetContainerColor = Color(0xFF141414) // Equivalent to White08 on a Black background @@ -71,7 +71,7 @@ sealed interface Sheet { val isConnecting: Boolean = false, val errorText: String? = null, ) : Sheet - data object QrScanner : Sheet + data class QrScanner(val isPubkyScan: Boolean = false) : Sheet data class PubkyAuth(val authUrl: String) : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 28f5b9340d..0b5d7c92c8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -52,19 +53,21 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import to.bitkit.R -import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.getClipboardText +import to.bitkit.ext.pubkyDisplayPublicKey import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.CenteredProfileHeader import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer @@ -72,9 +75,10 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent // region AddContactSheet (bottom sheet) @@ -144,7 +148,13 @@ private fun AddContactSheetContent( onScanQr: () -> Unit, onSubmit: () -> Unit, ) { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .sheetHeight(SheetSize.SMALL, isModal = true) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { SheetTopBar(titleText = stringResource(R.string.contacts__add_sheet_title)) VerticalSpacer(16.dp) @@ -160,7 +170,8 @@ private fun AddContactSheetContent( value = publicKeyInput, onValueChange = onPublicKeyChange, placeholder = stringResource(R.string.contacts__add_pubky_placeholder), - singleLine = true, + minLines = 2, + maxLines = 2, isError = validationMessage != null, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -197,6 +208,13 @@ private fun AddContactSheetContent( SecondaryButton( text = stringResource(R.string.contacts__add_scan_qr), onClick = onScanQr, + icon = { + Icon( + painter = painterResource(R.drawable.ic_scan), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, modifier = Modifier .weight(1f) .testTag("AddContactScanQR") @@ -222,7 +240,7 @@ private fun AddContactSheetContent( fun AddContactScreen( viewModel: AddContactViewModel, onBackClick: () -> Unit, - onContactSaved: () -> Unit, + onContactSaved: (String) -> Unit, onPayContact: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -230,7 +248,7 @@ fun AddContactScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - AddContactEffect.ContactSaved -> onContactSaved() + is AddContactEffect.ContactSaved -> onContactSaved(it.publicKey) is AddContactEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) } } @@ -273,14 +291,12 @@ private fun Content( isLoading = uiState.isLoading, hasPublicPaymentEndpoint = uiState.hasPublicPaymentEndpoint, onPay = onPay, - onDiscard = onBackClick, onSave = onSave, ) } } } -private const val TRUNCATED_PK_LENGTH = 11 private const val ELLIPSE_ANIMATION_DURATION_MS = 8000 @Composable @@ -294,7 +310,7 @@ private fun LoadingContent(publicKey: String) { VerticalSpacer(24.dp) Text13Up( - text = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH), + text = publicKey.pubkyDisplayPublicKey(), color = Colors.White64, ) @@ -303,7 +319,7 @@ private fun LoadingContent(publicKey: String) { Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(80.dp) + .size(96.dp) .clip(CircleShape) .background(Colors.Gray5) ) { @@ -313,11 +329,12 @@ private fun LoadingContent(publicKey: String) { ) } - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) Display( - text = stringResource(R.string.contacts__add_retrieving) - .withAccent(accentColor = Colors.PubkyGreen), + text = stringResource(R.string.contacts__add_retrieving), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) Box( @@ -447,14 +464,13 @@ private fun LoadedContent( isLoading: Boolean, hasPublicPaymentEndpoint: Boolean, onPay: () -> Unit, - onDiscard: () -> Unit, onSave: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -474,32 +490,32 @@ private fun LoadedContent( VerticalSpacer(16.dp) if (hasPublicPaymentEndpoint) { - SecondaryButton( - text = stringResource(R.string.wallet__send), - onClick = onPay, - modifier = Modifier.testTag("AddContactPay") - ) - VerticalSpacer(16.dp) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - SecondaryButton( - text = stringResource(R.string.contacts__add_discard), - onClick = onDiscard, - modifier = Modifier - .weight(1f) - .testTag("AddContactDiscard") - ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.contacts__add_pay), + onClick = onPay, + modifier = Modifier + .weight(1f) + .testTag("AddContactPay") + ) + PrimaryButton( + text = stringResource(R.string.common__save), + onClick = onSave, + enabled = !isLoading, + modifier = Modifier + .weight(1f) + .testTag("AddContactSave") + ) + } + } else { PrimaryButton( text = stringResource(R.string.common__save), onClick = onSave, enabled = !isLoading, - modifier = Modifier - .weight(1f) - .testTag("AddContactSave") + modifier = Modifier.testTag("AddContactSave") ) } VerticalSpacer(16.dp) @@ -514,15 +530,17 @@ private fun LoadedContent( @Composable private fun SheetPreview() { AppThemeSurface { - AddContactSheetContent( - publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", - validationMessage = null, - isSubmitEnabled = true, - onPublicKeyChange = {}, - onPaste = {}, - onScanQr = {}, - onSubmit = {}, - ) + BottomSheetPreview { + AddContactSheetContent( + publicKeyInput = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + validationMessage = null, + isSubmitEnabled = true, + onPublicKeyChange = {}, + onPaste = {}, + onScanQr = {}, + onSubmit = {}, + ) + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 1d35947bd6..93fd35c11c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -154,12 +154,7 @@ class AddContactViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } pubkyRepo.addContact(profile.publicKey, profile) .onSuccess { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.contacts__add_contact_saved), - testTag = "ContactSavedToast", - ) - _effects.emit(AddContactEffect.ContactSaved) + _effects.emit(AddContactEffect.ContactSaved(profile.publicKey)) } .onFailure { Logger.error("Failed to save contact", it, context = TAG) @@ -184,6 +179,6 @@ data class AddContactUiState( ) sealed interface AddContactEffect { - data object ContactSaved : AddContactEffect + data class ContactSaved(val publicKey: String) : AddContactEffect data class OpenPayment(val paymentRequest: String, val publicKey: String) : AddContactEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt index 80d8c507f5..0dc27d1067 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -58,7 +58,7 @@ private fun Content( ) { ScreenColumn { AppTopBar( - titleText = uiState.profile?.name ?: stringResource(R.string.wallet__activity), + titleText = stringResource(R.string.wallet__activity), onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) 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 08caea71a5..4a98744654 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -39,6 +40,7 @@ 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 import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn @@ -52,6 +54,8 @@ fun ContactDetailScreen( onBackClick: () -> Unit, onPayContact: (String, String) -> Unit, onActivityClick: (String) -> Unit, + showDeleteAction: Boolean = false, + onContactDeleted: () -> Unit = {}, onEditContact: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -61,6 +65,7 @@ fun ContactDetailScreen( viewModel.effects.collect { when (it) { is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) + ContactDetailEffect.ContactDeleted -> onContactDeleted() } } } @@ -69,6 +74,8 @@ fun ContactDetailScreen( uiState = uiState, onBackClick = onBackClick, onClickEdit = { uiState.profile?.publicKey?.let { onEditContact(it) } }, + showDeleteAction = showDeleteAction, + onClickDelete = { viewModel.showDeleteConfirmation() }, onClickCopy = { viewModel.copyPublicKey() }, onClickPay = { viewModel.payContact() }, onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, @@ -78,6 +85,8 @@ fun ContactDetailScreen( onRemoveTag = { viewModel.removeTag(it) }, onDismissAddTagSheet = { viewModel.dismissAddTagSheet() }, onSaveTag = { viewModel.addTag(it) }, + onDismissDeleteDialog = { viewModel.dismissDeleteConfirmation() }, + onConfirmDelete = { viewModel.deleteContact() }, ) } @@ -86,6 +95,8 @@ private fun Content( uiState: ContactDetailUiState, onBackClick: () -> Unit, onClickEdit: () -> Unit, + showDeleteAction: Boolean, + onClickDelete: () -> Unit, onClickCopy: () -> Unit, onClickPay: () -> Unit, onClickActivity: () -> Unit, @@ -95,6 +106,8 @@ private fun Content( onRemoveTag: (Int) -> Unit, onDismissAddTagSheet: () -> Unit, onSaveTag: (String) -> Unit, + onDismissDeleteDialog: () -> Unit, + onConfirmDelete: () -> Unit, ) { val currentProfile = uiState.profile @@ -111,7 +124,9 @@ private fun Content( profile = currentProfile, tags = uiState.tags, showPayButton = uiState.showPayButton, + showDeleteAction = showDeleteAction, onClickEdit = onClickEdit, + onClickDelete = onClickDelete, onClickCopy = onClickCopy, onClickPay = onClickPay, onClickActivity = onClickActivity, @@ -129,6 +144,16 @@ private fun Content( onSave = onSaveTag, ) } + + if (uiState.showDeleteDialog && currentProfile != null) { + AppAlertDialog( + title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name), + text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name), + confirmText = stringResource(R.string.common__delete_yes), + onConfirm = onConfirmDelete, + onDismiss = onDismissDeleteDialog, + ) + } } @OptIn(ExperimentalLayoutApi::class) @@ -137,7 +162,9 @@ private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, showPayButton: Boolean, + showDeleteAction: Boolean, onClickEdit: () -> Unit, + onClickDelete: () -> Unit, onClickCopy: () -> Unit, onClickPay: () -> Unit, onClickActivity: () -> Unit, @@ -150,7 +177,7 @@ private fun ContactBody( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -163,7 +190,13 @@ private fun ContactBody( notesTestTag = "ContactViewNotes", ) - VerticalSpacer(24.dp) + if (showDeleteAction) { + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + VerticalSpacer(16.dp) + } else { + VerticalSpacer(24.dp) + } FlowRow( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), @@ -192,14 +225,24 @@ private fun ContactBody( iconRes = R.drawable.ic_share, modifier = Modifier.testTag("ContactShare") ) - ActionButton( - onClick = onClickEdit, - iconRes = R.drawable.ic_edit, - modifier = Modifier.testTag("ContactEdit") - ) + if (showDeleteAction) { + ActionButton( + onClick = onClickDelete, + iconRes = R.drawable.ic_trash, + modifier = Modifier.testTag("ContactDelete") + ) + } else { + ActionButton( + onClick = onClickEdit, + iconRes = R.drawable.ic_edit, + modifier = Modifier.testTag("ContactEdit") + ) + } } - VerticalSpacer(32.dp) + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + VerticalSpacer(16.dp) profile.links.forEachIndexed { index, link -> LinkRow(label = link.label, value = link.url, linkIndex = index) @@ -292,6 +335,8 @@ private fun Preview() { ), onBackClick = {}, onClickEdit = {}, + showDeleteAction = true, + onClickDelete = {}, onClickCopy = {}, onClickPay = {}, onClickActivity = {}, @@ -301,6 +346,8 @@ private fun Preview() { onRemoveTag = {}, onDismissAddTagSheet = {}, onSaveTag = {}, + onDismissDeleteDialog = {}, + onConfirmDelete = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index dd15c57f51..d3f29877d0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -156,6 +156,33 @@ class ContactDetailViewModel @Inject constructor( _uiState.update { it.copy(showAddTagSheet = false) } } + fun showDeleteConfirmation() { + _uiState.update { it.copy(showDeleteDialog = true) } + } + + fun dismissDeleteConfirmation() { + _uiState.update { it.copy(showDeleteDialog = false) } + } + + fun deleteContact() { + viewModelScope.launch { + _uiState.update { it.copy(showDeleteDialog = false, isLoading = true) } + pubkyRepo.removeContact(publicKey) + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.contacts__delete_success), + testTag = "ContactDeletedToast", + ) + _effects.emit(ContactDetailEffect.ContactDeleted) + } + .onFailure { + Logger.error("Failed to delete contact '$redactedPublicKey'", it, context = TAG) + _uiState.update { state -> state.copy(isLoading = false) } + } + } + } + fun addTag(tag: String) { val newTags = (_uiState.value.tags + tag).distinct().toImmutableList() _uiState.update { it.copy(tags = newTags, showAddTagSheet = false) } @@ -192,8 +219,10 @@ data class ContactDetailUiState( val isLoading: Boolean = false, val showPayButton: Boolean = false, val showAddTagSheet: Boolean = false, + val showDeleteDialog: Boolean = false, ) sealed interface ContactDetailEffect { data class OpenPayment(val paymentRequest: String, val publicKey: String) : ContactDetailEffect + data object ContactDeleted : ContactDetailEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt index de39614c23..35aebff72c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.contacts import androidx.activity.compose.BackHandler 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 @@ -12,15 +13,22 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text 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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -32,6 +40,7 @@ import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.Headline import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyImage @@ -43,6 +52,19 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.ui.utils.withAccentBoldBright + +/** Figma's opaque muted fill for avatar fallbacks. */ +private val AvatarMutedColor = Color(0xFF303034) + +/** Figma's base background fill for the overflow avatar. */ +private val AvatarOverflowBackgroundColor = Color(0xFF05050A) + +/** Figma's muted foreground stroke for the overflow avatar. */ +private val AvatarOverflowBorderColor = Color(0xFF89898F) + +/** Figma's shadow color for avatar separation. */ +private val AvatarShadowColor = Color(0x4005050A) @Composable fun ContactImportOverviewScreen( @@ -96,7 +118,7 @@ private fun Content( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -109,7 +131,8 @@ private fun Content( val truncatedKey = uiState.profile?.truncatedPublicKey.orEmpty() BodyM( - text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey), + text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey) + .withAccentBoldBright(), color = Colors.White64, ) @@ -153,8 +176,8 @@ private fun ProfileRow(profile: PubkyProfile) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Display( - text = profile.name, + Headline( + text = AnnotatedString(profile.name), modifier = Modifier.weight(1f), ) @@ -196,26 +219,35 @@ private fun ContactCountRow(contacts: ImmutableList) { @Composable private fun AvatarStack(contacts: ImmutableList) { - val visibleCount = minOf(contacts.size, 4) + val visibleCount = minOf(contacts.size, 5) val overflow = contacts.size - visibleCount + val itemCount = visibleCount + if (overflow > 0) 1 else 0 + val stackWidth = 32 + ((itemCount - 1).coerceAtLeast(0) * 24) - Box { + Box( + modifier = Modifier.size( + width = stackWidth.dp, + height = 32.dp, + ) + ) { contacts.take(visibleCount).forEachIndexed { index, contact -> Box(modifier = Modifier.offset(x = (index * 24).dp)) { if (contact.imageUrl != null) { - PubkyImage(uri = contact.imageUrl, size = 36.dp) + PubkyImage( + uri = contact.imageUrl, + size = 32.dp, + modifier = Modifier.avatarShadow() + ) } else { Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(36.dp) + .size(32.dp) + .avatarShadow() .clip(CircleShape) - .background(Colors.White10) + .background(AvatarMutedColor) ) { - BodySSB( - text = contact.name.firstOrNull()?.uppercase().orEmpty(), - color = Colors.White, - ) + AvatarLabel(text = contact.name.firstOrNull()?.uppercase().orEmpty()) } } } @@ -226,16 +258,38 @@ private fun AvatarStack(contacts: ImmutableList) { contentAlignment = Alignment.Center, modifier = Modifier .offset(x = (visibleCount * 24).dp) - .size(36.dp) + .size(32.dp) + .avatarShadow() .clip(CircleShape) - .background(Colors.Gray4) + .background(AvatarOverflowBackgroundColor) + .border(1.dp, AvatarOverflowBorderColor, CircleShape) ) { - BodySSB(text = "+$overflow", color = Colors.White) + AvatarLabel(text = "+$overflow") } } } } +private fun Modifier.avatarShadow() = shadow( + elevation = 2.dp, + shape = CircleShape, + ambientColor = AvatarShadowColor, + spotColor = AvatarShadowColor, +) + +@Composable +private fun AvatarLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + color = Colors.White, + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ), + ) +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt index 4b596613e0..5d047216c5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -31,13 +32,13 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.Display import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -101,7 +102,7 @@ private fun Content( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -125,10 +126,16 @@ private fun Content( .fillMaxWidth() ) { items(uiState.contacts, key = { it.profile.publicKey }) { contact -> - SelectableContactRow( - contact = contact, - onToggle = { onToggleContact(contact.profile.publicKey) }, - ) + Column { + HorizontalDivider(color = Colors.White10) + SelectableContactRow( + contact = contact, + onToggle = { onToggleContact(contact.profile.publicKey) }, + ) + } + } + if (uiState.contacts.isNotEmpty()) { + item { HorizontalDivider(color = Colors.White10) } } } @@ -163,7 +170,7 @@ private fun SelectableContactRow( modifier = Modifier .fillMaxWidth() .clickableAlpha(onClick = onToggle) - .padding(vertical = 12.dp) + .padding(vertical = 24.dp) ) { ContactAvatar(profile = contact.profile) @@ -173,7 +180,7 @@ private fun SelectableContactRow( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f) ) { - BodyS( + Text13Up( text = contact.profile.truncatedPublicKey, color = Colors.White64, maxLines = 1, @@ -236,14 +243,14 @@ private fun FooterBar( HorizontalSpacer(16.dp) - val allSelected = selectedCount == totalCount TagButton( - text = if (allSelected) { - stringResource(R.string.contacts__import_select_none) - } else { - stringResource(R.string.contacts__import_select_all) - }, - onClick = if (allSelected) onSelectNone else onSelectAll, + text = stringResource(R.string.contacts__import_select_all), + onClick = onSelectAll.takeIf { selectedCount < totalCount }, + ) + HorizontalSpacer(8.dp) + TagButton( + text = stringResource(R.string.contacts__import_select_none), + onClick = onSelectNone.takeIf { selectedCount > 0 }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt index 31b8855ef3..5d673f3b4d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt @@ -68,7 +68,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.contacts__intro_add_contact), onClick = onContinue, - modifier = Modifier.testTag("ContactsIntro-button") + modifier = Modifier.testTag("ContactsIntroButton") ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt index e8793f10ab..86b186375c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt @@ -31,9 +31,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyS -import to.bitkit.ui.components.BodySSB -import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton @@ -97,6 +95,7 @@ private fun Content( ) Column(modifier = Modifier.padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() @@ -113,7 +112,7 @@ private fun Content( modifier = Modifier.testTag("ContactsAddButton") ) } - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) } when { @@ -167,12 +166,12 @@ private fun ContactsList( color = Colors.White64, modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) ContactRow( profile = myProfile, onClick = onClickMyProfile, modifier = Modifier.testTag("ContactsMyProfile") ) - HorizontalDivider() } } @@ -183,7 +182,7 @@ private fun ContactsList( color = Colors.White64, modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } items(contacts, key = { it.publicKey }) { contact -> @@ -192,7 +191,7 @@ private fun ContactsList( onClick = { onClickContact(contact.publicKey) }, modifier = Modifier.testTag("Contact_${contact.publicKey}") ) - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } @@ -210,7 +209,7 @@ private fun ContactRow( modifier = modifier .fillMaxWidth() .clickableAlpha(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) ) { PubkyContactAvatar(profile = profile) @@ -218,13 +217,13 @@ private fun ContactRow( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - BodyS( + Text13Up( text = profile.truncatedPublicKey, color = Colors.White64, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - BodySSB( + BodyMSB( text = profile.name, color = Colors.White, maxLines = 1, @@ -251,45 +250,44 @@ private fun EmptyState( onAddContact: () -> Unit, ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) + modifier = Modifier.fillMaxSize() ) { myProfile?.let { - VerticalSpacer(16.dp) Text13Up( text = stringResource(R.string.contacts__my_profile), color = Colors.White64, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) ContactRow( profile = it, onClick = onClickMyProfile, modifier = Modifier.testTag("ContactsMyProfile") ) - HorizontalDivider() } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + Text13Up( + text = stringResource(R.string.contacts__contacts_header), + color = Colors.White64, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 48.dp) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) { + BodyM( + text = stringResource(R.string.contacts__intro_description), + color = Colors.White64, + ) PrimaryButton( text = stringResource(R.string.contacts__intro_add_contact), onClick = onAddContact, modifier = Modifier.testTag("ContactsEmptyAddButton") ) - BodyM( - text = stringResource(R.string.contacts__empty_state), - color = Colors.White64, - ) } - - FillHeight() } } 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 d47d70cbe0..70820c97d2 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 @@ -1,15 +1,20 @@ package to.bitkit.ui.screens.contacts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon 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.draw.clip +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 @@ -19,10 +24,10 @@ import to.bitkit.R import to.bitkit.ui.components.AddLinkSheet import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.CenteredProfileHeader import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.ProfileEditForm import to.bitkit.ui.components.ProfileEditLink +import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog @@ -119,12 +124,7 @@ private fun Content( onCancel = onBackClick, isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving, avatarContent = { - CenteredProfileHeader( - publicKey = uiState.publicKey, - name = "", - bio = "", - imageUrl = uiState.imageUrl, - ) + ContactEditAvatar(imageUrl = uiState.imageUrl) }, publicKeyLabel = stringResource(R.string.contacts__pubky), bioPlaceholder = stringResource(R.string.contacts__edit_bio_placeholder), @@ -160,6 +160,29 @@ private fun Content( } } +@Composable +private fun ContactEditAvatar(imageUrl: String?) { + if (imageUrl != null) { + PubkyImage(uri = imageUrl, size = 96.dp) + return + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(Colors.Gray5) + ) { + Icon( + painter = painterResource(R.drawable.ic_user_square), + contentDescription = null, + tint = Colors.White32, + modifier = Modifier.size(48.dp) + ) + } +} + @Composable private fun LoadingState() { Box( 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 dcfb015e64..a1721cb827 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 @@ -35,7 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import to.bitkit.R import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.PrimaryButton @@ -43,6 +43,7 @@ import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.TextInput 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 @@ -108,6 +109,7 @@ private fun Content( AppTopBar( titleText = stringResource(navTitleRes), onBackClick = onBackClick, + actions = { DrawerNavIcon() }, ) if (uiState.isLoading) { @@ -128,7 +130,7 @@ private fun Content( modifier = Modifier.testTag("CreateProfileAvatar"), ) - VerticalSpacer(24.dp) + VerticalSpacer(32.dp) TextInput( value = uiState.name, @@ -151,7 +153,7 @@ private fun Content( color = Colors.White64, ) VerticalSpacer(8.dp) - BodyS( + BodyMSB( text = uiState.derivedPublicKey ?: "...", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), @@ -182,7 +184,7 @@ private fun AvatarPickerButton( Box( contentAlignment = Alignment.Center, modifier = modifier - .size(100.dp) + .size(96.dp) .clip(CircleShape) .background(Colors.Gray5) .clickable(onClick = onClick), 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 ea4e412f40..0d0c5a5e69 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 @@ -37,6 +37,7 @@ import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -131,6 +132,7 @@ private fun Content( AppTopBar( titleText = stringResource(R.string.profile__edit_nav_title), onBackClick = onBackClick, + actions = { DrawerNavIcon() }, ) if (uiState.isLoading) { @@ -220,7 +222,7 @@ private fun AvatarSection( Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(100.dp) + .size(96.dp) .clip(CircleShape) .background(Colors.Gray5) .testTag("EditProfileAvatar") @@ -233,7 +235,7 @@ private fun AvatarSection( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - imageUrl != null -> PubkyImage(uri = imageUrl, size = 100.dp) + imageUrl != null -> PubkyImage(uri = imageUrl, size = 96.dp) else -> Icon( painter = painterResource(R.drawable.ic_user_square), contentDescription = null, 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 b7bb3709f7..ee6baa0de5 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 @@ -2,15 +2,11 @@ package to.bitkit.ui.screens.profile import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults 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 @@ -21,7 +17,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display -import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar @@ -49,7 +44,6 @@ fun PayContactsScreen( Content( uiState = uiState, - onPaymentSharingChange = { viewModel.setPaymentSharingEnabled(it) }, onContinue = { viewModel.continueToProfile() }, onBackClick = onBackClick, ) @@ -58,7 +52,6 @@ fun PayContactsScreen( @Composable private fun Content( uiState: PayContactsUiState, - onPaymentSharingChange: (Boolean) -> Unit, onContinue: () -> Unit, onBackClick: () -> Unit, ) { @@ -90,33 +83,6 @@ private fun Content( text = stringResource(R.string.profile__pay_contacts_description), color = Colors.White64, ) - VerticalSpacer(24.dp) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - BodyM( - text = stringResource(R.string.profile__pay_contacts_toggle), - color = Colors.White, - modifier = Modifier.weight(1f) - ) - HorizontalSpacer(16.dp) - Switch( - checked = uiState.isPaymentSharingEnabled, - onCheckedChange = if (uiState.isLoading) null else onPaymentSharingChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Colors.White, - checkedTrackColor = Colors.PubkyGreen, - checkedBorderColor = Colors.PubkyGreen, - uncheckedThumbColor = Colors.White, - uncheckedTrackColor = Colors.Gray4, - uncheckedBorderColor = Colors.Gray4, - ), - modifier = Modifier.testTag("PayContactsToggle") - ) - } - VerticalSpacer(32.dp) PrimaryButton( text = stringResource(R.string.common__continue), @@ -135,7 +101,6 @@ private fun Preview() { AppThemeSurface { Content( uiState = PayContactsUiState(), - onPaymentSharingChange = {}, onContinue = {}, onBackClick = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 5ed92e43cf..b6d7404685 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -11,28 +11,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast -import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PublicPaykitError -import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject @HiltViewModel class PayContactsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val settingsStore: SettingsStore, - private val publicPaykitRepo: PublicPaykitRepo, - private val privatePaykitRepo: PrivatePaykitRepo, - private val pubkyRepo: PubkyRepo, + private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(PayContactsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -40,214 +31,26 @@ class PayContactsViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() - init { - viewModelScope.launch { - val settings = settingsStore.data.first() - val hasLocalSecretKey = pubkyRepo.hasSecretKey() - _uiState.update { - it.copy( - isPaymentSharingEnabled = resolvedSharingDefault(settings, hasLocalSecretKey), - ) - } - } - } - - fun setPaymentSharingEnabled(isEnabled: Boolean) { - _uiState.update { it.copy(isPaymentSharingEnabled = isEnabled) } - } - fun continueToProfile() { viewModelScope.launch { - val shouldPublish = _uiState.value.isPaymentSharingEnabled - val contacts = pubkyRepo.contacts.value.map { it.publicKey } _uiState.update { it.copy(isLoading = true) } - val result = if (shouldPublish) { - enableContactPayments(contacts) - } else { - disableContactPayments(contacts) - } - - result + contactPaymentSettingsRepo.setEnabled(true) .onSuccess { _uiState.update { it.copy(isLoading = false) } _effects.emit(PayContactsEffect.Continue) } .onFailure { - val settings = settingsStore.data.first() - val persistedValue = resolvedSharingDefault(settings, pubkyRepo.hasSecretKey()) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), description = syncErrorMessage(it), ) - _uiState.update { - it.copy( - isLoading = false, - isPaymentSharingEnabled = persistedValue, - ) - } - } - } - } - - private suspend fun enableContactPayments(contacts: List): Result { - val previous = settingsStore.data.first() - publicPaykitRepo.syncPublishedEndpoints(publish = true) - .onFailure { - rollbackEnabledContactPayments(previous, contacts, it) - return Result.failure(it) - } - - val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() - if (canUsePrivateContactPayments) { - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - rollbackEnabledContactPayments(previous, contacts, it) - return Result.failure(it) - } - } - - runSuspendCatching { - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = canUsePrivateContactPayments, - ) - } - }.onFailure { - rollbackEnabledContactPayments(previous, contacts, it) - return Result.failure(it) - } - - if (canUsePrivateContactPayments) { - privatePaykitRepo.prepareSavedContacts(contacts) - } - - return Result.success(Unit) - } - - private suspend fun rollbackEnabledContactPayments( - previous: SettingsData, - contacts: List, - error: Throwable, - ) { - runSuspendCatching { - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints, - sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints, - sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints, - ) - } - }.onFailure(error::addSuppressed) - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { rollbackError -> - error.addSuppressed(rollbackError) - markPublicPaykitRetry(error) - } - if (!previous.sharesPrivatePaykitEndpoints) { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) - } - } - - private suspend fun disableContactPayments(contacts: List): Result { - val previous = settingsStore.data.first() - runSuspendCatching { - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = false, - sharesPrivatePaykitEndpoints = false, - ) - } - }.onFailure { - return Result.failure(it) - } - - var publicCleanupError: Throwable? = null - var privateCleanupError: Throwable? = null - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { publicCleanupError = it } - - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure { privateCleanupError = it } - - publicCleanupError?.let { error -> - runSuspendCatching { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - }.onFailure { rollbackError -> - error.addSuppressed(rollbackError) - } - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { rollbackError -> - error.addSuppressed(rollbackError) - markPublicPaykitRetry(error) + _uiState.update { it.copy(isLoading = false) } } } - privateCleanupError?.let { error -> - if (previous.sharesPrivatePaykitEndpoints) { - restorePrivateContactPayments(contacts, error) - } else { - updatePrivateContactsPreference(isEnabled = false, error = error) - } - } - - val cleanupError = publicCleanupError ?: privateCleanupError - publicCleanupError?.let { publicError -> - privateCleanupError?.let { privateError -> publicError.addSuppressed(privateError) } - } - cleanupError?.let { - return Result.failure(it) - } - - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { return Result.failure(it) } - - return Result.success(Unit) } - private suspend fun restorePrivateContactPayments( - contacts: List, - error: Throwable, - ) { - val preferenceRestored = updatePrivateContactsPreference(isEnabled = true, error = error) - if (!preferenceRestored) return - - privatePaykitRepo.prepareSavedContacts( - publicKeys = contacts, - requireImmediatePublication = true, - ).exceptionOrNull()?.let { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) - return - } - - privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - } - publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) - } - - private suspend fun markPublicPaykitRetry(error: Throwable) { - runSuspendCatching { - settingsStore.update { it.copy(publicPaykitCleanupPending = true) } - }.onFailure(error::addSuppressed) - } - - private suspend fun updatePrivateContactsPreference( - isEnabled: Boolean, - error: Throwable, - ): Boolean = runSuspendCatching { - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) } - }.onFailure(error::addSuppressed).isSuccess - private fun syncErrorMessage(error: Throwable): String = when (error) { PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload) PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint) @@ -255,16 +58,10 @@ class PayContactsViewModel @Inject constructor( PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) else -> context.getString(R.string.common__error_body) } - - private fun resolvedSharingDefault(settings: SettingsData, hasLocalSecretKey: Boolean): Boolean = - settings.sharesPublicPaykitEndpoints || - (settings.sharesPrivatePaykitEndpoints && hasLocalSecretKey) || - !settings.hasConfirmedPublicPaykitEndpoints } @Immutable data class PayContactsUiState( - val isPaymentSharingEnabled: Boolean = true, val isLoading: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt index 35459ac79c..b10ba6d6e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt @@ -56,7 +56,7 @@ fun ProfileIntroScreen( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onContinue, - modifier = Modifier.testTag("ProfileIntro-button") + modifier = Modifier.testTag("ProfileIntroButton") ) 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 85099ff752..ae7abacb6f 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 @@ -23,6 +23,7 @@ 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 @@ -138,7 +139,7 @@ private fun ProfileBody( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp) + .padding(horizontal = 16.dp) ) { VerticalSpacer(24.dp) @@ -161,7 +162,7 @@ private fun ProfileBody( ) { QrCodeImage( content = profile.publicKey, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.size(279.dp), testTag = "ProfileQRCode", ) if (profile.imageUrl != null) { @@ -210,26 +211,35 @@ private fun ProfileBody( } } - if (profile.tags.isNotEmpty()) { - 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 = null) - } + 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 = onClickEdit, + displayIconClose = true, + ) } + TagButton( + text = stringResource(R.string.profile__add_tag), + onClick = onClickEdit, + icon = painterResource(R.drawable.ic_tag), + displayIconClose = true, + modifier = Modifier.testTag("ProfileAddTag") + ) } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index f8d09a1694..fbf4b87664 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -15,8 +15,10 @@ import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView 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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -66,6 +68,7 @@ import to.bitkit.models.sanitizedQrLogValue import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog @@ -85,6 +88,7 @@ private const val TAG = "QrScanningScreen" fun QrScanningScreen( onScanSuccess: (String) -> Unit, onBack: (() -> Unit)? = null, + isPubkyScan: Boolean = false, ) { val app = appViewModel ?: return @@ -197,6 +201,7 @@ fun QrScanningScreen( }, grantedContent = { Content( + isPubkyScan = isPubkyScan, previewView = previewView, onClickFlashlight = { isFlashlightOn = !isFlashlightOn @@ -236,6 +241,7 @@ private fun handlePaste( @Composable private fun Content( + isPubkyScan: Boolean, previewView: PreviewView, onClickFlashlight: () -> Unit, onClickGallery: () -> Unit, @@ -292,6 +298,31 @@ private fun Content( tint = Colors.White ) } + + if (isPubkyScan) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(999.dp)) + .background(Colors.Black50) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_broadcast), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp) + ) + Text13Up( + text = stringResource(R.string.contacts__scanner_status), + color = Colors.White64, + ) + } + } } VerticalSpacer(16.dp) PrimaryButton( @@ -301,7 +332,9 @@ private fun Content( contentDescription = stringResource(R.string.other__qr_paste), ) }, - text = stringResource(R.string.other__qr_paste), + text = stringResource( + if (isPubkyScan) R.string.contacts__scanner_paste else R.string.other__qr_paste + ), onClick = onPasteFromClipboard, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index e5fb80e977..d8e88edde8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -47,7 +47,6 @@ import to.bitkit.ui.navigateToDefaultUnitSettings import to.bitkit.ui.navigateToDevSettings import to.bitkit.ui.navigateToLanguageSettings import to.bitkit.ui.navigateToLocalCurrencySettings -import to.bitkit.ui.navigateToPaymentPreferenceSettings import to.bitkit.ui.navigateToPinManagement import to.bitkit.ui.navigateToQuickPaySettings import to.bitkit.ui.navigateToTagsSettings @@ -96,6 +95,8 @@ fun SettingsScreen( val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle() val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle() val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() + val contactPaymentsEnabled by settings.contactPaymentsEnabled.collectAsStateWithLifecycle() + val isUpdatingContactPayments by settings.isUpdatingContactPayments.collectAsStateWithLifecycle() val hardwareWallets by hwWalletViewModel.wallets.collectAsStateWithLifecycle() val languageUiState by languageViewModel.uiState.collectAsStateWithLifecycle() @@ -134,6 +135,8 @@ fun SettingsScreen( notificationsGranted = notificationsGranted, isPubkyAuthenticated = isPubkyAuthenticated, isPaykitEnabled = isPaykitEnabled, + contactPaymentsEnabled = contactPaymentsEnabled, + isUpdatingContactPayments = isUpdatingContactPayments, hardwareWalletCount = hardwareWallets.size, ), securityState = SecurityTabState( @@ -164,7 +167,9 @@ fun SettingsScreen( SettingsEvent.WidgetsClick -> navController.navigateToWidgetsSettings() SettingsEvent.TagsClick -> navController.navigateToTagsSettings() SettingsEvent.TransactionSpeedClick -> navController.navigateToTransactionSpeedSettings() - SettingsEvent.PaymentPreferenceClick -> navController.navigateToPaymentPreferenceSettings() + SettingsEvent.ContactPaymentsClick -> { + settings.setContactPaymentsEnabled(!contactPaymentsEnabled) + } SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen) SettingsEvent.BgPaymentsClick -> { if (bgPaymentsIntroSeen || notificationsGranted) { @@ -331,6 +336,17 @@ private fun GeneralTabContent( padding = PaddingValues(top = 16.dp), ) + if (state.isPaykitEnabled && state.isPubkyAuthenticated) { + SettingsSwitchRow( + title = stringResource(R.string.settings__general__enable_contact_payments), + isChecked = state.contactPaymentsEnabled, + icon = { SettingsIcon(R.drawable.ic_coins) }, + onClick = { onEvent(SettingsEvent.ContactPaymentsClick) }, + enabled = !state.isUpdatingContactPayments, + switchTestTag = "ContactPaymentsSwitch", + modifier = Modifier.testTag("ContactPaymentsSettings") + ) + } SettingsButtonRow( title = stringResource(R.string.settings__general__speed), icon = { @@ -346,14 +362,6 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.TransactionSpeedClick) }, modifier = Modifier.testTag("TransactionSpeedSettings") ) - if (state.isPaykitEnabled && state.isPubkyAuthenticated) { - SettingsButtonRow( - title = stringResource(R.string.settings__payment_pref_title), - icon = { SettingsIcon(R.drawable.ic_coins) }, - onClick = { onEvent(SettingsEvent.PaymentPreferenceClick) }, - modifier = Modifier.testTag("PaymentPreferenceSettings") - ) - } SettingsButtonRow( title = stringResource(R.string.settings__quickpay__nav_title), icon = { SettingsIcon(R.drawable.ic_caret_double_right) }, @@ -673,7 +681,7 @@ sealed interface SettingsEvent { data object WidgetsClick : SettingsEvent data object TagsClick : SettingsEvent data object TransactionSpeedClick : SettingsEvent - data object PaymentPreferenceClick : SettingsEvent + data object ContactPaymentsClick : SettingsEvent data object QuickPayClick : SettingsEvent data object BgPaymentsClick : SettingsEvent data object HardwareWalletsClick : SettingsEvent @@ -720,6 +728,8 @@ data class GeneralTabState( val notificationsGranted: Boolean = false, val isPubkyAuthenticated: Boolean = false, val isPaykitEnabled: Boolean = false, + val contactPaymentsEnabled: Boolean = false, + val isUpdatingContactPayments: Boolean = false, val hardwareWalletCount: Int = 0, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt deleted file mode 100644 index 520d07e4eb..0000000000 --- a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt +++ /dev/null @@ -1,145 +0,0 @@ -package to.bitkit.ui.settings.paymentPreference - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyS -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SectionHeader -import to.bitkit.ui.components.settings.SettingsSwitchRow -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -fun PaymentPreferenceScreen( - onBack: () -> Unit, - viewModel: PaymentPreferenceViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - PaymentPreferenceContent( - uiState = uiState, - onBack = onBack, - onToggleLightning = { viewModel.setLightningEnabled(!uiState.lightningEnabled) }, - onToggleOnchain = { viewModel.setOnchainEnabled(!uiState.onchainEnabled) }, - onTogglePrivateContacts = { viewModel.setPrivateContactsEnabled(!uiState.privateContactsEnabled) }, - onTogglePublicContacts = { viewModel.setPublicContactsEnabled(!uiState.publicContactsEnabled) }, - ) -} - -@Composable -private fun PaymentPreferenceContent( - uiState: PaymentPreferenceUiState, - onBack: () -> Unit = {}, - onToggleLightning: () -> Unit = {}, - onToggleOnchain: () -> Unit = {}, - onTogglePrivateContacts: () -> Unit = {}, - onTogglePublicContacts: () -> Unit = {}, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.settings__payment_pref_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) - .testTag("PaymentPreferenceScreen") - ) { - BodyM( - text = stringResource(R.string.settings__payment_pref_header), - color = Colors.White64, - modifier = Modifier.padding(top = 32.dp, bottom = 16.dp) - ) - - SectionHeader( - title = stringResource(R.string.settings__payment_pref_options), - padding = PaddingValues.Zero, - ) - - SettingsSwitchRow( - title = stringResource(R.string.settings__payment_pref_lightning), - isChecked = uiState.lightningEnabled, - onClick = onToggleLightning, - enabled = !uiState.isUpdatingPaymentOptions && (!uiState.lightningEnabled || uiState.onchainEnabled), - modifier = Modifier.testTag("PaymentPreferenceLightning") - ) - SettingsSwitchRow( - title = stringResource(R.string.settings__payment_pref_onchain), - isChecked = uiState.onchainEnabled, - onClick = onToggleOnchain, - enabled = !uiState.isUpdatingPaymentOptions && (!uiState.onchainEnabled || uiState.lightningEnabled), - modifier = Modifier.testTag("PaymentPreferenceOnchain") - ) - - if (uiState.hasPubkyProfile) { - SectionHeader( - title = stringResource(R.string.settings__payment_pref_contacts), - padding = PaddingValues(top = 16.dp), - ) - - if (uiState.canUsePrivateContacts) { - SettingsSwitchRow( - title = stringResource(R.string.settings__payment_pref_private_contacts), - isChecked = uiState.privateContactsEnabled, - onClick = onTogglePrivateContacts, - enabled = !uiState.isUpdatingPrivateContacts, - modifier = Modifier.testTag("PaymentPreferencePrivateContacts") - ) - } - SettingsSwitchRow( - title = stringResource(R.string.settings__payment_pref_public_contacts), - isChecked = uiState.publicContactsEnabled, - onClick = onTogglePublicContacts, - enabled = !uiState.isUpdatingPublicContacts, - modifier = Modifier.testTag("PaymentPreferencePublicContacts") - ) - } - - VerticalSpacer(220.dp) - if (uiState.hasPubkyProfile) { - BodyS( - text = stringResource(R.string.settings__payment_pref_contacts_footer), - color = Colors.White64, - ) - } - VerticalSpacer(32.dp) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun Preview() { - AppThemeSurface { - PaymentPreferenceContent( - uiState = PaymentPreferenceUiState( - lightningEnabled = true, - onchainEnabled = true, - privateContactsEnabled = true, - publicContactsEnabled = true, - hasPubkyProfile = true, - canUsePrivateContacts = true, - ), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt deleted file mode 100644 index 06d703c1bc..0000000000 --- a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt +++ /dev/null @@ -1,326 +0,0 @@ -package to.bitkit.ui.settings.paymentPreference - -import android.content.Context -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import to.bitkit.R -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.ext.runSuspendCatching -import to.bitkit.models.Toast -import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo -import to.bitkit.repositories.PublicPaykitError -import to.bitkit.repositories.PublicPaykitRepo -import to.bitkit.ui.shared.toast.ToastEventBus -import javax.inject.Inject - -@HiltViewModel -class PaymentPreferenceViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val settingsStore: SettingsStore, - private val publicPaykitRepo: PublicPaykitRepo, - private val privatePaykitRepo: PrivatePaykitRepo, - private val pubkyRepo: PubkyRepo, -) : ViewModel() { - private val _uiState = MutableStateFlow(PaymentPreferenceUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - private val privateContactsPendingValue = MutableStateFlow(null) - - init { - viewModelScope.launch { - combine( - settingsStore.data, - pubkyRepo.isAuthenticated, - privateContactsPendingValue, - ) { settings, isAuthenticated, pendingPrivateContactsEnabled -> - val canUsePrivateContacts = isAuthenticated && pubkyRepo.hasSecretKey() - if (!canUsePrivateContacts && settings.sharesPrivatePaykitEndpoints) { - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - } - PaymentPreferenceStateSource( - settings = settings, - isAuthenticated = isAuthenticated, - canUsePrivateContacts = canUsePrivateContacts, - pendingPrivateContactsEnabled = pendingPrivateContactsEnabled, - ) - }.collect { stateSource -> - _uiState.update { it.from(stateSource) } - } - } - } - - fun setLightningEnabled(isEnabled: Boolean) { - updatePaymentMethod(lightningEnabled = isEnabled) - } - - fun setOnchainEnabled(isEnabled: Boolean) { - updatePaymentMethod(onchainEnabled = isEnabled) - } - - fun setPrivateContactsEnabled(isEnabled: Boolean) { - if (_uiState.value.isUpdatingPrivateContacts) return - if (isEnabled && !_uiState.value.hasPubkyProfile) { - viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } - return - } - if (isEnabled && !_uiState.value.canUsePrivateContacts) { - viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } - return - } - viewModelScope.launch { - val previous = settingsStore.data.first() - privateContactsPendingValue.update { _uiState.value.privateContactsEnabled } - _uiState.update { it.copy(isUpdatingPrivateContacts = true) } - val result = runSuspendCatching { - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = isEnabled, - ) - } - if (isEnabled) { - privatePaykitRepo.enableSharingAndPrepareSavedContacts( - publicKeys = contactPublicKeys(), - requireImmediatePublication = true, - ).getOrThrow() - } else { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys()).getOrThrow() - } - if (!previous.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = isEnabled).getOrThrow() - } - } - - result.exceptionOrNull()?.let { - rollbackPrivateContactsPreference( - requestedEnabled = isEnabled, - previous = previous, - error = it, - ) - showSyncError(it) - } - privateContactsPendingValue.update { null } - _uiState.update { it.copy(isUpdatingPrivateContacts = false) } - } - } - - fun setPublicContactsEnabled(isEnabled: Boolean) { - if (_uiState.value.isUpdatingPublicContacts) return - if (isEnabled && !_uiState.value.hasPubkyProfile) { - viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } - return - } - viewModelScope.launch { - _uiState.update { it.copy(isUpdatingPublicContacts = true) } - val previous = settingsStore.data.first() - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = isEnabled, - ) - } - - publicPaykitRepo.syncPublishedEndpoints(publish = isEnabled).exceptionOrNull()?.let { error -> - rollbackPublicContactsPreference(previous, error) - showSyncError(error) - } - _uiState.update { it.copy(isUpdatingPublicContacts = false) } - } - } - - private fun updatePaymentMethod( - lightningEnabled: Boolean = _uiState.value.lightningEnabled, - onchainEnabled: Boolean = _uiState.value.onchainEnabled, - ) { - if (_uiState.value.isUpdatingPaymentOptions) return - if (!lightningEnabled && !onchainEnabled) { - viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.common__error), - description = context.getString(R.string.settings__payment_pref_keep_one), - ) - } - return - } - - viewModelScope.launch { - _uiState.update { it.copy(isUpdatingPaymentOptions = true) } - val previous = settingsStore.data.first() - settingsStore.update { - it.copy( - publicPaykitLightningEnabled = lightningEnabled, - publicPaykitOnchainEnabled = onchainEnabled, - ) - } - - val result = refreshPublishedPreferences() - result.exceptionOrNull()?.let { - settingsStore.update { settings -> - settings.copy( - publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled, - publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled, - ) - } - refreshPublishedPreferences() - showSyncError(it) - } - _uiState.update { it.copy(isUpdatingPaymentOptions = false) } - } - } - - private suspend fun refreshPublishedPreferences(): Result = runSuspendCatching { - val settings = settingsStore.data.first() - if (settings.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncCurrentPublishedEndpoints( - forceRefreshLightning = true, - requireEndpoint = true, - ).getOrThrow() - } - if (settings.sharesPrivatePaykitEndpoints) { - if (pubkyRepo.hasSecretKey()) { - privatePaykitRepo.prepareSavedContacts( - publicKeys = contactPublicKeys(), - requireImmediatePublication = true, - ).getOrThrow() - } else { - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = false).getOrThrow() - } - } - } - - private fun contactPublicKeys(): List = - pubkyRepo.contacts.value.map { it.publicKey } - - private suspend fun rollbackPublicContactsPreference(previous: SettingsData, error: Throwable) { - runSuspendCatching { - settingsStore.update { settings -> - settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) - } - }.onFailure(error::addSuppressed) - - publicPaykitRepo.syncPublishedEndpoints(publish = previous.sharesPublicPaykitEndpoints) - .onFailure { rollbackError -> - error.addSuppressed(rollbackError) - runSuspendCatching { - settingsStore.update { it.copy(publicPaykitCleanupPending = true) } - }.onFailure(error::addSuppressed) - } - } - - private suspend fun rollbackPrivateContactsPreference( - requestedEnabled: Boolean, - previous: SettingsData, - error: Throwable, - ) { - val contacts = contactPublicKeys() - if (!requestedEnabled && previous.sharesPrivatePaykitEndpoints) { - restorePrivateContactsPreference(contacts, error) - return - } - - runSuspendCatching { - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints) } - }.onFailure(error::addSuppressed) - if (!previous.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = previous.sharesPrivatePaykitEndpoints) - .onFailure(error::addSuppressed) - } - - if (requestedEnabled && !previous.sharesPrivatePaykitEndpoints) { - privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure(error::addSuppressed) - } - } - - private suspend fun restorePrivateContactsPreference( - contacts: List, - error: Throwable, - ) { - val preferenceRestored = updatePrivateContactsPreference(isEnabled = true, error = error) - if (!preferenceRestored) return - - privatePaykitRepo.prepareSavedContacts( - publicKeys = contacts, - requireImmediatePublication = true, - ).exceptionOrNull()?.let { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) - return - } - - privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { - error.addSuppressed(it) - updatePrivateContactsPreference(isEnabled = false, error = error) - } - publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) - } - - private suspend fun updatePrivateContactsPreference( - isEnabled: Boolean, - error: Throwable, - ): Boolean = runSuspendCatching { - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = isEnabled) } - }.onFailure(error::addSuppressed).isSuccess - - private suspend fun showSyncError(error: Throwable) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = when (error) { - PublicPaykitError.InvalidPayload -> - context.getString(R.string.profile__pay_contacts_error_invalid_payload) - - PublicPaykitError.NoSupportedEndpoint -> - context.getString(R.string.profile__pay_contacts_error_no_endpoint) - - PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__session_expired) - PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) - else -> context.getString(R.string.common__error_body) - }, - ) - } -} - -@Immutable -data class PaymentPreferenceUiState( - val lightningEnabled: Boolean = true, - val onchainEnabled: Boolean = true, - val privateContactsEnabled: Boolean = false, - val publicContactsEnabled: Boolean = false, - val hasPubkyProfile: Boolean = false, - val canUsePrivateContacts: Boolean = false, - val isUpdatingPaymentOptions: Boolean = false, - val isUpdatingPrivateContacts: Boolean = false, - val isUpdatingPublicContacts: Boolean = false, -) - -private data class PaymentPreferenceStateSource( - val settings: SettingsData, - val isAuthenticated: Boolean, - val canUsePrivateContacts: Boolean, - val pendingPrivateContactsEnabled: Boolean?, -) - -private fun PaymentPreferenceUiState.from(source: PaymentPreferenceStateSource) = copy( - lightningEnabled = source.settings.publicPaykitLightningEnabled, - onchainEnabled = source.settings.publicPaykitOnchainEnabled, - privateContactsEnabled = source.pendingPrivateContactsEnabled - ?: (source.settings.sharesPrivatePaykitEndpoints && source.canUsePrivateContacts), - publicContactsEnabled = source.settings.sharesPublicPaykitEndpoints, - hasPubkyProfile = source.isAuthenticated, - canUsePrivateContacts = source.canUsePrivateContacts, -) diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt index eb37d5ad13..515f5e1ab0 100644 --- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt @@ -46,6 +46,7 @@ fun Modifier.sheetHeight( maxOf(preferred, min) } + SheetSize.COMPACT -> 460.dp + Insets.Bottom SheetSize.SMALL -> 400.dp + Insets.Bottom } diff --git a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt index 0b6671fbda..d0086f37a6 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/QrScanningSheet.kt @@ -4,13 +4,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import to.bitkit.ui.components.Sheet import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.viewmodels.AppViewModel @Composable -fun QrScanningSheet(appViewModel: AppViewModel) { +fun QrScanningSheet( + sheet: Sheet.QrScanner, + appViewModel: AppViewModel, +) { Content( + isPubkyScan = sheet.isPubkyScan, onBack = { appViewModel.hideScannerSheet() }, onScanSuccess = { appViewModel.onScannerSheetResult(it) }, ) @@ -18,6 +23,7 @@ fun QrScanningSheet(appViewModel: AppViewModel) { @Composable private fun Content( + isPubkyScan: Boolean, onBack: () -> Unit, onScanSuccess: (String) -> Unit, ) { @@ -27,6 +33,7 @@ private fun Content( .sheetHeight() ) { QrScanningScreen( + isPubkyScan = isPubkyScan, onScanSuccess = onScanSuccess, onBack = onBack, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3449a58acf..ae40b68c2d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2770,9 +2770,12 @@ class AppViewModel @Inject constructor( // region Sheets private var scanResultHandler: ((String) -> Unit)? = null - fun showScannerSheet(onResult: ((String) -> Unit)? = null) { + fun showScannerSheet( + isPubkyScan: Boolean = false, + onResult: ((String) -> Unit)? = null, + ) { scanResultHandler = onResult - showSheet(Sheet.QrScanner) + showSheet(Sheet.QrScanner(isPubkyScan = isPubkyScan)) } fun onScannerSheetResult(data: String) { diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 91c2e38e19..a4a5429e17 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -1,36 +1,48 @@ package to.bitkit.viewmodels +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.hasPublicPaykitPublicationState import to.bitkit.data.paykitDisabled import to.bitkit.flags.PaykitFeatureFlags +import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class SettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val settingsStore: SettingsStore, private val pubkyRepo: PubkyRepo, + private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, private val widgetsStore: WidgetsStore, @@ -188,12 +200,43 @@ class SettingsViewModel @Inject constructor( val isPaykitStateLoaded = settingsStore.isPaykitEnabled.map { true } .asStateFlow(initialValue = false) + val contactPaymentsEnabled = contactPaymentSettingsRepo.isEnabled + .asStateFlow(initialValue = false) + + private val _isUpdatingContactPayments = MutableStateFlow(false) + val isUpdatingContactPayments = _isUpdatingContactPayments.asStateFlow() + + fun setContactPaymentsEnabled(value: Boolean) { + if (_isUpdatingContactPayments.value) return + + viewModelScope.launch { + _isUpdatingContactPayments.update { true } + contactPaymentSettingsRepo.setEnabled(value) + .onFailure { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = contactPaymentSyncErrorMessage(it), + ) + } + _isUpdatingContactPayments.update { false } + } + } + fun setIsPaykitEnabled(value: Boolean) { viewModelScope.launch { updatePaykitEnabled(value) } } + private fun contactPaymentSyncErrorMessage(error: Throwable): String = when (error) { + PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload) + PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint) + PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__pay_contacts_error_session) + PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) + else -> context.getString(R.string.common__error_body) + } + private suspend fun updatePaykitEnabled(value: Boolean) { val shouldEnable = value && PaykitFeatureFlags.isUiAvailable val previousSettings = settingsStore.data.first() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e205f76fb7..10a4fe39cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,18 +103,17 @@ Received from %1$s Sent to %1$s Add - Contact saved Add Contact - Add a new contact by scanning their QR or pasting their pubky below. - Discard + Add a new contact by scanning their QR or by pasting their pubky below. This pubky is already in your contacts. Could not retrieve contact info. Please check the public key and try again. Invalid pubky key format. Please check and try again. You can\'t add your own pubky as a contact. Please note that you and %1$s must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly. + Pay PUBKY Paste a pubky - Retrieving\n<accent>contact info</accent> + Retrieving\ncontact info Scan QR Add Contact CONTACTS @@ -128,11 +127,10 @@ Contact updated Edit Contact Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile. - You don\'t have any contacts yet. Import All %1$d friends Found\n<accent>profile & contacts</accent> - Bitkit found profile and contacts data connected to pubky %1$s + Bitkit found profile and contacts data connected to pubky <accent>%1$s</accent> Select Select all Select\n<accent>contacts</accent> @@ -141,11 +139,13 @@ %1$d selected Import Add Contact - Get automatic updates from contacts, pay them, and follow their public profiles. + Pay your contacts with just a tap. Send payments directly, to anyone, anywhere. Dynamic\n<accent>contacts</accent> MY PROFILE Contacts Pubky + Paste QR Or Link + SCANNING QR, NFC & BLUETOOTH Depends on the fee Depends on the fee Custom @@ -604,18 +604,18 @@ Profile deleted Deriving your keys… Failed to disconnect profile - NOTES + BIO Short note. Tell a bit about yourself. DELETE YOUR NAME Edit Profile - Please note profile information is stored in public files. Changes you make in Bitkit will not update your pubky.app profile. + Please note profile information is stored in public files. Failed to save profile Profile saved TAGS Unable to load your profile. - Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem. - Portable\n<accent>pubky\nprofile</accent> + With your portable profile your contacts can reach you and pay you anywhere. + PORTABLE\n<accent>PROFILE</accent> Profile Use Bitkit with your contacts to send payments directly, anytime, anywhere. Payment endpoint data could not be prepared. @@ -624,7 +624,6 @@ Wallet is still starting. Try again in a moment. Let your\ncontacts\n<accent>pay you</accent> Pay Contacts - Share payment data and enable payments with contacts Public Key Scan to add {name} Restore Profile @@ -884,6 +883,7 @@ Classic (₿ 0.00010000) Bitcoin denomination Modern (₿ 10 000) + Enable payments with contacts Interface Payments Transaction Speed @@ -906,16 +906,6 @@ Rename Hardware Wallet System Settings Language - Payments from contacts - *Public payments with contacts requires payment data to be shared publicly. - Choose how you prefer to receive money when users send funds to your profile key. - Keep at least one payment method enabled. - Lightning (Bitkit) - On-chain (Bitkit) - Payment options - Private payments with contacts - Public payments with contacts* - Payment Preference Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. <accent>Frictionless</accent>\npayments QuickPay diff --git a/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt new file mode 100644 index 0000000000..5e39ec4e0d --- /dev/null +++ b/app/src/test/java/to/bitkit/data/serializers/SettingsSerializerTest.kt @@ -0,0 +1,26 @@ +package to.bitkit.data.serializers + +import kotlinx.serialization.encodeToString +import org.junit.Test +import to.bitkit.data.SettingsData +import to.bitkit.di.json +import to.bitkit.test.BaseUnitTest +import java.io.ByteArrayInputStream +import kotlin.test.assertTrue + +class SettingsSerializerTest : BaseUnitTest() { + @Test + fun `read resets hidden Paykit payment methods`() = test { + val stored = SettingsData( + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = false, + ) + + val result = SettingsSerializer.readFrom( + ByteArrayInputStream(json.encodeToString(stored).encodeToByteArray()) + ) + + assertTrue(result.publicPaykitLightningEnabled) + assertTrue(result.publicPaykitOnchainEnabled) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt new file mode 100644 index 0000000000..5f92c5fc26 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -0,0 +1,167 @@ +package to.bitkit.repositories + +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyProfile +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ContactPaymentSettingsRepoTest : BaseUnitTest() { + companion object { + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + + private val settingsStore: SettingsStore = mock() + private val publicPaykitRepo: PublicPaykitRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + private val settingsFlow = MutableStateFlow(SettingsData()) + + @Before + fun setUp() { + settingsFlow.value = SettingsData() + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + Unit + } + whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } + .thenReturn(Result.success(Unit)) + } + + @Test + fun `enabling publishes public and private contact payments`() = test { + settingsFlow.value = SettingsData( + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = false, + ) + + val result = createSut().setEnabled(true) + + assertTrue(result.isSuccess) + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitLightningEnabled) + assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) + } + + @Test + fun `enabling without local key enables only public payments`() = test { + whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + + val result = createSut().setEnabled(true) + + assertTrue(result.isSuccess) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + } + + @Test + fun `failed publication restores disabled settings`() = test { + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) } + .thenReturn(Result.failure(ContactPaymentSettingsTestError("publish failed"))) + + val result = createSut().setEnabled(true) + + assertTrue(result.isFailure) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + } + + @Test + fun `disabling removes public and private contact payments`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + + val result = createSut().setEnabled(false) + + assertTrue(result.isSuccess) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).setContactSharingCleanupPending(false) + } + + @Test + fun `failed private cleanup restores private contact payments`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } + .thenReturn(Result.failure(ContactPaymentSettingsTestError("cleanup failed"))) + + val result = createSut().setEnabled(false) + + assertTrue(result.isFailure) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) + } + + @Test + fun `failed private restore leaves private payments disabled`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } + .thenReturn(Result.failure(ContactPaymentSettingsTestError("cleanup failed"))) + whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } + .thenReturn(Result.failure(ContactPaymentSettingsTestError("restore failed"))) + + val result = createSut().setEnabled(false) + + assertTrue(result.isFailure) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + } + + private fun createSut() = ContactPaymentSettingsRepo( + settingsStore = settingsStore, + publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, + pubkyRepo = pubkyRepo, + ioDispatcher = testDispatcher, + ) + + private fun createContact() = PubkyProfile( + publicKey = CONTACT_KEY, + name = "Alice", + bio = "", + imageUrl = null, + links = emptyList(), + tags = persistentListOf(), + status = null, + ) +} + +private class ContactPaymentSettingsTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 15041d7082..6ad1eb9c06 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.contacts import android.content.Context import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test @@ -80,6 +81,23 @@ class AddContactViewModelTest : BaseUnitTest() { assertNull(sut.uiState.value.error) } + @Test + fun `saving emits saved contact key`() = test { + val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY) + whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile)) + whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).thenReturn(Result.success(false)) + whenever { pubkyRepo.addContact(TEST_PUBLIC_KEY, profile) }.thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.saveContact() + advanceUntilIdle() + + assertEquals(AddContactEffect.ContactSaved(TEST_PUBLIC_KEY), awaitItem()) + } + } + private fun createSut(publicKey: String = TEST_PUBLIC_KEY): AddContactViewModel { return AddContactViewModel( context = context, diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt new file mode 100644 index 0000000000..2ccce1ff63 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -0,0 +1,85 @@ +package to.bitkit.ui.screens.contacts + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +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 ContactDetailViewModelTest : BaseUnitTest() { + companion object { + private const val TEST_PUBLIC_KEY = "pubkytest-contact" + } + + private val context: Context = mock() + private val pubkyRepo: PubkyRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + + @Test + fun `deleting contact emits deleted effect`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(pubkyRepo.removeContact(TEST_PUBLIC_KEY)).thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.showDeleteConfirmation() + assertTrue(sut.uiState.value.showDeleteDialog) + + sut.deleteContact() + advanceUntilIdle() + + verify(pubkyRepo).removeContact(TEST_PUBLIC_KEY) + assertFalse(sut.uiState.value.showDeleteDialog) + assertEquals(ContactDetailEffect.ContactDeleted, awaitItem()) + } + } + + @Test + fun `failed contact deletion restores loading state`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(pubkyRepo.removeContact(TEST_PUBLIC_KEY)) + .thenReturn(Result.failure(ContactDetailTestError("delete failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.showDeleteConfirmation() + sut.deleteContact() + advanceUntilIdle() + + verify(pubkyRepo).removeContact(TEST_PUBLIC_KEY) + assertFalse(sut.uiState.value.showDeleteDialog) + assertFalse(sut.uiState.value.isLoading) + expectNoEvents() + } + } + + private fun createSut() = ContactDetailViewModel( + context = context, + pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepo, + savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), + ) + + private fun createContact() = PubkyProfile.placeholder(TEST_PUBLIC_KEY) +} + +private class ContactDetailTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 0cdd04d145..4cd96547e7 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -2,337 +2,66 @@ package to.bitkit.ui.screens.profile import android.content.Context import app.cash.turbine.test -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.PubkyProfile -import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo -import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo 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 PayContactsViewModelTest : BaseUnitTest() { - companion object { - private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - } - private val context: Context = mock() - private val settingsStore: SettingsStore = mock() - private val publicPaykitRepo: PublicPaykitRepo = mock() - private val privatePaykitRepo: PrivatePaykitRepo = mock() - private val pubkyRepo: PubkyRepo = mock() - - private val settingsFlow = MutableStateFlow(SettingsData()) - private val contactsFlow = MutableStateFlow(listOf(createContact(CONTACT_KEY))) + private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo = mock() @Before fun setUp() { - settingsFlow.value = SettingsData() - contactsFlow.value = listOf(createContact(CONTACT_KEY)) - whenever(context.getString(any())).thenReturn("") - whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(pubkyRepo.contacts).thenReturn(contactsFlow) - whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) - whenever { settingsStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - settingsFlow.value = transform(settingsFlow.value) - Unit - } - whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.success(Unit)) - } - - @Test - fun `continueToProfile enables sharing and prepares private contacts`() = test { - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) - verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) - } - - @Test - fun `continueToProfile enables only public sharing without local secret key`() = test { - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - } - - @Test - fun `continueToProfile cleans up when initial public publish fails`() = test { - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = true) } - .thenReturn(Result.failure(PayContactsTestAppError("publish failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - } - - @Test - fun `continueToProfile keeps sharing disabled when cleanup marker clear fails`() = test { - whenever { privatePaykitRepo.setContactSharingCleanupPending(false) } - .thenReturn(Result.failure(PayContactsTestAppError("marker failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) - } - - @Test - fun `continueToProfile proceeds when private contact preparation fails`() = test { - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.failure(PayContactsTestAppError("private setup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(true) - sut.continueToProfile() - advanceUntilIdle() - - assertEquals(PayContactsEffect.Continue, awaitItem()) - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) + whenever { contactPaymentSettingsRepo.setEnabled(true) }.thenReturn(Result.success(Unit)) } @Test - fun `continueToProfile clears cleanup marker after disabling succeeds`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - ) + fun `continue enables contact payments and continues`() = test { val sut = createSut() - advanceUntilIdle() sut.effects.test { - sut.setPaymentSharingEnabled(false) sut.continueToProfile() advanceUntilIdle() assertEquals(PayContactsEffect.Continue, awaitItem()) } - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) + verify(contactPaymentSettingsRepo).setEnabled(true) assertFalse(sut.uiState.value.isLoading) } @Test - fun `continueToProfile marks cleanup pending when disabling fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) + fun `continue stays on screen when enabling fails`() = test { + whenever { contactPaymentSettingsRepo.setEnabled(true) } + .thenReturn(Result.failure(PayContactsTestAppError("sync failed"))) val sut = createSut() - advanceUntilIdle() sut.effects.test { - sut.setPaymentSharingEnabled(false) sut.continueToProfile() advanceUntilIdle() expectNoEvents() } - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(sut.uiState.value.isLoading) - assertFalse(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) - } - - @Test - fun `continueToProfile restores private sharing when private cleanup fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertTrue(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo).syncLocalReceiverMarker() - } - - @Test - fun `continueToProfile keeps private sharing disabled when private restore fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PayContactsTestAppError("restore failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertFalse(sut.uiState.value.isPaymentSharingEnabled) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - } - - @Test - fun `continueToProfile restores public sharing when public cleanup fails`() = test { - settingsFlow.value = SettingsData( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } - .thenReturn(Result.failure(PayContactsTestAppError("public cleanup failed"))) - val sut = createSut() - advanceUntilIdle() - - sut.effects.test { - sut.setPaymentSharingEnabled(false) - sut.continueToProfile() - advanceUntilIdle() - - expectNoEvents() - } - - assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isLoading) - assertTrue(sut.uiState.value.isPaymentSharingEnabled) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) } private fun createSut() = PayContactsViewModel( context = context, - settingsStore = settingsStore, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, - pubkyRepo = pubkyRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, ) } -private fun createContact(publicKey: String) = PubkyProfile( - publicKey = publicKey, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = persistentListOf(), - status = null, -) - private class PayContactsTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt deleted file mode 100644 index f12d7cc09f..0000000000 --- a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt +++ /dev/null @@ -1,337 +0,0 @@ -package to.bitkit.ui.settings.paymentPreference - -import android.content.Context -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceUntilIdle -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.eq -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.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.PubkyProfile -import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyRepo -import to.bitkit.repositories.PublicPaykitError -import to.bitkit.repositories.PublicPaykitRepo -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class PaymentPreferenceViewModelTest : BaseUnitTest() { - companion object { - private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - } - - private val context: Context = mock() - private val settingsStore: SettingsStore = mock() - private val publicPaykitRepo: PublicPaykitRepo = mock() - private val privatePaykitRepo: PrivatePaykitRepo = mock() - private val pubkyRepo: PubkyRepo = mock() - - private val settingsFlow = MutableStateFlow(SettingsData()) - private val contactsFlow = MutableStateFlow(listOf(createPaymentPreferenceContact(CONTACT_KEY))) - private val isAuthenticatedFlow = MutableStateFlow(true) - - @Before - fun setUp() { - settingsFlow.value = SettingsData() - contactsFlow.value = listOf(createPaymentPreferenceContact(CONTACT_KEY)) - isAuthenticatedFlow.value = true - - whenever(context.getString(any())).thenReturn("") - whenever(settingsStore.data).thenReturn(settingsFlow) - whenever(pubkyRepo.contacts).thenReturn(contactsFlow) - whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticatedFlow) - whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) - whenever { settingsStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - settingsFlow.value = transform(settingsFlow.value) - Unit - } - whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) } - .thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncPublishedEndpoints(any()) } - .thenReturn(Result.success(Unit)) - whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.success(Unit)) - } - - @Test - fun `setPrivateContactsEnabled prepares contacts with immediate publication`() = test { - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo).syncLocalReceiverMarker(privateSharingEnabled = true) - } - - @Test - fun `setPrivateContactsEnabled does not republish marker while public sharing is enabled`() = test { - settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(publicPaykitRepo, never()).syncLocalReceiverMarker(anyOrNull(), anyOrNull()) - } - - @Test - fun `setPublicContactsEnabled republishes previous state when disabling fails`() = test { - settingsFlow.value = SettingsData(sharesPublicPaykitEndpoints = true) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } - .thenReturn(Result.failure(PublicPaykitError.PublicationFailed)) - val sut = createSut() - advanceUntilIdle() - - sut.setPublicContactsEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) - assertFalse(settingsFlow.value.publicPaykitCleanupPending) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - } - - @Test - fun `setPrivateContactsEnabled rolls back when receiver marker sync fails`() = test { - whenever { publicPaykitRepo.syncLocalReceiverMarker(privateSharingEnabled = true) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - } - - @Test - fun `setPrivateContactsEnabled does not enable without profile`() = test { - isAuthenticatedFlow.value = false - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.hasPubkyProfile) - verify(settingsStore, never()).update(any()) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) - } - - @Test - fun `setPrivateContactsEnabled does not enable without local secret key`() = test { - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.canUsePrivateContacts) - verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) - } - - @Test - fun `init clears hidden private sharing when local secret key is unavailable`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) - val sut = createSut() - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.canUsePrivateContacts) - } - - @Test - fun `setPrivateContactsEnabled rolls back when private enable fails`() = test { - whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(true) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - } - - @Test - fun `setPrivateContactsEnabled restores enabled state when private disable fails`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertTrue(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.isUpdatingPrivateContacts) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - @Test - fun `setPrivateContactsEnabled keeps disabled when private disable rollback publish fails`() = test { - settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setPrivateContactsEnabled(false) - advanceUntilIdle() - - assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.privateContactsEnabled) - assertFalse(sut.uiState.value.isUpdatingPrivateContacts) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) - } - - @Test - fun `setOnchainEnabled rolls back when public sync has no supported endpoint`() = test { - settingsFlow.value = SettingsData( - sharesPublicPaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - whenever { - publicPaykitRepo.syncCurrentPublishedEndpoints( - forceRefreshLightning = true, - requireEndpoint = true, - ) - }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints( - forceRefreshLightning = true, - requireEndpoint = true, - ) - } - - @Test - fun `setOnchainEnabled rollback preserves concurrent sharing changes`() = test { - settingsFlow.value = SettingsData( - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = false, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - var updateCount = 0 - whenever { settingsStore.update(any()) }.thenAnswer { - updateCount += 1 - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - if (updateCount == 2) { - settingsFlow.value = settingsFlow.value.copy( - sharesPublicPaykitEndpoints = false, - sharesPrivatePaykitEndpoints = true, - ) - } - settingsFlow.value = transform(settingsFlow.value) - Unit - } - whenever { - publicPaykitRepo.syncCurrentPublishedEndpoints(true, true) - }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(publicPaykitRepo).syncCurrentPublishedEndpoints(true, true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - @Test - fun `setOnchainEnabled rolls back when private immediate publication fails`() = test { - settingsFlow.value = SettingsData( - sharesPrivatePaykitEndpoints = true, - publicPaykitLightningEnabled = true, - publicPaykitOnchainEnabled = true, - ) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } - .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) - val sut = createSut() - advanceUntilIdle() - - sut.setOnchainEnabled(false) - advanceUntilIdle() - - assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) - assertTrue(settingsFlow.value.publicPaykitLightningEnabled) - assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - assertFalse(sut.uiState.value.isUpdatingPaymentOptions) - verify(privatePaykitRepo, times(2)).prepareSavedContacts(listOf(CONTACT_KEY), true) - } - - private fun createSut() = PaymentPreferenceViewModel( - context = context, - settingsStore = settingsStore, - publicPaykitRepo = publicPaykitRepo, - privatePaykitRepo = privatePaykitRepo, - pubkyRepo = pubkyRepo, - ) -} - -private fun createPaymentPreferenceContact(publicKey: String) = PubkyProfile( - publicKey = publicKey, - name = "Alice", - bio = "", - imageUrl = null, - links = emptyList(), - tags = persistentListOf(), - status = null, -) diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index fc6fc4d2c0..8dfd31cab0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.viewmodels +import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -16,6 +17,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo @@ -30,15 +32,18 @@ import kotlin.test.assertTrue class SettingsViewModelTest : BaseUnitTest() { private lateinit var sut: SettingsViewModel + private val context = mock() private val settingsStore = mock() private val pubkyRepo = mock() private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val widgetsStore = mock() private val widgetsRepo = mock() + private val contactPaymentSettingsRepo = mock() private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) + private val contactPaymentsEnabled = MutableStateFlow(false) private val contacts = MutableStateFlow( listOf( PubkyProfile( @@ -56,6 +61,8 @@ class SettingsViewModelTest : BaseUnitTest() { fun setUp() { whenever(settingsStore.data).thenReturn(settingsData) whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(contactPaymentSettingsRepo.isEnabled).thenReturn(contactPaymentsEnabled) + whenever { contactPaymentSettingsRepo.setEnabled(any()) }.thenReturn(Result.success(Unit)) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsData.value = transform(settingsData.value) @@ -204,9 +211,20 @@ class SettingsViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.keepBitkitActiveInBackground) } + @Test + fun `contact payment switch delegates to shared settings repo`() = test { + sut.setContactPaymentsEnabled(true) + advanceUntilIdle() + + verify(contactPaymentSettingsRepo).setEnabled(true) + assertFalse(sut.isUpdatingContactPayments.value) + } + private fun createViewModel() = SettingsViewModel( + context = context, settingsStore = settingsStore, pubkyRepo = pubkyRepo, + contactPaymentSettingsRepo = contactPaymentSettingsRepo, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, widgetsStore = widgetsStore, diff --git a/changelog.d/next/970.changed.md b/changelog.d/next/970.changed.md new file mode 100644 index 0000000000..4b493b63a8 --- /dev/null +++ b/changelog.d/next/970.changed.md @@ -0,0 +1 @@ +Updated Pubky profiles, contacts, and Paykit settings to match the latest designs and simplified contact payments. From 0a3c85c9fd38d6c62254d40aea2c6d28680284b8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 17:33:37 +0200 Subject: [PATCH 2/3] fix: polish pubky and paykit ui --- .../to/bitkit/ui/components/TagButtonTest.kt | 47 ++++++ .../settings/SettingsSwitchRowTest.kt | 90 +++++++++++ app/src/main/java/to/bitkit/ext/String.kt | 5 - .../java/to/bitkit/models/PubkyProfile.kt | 3 +- .../to/bitkit/models/PubkyPublicKeyFormat.kt | 10 ++ .../ContactPaymentSettingsRepo.kt | 52 +++--- .../ui/components/CenteredProfileHeader.kt | 4 +- .../main/java/to/bitkit/ui/components/Tag.kt | 10 +- .../components/settings/SettingsSwitchRow.kt | 25 ++- .../ui/screens/contacts/AddContactScreen.kt | 3 +- .../screens/contacts/ContactDetailScreen.kt | 9 +- .../contacts/ContactDetailViewModel.kt | 62 +++++--- .../screens/profile/PayContactsViewModel.kt | 35 ++-- .../ui/screens/profile/ProfileScreen.kt | 29 +++- .../ui/screens/profile/ProfileViewModel.kt | 80 +++++++++- app/src/main/res/values/strings.xml | 4 +- .../bitkit/models/PubkyPublicKeyFormatTest.kt | 12 ++ .../ContactPaymentSettingsRepoTest.kt | 15 +- .../contacts/ContactDetailViewModelTest.kt | 131 ++++++++++++++- .../profile/PayContactsViewModelTest.kt | 2 +- .../screens/profile/ProfileViewModelTest.kt | 149 +++++++++++++++++- .../next/{970.changed.md => 1097.changed.md} | 0 22 files changed, 669 insertions(+), 108 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt rename changelog.d/next/{970.changed.md => 1097.changed.md} (100%) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt new file mode 100644 index 0000000000..a7212a8d4b --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/TagButtonTest.kt @@ -0,0 +1,47 @@ +package to.bitkit.ui.components + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@HiltAndroidTest +@ComposeUi +class TagButtonTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun removableTagExposesItsAction() { + composeTestRule.setContent { + AppThemeSurface { + TagButton( + text = "Founder", + onClick = {}, + accessibilityLabel = "Remove Founder tag", + displayIconClose = true, + ) + } + } + + composeTestRule.onNodeWithContentDescription("Remove Founder tag") + .assertHasClickAction() + composeTestRule.onNodeWithTag("Tag-Founder") + .assertHasClickAction() + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt new file mode 100644 index 0000000000..5acb595cec --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/settings/SettingsSwitchRowTest.kt @@ -0,0 +1,90 @@ +package to.bitkit.ui.components.settings + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@HiltAndroidTest +@ComposeUi +class SettingsSwitchRowTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun switchSemanticsReflectCheckedState() { + composeTestRule.setContent { + AppThemeSurface { + SettingsSwitchRow( + title = "Contact payments", + isChecked = true, + onClick = {}, + switchTestTag = "ContactPaymentsSwitch", + ) + } + } + + composeTestRule.onNodeWithTag("ContactPaymentsSwitch") + .assertIsOn() + .assertIsEnabled() + .assertHasClickAction() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Switch)) + } + + @Test + fun switchSemanticsReflectUncheckedState() { + composeTestRule.setContent { + AppThemeSurface { + SettingsSwitchRow( + title = "Contact payments", + isChecked = false, + onClick = {}, + switchTestTag = "ContactPaymentsSwitch", + ) + } + } + + composeTestRule.onNodeWithTag("ContactPaymentsSwitch").assertIsOff() + } + + @Test + fun switchSemanticsReflectDisabledState() { + composeTestRule.setContent { + AppThemeSurface { + SettingsSwitchRow( + title = "Contact payments", + isChecked = false, + onClick = {}, + enabled = false, + switchTestTag = "ContactPaymentsSwitch", + ) + } + } + + composeTestRule.onNodeWithTag("ContactPaymentsSwitch") + .assertIsOff() + .assertIsNotEnabled() + } +} diff --git a/app/src/main/java/to/bitkit/ext/String.kt b/app/src/main/java/to/bitkit/ext/String.kt index a2aa35a8a1..045279bca2 100644 --- a/app/src/main/java/to/bitkit/ext/String.kt +++ b/app/src/main/java/to/bitkit/ext/String.kt @@ -15,11 +15,6 @@ fun String.ellipsisMiddle(totalLength: Int): String { } } -fun String.pubkyDisplayPublicKey(): String { - val rawKey = removePrefix("pubky") - return if (rawKey.length > 8) "${rawKey.take(4)}...${rawKey.takeLast(4)}" else rawKey -} - fun String.truncate(length: Int): String { return if (this.length > length) { "${this.substring(0, length - 3)}..." diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index bdbfbf4e40..d5e846c802 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import to.bitkit.ext.ellipsisMiddle -import to.bitkit.ext.pubkyDisplayPublicKey import com.synonym.paykit.PaykitProfile as SdkPaykitProfile import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @@ -68,7 +67,7 @@ data class PubkyProfile( } val truncatedPublicKey: String - get() = publicKey.pubkyDisplayPublicKey() + get() = PubkyPublicKeyFormat.display(publicKey) fun withNameFallback(fallbackName: String?): PubkyProfile { return if (name.isBlank() && !fallbackName.isNullOrBlank()) copy(name = fallbackName) else this diff --git a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt index 069552f88f..fc4b16ab06 100644 --- a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt +++ b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt @@ -5,6 +5,7 @@ import to.bitkit.ext.ellipsisMiddle import java.util.Locale object PubkyPublicKeyFormat { + private const val displayEdgeLength = 4 private const val redactedLength = 16 const val maximumInputLength = 57 @@ -25,6 +26,15 @@ object PubkyPublicKeyFormat { return normalizedLhs == normalizedRhs } + fun display(input: String): String { + val rawKey = bounded(input).removePrefix("pubky") + return if (rawKey.length > displayEdgeLength * 2) { + "${rawKey.take(displayEdgeLength)}...${rawKey.takeLast(displayEdgeLength)}" + } else { + rawKey + } + } + fun redacted(input: String): String { val normalizedInput = normalized(input) ?: input.trim() return normalizedInput.ellipsisMiddle(redactedLength) diff --git a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt index c6ffe0d185..efbbf7216b 100644 --- a/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ContactPaymentSettingsRepo.kt @@ -30,22 +30,8 @@ class ContactPaymentSettingsRepo @Inject constructor( private suspend fun enable(contacts: List): Result { val previous = settingsStore.data.first() - publicPaykitRepo.syncPublishedEndpoints(publish = true) - .onFailure { - rollbackEnabled(previous, contacts, it) - return Result.failure(it) - } - val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() - if (canUsePrivateContactPayments) { - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - rollbackEnabled(previous, contacts, it) - return Result.failure(it) - } - } - - runSuspendCatching { + return runSuspendCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, @@ -55,16 +41,15 @@ class ContactPaymentSettingsRepo @Inject constructor( publicPaykitOnchainEnabled = true, ) } - }.onFailure { - rollbackEnabled(previous, contacts, it) - return Result.failure(it) - } - - if (canUsePrivateContactPayments) { - privatePaykitRepo.prepareSavedContacts(contacts) - } + publicPaykitRepo.syncPublishedEndpoints(publish = true).getOrThrow() - return Result.success(Unit) + if (canUsePrivateContactPayments) { + privatePaykitRepo.enableSharingAndPrepareSavedContacts( + publicKeys = contacts, + requireImmediatePublication = true, + ).getOrThrow() + } + }.onFailure { rollbackEnabled(previous, contacts, it) } } private suspend fun rollbackEnabled( @@ -78,6 +63,8 @@ class ContactPaymentSettingsRepo @Inject constructor( hasConfirmedPublicPaykitEndpoints = previous.hasConfirmedPublicPaykitEndpoints, sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints, sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints, + publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled, + publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled, ) } }.onFailure(error::addSuppressed) @@ -86,7 +73,12 @@ class ContactPaymentSettingsRepo @Inject constructor( error.addSuppressed(it) markPublicPaykitRetry(error) } - if (!previous.sharesPrivatePaykitEndpoints) { + if (previous.sharesPrivatePaykitEndpoints) { + privatePaykitRepo.enableSharingAndPrepareSavedContacts( + publicKeys = contacts, + requireImmediatePublication = true, + ).onFailure(error::addSuppressed) + } else { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure(error::addSuppressed) } @@ -142,9 +134,6 @@ class ContactPaymentSettingsRepo @Inject constructor( } cleanupError?.let { return Result.failure(it) } - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { return Result.failure(it) } - return Result.success(Unit) } @@ -154,7 +143,7 @@ class ContactPaymentSettingsRepo @Inject constructor( ) { if (!updatePrivatePreference(isEnabled = true, error = error)) return - privatePaykitRepo.prepareSavedContacts( + privatePaykitRepo.enableSharingAndPrepareSavedContacts( publicKeys = contacts, requireImmediatePublication = true, ).exceptionOrNull()?.let { @@ -163,10 +152,7 @@ class ContactPaymentSettingsRepo @Inject constructor( return } - privatePaykitRepo.setContactSharingCleanupPending(false).exceptionOrNull()?.let { - error.addSuppressed(it) - updatePrivatePreference(isEnabled = false, error = error) - } + publicPaykitRepo.syncLocalReceiverMarker().onFailure(error::addSuppressed) } private suspend fun markPublicPaykitRetry(error: Throwable) { 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 6bcd545bc2..4b86c8b1df 100644 --- a/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt +++ b/app/src/main/java/to/bitkit/ui/components/CenteredProfileHeader.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ext.pubkyDisplayPublicKey +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -36,7 +36,7 @@ fun CenteredProfileHeader( modifier = modifier ) { Text13Up( - text = publicKey.pubkyDisplayPublicKey(), + text = PubkyPublicKeyFormat.display(publicKey), color = Colors.White64, textAlign = TextAlign.Center, ) diff --git a/app/src/main/java/to/bitkit/ui/components/Tag.kt b/app/src/main/java/to/bitkit/ui/components/Tag.kt index 47c1cfa217..f69178b0fe 100644 --- a/app/src/main/java/to/bitkit/ui/components/Tag.kt +++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -29,17 +31,23 @@ fun TagButton( text: String, onClick: (() -> Unit)?, modifier: Modifier = Modifier, + accessibilityLabel: String? = null, isSelected: Boolean = false, displayIconClose: Boolean = false, icon: Painter = painterResource(R.drawable.ic_x), ) { val borderColor = if (isSelected) Colors.Brand else Colors.White16 val textColor = if (isSelected) Colors.Brand else MaterialTheme.colorScheme.onSurface + val accessibilityModifier = accessibilityLabel?.let { label -> + Modifier.semantics(mergeDescendants = true) { contentDescription = label } + } ?: Modifier Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier + .testTag("Tag-$text") + .then(accessibilityModifier) .wrapContentWidth() .border(width = 1.dp, color = borderColor, shape = AppShapes.small) .clickableAlpha(onClick = onClick) @@ -50,7 +58,7 @@ fun TagButton( color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.testTag("Tag-$text") + modifier = Modifier ) if (displayIconClose) { diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt index 343168d7bb..80023d0e27 100644 --- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Switch @@ -17,6 +18,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,7 +27,8 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.modifiers.alphaFeedback +import to.bitkit.ui.shared.modifiers.rememberDebouncedClick import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -40,7 +44,7 @@ fun SettingsSwitchRow( iconRes: Int? = null, iconTint: Color = Color.Unspecified, switchTestTag: String? = null, - colors: SwitchColors = AppSwitchDefaults.colors + colors: SwitchColors = AppSwitchDefaults.colors, ) { SettingsSwitchRowCore( title = title, @@ -77,7 +81,7 @@ fun SettingsSwitchRow( enabled: Boolean = true, subtitle: String? = null, switchTestTag: String? = null, - colors: SwitchColors = AppSwitchDefaults.colors + colors: SwitchColors = AppSwitchDefaults.colors, ) { SettingsSwitchRowCore( title = title, @@ -105,16 +109,23 @@ private fun SettingsSwitchRowCore( subtitle: String? = null, icon: (@Composable () -> Unit)? = null, switchTestTag: String? = null, - colors: SwitchColors = AppSwitchDefaults.colors + colors: SwitchColors = AppSwitchDefaults.colors, ) { + val debouncedOnClick = rememberDebouncedClick(onClick = onClick) Column(modifier = modifier) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = (switchTestTag?.let { Modifier.testTag(it) } ?: Modifier) .fillMaxWidth() .heightIn(min = 52.dp) - .clickableAlpha(enabled = enabled) { onClick() } + .alphaFeedback(enabled = enabled) + .toggleable( + value = isChecked, + enabled = enabled, + role = Role.Switch, + onValueChange = { debouncedOnClick() }, + ) ) { if (icon != null) { icon() @@ -137,7 +148,7 @@ private fun SettingsSwitchRowCore( onCheckedChange = null, // handled by parent enabled = enabled, colors = colors, - modifier = switchTestTag?.let { Modifier.testTag(it) } ?: Modifier + modifier = Modifier.clearAndSetSemantics { } ) } HorizontalDivider(color = Colors.White10) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 0b5d7c92c8..63ae007ec6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -54,7 +54,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import to.bitkit.R import to.bitkit.ext.getClipboardText -import to.bitkit.ext.pubkyDisplayPublicKey import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat @@ -310,7 +309,7 @@ private fun LoadingContent(publicKey: String) { VerticalSpacer(24.dp) Text13Up( - text = publicKey.pubkyDisplayPublicKey(), + text = PubkyPublicKeyFormat.display(publicKey), color = Colors.White64, ) 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 4a98744654..7fb3da656b 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 @@ -103,7 +103,7 @@ private fun Content( onClickShare: () -> Unit, onClickRetry: () -> Unit, onAddTag: () -> Unit, - onRemoveTag: (Int) -> Unit, + onRemoveTag: (String) -> Unit, onDismissAddTagSheet: () -> Unit, onSaveTag: (String) -> Unit, onDismissDeleteDialog: () -> Unit, @@ -170,7 +170,7 @@ private fun ContactBody( onClickActivity: () -> Unit, onClickShare: () -> Unit, onAddTag: () -> Unit, - onRemoveTag: (Int) -> Unit, + onRemoveTag: (String) -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -262,10 +262,11 @@ private fun ContactBody( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { - tags.forEachIndexed { index, tag -> + tags.forEach { tag -> TagButton( text = tag, - onClick = { onRemoveTag(index) }, + onClick = { onRemoveTag(tag) }, + accessibilityLabel = stringResource(R.string.common__remove_tag, tag), displayIconClose = true, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index d3f29877d0..495d8bf73f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow 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 @@ -47,6 +49,7 @@ class ContactDetailViewModel @Inject constructor( ) { "publicKey not found in SavedStateHandle" } private val redactedPublicKey = PubkyPublicKeyFormat.redacted(publicKey) + private val tagPersistenceMutex = Mutex() private val _uiState = MutableStateFlow(ContactDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -184,29 +187,52 @@ class ContactDetailViewModel @Inject constructor( } fun addTag(tag: String) { - val newTags = (_uiState.value.tags + tag).distinct().toImmutableList() - _uiState.update { it.copy(tags = newTags, showAddTagSheet = false) } - persistTags(newTags) + updateTags( + transform = { (it + tag).distinct().toImmutableList() }, + onSuccess = { _uiState.update { it.copy(showAddTagSheet = false) } }, + ) } - fun removeTag(index: Int) { - val newTags = _uiState.value.tags.filterIndexed { i, _ -> i != index }.toImmutableList() - _uiState.update { it.copy(tags = newTags) } - persistTags(newTags) + fun removeTag(tag: String) { + updateTags(transform = { tags -> tags.filterNot { it == tag }.toImmutableList() }) } - private fun persistTags(tags: List) { - val profile = _uiState.value.profile ?: return + private fun updateTags( + transform: (ImmutableList) -> ImmutableList, + onSuccess: () -> Unit = {}, + ) { viewModelScope.launch { - pubkyRepo.updateContact( - publicKey = publicKey, - name = profile.name, - bio = profile.bio, - imageUrl = profile.imageUrl, - links = profile.links.map { PubkyProfileLink(it.label, it.url) }, - tags = tags, - ).onFailure { - Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG) + tagPersistenceMutex.withLock { + val state = _uiState.value + val profile = state.profile ?: return@withLock + val tags = transform(state.tags) + if (tags == state.tags) { + onSuccess() + return@withLock + } + pubkyRepo.updateContact( + publicKey = publicKey, + name = profile.name, + bio = profile.bio, + imageUrl = profile.imageUrl, + links = profile.links.map { PubkyProfileLink(it.label, it.url) }, + tags = tags, + ).onSuccess { + _uiState.update { + it.copy( + profile = it.profile?.copy(tags = tags), + tags = tags, + ) + } + onSuccess() + }.onFailure { + Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.contacts__edit_save_error), + description = it.message, + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index b6d7404685..7b9edc0b3f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -18,6 +18,7 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ContactPaymentSettingsRepo import to.bitkit.repositories.PublicPaykitError import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -25,6 +26,10 @@ class PayContactsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val contactPaymentSettingsRepo: ContactPaymentSettingsRepo, ) : ViewModel() { + companion object { + private const val TAG = "PayContactsViewModel" + } + private val _uiState = MutableStateFlow(PayContactsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -34,20 +39,22 @@ class PayContactsViewModel @Inject constructor( fun continueToProfile() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - - contactPaymentSettingsRepo.setEnabled(true) - .onSuccess { - _uiState.update { it.copy(isLoading = false) } - _effects.emit(PayContactsEffect.Continue) - } - .onFailure { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = syncErrorMessage(it), - ) - _uiState.update { it.copy(isLoading = false) } - } + try { + contactPaymentSettingsRepo.setEnabled(true) + .onSuccess { + _effects.emit(PayContactsEffect.Continue) + } + .onFailure { + Logger.error("Failed to enable contact payments", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = syncErrorMessage(it), + ) + } + } finally { + _uiState.update { it.copy(isLoading = false) } + } } } 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 ae7abacb6f..38e0e65c9e 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 @@ -32,6 +32,7 @@ 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 @@ -80,6 +81,10 @@ 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) }, ) } @@ -94,6 +99,10 @@ private fun Content( onDismissSignOutDialog: () -> Unit, onConfirmSignOut: () -> Unit, onClickRetry: () -> Unit, + onClickAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onDismissAddTagSheet: () -> Unit, + onSaveTag: (String) -> Unit, ) { val currentProfile = uiState.profile @@ -111,6 +120,8 @@ private fun Content( onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickShare = onClickShare, + onClickAddTag = onClickAddTag, + onRemoveTag = onRemoveTag, ) else -> EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) } @@ -125,6 +136,13 @@ private fun Content( onDismiss = onDismissSignOutDialog, ) } + + if (uiState.showAddTagSheet) { + AddTagSheet( + onDismiss = onDismissAddTagSheet, + onSave = onSaveTag, + ) + } } @Composable @@ -133,6 +151,8 @@ private fun ProfileBody( onClickEdit: () -> Unit, onClickCopy: () -> Unit, onClickShare: () -> Unit, + onClickAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -229,13 +249,14 @@ private fun ProfileBody( profile.tags.forEach { tag -> TagButton( text = tag, - onClick = onClickEdit, + onClick = { onRemoveTag(tag) }, + accessibilityLabel = stringResource(R.string.common__remove_tag, tag), displayIconClose = true, ) } TagButton( text = stringResource(R.string.profile__add_tag), - onClick = onClickEdit, + onClick = onClickAddTag, icon = painterResource(R.drawable.ic_tag), displayIconClose = true, modifier = Modifier.testTag("ProfileAddTag") @@ -309,6 +330,10 @@ 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 f967e9ece4..b4a6a3ebaa 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,6 +15,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 @@ -37,20 +39,29 @@ 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) + } val uiState: StateFlow = combine( pubkyRepo.profile, pubkyRepo.publicKey, pubkyRepo.isLoadingProfile, - _showSignOutDialog, - _isSigningOut, - ) { profile, publicKey, isLoading, showSignOutDialog, isSigningOut -> + controls, + ) { profile, publicKey, isLoading, controls -> ProfileUiState( profile = profile, publicKey = publicKey, isLoading = isLoading, - showSignOutDialog = showSignOutDialog, - isSigningOut = isSigningOut, + showSignOutDialog = controls.showSignOutDialog, + isSigningOut = controls.isSigningOut, + showAddTagSheet = controls.showAddTagSheet, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState()) @@ -73,6 +84,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 } @@ -119,6 +149,39 @@ 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 @@ -128,6 +191,13 @@ 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10a4fe39cf..b8b74317fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ Preview Ready Remove + Remove %1$s tag Reset Retry ₿ / vbyte @@ -127,6 +128,7 @@ Contact updated Edit Contact Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile. + Failed to save contact Import All %1$d friends Found\n<accent>profile & contacts</accent> @@ -145,7 +147,7 @@ Contacts Pubky Paste QR Or Link - SCANNING QR, NFC & BLUETOOTH + SCANNING QR & NFC Depends on the fee Depends on the fee Custom diff --git a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt index 68fbfb623c..d8424a5106 100644 --- a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt @@ -45,6 +45,18 @@ class PubkyPublicKeyFormatTest { assertEquals("pubky3r…k8yw5xg", PubkyPublicKeyFormat.redacted(rawKey)) } + @Test + fun `display normalizes and shortens a prefixed key`() { + val key = " PUBKY3RSDUHCXPW74SNWYCT86M38C63J3PQ8X4YCQIKXG64ROIK8YW5X " + + assertEquals("3rsd...yw5x", PubkyPublicKeyFormat.display(key)) + } + + @Test + fun `display leaves a short raw key unchanged`() { + assertEquals("short", PubkyPublicKeyFormat.display("pubkyshort")) + } + @Test fun `matches compares equivalent pubky representations`() { val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt index 5f92c5fc26..d5eba52104 100644 --- a/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ContactPaymentSettingsRepoTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -43,8 +44,9 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { Unit } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } + whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -66,7 +68,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.publicPaykitLightningEnabled) assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) } @Test @@ -78,7 +80,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>(), any()) } @Test @@ -109,7 +111,6 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - verify(privatePaykitRepo).setContactSharingCleanupPending(false) } @Test @@ -125,7 +126,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY), true) } @Test @@ -136,7 +137,7 @@ class ContactPaymentSettingsRepoTest : BaseUnitTest() { ) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.failure(ContactPaymentSettingsTestError("cleanup failed"))) - whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>(), eq(true)) } .thenReturn(Result.failure(ContactPaymentSettingsTestError("restore failed"))) val result = createSut().setEnabled(false) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 2ccce1ff63..8f9d3467ad 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -72,6 +76,130 @@ class ContactDetailViewModelTest : BaseUnitTest() { } } + @Test + fun `adding a tag persists the updated contact and closes the sheet`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact(tags = listOf("Friend"))))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.showAddTagSheet() + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertEquals(listOf("Friend", "Bitcoin"), sut.uiState.value.tags) + assertFalse(sut.uiState.value.showAddTagSheet) + verify(pubkyRepo).updateContact( + publicKey = eq(TEST_PUBLIC_KEY), + name = any(), + bio = any(), + imageUrl = anyOrNull(), + links = any(), + tags = eq(listOf("Friend", "Bitcoin")), + ) + } + + @Test + fun `removing a tag persists the remaining contact tags`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + advanceUntilIdle() + + assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) + verify(pubkyRepo).updateContact( + publicKey = eq(TEST_PUBLIC_KEY), + name = any(), + bio = any(), + imageUrl = anyOrNull(), + links = any(), + tags = eq(listOf("Bitcoin")), + ) + } + + @Test + fun `rapid tag removals use stable tag identity`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + sut.removeTag("Bitcoin") + advanceUntilIdle() + + inOrder(pubkyRepo).apply { + verify(pubkyRepo).updateContact(any(), any(), any(), anyOrNull(), any(), eq(listOf("Bitcoin"))) + verify(pubkyRepo).updateContact(any(), any(), any(), anyOrNull(), any(), eq(emptyList())) + } + assertEquals(emptyList(), sut.uiState.value.tags) + } + + @Test + fun `double tag removal does not remove a neighboring tag`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn( + MutableStateFlow(listOf(createContact(tags = listOf("Friend", "Bitcoin")))), + ) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.removeTag("Friend") + sut.removeTag("Friend") + advanceUntilIdle() + + verify(pubkyRepo, times(1)).updateContact( + any(), + any(), + any(), + anyOrNull(), + any(), + eq(listOf("Bitcoin")), + ) + assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) + } + + @Test + fun `failed tag addition stays open and can be retried`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact(tags = listOf("Friend"))))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())).thenReturn( + Result.failure(ContactDetailTestError("save failed")), + Result.success(Unit), + ) + val sut = createSut() + advanceUntilIdle() + sut.showAddTagSheet() + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertTrue(sut.uiState.value.showAddTagSheet) + assertEquals(listOf("Friend"), sut.uiState.value.tags) + + sut.addTag("Bitcoin") + advanceUntilIdle() + + assertFalse(sut.uiState.value.showAddTagSheet) + assertEquals(listOf("Friend", "Bitcoin"), sut.uiState.value.tags) + verify(pubkyRepo, times(2)).updateContact(any(), any(), any(), anyOrNull(), any(), any()) + } + private fun createSut() = ContactDetailViewModel( context = context, pubkyRepo = pubkyRepo, @@ -79,7 +207,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), ) - private fun createContact() = PubkyProfile.placeholder(TEST_PUBLIC_KEY) + private fun createContact(tags: List = emptyList()) = + PubkyProfile.placeholder(TEST_PUBLIC_KEY).copy(tags = tags) } private class ContactDetailTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 4cd96547e7..e0aa67f34c 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -44,7 +44,7 @@ class PayContactsViewModelTest : BaseUnitTest() { @Test fun `continue stays on screen when enabling fails`() = test { - whenever { contactPaymentSettingsRepo.setEnabled(true) } + whenever(contactPaymentSettingsRepo.setEnabled(true)) .thenReturn(Result.failure(PayContactsTestAppError("sync failed"))) val sut = createSut() 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 1059215cba..7473f30ce7 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() { @@ -62,7 +68,7 @@ class ProfileViewModelTest : BaseUnitTest() { @Test fun `signOut clears local Paykit state when Pubky sign out fails`() = test { val sut = createSut() - whenever { pubkyRepo.signOut() }.thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) + whenever(pubkyRepo.signOut()).thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) advanceUntilIdle() sut.signOut() @@ -71,13 +77,140 @@ class ProfileViewModelTest : BaseUnitTest() { verify(privatePaykitRepo).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) diff --git a/changelog.d/next/970.changed.md b/changelog.d/next/1097.changed.md similarity index 100% rename from changelog.d/next/970.changed.md rename to changelog.d/next/1097.changed.md From cf58052a65ee9d0847b1ec826b65d769153cd2fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 16:26:05 +0200 Subject: [PATCH 3/3] fix: restore intro test ids --- .../java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt | 2 +- .../java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt index 5d673f3b4d..31b8855ef3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsIntroScreen.kt @@ -68,7 +68,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.contacts__intro_add_contact), onClick = onContinue, - modifier = Modifier.testTag("ContactsIntroButton") + modifier = Modifier.testTag("ContactsIntro-button") ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt index b10ba6d6e4..35459ac79c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt @@ -56,7 +56,7 @@ fun ProfileIntroScreen( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onContinue, - modifier = Modifier.testTag("ProfileIntroButton") + modifier = Modifier.testTag("ProfileIntro-button") ) VerticalSpacer(16.dp) }