From c568a8facee6f5a6aad3435b2f4f89ee8331dbb6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 12:48:13 +0300 Subject: [PATCH 1/7] feat: add subscription requests --- .../subscriptions/SubscriptionsScreenTest.kt | 1 + .../repositories/PaykitPaymentRequestRepo.kt | 253 +++++++- .../bitkit/repositories/PaykitSubscription.kt | 85 ++- ...PaykitSubscriptionNotificationScheduler.kt | 3 +- .../to/bitkit/services/PaykitSdkService.kt | 20 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 + .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../CreatePaymentRequestScreen.kt | 41 +- .../subscriptions/CreateSubscriptionScreen.kt | 572 ++++++++++++++++++ .../subscriptions/SubscriptionsScreen.kt | 207 +++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 37 ++ app/src/main/res/values/strings.xml | 22 + ...aykitPaymentRequestRepoSubscriptionTest.kt | 133 +++- .../next/subscription-requests.added.md | 1 + 14 files changed, 1302 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt create mode 100644 changelog.d/next/subscription-requests.added.md diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index f1f92218a9..9ab9df1c12 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -36,6 +36,7 @@ class SubscriptionsScreenTest { initialTab = SubscriptionTab.Payments, pendingPaymentRequestCount = 0, onSubscription = {}, + onCreateSubscription = {}, paymentsContent = { PaymentRequestsContent( requests = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index f2b82415a9..deaf3c3917 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories +import android.graphics.Bitmap +import android.graphics.BitmapFactory import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus @@ -25,6 +27,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -39,10 +43,12 @@ import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.io.ByteArrayOutputStream import java.math.BigDecimal import java.util.UUID import java.util.concurrent.atomic.AtomicLong @@ -128,6 +134,21 @@ data class PaykitPaymentRequestCreation( val wasPublishedToActiveState: Boolean, ) +class PaykitSubscriptionDraft( + val amountSats: ULong, + val name: String, + val description: String, + val frequency: PaykitRecurrenceUnit, + val expiresAt: Instant, + val iconBytes: ByteArray? = null, +) + +data class PaykitSubscriptionCreation( + val subscription: PaykitSubscription, + val creatorIdentity: String, + val wasPublishedToActiveState: Boolean, +) + private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, val linkedReceiverPaths: Map>, @@ -158,6 +179,8 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" + private const val SUBSCRIPTION_ICON_MAX_SIZE = 400 + private const val SUBSCRIPTION_ICON_QUALITY = 80 private val TARGET_DISCOVERY_TIMEOUT = 5.seconds } @@ -221,7 +244,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _pendingRequests.value.filterNot { it.id in presentedRequestIds } fun subscriptionProposals(): List = - _subscriptions.value.filter { it.isProposalVisible(clock.now()) } + _subscriptions.value.filter { it.isPayer && it.isProposalVisible(clock.now()) } fun automaticSubscriptionProposals(): List = subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } @@ -256,7 +279,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): Boolean = withContext(ioDispatcher) { operationMutex.withLock { val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?.takeIf { it.isProposalVisible(clock.now()) } + ?.takeIf { it.isPayer && it.isProposalVisible(clock.now()) } ?: return@withLock false if (current.id in presentedSubscriptionProposalIds) return@withLock true val identity = activeIdentity ?: return@withLock false @@ -419,6 +442,166 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun proposeSubscription( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!creationMutex.tryLock()) throw PaykitPaymentRequestError.OperationInProgress + _isCreatingRequest.update { true } + try { + operationMutex.withLock { + createSubscriptionLocked(draft, target, savedPublicKeys) + } + } finally { + _isCreatingRequest.update { false } + creationMutex.unlock() + } + }.onFailure { + Logger.warn("Failed to create Paykit subscription proposal", it, context = TAG) + } + } + + private suspend fun createSubscriptionLocked( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + ): PaykitSubscriptionCreation { + val generation = stateGeneration.get() + val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val validationDate = clock.now() + val name = draft.name.trim().take(256) + val description = draft.description.trim().take(1024) + validateSubscriptionDraft(draft, name, validationDate) + + val endpoints = subscriptionProposalEndpoints(target, savedPublicKeys, expectedIdentity) + val iconUri = draft.iconBytes?.let { + paykitSdkService.uploadProfileAvatar( + bytes = compressSubscriptionIcon(it), + contentType = "image/jpeg", + ) + } + val proposalDate = clock.now() + validateProposalExpiration(draft.expiresAt, proposalDate) + val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, proposalDate) + val record = paykitSdkService.proposePaymentRequest( + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + proposal = proposal, + expectedIdentity = expectedIdentity, + ) + val deliveryStatus = if (proposalWasSent(record, processPendingMessages())) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } + val subscription = record.toPaykitSubscription(deliveryStatus) + ?: throw PaykitPaymentRequestError.RequestUnavailable + val wasPublished = publishCreatedSubscription(subscription, generation, expectedIdentity) + return PaykitSubscriptionCreation(subscription, expectedIdentity, wasPublished) + } + + private suspend fun validateSubscriptionDraft( + draft: PaykitSubscriptionDraft, + name: String, + proposalDate: Instant, + ) { + val hasValidContent = draft.amountSats != 0uL && name.isNotEmpty() + val canPropose = draft.frequency.isSupported && isAvailable() + if (!hasValidContent || !canPropose) { + throw PaykitPaymentRequestError.RequestUnavailable + } + validateProposalExpiration(draft.expiresAt, proposalDate) + } + + private fun validateProposalExpiration(expiresAt: Instant, proposalDate: Instant) { + if (expiresAt <= proposalDate) throw PaykitPaymentRequestError.RequestExpired + } + + private suspend fun subscriptionProposalEndpoints( + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + expectedIdentity: String, + ): List { + val selectedSavedKey = savedPublicKeys.firstOrNull { + PubkyPublicKeyFormat.matches(it, target.publicKey) + } + val targets = selectedSavedKey?.let { savedKey -> + targetContext(listOf(savedKey), expectedIdentity)?.let { eligibleTargets(it).targets } + } + .orEmpty() + val settings = settingsStore.data.first() + val endpoints = acceptedPaymentEndpointIdentifiers(settings) + if (target !in targets || !settings.sharesPrivatePaykitEndpoints || endpoints.isEmpty()) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return endpoints + } + + private fun buildSubscriptionProposal( + draft: PaykitSubscriptionDraft, + name: String, + description: String, + iconUri: String?, + endpointIdentifiers: List, + proposalDate: Instant, + ): PaykitPaymentRequestProposalTerms { + val subscriptionMetadata = mutableMapOf( + "version" to JsonPrimitive(1), + "description" to JsonPrimitive(description), + "benefits" to JsonArray(emptyList()), + ) + iconUri?.let { subscriptionMetadata["icon_uri"] = JsonPrimitive(it) } + val timestamp = proposalDate.toString() + return PaykitPaymentRequestProposalTerms( + amountValue = draft.amountSats.toBitcoinAmount(), + paymentReference = "bitkit-${UUID.randomUUID()}", + proposalExpiresAt = draft.expiresAt.toString(), + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + metadataJson = JsonObject( + mapOf( + "note" to JsonPrimitive(name), + "subscription" to JsonObject(subscriptionMetadata), + ) + ).toString(), + recurrence = PaykitPaymentRequestRecurrenceTerms( + every = 1u, + unit = draft.frequency.rawValue, + startsAt = timestamp, + anchor = timestamp, + ), + ) + } + + private fun proposalWasSent( + record: PaymentRequestRecord, + reports: List, + ): Boolean { + if (record.proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) return true + val messageId = record.proposalOutboundMessageId ?: return false + return reports.any { + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && + it.counterpartyReceiverPath == record.counterpartyReceiverPath && + messageId in it.report?.sent.orEmpty() + } + } + + private fun publishCreatedSubscription( + subscription: PaykitSubscription, + generation: Long, + expectedIdentity: String, + ): Boolean { + val isCurrent = isCurrentState(generation, expectedIdentity) + if (isCurrent) { + _subscriptions.update { subscriptions -> + subscriptions.filterNot { it.id == subscription.id } + subscription + } + scheduleExpirationLocked() + } + return isCurrent + } + private fun publishCreatedRequest( request: PaykitPaymentRequest, generation: Long, @@ -500,7 +683,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val validationDate = clock.now() val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?.takeIf { it == subscription && it.isProposalActionable(validationDate) } + ?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) } ?: throw PaykitPaymentRequestError.RequestUnavailable val record = paykitSdkService.acceptPaymentRequest( current.counterparty, @@ -521,14 +704,16 @@ class PaykitPaymentRequestRepo @Inject constructor( } suspend fun cancel(subscription: PaykitSubscription): Result = updateSubscription(subscription) { - if (!it.isActive(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable - val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( - identity = identity, - subscriptionId = it.id, - ).getOrThrow() - if (protectedRequestIds.isNotEmpty()) { - throw PaykitPaymentRequestError.OperationInProgress + if (!it.canCancel(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + if (it.isPayer) { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( + identity = identity, + subscriptionId = it.id, + ).getOrThrow() + if (protectedRequestIds.isNotEmpty()) { + throw PaykitPaymentRequestError.OperationInProgress + } } paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) } @@ -575,7 +760,8 @@ class PaykitPaymentRequestRepo @Inject constructor( .map { it.withExpiredLifecycle(now) } val restoredAcceptances = subscriptions .filter { - it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty() + it.isPayer && + (it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty()) } .filterNot { it.id in subscriptionAcceptedAt } .associate { subscription -> @@ -585,7 +771,7 @@ class PaykitPaymentRequestRepo @Inject constructor( subscription.id to acceptedAt } val updatedSubscriptionAcceptedAt = subscriptionAcceptedAt + restoredAcceptances - val recurringRequestsBySubscription = subscriptions.associateWith { subscription -> + val recurringRequestsBySubscription = subscriptions.filter { it.isPayer }.associateWith { subscription -> updatedSubscriptionAcceptedAt[subscription.id] ?.let { subscription.requestsThrough(now, it) } .orEmpty() @@ -744,6 +930,32 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private fun compressSubscriptionIcon(bytes: ByteArray): ByteArray { + val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw PaykitPaymentRequestError.RequestUnavailable + val scale = minOf( + SUBSCRIPTION_ICON_MAX_SIZE.toFloat() / original.width, + SUBSCRIPTION_ICON_MAX_SIZE.toFloat() / original.height, + 1f, + ) + val scaled = if (scale < 1f) { + Bitmap.createScaledBitmap( + original, + (original.width * scale).toInt().coerceAtLeast(1), + (original.height * scale).toInt().coerceAtLeast(1), + true, + ) + } else { + original + } + return ByteArrayOutputStream().use { output -> + if (!scaled.compress(Bitmap.CompressFormat.JPEG, SUBSCRIPTION_ICON_QUALITY, output)) { + throw PaykitPaymentRequestError.RequestUnavailable + } + output.toByteArray() + } + } + private suspend fun isAvailable(): Boolean = activeIdentity != null && PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) @@ -814,6 +1026,19 @@ class PaykitPaymentRequestRepo @Inject constructor( subscriptions.filterNot { it.id == subscription.id } + subscription } + if (!subscription.isPayer) { + prunePresentedSubscriptionProposalIds(_subscriptions.value) + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = activeIdentity ?: return, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) + scheduleExpirationLocked() + return + } + val recurringRequests = requestsThroughAcceptance(subscription, now) val unpaidRequests = if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) { recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } @@ -901,7 +1126,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { val proposalIds = subscriptions - .filter { it.isProposalVisible(clock.now()) } + .filter { it.isPayer && it.isProposalVisible(clock.now()) } .mapTo(mutableSetOf()) { it.id } val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) if (prunedIds == presentedSubscriptionProposalIds) return diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index ff103cff4c..de0a51aa24 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -157,8 +158,11 @@ data class PaykitSubscriptionRecurrence( data class PaykitSubscriptionMetadata( val description: String?, val benefits: List, + val iconUri: String? = null, ) +enum class PaykitSubscriptionRole { Payer, Payee } + @Serializable data class PaykitSubscriptionId( val paymentRequestId: String, @@ -178,6 +182,8 @@ data class PaykitSubscription( val recurrence: PaykitSubscriptionRecurrence, val metadata: PaykitSubscriptionMetadata, val acceptedPaymentEndpointIdentifiers: List, + val role: PaykitSubscriptionRole = PaykitSubscriptionRole.Payer, + val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, val lifecycleState: PaymentRequestLifecycleState, val paidPeriods: List, val paymentProofKinds: Map = emptyMap(), @@ -185,6 +191,12 @@ data class PaykitSubscription( val id: PaykitSubscriptionId get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + val isPayer: Boolean + get() = role == PaykitSubscriptionRole.Payer + + val isCreatedByUser: Boolean + get() = role == PaykitSubscriptionRole.Payee + fun isProposalVisible(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.PROPOSED && proposalExpiresAt?.let { it > now } != false && @@ -199,6 +211,14 @@ data class PaykitSubscription( fun isActive(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && recurrence.endsAt?.let { it > now } != false + fun isCreatedVisible(now: Instant): Boolean = isCreatedByUser && (isProposalVisible(now) || isActive(now)) + + fun canCancel(now: Instant): Boolean = recurrence.endsAt == null && if (isCreatedByUser) { + isProposalVisible(now) || isActive(now) + } else { + isActive(now) + } + fun isExpired(now: Instant): Boolean = lifecycleState in setOf( PaymentRequestLifecycleState.CANCELED, PaymentRequestLifecycleState.REJECTED, @@ -214,8 +234,9 @@ data class PaykitSubscription( else -> this } - fun requestsThrough(date: Instant, acceptedAt: Instant): List = - recurrence.periodsThrough(date, acceptedAt).map { period -> + fun requestsThrough(date: Instant, acceptedAt: Instant): List { + if (!isPayer) return emptyList() + return recurrence.periodsThrough(date, acceptedAt).map { period -> PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, @@ -235,13 +256,37 @@ data class PaykitSubscription( paymentProofKind = paymentProofKinds[period], ) } + } fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = requestsThrough(now, now).firstOrNull() + + fun receivedPaymentRequests(): List { + if (!isCreatedByUser) return emptyList() + return paidPeriods.map { period -> + PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = amountValue, + amountSats = amountSats, + note = note, + createdAt = period.startsAt, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers, + direction = PaykitPaymentRequestDirection.Outgoing, + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + billingPeriod = period, + paymentProofKind = paymentProofKinds[period], + ) + } + } } @Suppress("CyclomaticComplexMethod", "ReturnCount") -internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { - if (localRole != PaymentRequestLocalRole.PAYER) return null +internal fun PaymentRequestRecord.toPaykitSubscription( + deliveryStatusOverride: PaykitPaymentRequestDeliveryStatus? = null, +): PaykitSubscription? { + val role = localRole.toSubscriptionRole() ?: return null val requestTerms = terms ?: return null val sdkRecurrence = requestTerms.recurrence ?: return null if ( @@ -292,24 +337,48 @@ internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { ), metadata = metadataObject, acceptedPaymentEndpointIdentifiers = endpoints, + role = role, + deliveryStatus = subscriptionDeliveryStatus(role, deliveryStatusOverride), lifecycleState = state, paidPeriods = payments.map { it.first }, paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), ) } +private fun PaymentRequestLocalRole?.toSubscriptionRole(): PaykitSubscriptionRole? = when (this) { + PaymentRequestLocalRole.PAYER -> PaykitSubscriptionRole.Payer + PaymentRequestLocalRole.PAYEE -> PaykitSubscriptionRole.Payee + PaymentRequestLocalRole.UNKNOWN, null -> null +} + +private fun PaymentRequestRecord.subscriptionDeliveryStatus( + role: PaykitSubscriptionRole, + override: PaykitPaymentRequestDeliveryStatus?, +): PaykitPaymentRequestDeliveryStatus? { + if (role != PaykitSubscriptionRole.Payee) return null + if (override != null) return override + return if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } +} + private fun PrivateJsonObject.subscriptionMetadata(): PaykitSubscriptionMetadata = runCatching { val subscription = Json.parseToJsonElement(exportText()).jsonObject["subscription"]?.jsonObject - ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList(), null) if (subscription["version"]?.jsonPrimitive?.contentOrNull != "1") { - return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + return@runCatching PaykitSubscriptionMetadata(null, emptyList(), null) } val description = subscription["description"]?.jsonPrimitive?.contentOrNull?.clean(1024) val benefits = subscription["benefits"]?.jsonArray.orEmpty() .take(8) .mapNotNull { it.jsonPrimitive.contentOrNull?.clean(160) } - PaykitSubscriptionMetadata(description, benefits) -}.getOrDefault(PaykitSubscriptionMetadata(null, emptyList())) + val iconUri = subscription["icon_uri"]?.jsonPrimitive?.contentOrNull + ?.clean(512) + ?.takeIf { it.startsWith("pubky://") } + PaykitSubscriptionMetadata(description, benefits, iconUri) +}.getOrDefault(PaykitSubscriptionMetadata(null, emptyList(), null)) private fun String.clean(limit: Int): String? = trim().take(limit).takeIf(String::isNotEmpty) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 9d04672f16..63d371cae2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -67,7 +67,8 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( val now = clock.now() val scheduledWork = subscriptions .filter { - it.isActive(now) && + it.isPayer && + it.isActive(now) && it.recurrence.unit.isSupported && acceptedAt(it) != null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 32fcb78af9..ca9bc5d990 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -26,6 +26,7 @@ import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution @@ -122,6 +123,15 @@ data class PaykitPaymentRequestProposalTerms( val proposalExpiresAt: String, val acceptedPaymentEndpointIdentifiers: List, val metadataJson: String, + val recurrence: PaykitPaymentRequestRecurrenceTerms? = null, +) + +data class PaykitPaymentRequestRecurrenceTerms( + val every: UInt, + val unit: String, + val startsAt: String, + val anchor: String, + val endsAt: String? = null, ) data class PaykitPrivateReceiverPathSelection( @@ -641,7 +651,15 @@ class PaykitSdkService @Inject constructor( amount = PaymentRequestAmount(proposal.amountValue, "btc"), paymentReference = PaymentReference(proposal.paymentReference), proposalExpiresAt = proposal.proposalExpiresAt, - recurrence = null, + recurrence = proposal.recurrence?.let { + PaymentRequestRecurrence( + every = it.every, + unit = it.unit, + startsAt = it.startsAt, + anchor = it.anchor, + endsAt = it.endsAt, + ) + }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, metadata = PrivateJsonObject(proposal.metadataJson), ) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d903bebf26..694361b326 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -120,6 +120,7 @@ import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen +import to.bitkit.ui.screens.subscriptions.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen @@ -555,6 +556,8 @@ fun ContentView( }, ) + Sheet.CreateSubscription -> CreateSubscriptionSheet(appViewModel) + is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() @@ -778,6 +781,7 @@ private fun RootNavHost( onRequestPayment = { appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestRecipient)) }, + onCreateSubscription = appViewModel::showSubscriptionCreator, onDetails = { navController.navigateTo( Routes.SubscriptionDetail( 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 69c896ac3c..1208e0ac21 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -73,6 +73,7 @@ sealed interface Sheet { val presentationId: String = UUID.randomUUID().toString(), ) : Sheet data object PaymentRequests : Sheet + data object CreateSubscription : Sheet data class Subscription(val route: SubscriptionRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index e28a15d763..8865daeabb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -3,9 +3,11 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -386,6 +388,11 @@ internal fun PaymentRequestRecipientContent( onBack: () -> Unit, onPaste: () -> String, onSelected: (PaykitPaymentRequestTarget) -> Unit, + @StringRes titleRes: Int = R.string.wallet__payment_request_choose_recipient, + selectedTarget: PaykitPaymentRequestTarget? = null, + testTagPrefix: String = "PaymentRequest", + action: (@Composable () -> Unit)? = null, + footer: @Composable () -> Unit = {}, ) { var query by remember { mutableStateOf("") } @@ -406,10 +413,11 @@ internal fun PaymentRequestRecipientContent( .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp) - .testTag("PaymentRequestRecipient") + .testTag("${testTagPrefix}Recipient") ) { SheetTopBar( - titleText = stringResource(R.string.wallet__payment_request_choose_recipient), + titleText = stringResource(titleRes), + action = action, onBack = onBack, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) @@ -428,7 +436,7 @@ internal fun PaymentRequestRecipientContent( query = PubkyPublicKeyFormat.bounded(onPaste()) } .padding(horizontal = 12.dp) - .testTag("PaymentRequestRecipientPaste"), + .testTag("${testTagPrefix}RecipientPaste"), ) { Icon( painter = painterResource(R.drawable.ic_clipboard_text), @@ -441,7 +449,7 @@ internal fun PaymentRequestRecipientContent( }, modifier = Modifier .fillMaxWidth() - .testTag("PaymentRequestRecipientSearch"), + .testTag("${testTagPrefix}RecipientSearch"), ) VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) @@ -463,7 +471,7 @@ internal fun PaymentRequestRecipientContent( modifier = Modifier .fillMaxWidth() .padding(top = 24.dp) - .testTag("PaymentRequestRecipientUnavailable"), + .testTag("${testTagPrefix}RecipientUnavailable"), ) } } @@ -471,15 +479,26 @@ internal fun PaymentRequestRecipientContent( items = recipients, key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, ) { (target, contact) -> - PubkyContactRow( - profile = contact, - onClick = { onSelected(target) }, - verticalPadding = 16.dp, - modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), - ) + Box(contentAlignment = Alignment.CenterEnd) { + PubkyContactRow( + profile = contact, + onClick = { onSelected(target) }, + verticalPadding = 16.dp, + modifier = Modifier.testTag("${testTagPrefix}Contact${contact.publicKey}"), + ) + if (target == selectedTarget) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.padding(end = 16.dp).size(24.dp), + ) + } + } HorizontalDivider(color = Colors.White10) } } + footer() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt new file mode 100644 index 0000000000..cc5b02c552 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -0,0 +1,572 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.R +import to.bitkit.ext.getClipboardText +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionDraft +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.MoneyDisplay +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.PubkyImage +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountContent +import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration +import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientContent +import to.bitkit.ui.shared.modifiers.clickableAlpha +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.removeAccentTags +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Composable +fun CreateSubscriptionSheet( + appViewModel: AppViewModel, + amountInputViewModel: AmountInputViewModel = hiltViewModel(key = "SubscriptionAmount"), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + var step by remember { mutableStateOf(SubscriptionCreationStep.Details) } + var amountSats by remember { mutableStateOf(0uL) } + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var frequency by remember { mutableStateOf(PaykitRecurrenceUnit.Month) } + var expiration by remember { mutableStateOf(PaymentRequestExpiration.Week) } + var selectedTarget by remember { mutableStateOf(null) } + var selectedIconUri by remember { mutableStateOf(null) } + var iconBytes by remember { mutableStateOf(null) } + var isLoadingIcon by remember { mutableStateOf(false) } + var createdSubscription by remember { mutableStateOf(null) } + + when (step) { + SubscriptionCreationStep.Details -> CreateSubscriptionDetails( + amountSats = amountSats, + name = name, + description = description, + frequency = frequency, + selectedIconUri = selectedIconUri, + isLoadingIcon = isLoadingIcon, + onAmountClick = { step = SubscriptionCreationStep.Amount }, + onNameChange = { name = it.take(256) }, + onDescriptionChange = { description = it.take(1024) }, + onFrequencyChange = { frequency = it }, + onIconSelected = { uri -> + isLoadingIcon = true + scope.launch { + val bytes = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + } + iconBytes = bytes + selectedIconUri = uri.takeIf { bytes != null } + isLoadingIcon = false + } + }, + onChooseRecipient = { step = SubscriptionCreationStep.Recipient }, + ) + SubscriptionCreationStep.Amount -> PaymentRequestAmountContent( + amountInputViewModel = amountInputViewModel, + initialDraft = PaykitPaymentRequestDraft( + amountSats = amountSats, + note = name, + expiresAt = Clock.System.now() + expiration.duration, + ), + contact = null, + onBack = { step = SubscriptionCreationStep.Details }, + onContinue = { + amountSats = it.amountSats + step = SubscriptionCreationStep.Details + }, + modifier = Modifier.sheetHeight() + ) + SubscriptionCreationStep.Recipient -> SubscriptionRecipient( + targets = targets.toImmutableList(), + contacts = contacts.toImmutableList(), + selectedTarget = selectedTarget, + expiration = expiration, + isCreating = isCreating, + isLoadingIcon = isLoadingIcon, + onBack = { step = SubscriptionCreationStep.Details }, + onPaste = { context.getClipboardText()?.trim().orEmpty() }, + onSelected = { selectedTarget = it }, + onExpirationChange = { expiration = it }, + onPropose = { + selectedTarget?.let { target -> + appViewModel.createSubscription( + draft = PaykitSubscriptionDraft( + amountSats = amountSats, + name = name, + description = description, + frequency = frequency, + expiresAt = Clock.System.now() + expiration.duration, + iconBytes = iconBytes, + ), + target = target, + ) { + createdSubscription = it + step = SubscriptionCreationStep.Sent + } + } + }, + ) + SubscriptionCreationStep.Sent -> createdSubscription?.let { + SubscriptionProposalSent( + subscription = it, + contact = contacts.firstOrNull { contact -> + PubkyPublicKeyFormat.matches(contact.publicKey, it.counterparty) + } ?: PubkyProfile.placeholder(it.counterparty), + onDone = appViewModel::hideSheet, + ) + } + } +} + +@Composable +private fun CreateSubscriptionDetails( + amountSats: ULong, + name: String, + description: String, + frequency: PaykitRecurrenceUnit, + selectedIconUri: Uri?, + isLoadingIcon: Boolean, + onAmountClick: () -> Unit, + onNameChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onFrequencyChange: (PaykitRecurrenceUnit) -> Unit, + onIconSelected: (Uri) -> Unit, + onChooseRecipient: () -> Unit, + modifier: Modifier = Modifier, +) { + val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { + it?.let(onIconSelected) + } + val gallery = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + it?.let(onIconSelected) + } + val launchPhotoPicker = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } else { + gallery.launch("image/*") + } + } + + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("CreateSubscription") + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__create_subscription)) + LazyColumn(modifier = Modifier.weight(1f)) { + item { + rememberMoneyText( + sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + reversed = true, + showSymbol = true, + )?.let { Caption13Up(text = it.removeAccentTags(), color = Colors.White64) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().clickableAlpha(onClick = onAmountClick), + ) { + MoneyDisplay(sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), showSymbol = true) + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = stringResource(R.string.common__edit), + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) + } + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__frequency), color = Colors.White64) + VerticalSpacer(8.dp) + SubscriptionFrequencyPicker(frequency, onFrequencyChange) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.subscriptions__name), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.subscriptions__name_placeholder), + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("SubscriptionName"), + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.subscriptions__description), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = description, + onValueChange = onDescriptionChange, + placeholder = stringResource(R.string.subscriptions__description_placeholder), + maxLines = 3, + modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription"), + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.subscriptions__custom_icon), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickable(onClick = launchPhotoPicker) + .padding(16.dp) + .testTag("SubscriptionIconPicker"), + ) { + if (selectedIconUri == null) { + Icon( + painter = painterResource(R.drawable.ic_image_square), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(48.dp).padding(10.dp), + ) + } else { + AsyncImage( + model = selectedIconUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp).clip(RoundedCornerShape(12.dp)), + ) + } + Column(Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB(stringResource(R.string.subscriptions__custom_icon)) + BodyS(stringResource(R.string.subscriptions__custom_icon_description), color = Colors.White64) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + ) + } + VerticalSpacer(24.dp) + } + } + PrimaryButton( + text = stringResource(R.string.subscriptions__choose_recipient), + onClick = onChooseRecipient, + icon = { + Icon( + painter = painterResource(R.drawable.ic_user_plus), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + enabled = amountSats > 0uL && name.isNotBlank() && !isLoadingIcon, + modifier = Modifier.testTag("SubscriptionChooseRecipient") + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionFrequencyPicker( + selected: PaykitRecurrenceUnit, + onSelected: (PaykitRecurrenceUnit) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth()) { + creationFrequencies.forEach { option -> + val isSelected = option == selected + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { onSelected(option) } + .testTag("SubscriptionFrequency${option.name}"), + ) { + BodyS(text = option.creationTitle(), color = if (isSelected) Colors.White else Colors.White64) + VerticalSpacer(8.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White16, + ) + } + } + } +} + +@Composable +private fun SubscriptionRecipient( + targets: ImmutableList, + contacts: ImmutableList, + selectedTarget: PaykitPaymentRequestTarget?, + expiration: PaymentRequestExpiration, + isCreating: Boolean, + isLoadingIcon: Boolean, + onBack: () -> Unit, + onPaste: () -> String, + onSelected: (PaykitPaymentRequestTarget) -> Unit, + onExpirationChange: (PaymentRequestExpiration) -> Unit, + onPropose: () -> Unit, +) { + PaymentRequestRecipientContent( + targets = targets, + contacts = contacts, + onBack = onBack, + onPaste = onPaste, + onSelected = onSelected, + titleRes = R.string.subscriptions__choose_recipient, + selectedTarget = selectedTarget, + testTagPrefix = "Subscription", + action = { SubscriptionExpirationMenu(expiration, onExpirationChange) }, + footer = { + PrimaryButton( + text = stringResource(R.string.subscriptions__propose_subscription), + onClick = onPropose, + icon = { + Icon( + painter = painterResource(R.drawable.ic_sent), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + isLoading = isCreating, + enabled = selectedTarget != null && !isLoadingIcon, + modifier = Modifier.testTag("SubscriptionPropose"), + ) + VerticalSpacer(16.dp) + }, + modifier = Modifier.sheetHeight() + ) +} + +@Composable +private fun SubscriptionExpirationMenu( + expiration: PaymentRequestExpiration, + onExpirationChange: (PaymentRequestExpiration) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + IconButton( + onClick = { expanded = true }, + modifier = Modifier.testTag("SubscriptionExpiration"), + ) { + Icon( + painter = painterResource(R.drawable.ic_timer), + contentDescription = stringResource(R.string.wallet__payment_request_expires), + tint = Colors.White, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PaymentRequestExpiration.entries.forEach { option -> + DropdownMenuItem( + text = { BodyM(option.creationTitle()) }, + onClick = { + onExpirationChange(option) + expanded = false + }, + trailingIcon = if (option == expiration) { + { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Colors.Purple, + ) + } + } else { + null + }, + ) + } + } + } +} + +@Composable +private fun SubscriptionProposalSent( + subscription: PaykitSubscription, + contact: PubkyProfile, + onDone: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("SubscriptionProposalSent") + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) + FillHeight() + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + FillHeight() + Display( + text = stringResource(R.string.subscriptions__proposal_sent_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_description + } else { + R.string.subscriptions__proposal_queued_description + } + ), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + PubkyContactRow(profile = contact, onClick = {}, verticalPadding = 12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(16.dp), + ) { + val iconUri = subscription.metadata.iconUri + if (iconUri == null) { + PubkyContactAvatar(profile = contact, size = 40.dp) + } else { + PubkyImage(uri = iconUri, size = 40.dp) + } + Column(Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB(subscription.note ?: stringResource(R.string.subscriptions__subscription)) + BodyS( + text = subscription.frequencyTitle(), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + } + VerticalSpacer(24.dp) + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun PaykitRecurrenceUnit.creationTitle(): String = stringResource( + when (this) { + PaykitRecurrenceUnit.Day -> R.string.subscriptions__daily + PaykitRecurrenceUnit.Week -> R.string.subscriptions__weekly + PaykitRecurrenceUnit.Month -> R.string.subscriptions__monthly + PaykitRecurrenceUnit.Year -> R.string.subscriptions__yearly + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> R.string.subscriptions__unsupported_frequency + } +) + +@Composable +private fun PaykitSubscription.frequencyTitle(): String = recurrence.unit.creationTitle() + +private enum class SubscriptionCreationStep { Details, Amount, Recipient, Sent } + +private val creationFrequencies = persistentListOf( + PaykitRecurrenceUnit.Day, + PaykitRecurrenceUnit.Week, + PaykitRecurrenceUnit.Month, + PaykitRecurrenceUnit.Year, +) + +@Preview(showSystemUi = true) +@Composable +private fun CreateSubscriptionDetailsPreview() { + AppThemeSurface { + BottomSheetPreview { + CreateSubscriptionDetails( + amountSats = 100_000uL, + name = "Rent Support", + description = "Monthly support", + frequency = PaykitRecurrenceUnit.Month, + selectedIconUri = null, + isLoadingIcon = false, + onAmountClick = {}, + onNameChange = {}, + onDescriptionChange = {}, + onFrequencyChange = {}, + onIconSelected = {}, + onChooseRecipient = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 3b0acbc0da..be192886ff 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -44,11 +44,13 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -59,6 +61,7 @@ import to.bitkit.ext.dateTimeFormatterOf import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription @@ -76,6 +79,7 @@ import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.MoneyMSB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute @@ -107,6 +111,7 @@ fun SubscriptionsScreen( appViewModel: AppViewModel, onBack: () -> Unit, onRequestPayment: () -> Unit, + onCreateSubscription: () -> Unit, onDetails: (PaykitSubscriptionId) -> Unit, onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, showPayments: Boolean = false, @@ -125,12 +130,13 @@ fun SubscriptionsScreen( initialTab = if (showPayments) SubscriptionTab.Payments else SubscriptionTab.Overview, pendingPaymentRequestCount = pendingPaymentRequests.size, onSubscription = { subscription -> - if (subscription.isProposalVisible(now)) { + if (subscription.isPayer && subscription.isProposalVisible(now)) { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) } else { onDetails(subscription.id) } }, + onCreateSubscription = onCreateSubscription, paymentsContent = { PaymentRequestsScreen( appViewModel = appViewModel, @@ -153,12 +159,15 @@ internal fun SubscriptionsContent( initialTab: SubscriptionTab, pendingPaymentRequestCount: Int, onSubscription: (PaykitSubscription) -> Unit, + onCreateSubscription: () -> Unit, paymentsContent: @Composable () -> Unit, ) { - val proposals = subscriptions.filter { it.isProposalVisible(now) } - val active = subscriptions.filter { it.isActive(now) } - val expired = subscriptions.filter { it.isExpired(now) && acceptedAt(it.id) != null } - val hasVisibleSubscriptions = proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() + val proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) } + val active = subscriptions.filter { it.isPayer && it.isActive(now) } + val expired = subscriptions.filter { it.isPayer && it.isExpired(now) && acceptedAt(it.id) != null } + val created = subscriptions.filter { it.isCreatedVisible(now) } + val hasVisibleSubscriptions = + proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() || created.isNotEmpty() var selectedTabIndex by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } val selectedTab = SubscriptionTab.entries[selectedTabIndex] @@ -196,6 +205,7 @@ internal fun SubscriptionsContent( SubscriptionMetrics( monthlyCostSats = subscriptionMonthlyCostSats(subscriptions, now), activeCount = active.size, + createdCount = created.size, ) } subscriptionSection( @@ -219,8 +229,31 @@ internal fun SubscriptionsContent( now = now, onSubscription = onSubscription, ) + subscriptionSection( + titleRes = R.string.subscriptions__created, + subscriptions = created, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) } } + + if (selectedTab == SubscriptionTab.Overview) { + PrimaryButton( + text = stringResource(R.string.subscriptions__create), + onClick = onCreateSubscription, + modifier = Modifier.padding(horizontal = 16.dp).testTag("SubscriptionCreate"), + icon = { + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + VerticalSpacer(16.dp) + } } } @@ -302,7 +335,7 @@ private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { } @Composable -private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int) { +private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, createdCount: Int) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() @@ -324,6 +357,15 @@ private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int) { BodyMSB(text = activeCount.toString()) } } + Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Caption13Up(text = stringResource(R.string.subscriptions__created), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_plus), contentDescription = null, tint = Colors.Purple) + BodyMSB(text = createdCount.toString()) + } + } } } @@ -345,7 +387,7 @@ private fun SubscriptionRow( .clickable(onClick = onClick) .padding(16.dp), ) { - PubkyContactAvatar(profile = contact, size = 40.dp) + SubscriptionAvatar(subscription = subscription, contact = contact, size = 40.dp) Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { BodyMSB( text = subscription.note ?: stringResource(R.string.subscriptions__subscription), @@ -363,6 +405,20 @@ private fun SubscriptionRow( } } +@Composable +private fun SubscriptionAvatar( + subscription: PaykitSubscription, + contact: PubkyProfile, + size: Dp, +) { + val iconUri = subscription.metadata.iconUri + if (iconUri == null) { + PubkyContactAvatar(profile = contact, size = size) + } else { + PubkyImage(uri = iconUri, size = size) + } +} + @Composable fun SubscriptionDetailScreen( appViewModel: AppViewModel, @@ -410,12 +466,20 @@ fun SubscriptionDetailScreen( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { MoneyDisplay(sats = subscription.displaySats, showSymbol = true) FillWidth() - PubkyContactAvatar(profile = contacts.contactFor(subscription), size = 48.dp) + SubscriptionAvatar( + subscription = subscription, + contact = contacts.contactFor(subscription), + size = 48.dp, + ) } } } item { SubscriptionDetailsGrid(subscription, now) } - val payments = paymentHistory.filter { it.belongsTo(subscription) } + val payments = if (subscription.isCreatedByUser) { + subscription.receivedPaymentRequests() + } else { + paymentHistory.filter { it.belongsTo(subscription) } + } if (payments.isNotEmpty()) { item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -426,7 +490,8 @@ fun SubscriptionDetailScreen( contact = contacts.contactFor(subscription), compactSubtitle = subscription.note?.takeIf(String::isNotBlank) ?: stringResource(R.string.subscriptions__subscription), - isOutgoingPayment = true, + isOutgoingPayment = !subscription.isCreatedByUser, + showSignedAmount = subscription.isCreatedByUser, ) } } @@ -457,11 +522,7 @@ private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Insta Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SubscriptionDetailCell( stringResource(R.string.subscriptions__status), - if (subscription.isActive(now)) { - stringResource(R.string.subscriptions__active) - } else { - stringResource(R.string.subscriptions__expired) - }, + subscription.statusText(now), R.drawable.ic_check, Modifier.weight(1f), ) @@ -476,6 +537,22 @@ private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Insta FillWidth() } } + if (subscription.isCreatedByUser) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__subscribers), + if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) "1" else "0", + R.drawable.ic_users, + Modifier.weight(1f), + ) + SubscriptionDetailCell( + stringResource(R.string.subscriptions__payments), + subscription.paidPeriods.size.toString(), + R.drawable.ic_received, + Modifier.weight(1f), + ) + } + } } } @@ -513,13 +590,13 @@ private fun SubscriptionDetailFooter( now: Instant, ) { val hasMoreInfo = subscription.metadata.description != null || subscription.metadata.benefits.isNotEmpty() - val canCancel = subscription.canCancel(now) + val canCancel = subscription.canCancel(now) && subscription.recurrence.endsAt == null if (!hasMoreInfo && !canCancel) return Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) { - if (hasMoreInfo) { + if (hasMoreInfo && !subscription.isCreatedByUser) { SecondaryButton( text = stringResource(R.string.subscriptions__more_info), onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Details(subscription.id))) }, @@ -528,7 +605,9 @@ private fun SubscriptionDetailFooter( } if (canCancel) { PrimaryButton( - text = stringResource(R.string.subscriptions__cancel), + text = stringResource( + if (subscription.isCreatedByUser) R.string.common__delete else R.string.subscriptions__cancel + ), onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Cancel(subscription.id))) }, modifier = Modifier.weight(1f), icon = { @@ -749,7 +828,7 @@ private fun SubscriptionProviderCard( verticalAlignment = Alignment.CenterVertically, modifier = cardModifier ) { - PubkyContactAvatar(profile = contact, size = 40.dp) + SubscriptionAvatar(subscription = subscription, contact = contact, size = 40.dp) Column(Modifier.padding(start = 16.dp).weight(1f)) { BodyMSB( text = subscription.note ?: stringResource(R.string.subscriptions__subscription), @@ -856,7 +935,15 @@ private fun SubscriptionCancel( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - SheetTopBar(titleText = stringResource(R.string.subscriptions__cancel_subscription)) + SheetTopBar( + titleText = stringResource( + if (subscription.isCreatedByUser) { + R.string.subscriptions__delete_subscription + } else { + R.string.subscriptions__cancel_subscription + } + ) + ) rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { Caption13Up(text = it.removeAccentTags(), color = Colors.White64) } @@ -878,7 +965,13 @@ private fun SubscriptionCancel( ) FillHeight() SwipeToConfirm( - text = stringResource(R.string.subscriptions__swipe_to_cancel), + text = stringResource( + if (subscription.isCreatedByUser) { + R.string.subscriptions__swipe_to_delete + } else { + R.string.subscriptions__swipe_to_cancel + } + ), color = Colors.Red, loading = loading, onConfirm = { @@ -949,33 +1042,63 @@ private fun PaykitSubscription.subscriptionFrequencyText(): String { } @Composable -private fun PaykitSubscription.rowSubtitle(now: Instant): String = when { - isProposalVisible(now) || !recurrence.unit.isSupported -> subscriptionFrequencyText() - isExpired(now) -> recurrence.endsAt?.let { - stringResource(R.string.subscriptions__expires_date, it.formatShortDate()) - } ?: stringResource(R.string.subscriptions__expired) - recurrence.endsAt != null -> stringResource( - R.string.subscriptions__expires_date, - recurrence.endsAt.formatShortDate(), - ) - else -> { - val renewal = recurrence.nextPeriodAfter(now)?.startsAt - if (renewal == null) { - subscriptionFrequencyText() - } else { - stringResource( - R.string.subscriptions__renews_date, - renewal.formatShortDate(), - ) +private fun PaykitSubscription.rowSubtitle(now: Instant): String { + createdRowSubtitle(now)?.let { return it } + return when { + isProposalVisible(now) || !recurrence.unit.isSupported -> subscriptionFrequencyText() + isExpired(now) -> recurrence.endsAt?.let { + stringResource(R.string.subscriptions__expires_date, it.formatShortDate()) + } ?: stringResource(R.string.subscriptions__expired) + recurrence.endsAt != null -> stringResource( + R.string.subscriptions__expires_date, + recurrence.endsAt.formatShortDate(), + ) + else -> { + val renewal = recurrence.nextPeriodAfter(now)?.startsAt + if (renewal == null) { + subscriptionFrequencyText() + } else { + stringResource( + R.string.subscriptions__renews_date, + renewal.formatShortDate(), + ) + } } } } +@Composable +private fun PaykitSubscription.createdRowSubtitle(now: Instant): String? { + if (!isCreatedByUser) return null + return when { + isProposalVisible(now) -> stringResource( + if (deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_status + } else { + R.string.subscriptions__proposal_queued_status + } + ) + isActive(now) -> stringResource( + if (paidPeriods.size == 1) { + R.string.subscriptions__created_summary_single_payment + } else { + R.string.subscriptions__created_summary + }, + paidPeriods.size, + ) + else -> null + } +} + internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = isActive(now) || recurrence.endsAt != null -internal fun PaykitSubscription.canCancel(now: Instant): Boolean = - isActive(now) && recurrence.endsAt == null +@Composable +private fun PaykitSubscription.statusText(now: Instant): String = when { + isProposalVisible(now) -> stringResource(R.string.subscriptions__pending) + isActive(now) -> stringResource(R.string.subscriptions__active) + else -> stringResource(R.string.subscriptions__expired) +} @Composable private fun PaykitSubscription.timingTitle(now: Instant): String = when { @@ -1026,7 +1149,7 @@ internal fun subscriptionMonthlyCostSats( subscriptions: List, now: Instant, ): Long { - val total = subscriptions.filter { it.isActive(now) }.fold(BigDecimal.ZERO) { total, subscription -> + val total = subscriptions.filter { it.isPayer && it.isActive(now) }.fold(BigDecimal.ZERO) { total, subscription -> val annualPeriods = when (subscription.recurrence.unit) { PaykitRecurrenceUnit.Minute -> 525_600L PaykitRecurrenceUnit.Hour -> 8_760L diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b80600e2b9..d82832bdba 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -158,6 +158,7 @@ import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionDraft import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification @@ -4756,6 +4757,11 @@ class AppViewModel @Inject constructor( viewModelScope.launch { refreshPaymentRequestTargets(force = true) } } + fun showSubscriptionCreator() { + showSheet(Sheet.CreateSubscription) + viewModelScope.launch { refreshPaymentRequestTargets(force = true) } + } + fun subscription(id: PaykitSubscriptionId): PaykitSubscription? = paykitPaymentRequestRepo.subscriptions.value.firstOrNull { it.id == id } @@ -4852,6 +4858,37 @@ class AppViewModel @Inject constructor( .onFailure(::toast) }.await() + fun createSubscription( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + onCreated: (PaykitSubscription) -> Unit, + ) { + val sourceSheet = currentSheet.value.takeIf { it is Sheet.CreateSubscription } + viewModelScope.launch { + paykitPaymentRequestRepo.proposeSubscription( + draft = draft, + target = target, + savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey }, + ).onSuccess { creation -> + val creatorIsCurrent = PubkyPublicKeyFormat.matches( + creation.creatorIdentity, + pubkyRepo.publicKey.value, + ) + if (creation.wasPublishedToActiveState && creatorIsCurrent) { + onCreated(creation.subscription) + } else { + if (sourceSheet != null && currentSheet.value === sourceSheet) hideSheet() + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.subscriptions__title), + description = context.getString(R.string.subscriptions__proposal_queued_description), + testTag = "SubscriptionProposalQueuedToast", + ) + } + }.onFailure(::toast) + } + } + fun openIncomingPaymentRequest(id: PaykitPaymentRequestId) { openIncomingPaymentRequestWithTags(id, emptyList()) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd5d2e4ff5..c814b23e1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1038,8 +1038,19 @@ Active Cancel Cancel Subscription + Choose Recipient + Create + Create Subscription + Created + 1 subscriber · %1$d payments + 1 subscriber · 1 payment + Custom Icon + Tap to upload a custom icon Daily Daily Subscription + Delete Subscription + Description + What is this subscription for? Subscription Details At the moment, you don’t have any active subscriptions from any providers. Subscriptions]]> @@ -1058,6 +1069,8 @@ Monthly Cost Monthly Subscription More Info + Name + Subscription name Ongoing Overview Open Bitkit to review a subscription payment. @@ -1067,15 +1080,24 @@ Per Month Per Week Per Year + Pending + Your subscription proposal is queued and will send automatically. + Proposal queued + You have sent a subscription proposal. + Proposal]]> + Proposal sent Proposals + Propose Subscription Renews Renews %1$s Retry Payment Review & Subscribe Status Subscribed + Subscribers Subscription Swipe To Cancel + Swipe To Delete Swipe To Subscribe Swipe To Subscribe & Pay Subscriptions diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index c8b87c99a8..8dfcad0f74 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -28,6 +28,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -37,6 +38,7 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -143,6 +145,134 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `refresh keeps creator subscription without generating a payer payment`() = test { + val metadataText = """ + { + "note":"Creator plan", + "subscription":{ + "version":1, + "description":"Monthly support", + "benefits":[], + "icon_uri":"pubky://creator/icon" + } + } + """.trimIndent() + val metadata = mock { + on { exportText() } doReturn metadataText + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + metadata = metadata, + ), + ), + ) + + sut.refresh().getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertTrue(subscription.isCreatedByUser) + assertEquals("Creator plan", subscription.note) + assertEquals("Monthly support", subscription.metadata.description) + assertEquals("pubky://creator/icon", subscription.metadata.iconUri) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + + @Test + fun `creator proposal sends recurring terms and stays queued until delivery`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + .thenAnswer { invocation -> + val proposal = invocation.getArgument(2) + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + expiresAt = proposal.proposalExpiresAt, + recurrence = requireNotNull(proposal.recurrence).let { + PaymentRequestRecurrence(it.every, it.unit, it.startsAt, it.anchor, it.endsAt) + }, + metadata = mock { + on { exportText() } doReturn proposal.metadataJson + }, + ) + } + val expiresAt = clock.now().plus(60.seconds) + + val creation = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 100_000uL, + name = " Monthly support ", + description = " Thank you ", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = expiresAt, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + + val captured = argumentCaptor() + verifyBlocking(paykitSdkService) { + proposePaymentRequest( + eq(COUNTERPARTY), + eq(PaykitReceiverPaths.SERVER), + captured.capture(), + eq(LOCAL_IDENTITY), + ) + } + with(captured.firstValue) { + assertEquals("0.001", amountValue) + assertEquals(expiresAt.toString(), proposalExpiresAt) + assertEquals(1u, recurrence?.every) + assertEquals("month", recurrence?.unit) + assertEquals(clock.now().toString(), recurrence?.startsAt) + assertEquals(recurrence?.startsAt, recurrence?.anchor) + assertEquals(null, recurrence?.endsAt) + assertTrue(metadataJson.contains("\"note\":\"Monthly support\"")) + assertTrue(metadataJson.contains("\"description\":\"Thank you\"")) + } + assertTrue(creation.subscription.isCreatedByUser) + assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, creation.subscription.deliveryStatus) + assertEquals(listOf(creation.subscription), sut.subscriptions.value) + } + + @Test + fun `creator can delete a pending proposal without payer proof checks`() = test { + val proposed = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) + val canceled = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.CANCELED, + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposed), listOf(canceled)) + whenever( + paykitSdkService.cancelPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(canceled) + sut.refresh().getOrThrow() + + sut.cancel(sut.subscriptions.value.single()).getOrThrow() + + verifyBlocking(paymentProofRepo, never()) { + protectedRequestIdsForSubscriptionCancellation(any(), any()) + } + verifyBlocking(paykitSdkService) { + cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) + } + @Test fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() @@ -508,6 +638,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, + role: PaymentRequestLocalRole = PaymentRequestLocalRole.PAYER, state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", expiresAt: String? = null, @@ -519,7 +650,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat counterparty = COUNTERPARTY, counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, - localRole = PaymentRequestLocalRole.PAYER, + localRole = role, state = state, proposalStreamItemId = 1uL, proposalOutboundMessageId = null, diff --git a/changelog.d/next/subscription-requests.added.md b/changelog.d/next/subscription-requests.added.md new file mode 100644 index 0000000000..6da1146f75 --- /dev/null +++ b/changelog.d/next/subscription-requests.added.md @@ -0,0 +1 @@ +Bitkit can now propose recurring subscriptions to Paykit contacts. From 2a1d0d011b24a892fbdfa48403ca634e587a3fac Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 14:14:55 +0300 Subject: [PATCH 2/7] fix: harden subscription request delivery --- .../CreateSubscriptionScreenTest.kt | 171 ++++++++++++++++ .../repositories/PaykitPaymentRequestRepo.kt | 46 +---- .../bitkit/repositories/PaykitSubscription.kt | 39 ++-- .../PaykitSubscriptionProposal.kt | 53 +++++ .../to/bitkit/services/PaykitSdkService.kt | 9 +- .../CreatePaymentRequestScreen.kt | 27 +-- .../subscriptions/CreateSubscriptionScreen.kt | 193 ++++++++++-------- .../subscriptions/SubscriptionsScreen.kt | 9 +- .../java/to/bitkit/utils/SubscriptionIcon.kt | 40 ++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 +- .../subscription_default_icon.png | Bin 0 -> 17038 bytes app/src/main/res/values/strings.xml | 10 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 62 ++++++ .../PaykitSubscriptionProposalTest.kt | 60 ++++++ 14 files changed, 575 insertions(+), 156 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt create mode 100644 app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt create mode 100644 app/src/main/res/drawable-nodpi/subscription_default_icon.png create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt new file mode 100644 index 0000000000..60eb6501f0 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -0,0 +1,171 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence +import to.bitkit.repositories.PaykitSubscriptionRole +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class CreateSubscriptionScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun detailsRequiresAmountNameAndCompletedIconLoad() { + var amount by mutableStateOf(0uL) + var name by mutableStateOf("") + var frequency by mutableStateOf(PaykitRecurrenceUnit.Month) + var isLoadingIcon by mutableStateOf(false) + var choseRecipient = false + composeTestRule.setContent { + AppThemeSurface { + CreateSubscriptionDetails( + amountSats = amount, + name = name, + description = "Monthly support", + frequency = frequency, + selectedIconUri = null, + isLoadingIcon = isLoadingIcon, + onAmountClick = {}, + onNameChange = { name = it }, + onDescriptionChange = {}, + onFrequencyChange = { frequency = it }, + onIconSelected = {}, + onChooseRecipient = { choseRecipient = true }, + ) + } + } + + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.onNodeWithTag("SubscriptionName").performTextInput("Support") + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.runOnIdle { amount = 1000uL } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsEnabled() + composeTestRule.onNodeWithTag("SubscriptionFrequencyWeek").performClick() + assertEquals(PaykitRecurrenceUnit.Week, frequency) + composeTestRule.runOnIdle { isLoadingIcon = true } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.runOnIdle { isLoadingIcon = false } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").performClick() + assertTrue(choseRecipient) + } + + @Test + fun recipientAllowsExactlyOneSelectionAndChangesExpiry() { + val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet") + var selected by mutableStateOf(null) + var expiration by mutableStateOf(PaymentRequestExpiration.Week) + var proposedTo: PaykitPaymentRequestTarget? = null + composeTestRule.setContent { + AppThemeSurface { + SubscriptionRecipient( + targets = persistentListOf(target, second), + contacts = persistentListOf(contact, PubkyProfile.forDisplay(second.publicKey, "Bob", null)), + selectedTarget = selected, + expiration = expiration, + isCreating = false, + isLoadingIcon = false, + onBack = {}, + onPaste = { "" }, + onSelected = { selected = it }, + onExpirationChange = { expiration = it }, + onPropose = { proposedTo = selected }, + ) + } + } + + composeTestRule.onNodeWithTag("SubscriptionPropose").assertIsNotEnabled() + composeTestRule.onNodeWithTag("SubscriptionContact${target.publicKey}").performClick().assertIsSelected() + composeTestRule.onNodeWithTag("SubscriptionContact${second.publicKey}").performClick().assertIsSelected() + composeTestRule.onNodeWithTag("SubscriptionContact${target.publicKey}").assertIsNotSelected() + composeTestRule.onNodeWithTag("SubscriptionExpiration").performClick() + composeTestRule.onNodeWithText("1 hour").performClick() + assertEquals(PaymentRequestExpiration.Hour, expiration) + composeTestRule.onNodeWithTag("SubscriptionPropose").performClick() + assertEquals(second, proposedTo) + } + + @Test + fun confirmationNeverClaimsQueuedProposalWasSent() { + var delivery by mutableStateOf(PaykitPaymentRequestDeliveryStatus.Queued) + composeTestRule.setContent { + AppThemeSurface { + Box(Modifier.height(520.dp)) { + SubscriptionProposalSent( + subscription.copy(deliveryStatus = delivery, note = "Long subscription name ".repeat(7)), + contact, + onDone = {}, + ) + } + } + } + + composeTestRule.onNodeWithText("Queued").assertIsDisplayed() + composeTestRule.onNodeWithText("OK").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody").performScrollToNode(hasText("Anna")) + composeTestRule.onNodeWithText("Anna").assertIsDisplayed() + composeTestRule.onAllNodesWithText("Sent", substring = true, ignoreCase = true).assertCountEquals(0) + composeTestRule.runOnIdle { delivery = PaykitPaymentRequestDeliveryStatus.Sent } + composeTestRule.onNodeWithText("Sent").assertIsDisplayed() + composeTestRule.onAllNodesWithText("Queued", substring = true, ignoreCase = true).assertCountEquals(0) + composeTestRule.onNodeWithText("OK").assertIsDisplayed() + } + + private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet") + private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null) + private val startsAt = Instant.parse("2027-01-15T08:00:00Z") + private val subscription = PaykitSubscription( + paymentRequestId = "creator-proposal", + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + amountValue = "0.00001", + amountSats = 1000uL, + note = "Support", + createdAt = startsAt, + proposalExpiresAt = Instant.parse("2027-01-22T08:00:00Z"), + recurrence = PaykitSubscriptionRecurrence(1, PaykitRecurrenceUnit.Month, startsAt, startsAt, null), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = emptyList(), + role = PaykitSubscriptionRole.Payee, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + paidPeriods = emptyList(), + ) +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index deaf3c3917..3111f40bfa 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -2,8 +2,6 @@ package to.bitkit.repositories -import android.graphics.Bitmap -import android.graphics.BitmapFactory import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.OutboundPrivateMessageStatus @@ -48,7 +46,7 @@ import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import java.io.ByteArrayOutputStream +import to.bitkit.utils.SubscriptionIcon import java.math.BigDecimal import java.util.UUID import java.util.concurrent.atomic.AtomicLong @@ -163,6 +161,7 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object RequestUnavailable : PaykitPaymentRequestError("Payment request is unavailable") data object RequestExpired : PaykitPaymentRequestError("Payment request has expired") data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress") + data object SubscriptionTooLong : PaykitPaymentRequestError("Subscription proposal exceeds the message size limit") } @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @@ -179,8 +178,6 @@ class PaykitPaymentRequestRepo @Inject constructor( ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" - private const val SUBSCRIPTION_ICON_MAX_SIZE = 400 - private const val SUBSCRIPTION_ICON_QUALITY = 80 private val TARGET_DISCOVERY_TIMEOUT = 5.seconds } @@ -471,20 +468,25 @@ class PaykitPaymentRequestRepo @Inject constructor( val generation = stateGeneration.get() val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val validationDate = clock.now() - val name = draft.name.trim().take(256) - val description = draft.description.trim().take(1024) + val name = draft.name.trim() + val description = draft.description.trim() validateSubscriptionDraft(draft, name, validationDate) val endpoints = subscriptionProposalEndpoints(target, savedPublicKeys, expectedIdentity) + val reservedIconUri = PaykitSubscriptionProposal.reservedIconUri.takeIf { draft.iconBytes != null } + val preflight = buildSubscriptionProposal(draft, name, description, reservedIconUri, endpoints, validationDate) + PaykitSubscriptionProposal.validate(preflight) val iconUri = draft.iconBytes?.let { paykitSdkService.uploadProfileAvatar( - bytes = compressSubscriptionIcon(it), + bytes = SubscriptionIcon.compress(it), contentType = "image/jpeg", + expectedIdentity = expectedIdentity, ) } val proposalDate = clock.now() validateProposalExpiration(draft.expiresAt, proposalDate) val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, proposalDate) + PaykitSubscriptionProposal.validate(proposal) val record = paykitSdkService.proposePaymentRequest( counterparty = target.publicKey, counterpartyReceiverPath = target.receiverPath, @@ -553,7 +555,7 @@ class PaykitPaymentRequestRepo @Inject constructor( "benefits" to JsonArray(emptyList()), ) iconUri?.let { subscriptionMetadata["icon_uri"] = JsonPrimitive(it) } - val timestamp = proposalDate.toString() + val timestamp = Instant.fromEpochSeconds(proposalDate.epochSeconds).toString() return PaykitPaymentRequestProposalTerms( amountValue = draft.amountSats.toBitcoinAmount(), paymentReference = "bitkit-${UUID.randomUUID()}", @@ -930,32 +932,6 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private fun compressSubscriptionIcon(bytes: ByteArray): ByteArray { - val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?: throw PaykitPaymentRequestError.RequestUnavailable - val scale = minOf( - SUBSCRIPTION_ICON_MAX_SIZE.toFloat() / original.width, - SUBSCRIPTION_ICON_MAX_SIZE.toFloat() / original.height, - 1f, - ) - val scaled = if (scale < 1f) { - Bitmap.createScaledBitmap( - original, - (original.width * scale).toInt().coerceAtLeast(1), - (original.height * scale).toInt().coerceAtLeast(1), - true, - ) - } else { - original - } - return ByteArrayOutputStream().use { output -> - if (!scaled.compress(Bitmap.CompressFormat.JPEG, SUBSCRIPTION_ICON_QUALITY, output)) { - throw PaykitPaymentRequestError.RequestUnavailable - } - output.toByteArray() - } - } - private suspend fun isAvailable(): Boolean = activeIdentity != null && PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index de0a51aa24..ff7fb2430a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -4,6 +4,7 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -311,14 +312,14 @@ internal fun PaymentRequestRecord.toPaykitSubscription( .filter { MethodId.fromRawValue(it) != null } .distinct() val metadataObject = requestTerms.metadata.subscriptionMetadata() - val payments = paymentProofs.mapNotNull { proof -> - val period = proof.billingPeriod ?: return@mapNotNull null - val periodStart = period.startsAt.parseInstant() ?: return@mapNotNull null - val periodEnd = period.endsAt.parseInstant() ?: return@mapNotNull null - val billingPeriod = PaykitBillingPeriod(periodStart, periodEnd).takeIf { periodStart < periodEnd } - ?: return@mapNotNull null - billingPeriod to PaykitPaymentProofKind.fromPaymentEndpointIdentifier(proof.paymentEndpointIdentifier) - } + val recurrence = PaykitSubscriptionRecurrence( + every = sdkRecurrence.every.toInt(), + unit = recurrenceUnit, + startsAt = startsAt, + anchor = anchor, + endsAt = recurrenceEndsAt, + ) + val payments = paymentProofs.mapNotNull { it.toSubscriptionPayment(recurrence) } return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, @@ -328,23 +329,29 @@ internal fun PaymentRequestRecord.toPaykitSubscription( note = requestTerms.metadata.note()?.take(256), createdAt = lastEventAt?.parseInstant(), proposalExpiresAt = proposalExpiresAt, - recurrence = PaykitSubscriptionRecurrence( - every = sdkRecurrence.every.toInt(), - unit = recurrenceUnit, - startsAt = startsAt, - anchor = anchor, - endsAt = recurrenceEndsAt, - ), + recurrence = recurrence, metadata = metadataObject, acceptedPaymentEndpointIdentifiers = endpoints, role = role, deliveryStatus = subscriptionDeliveryStatus(role, deliveryStatusOverride), lifecycleState = state, - paidPeriods = payments.map { it.first }, + paidPeriods = payments.map { it.first }.distinct(), paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), ) } +@Suppress("ReturnCount") +private fun PaymentProofRecord.toSubscriptionPayment( + recurrence: PaykitSubscriptionRecurrence, +): Pair? { + val period = billingPeriod ?: return null + val start = period.startsAt.parseInstant() ?: return null + val end = period.endsAt.parseInstant() ?: return null + val parsedPeriod = PaykitBillingPeriod(start, end).takeIf { start < end } ?: return null + if (recurrence.periodsThrough(date = start, acceptedAt = start).firstOrNull() != parsedPeriod) return null + return parsedPeriod to PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) +} + private fun PaymentRequestLocalRole?.toSubscriptionRole(): PaykitSubscriptionRole? = when (this) { PaymentRequestLocalRole.PAYER -> PaykitSubscriptionRole.Payer PaymentRequestLocalRole.PAYEE -> PaykitSubscriptionRole.Payee diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt new file mode 100644 index 0000000000..e7cf4342bc --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt @@ -0,0 +1,53 @@ +package to.bitkit.repositories + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import to.bitkit.services.PaykitPaymentRequestProposalTerms + +internal object PaykitSubscriptionProposal { + /** Maximum plaintext size accepted by Paykit's pubky-noise transport. */ + const val MAX_MESSAGE_BYTES = 1000 + + /** Longest JPEG avatar URI produced by Bitkit's staging profile namespace. */ + val reservedIconUri = "pubky://" + "x".repeat(122) + + fun validate(terms: PaykitPaymentRequestProposalTerms) { + if (encodedSize(terms) > MAX_MESSAGE_BYTES) throw PaykitPaymentRequestError.SubscriptionTooLong + } + + fun encodedSize(terms: PaykitPaymentRequestProposalTerms): Int { + val recurrence = requireNotNull(terms.recurrence) + val uuid = "00000000-0000-0000-0000-000000000000" + val wire = buildJsonObject { + put("version", 1) + put("kind", "paykit.payment_request") + put("event_id", uuid) + put("payment_request_id", uuid) + putJsonObject("request") { + putJsonObject("amount") { + put("value", terms.amountValue) + put("asset", "btc") + } + put("payment_reference", terms.paymentReference) + put("proposal_expires_at", terms.proposalExpiresAt) + putJsonObject("recurrence") { + put("every", recurrence.every.toLong()) + put("unit", recurrence.unit) + put("starts_at", recurrence.startsAt) + put("anchor", recurrence.anchor) + put("ends_at", recurrence.endsAt?.let(::JsonPrimitive) ?: JsonNull) + } + putJsonArray("accepted_payment_endpoint_identifiers") { + terms.acceptedPaymentEndpointIdentifiers.forEach { add(JsonPrimitive(it)) } + } + put("metadata", Json.parseToJsonElement(terms.metadataJson)) + } + } + return wire.toString().encodeToByteArray().size + } +} diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ca9bc5d990..a32c02de41 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -385,9 +385,16 @@ class PaykitSdkService @Inject constructor( } } - suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String { + suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String? = null): String { isSetup.await() return operationMutex.withLock { + if (expectedIdentity != null) { + val identityStatus = handle().identityStatus() + check( + identityStatus?.liveSessionAvailable == true && + PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + ) { "Paykit identity changed before uploading the subscription icon" } + } handle().uploadProfileAvatar(bytes, contentType).uri.also { notifyBackupStateChanged() } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 8865daeabb..ba57618785 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -7,7 +7,6 @@ import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -479,22 +478,14 @@ internal fun PaymentRequestRecipientContent( items = recipients, key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, ) { (target, contact) -> - Box(contentAlignment = Alignment.CenterEnd) { - PubkyContactRow( - profile = contact, - onClick = { onSelected(target) }, - verticalPadding = 16.dp, - modifier = Modifier.testTag("${testTagPrefix}Contact${contact.publicKey}"), - ) - if (target == selectedTarget) { - Icon( - painter = painterResource(R.drawable.ic_check), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.padding(end = 16.dp).size(24.dp), - ) - } - } + PubkyContactRow( + profile = contact, + onClick = { onSelected(target) }, + verticalPadding = 16.dp, + isSelected = selectedTarget?.let { it == target }, + selectionColor = Colors.Brand, + modifier = Modifier.testTag("${testTagPrefix}Contact${contact.publicKey}"), + ) HorizontalDivider(color = Colors.White10) } } @@ -579,7 +570,7 @@ internal fun PaymentRequestSentContent( } @Composable -private fun PaymentRequestExpiration.title(): String = stringResource( +internal fun PaymentRequestExpiration.title(): String = stringResource( when (this) { PaymentRequestExpiration.Hour -> R.string.wallet__payment_request_expiry_hour PaymentRequestExpiration.Day -> R.string.wallet__payment_request_expiry_day diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index cc5b02c552..e902762c89 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -53,8 +55,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.getClipboardText +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.Toast import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget @@ -67,27 +71,25 @@ import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.MoneyCell import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountContent import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientContent +import to.bitkit.ui.screens.paymentrequests.title import to.bitkit.ui.shared.modifiers.clickableAlpha 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.removeAccentTags import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.SubscriptionIcon import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel import kotlin.time.Clock @@ -131,12 +133,18 @@ fun CreateSubscriptionSheet( isLoadingIcon = true scope.launch { val bytes = withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() + runSuspendCatching { SubscriptionIcon.load(context.contentResolver, uri) } + } + bytes.onSuccess { + iconBytes = it + selectedIconUri = uri + }.onFailure { + appViewModel.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.subscriptions__icon_error), + ) } - iconBytes = bytes - selectedIconUri = uri.takeIf { bytes != null } isLoadingIcon = false } }, @@ -200,7 +208,7 @@ fun CreateSubscriptionSheet( } @Composable -private fun CreateSubscriptionDetails( +internal fun CreateSubscriptionDetails( amountSats: ULong, name: String, description: String, @@ -240,11 +248,7 @@ private fun CreateSubscriptionDetails( SheetTopBar(titleText = stringResource(R.string.subscriptions__create_subscription)) LazyColumn(modifier = Modifier.weight(1f)) { item { - rememberMoneyText( - sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - reversed = true, - showSymbol = true, - )?.let { Caption13Up(text = it.removeAccentTags(), color = Colors.White64) } + Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -291,33 +295,29 @@ private fun CreateSubscriptionDetails( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(Colors.Gray6) - .clickable(onClick = launchPhotoPicker) + .clickable(enabled = !isLoadingIcon, onClick = launchPhotoPicker) .padding(16.dp) .testTag("SubscriptionIconPicker"), ) { if (selectedIconUri == null) { - Icon( - painter = painterResource(R.drawable.ic_image_square), + Image( + painter = painterResource(R.drawable.subscription_default_icon), contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(48.dp).padding(10.dp), + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + .background(Colors.White).padding(5.dp), ) } else { AsyncImage( model = selectedIconUri, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.size(48.dp).clip(RoundedCornerShape(12.dp)), + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)), ) } - Column(Modifier.padding(start = 16.dp).weight(1f)) { - BodyMSB(stringResource(R.string.subscriptions__custom_icon)) - BodyS(stringResource(R.string.subscriptions__custom_icon_description), color = Colors.White64) - } - Icon( - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = Colors.White64, + BodyM( + text = stringResource(R.string.subscriptions__custom_icon_description), + modifier = Modifier.padding(start = 16.dp).weight(1f), + color = Colors.White64, ) } VerticalSpacer(24.dp) @@ -326,13 +326,6 @@ private fun CreateSubscriptionDetails( PrimaryButton( text = stringResource(R.string.subscriptions__choose_recipient), onClick = onChooseRecipient, - icon = { - Icon( - painter = painterResource(R.drawable.ic_user_plus), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, enabled = amountSats > 0uL && name.isNotBlank() && !isLoadingIcon, modifier = Modifier.testTag("SubscriptionChooseRecipient") ) @@ -367,7 +360,7 @@ private fun SubscriptionFrequencyPicker( } @Composable -private fun SubscriptionRecipient( +internal fun SubscriptionRecipient( targets: ImmutableList, contacts: ImmutableList, selectedTarget: PaykitPaymentRequestTarget?, @@ -429,7 +422,7 @@ private fun SubscriptionExpirationMenu( DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { PaymentRequestExpiration.entries.forEach { option -> DropdownMenuItem( - text = { BodyM(option.creationTitle()) }, + text = { BodyM(option.title()) }, onClick = { onExpirationChange(option) expanded = false @@ -452,7 +445,7 @@ private fun SubscriptionExpirationMenu( } @Composable -private fun SubscriptionProposalSent( +internal fun SubscriptionProposalSent( subscription: PaykitSubscription, contact: PubkyProfile, onDone: () -> Unit, @@ -465,57 +458,95 @@ private fun SubscriptionProposalSent( .padding(horizontal = 16.dp) .testTag("SubscriptionProposalSent") ) { - SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) - FillHeight() - Image( - painter = painterResource(R.drawable.check), - contentDescription = null, - modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), - ) - FillHeight() - Display( - text = stringResource(R.string.subscriptions__proposal_sent_headline) - .withAccent(accentColor = Colors.Purple), - ) - VerticalSpacer(12.dp) - BodyM( - text = stringResource( + SheetTopBar( + titleText = stringResource( if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - R.string.subscriptions__proposal_sent_description + R.string.wallet__payment_request_sent_title } else { - R.string.subscriptions__proposal_queued_description + R.string.subscriptions__proposal_queued_title } ), - color = Colors.White64, ) - VerticalSpacer(16.dp) - PubkyContactRow(profile = contact, onClick = {}, verticalPadding = 12.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Colors.Gray6) - .padding(16.dp), + Column( + modifier = Modifier.weight( + 1f + ).verticalScroll(rememberScrollState()).testTag("SubscriptionConfirmationBody"), ) { - val iconUri = subscription.metadata.iconUri - if (iconUri == null) { - PubkyContactAvatar(profile = contact, size = 40.dp) - } else { - PubkyImage(uri = iconUri, size = 40.dp) - } - Column(Modifier.padding(start = 16.dp).weight(1f)) { - BodyMSB(subscription.note ?: stringResource(R.string.subscriptions__subscription)) - BodyS( - text = subscription.frequencyTitle(), - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + VerticalSpacer(16.dp) + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + VerticalSpacer(16.dp) + Display( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_headline + } else { + R.string.subscriptions__proposal_queued_headline + } ) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_description + } else { + R.string.subscriptions__proposal_queued_description + } + ), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + PubkyContactRow( + profile = contact, + onClick = {}, + verticalPadding = 16.dp, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(horizontal = 16.dp), + ) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(16.dp), + ) { + val iconUri = subscription.metadata.iconUri + if (iconUri == null) { + Image( + painter = painterResource(R.drawable.subscription_default_icon), + contentDescription = null, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + .background(Colors.White).padding(5.dp), + ) + } else { + PubkyImage(uri = iconUri, size = 40.dp) + } + Column(Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subscription.frequencyTitle(), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) } - MoneyCell(sats = subscription.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + VerticalSpacer(24.dp) } - VerticalSpacer(24.dp) PrimaryButton( text = stringResource(R.string.common__ok), onClick = onDone, diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index be192886ff..ca53f773c5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -412,7 +412,14 @@ private fun SubscriptionAvatar( size: Dp, ) { val iconUri = subscription.metadata.iconUri - if (iconUri == null) { + if (iconUri == null && subscription.isCreatedByUser) { + Image( + painter = painterResource(R.drawable.subscription_default_icon), + contentDescription = null, + modifier = Modifier.size(size).clip(RoundedCornerShape(size / 5)) + .background(Colors.White).padding(size / 8), + ) + } else if (iconUri == null) { PubkyContactAvatar(profile = contact, size = size) } else { PubkyImage(uri = iconUri, size = size) diff --git a/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt b/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt new file mode 100644 index 0000000000..f6c5711537 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt @@ -0,0 +1,40 @@ +package to.bitkit.utils + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +internal object SubscriptionIcon { + /** Maximum pixel dimension of a subscription's public icon. */ + private const val MAX_DIMENSION = 400 + + /** JPEG quality used for public subscription icons. */ + private const val JPEG_QUALITY = 80 + + fun load(resolver: ContentResolver, uri: Uri): ByteArray = + encode(ImageDecoder.createSource(resolver, uri)) + + fun compress(bytes: ByteArray): ByteArray = encode(ImageDecoder.createSource(ByteBuffer.wrap(bytes))) + + private fun encode(source: ImageDecoder.Source): ByteArray { + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val scale = minOf(MAX_DIMENSION.toFloat() / info.size.width, MAX_DIMENSION.toFloat() / info.size.height, 1f) + decoder.setTargetSize( + (info.size.width * scale).toInt().coerceAtLeast(1), + (info.size.height * scale).toInt().coerceAtLeast(1), + ) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + return try { + ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output)) + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d82832bdba..864fd2101f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4885,7 +4885,17 @@ class AppViewModel @Inject constructor( testTag = "SubscriptionProposalQueuedToast", ) } - }.onFailure(::toast) + }.onFailure { + if (it is PaykitPaymentRequestError.SubscriptionTooLong) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.subscriptions__content_too_long), + ) + } else { + toast(it) + } + } } } diff --git a/app/src/main/res/drawable-nodpi/subscription_default_icon.png b/app/src/main/res/drawable-nodpi/subscription_default_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8301897232a103d857bd780976877a6b243e5f01 GIT binary patch literal 17038 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 J%Wl{_`d_n;H(US! literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c814b23e1b..2feb8fbd0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1039,12 +1039,13 @@ Cancel Cancel Subscription Choose Recipient + Shorten the subscription name or description and try again. Create Create Subscription Created 1 subscriber · %1$d payments 1 subscriber · 1 payment - Custom Icon + Icon Tap to upload a custom icon Daily Daily Subscription @@ -1065,11 +1066,12 @@ You’re subscribed, but your first payment wasn’t sent. First billing period ends %1$s. Each period is charged in full. Frequency + Could not load this image. Choose another image and try again. Monthly Monthly Cost Monthly Subscription More Info - Name + Subscription Name Subscription name Ongoing Overview @@ -1082,9 +1084,11 @@ Per Year Pending Your subscription proposal is queued and will send automatically. + Proposal]]> Proposal queued + Queued You have sent a subscription proposal. - Proposal]]> + Proposal]]> Proposal sent Proposals Propose Subscription diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 8dfcad0f74..a3967884d1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -182,6 +182,36 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertTrue(sut.paymentRequestHistory.value.isEmpty()) } + @Test + fun `creator payments aggregate duplicate proof events for one billing period`() = test { + val period = BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") + val first = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val second = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val offSchedule = mock { + on { billingPeriod } doReturn BillingPeriod(period.startsAt, "2027-02-02T08:00:00Z") + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val subscription = requireNotNull( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(first, second, offSchedule), + ).toPaykitSubscription() + ) + + assertEquals(1, subscription.paidPeriods.size) + val received = subscription.receivedPaymentRequests().single() + assertEquals(PaykitPaymentRequestDirection.Outgoing, received.direction) + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, received.lifecycleState) + assertEquals(PaykitPaymentProofKind.Lightning, received.paymentProofKind) + } + @Test fun `creator proposal sends recurring terms and stays queued until delivery`() = test { val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) @@ -245,6 +275,38 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(listOf(creation.subscription), sut.subscriptions.value) } + @Test + fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + + listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> + val result = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 1000uL, + name = "Support", + description = "💜".repeat(256), + frequency = PaykitRecurrenceUnit.Month, + expiresAt = clock.now() + 60.seconds, + iconBytes = icon, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ) + assertEquals(PaykitPaymentRequestError.SubscriptionTooLong, result.exceptionOrNull()) + } + + verifyBlocking(paykitSdkService, never()) { uploadProfileAvatar(any(), any(), anyOrNull()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + assertTrue(sut.subscriptions.value.isEmpty()) + assertFalse(sut.isCreatingRequest.value) + } + @Test fun `creator can delete a pending proposal without payer proof checks`() = test { val proposed = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt new file mode 100644 index 0000000000..82ce0ec167 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt @@ -0,0 +1,60 @@ +package to.bitkit.repositories + +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Test +import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PaykitSubscriptionProposalTest { + @Test + fun `transport limit includes envelope endpoints and public icon`() { + val empty = terms("", PaykitSubscriptionProposal.reservedIconUri) + val available = PaykitSubscriptionProposal.MAX_MESSAGE_BYTES - PaykitSubscriptionProposal.encodedSize(empty) + assertTrue(available > 0) + val full = terms("a".repeat(available), PaykitSubscriptionProposal.reservedIconUri) + assertEquals(1000, PaykitSubscriptionProposal.encodedSize(full)) + PaykitSubscriptionProposal.validate(full) + assertFailsWith { + val oversized = terms("a".repeat(available + 1), PaykitSubscriptionProposal.reservedIconUri) + PaykitSubscriptionProposal.validate(oversized) + } + } + + @Test + fun `size counts UTF8 and JSON escaping rather than characters`() { + val base = PaykitSubscriptionProposal.encodedSize(terms("")) + assertEquals(base + 4, PaykitSubscriptionProposal.encodedSize(terms("💜"))) + assertEquals(base + 6, PaykitSubscriptionProposal.encodedSize(terms("\"\n\\"))) + assertEquals(base + 19, PaykitSubscriptionProposal.encodedSize(terms("https://example.com"))) + } + + private fun terms(description: String, iconUri: String? = null) = PaykitPaymentRequestProposalTerms( + amountValue = "0.001", + paymentReference = "bitkit-00000000-0000-0000-0000-000000000000", + proposalExpiresAt = "2027-01-22T08:00:00.000Z", + recurrence = PaykitPaymentRequestRecurrenceTerms( + every = 1u, + unit = "month", + startsAt = "2027-01-15T08:00:00.000Z", + anchor = "2027-01-15T08:00:00.000Z", + ), + acceptedPaymentEndpointIdentifiers = listOf("bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"), + metadataJson = buildJsonObject { + put("note", "Support") + put( + "subscription", + buildJsonObject { + put("version", 1) + put("description", description) + put("benefits", buildJsonArray { }) + iconUri?.let { put("icon_uri", it) } + }, + ) + }.toString(), + ) +} From dfea56dd51c625c47c43e5181b7e7392f51e4977 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 14:21:48 +0300 Subject: [PATCH 3/7] docs: map subscription request screens --- docs/screens-map.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/screens-map.md b/docs/screens-map.md index c27b255d53..178eb9c855 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -71,6 +71,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | | CreatePaymentRequestScreen.kt | Payment Request (from Receive > Contacts OR Contact profile OR Payment Requests) › Request payment enter amount | +| IncomingPaymentRequestDetailsScreen.kt | `todo` | | PaymentRequestsScreen.kt | Payment Requests › Payment Requests | ## ui/screens/profile @@ -116,6 +117,13 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | shopDiscover/ShopDiscoverScreen.kt | Shop (Bitrefill & BTCMaps) › Shop Discover | | shopWebView/ShopWebViewScreen.kt | Shop (Bitrefill & BTCMaps) › Shop Gift Cards | +## ui/screens/subscriptions + +| Android | Figma | +| - | - | +| CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | +| SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | + ## ui/screens/transfer | Android | Figma | From be2baa113903ff844e6d47e9b2076fb75559eaa6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 17:28:53 +0300 Subject: [PATCH 4/7] fix: align subscription request ui with designs --- .../CreateSubscriptionScreenTest.kt | 13 +- .../subscriptions/SubscriptionsScreenTest.kt | 30 +++ .../java/to/bitkit/ui/components/Money.kt | 6 +- .../bitkit/ui/components/PubkyContactRow.kt | 6 +- .../to/bitkit/ui/components/PubkyImage.kt | 6 +- .../CreatePaymentRequestScreen.kt | 19 +- .../subscriptions/CreateSubscriptionScreen.kt | 236 +++++++----------- .../screens/subscriptions/SubscriptionRow.kt | 92 +++++++ .../subscriptions/SubscriptionsScreen.kt | 112 ++------- .../subscription_intro_clock.png | Bin 0 -> 31061 bytes app/src/main/res/drawable/ic_asterisk.xml | 11 + .../main/res/drawable/ic_timer_outline.xml | 24 ++ app/src/main/res/values/strings.xml | 10 +- 13 files changed, 314 insertions(+), 251 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt create mode 100644 app/src/main/res/drawable-nodpi/subscription_intro_clock.png create mode 100644 app/src/main/res/drawable/ic_asterisk.xml create mode 100644 app/src/main/res/drawable/ic_timer_outline.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt index 60eb6501f0..bc242a2993 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -79,8 +79,11 @@ class CreateSubscriptionScreenTest { composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() composeTestRule.runOnIdle { amount = 1000uL } composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsEnabled() - composeTestRule.onNodeWithTag("SubscriptionFrequencyWeek").performClick() - assertEquals(PaykitRecurrenceUnit.Week, frequency) + listOf(PaykitRecurrenceUnit.Day, PaykitRecurrenceUnit.Week, PaykitRecurrenceUnit.Month, PaykitRecurrenceUnit.Year) + .forEach { option -> + composeTestRule.onNodeWithTag("Tab-${option.name.lowercase()}").performClick() + assertEquals(option, frequency) + } composeTestRule.runOnIdle { isLoadingIcon = true } composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() composeTestRule.runOnIdle { isLoadingIcon = false } @@ -145,6 +148,12 @@ class CreateSubscriptionScreenTest { composeTestRule.onAllNodesWithText("Sent", substring = true, ignoreCase = true).assertCountEquals(0) composeTestRule.runOnIdle { delivery = PaykitPaymentRequestDeliveryStatus.Sent } composeTestRule.onNodeWithText("Sent").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody") + .performScrollToNode(hasText("You have sent a subscription proposal to")) + composeTestRule.onNodeWithText("You have sent a subscription proposal to").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody") + .performScrollToNode(hasText("Monthly subscription")) + composeTestRule.onNodeWithText("Monthly subscription").assertIsDisplayed() composeTestRule.onAllNodesWithText("Queued", substring = true, ignoreCase = true).assertCountEquals(0) composeTestRule.onNodeWithText("OK").assertIsDisplayed() } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index 9ab9df1c12..37d00e00f9 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -2,9 +2,12 @@ package to.bitkit.ui.screens.subscriptions +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -22,6 +25,33 @@ class SubscriptionsScreenTest { @get:Rule val composeTestRule = createComposeRule() + @Test + fun emptyOverviewOffersCreationWithoutDiscover() { + var requestedCreation = false + composeTestRule.setContent { + AppThemeSurface { + SubscriptionsContent( + subscriptions = persistentListOf(), + contacts = persistentListOf(), + acceptedAt = { null }, + now = Instant.parse("2027-01-15T08:00:00Z"), + onBack = {}, + initialTab = SubscriptionTab.Overview, + pendingPaymentRequestCount = 0, + onSubscription = {}, + onCreateSubscription = { requestedCreation = true }, + paymentsContent = {}, + ) + } + } + + composeTestRule.onNodeWithText("At the moment, you don’t have any active subscriptions from any providers.") + .assertIsDisplayed() + composeTestRule.onAllNodesWithText("Discover").assertCountEquals(0) + composeTestRule.onNodeWithTag("SubscriptionCreate").assertIsDisplayed().performClick() + assertTrue(requestedCreation) + } + @Test fun paymentsTabShowsEligibleOneTimeRequestAction() { var requestedPayment = false diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index 00443e067c..3f019a3183 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -28,6 +28,7 @@ import java.math.BigDecimal @Composable fun MoneyDisplay( sats: Long, + modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, showSymbol: Boolean? = null, ) { @@ -35,7 +36,7 @@ fun MoneyDisplay( text?.let { Display( text = it.withAccent(accentColor = Colors.White64), - modifier = Modifier + modifier = modifier .clickableAlpha(onClick = onClick) .testTag("MoneyText") ) @@ -95,8 +96,9 @@ fun MoneyMSB( unit: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, color: Color = MaterialTheme.colorScheme.primary, accent: Color = Colors.White64, + showSymbol: Boolean = unit == PrimaryDisplay.FIAT, ) { - rememberMoneyText(sats = sats, unit = unit)?.let { text -> + rememberMoneyText(sats = sats, unit = unit, showSymbol = showSymbol)?.let { text -> BodyMSB( text = text.withAccent(accentColor = accent), color = color, diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt index f2e837add0..ddb83c7426 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -56,16 +56,16 @@ fun PubkyContactRow( ) { PubkyContactAvatar(profile = profile) Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.weight(1f) ) { - BodyS( + Caption13Up( text = profile.truncatedPublicKey, color = Colors.White64, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - BodySSB( + BodyMSB( text = profile.name, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index 3e59c5ba95..9a31f4046c 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -38,6 +39,7 @@ fun PubkyImage( uri: String, size: Dp, modifier: Modifier = Modifier, + shape: Shape = CircleShape, ) { var imageState by remember { mutableStateOf(ImageState.Loading) } @@ -54,7 +56,7 @@ fun PubkyImage( contentAlignment = Alignment.Center, modifier = modifier .size(size) - .clip(CircleShape) + .clip(shape) ) { AsyncImage( model = uri, @@ -108,7 +110,7 @@ private fun ImageOverlay(state: ImageState, size: Dp) { modifier = Modifier .fillMaxSize() .graphicsLayer { alpha = errorAlpha } - .background(Colors.Gray5, CircleShape) + .background(Colors.Gray5) ) { Icon( painter = painterResource(R.drawable.ic_user_square), diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index ba57618785..0a54dd3e57 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -12,6 +12,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.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -54,6 +55,7 @@ import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -434,25 +436,26 @@ internal fun PaymentRequestRecipientContent( .clickableAlpha { query = PubkyPublicKeyFormat.bounded(onPaste()) } - .padding(horizontal = 12.dp) - .testTag("${testTagPrefix}RecipientPaste"), + .padding(horizontal = 24.dp) + .testTag("${testTagPrefix}RecipientPaste") ) { Icon( painter = painterResource(R.drawable.ic_clipboard_text), contentDescription = null, tint = Colors.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(16.dp), ) - BodyMSB(text = stringResource(R.string.wallet__payment_request_paste)) + CaptionB(text = stringResource(R.string.wallet__payment_request_paste)) } }, modifier = Modifier .fillMaxWidth() - .testTag("${testTagPrefix}RecipientSearch"), + .height(52.dp) + .testTag("${testTagPrefix}RecipientSearch") ) - VerticalSpacer(24.dp) + VerticalSpacer(32.dp) Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) HorizontalDivider(color = Colors.White10) LazyColumn(modifier = Modifier.weight(1f)) { if (recipients.isEmpty()) { @@ -481,7 +484,7 @@ internal fun PaymentRequestRecipientContent( PubkyContactRow( profile = contact, onClick = { onSelected(target) }, - verticalPadding = 16.dp, + verticalPadding = 24.dp, isSelected = selectedTarget?.let { it == target }, selectionColor = Colors.Brand, modifier = Modifier.testTag("${testTagPrefix}Contact${contact.publicKey}"), diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index e902762c89..f98a81ffbc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -24,7 +26,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -41,14 +42,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -66,16 +65,13 @@ import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionDraft import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display -import to.bitkit.ui.components.MoneyCell import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactRow -import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar @@ -83,6 +79,8 @@ import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountContent import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientContent import to.bitkit.ui.screens.paymentrequests.title +import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.activity.components.TabItem import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -249,24 +247,29 @@ internal fun CreateSubscriptionDetails( LazyColumn(modifier = Modifier.weight(1f)) { item { Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) + VerticalSpacer(12.dp) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().clickableAlpha(onClick = onAmountClick), + modifier = Modifier.fillMaxWidth().clickableAlpha(onClick = onAmountClick) ) { - MoneyDisplay(sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), showSymbol = true) + MoneyDisplay( + sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + modifier = Modifier.weight(1f, fill = false) + ) Icon( painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = stringResource(R.string.common__edit), tint = Colors.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.subscriptions__frequency), color = Colors.White64) VerticalSpacer(8.dp) SubscriptionFrequencyPicker(frequency, onFrequencyChange) - VerticalSpacer(20.dp) + VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.subscriptions__name), color = Colors.White64) VerticalSpacer(8.dp) TextInput( @@ -274,50 +277,46 @@ internal fun CreateSubscriptionDetails( onValueChange = onNameChange, placeholder = stringResource(R.string.subscriptions__name_placeholder), singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("SubscriptionName"), + modifier = Modifier.fillMaxWidth().testTag("SubscriptionName") ) - VerticalSpacer(20.dp) + VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.subscriptions__description), color = Colors.White64) VerticalSpacer(8.dp) TextInput( value = description, onValueChange = onDescriptionChange, placeholder = stringResource(R.string.subscriptions__description_placeholder), + minLines = 3, maxLines = 3, - modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription"), + modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription") ) - VerticalSpacer(20.dp) + VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.subscriptions__custom_icon), color = Colors.White64) VerticalSpacer(8.dp) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Colors.Gray6) + .clip(RoundedCornerShape(8.dp)) + .background(Colors.White10) .clickable(enabled = !isLoadingIcon, onClick = launchPhotoPicker) .padding(16.dp) - .testTag("SubscriptionIconPicker"), + .testTag("SubscriptionIconPicker") ) { if (selectedIconUri == null) { - Image( - painter = painterResource(R.drawable.subscription_default_icon), - contentDescription = null, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) - .background(Colors.White).padding(5.dp), - ) + SubscriptionDefaultIcon(size = 40.dp) } else { AsyncImage( model = selectedIconUri, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)), + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) ) } - BodyM( + CaptionB( text = stringResource(R.string.subscriptions__custom_icon_description), - modifier = Modifier.padding(start = 16.dp).weight(1f), color = Colors.White64, + modifier = Modifier.padding(start = 16.dp).weight(1f) ) } VerticalSpacer(24.dp) @@ -338,25 +337,12 @@ private fun SubscriptionFrequencyPicker( selected: PaykitRecurrenceUnit, onSelected: (PaykitRecurrenceUnit) -> Unit, ) { - Row(modifier = Modifier.fillMaxWidth()) { - creationFrequencies.forEach { option -> - val isSelected = option == selected - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .clickableAlpha { onSelected(option) } - .testTag("SubscriptionFrequency${option.name}"), - ) { - BodyS(text = option.creationTitle(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, - ) - } - } - } + CustomTabRowWithSpacing( + tabs = creationFrequencies, + currentTabIndex = creationFrequencies.indexOfFirst { it.unit == selected }, + onTabChange = { onSelected(it.unit) }, + selectedColor = Colors.White, + ) } @Composable @@ -387,16 +373,9 @@ internal fun SubscriptionRecipient( PrimaryButton( text = stringResource(R.string.subscriptions__propose_subscription), onClick = onPropose, - icon = { - Icon( - painter = painterResource(R.drawable.ic_sent), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, isLoading = isCreating, enabled = selectedTarget != null && !isLoadingIcon, - modifier = Modifier.testTag("SubscriptionPropose"), + modifier = Modifier.testTag("SubscriptionPropose") ) VerticalSpacer(16.dp) }, @@ -412,10 +391,10 @@ private fun SubscriptionExpirationMenu( var expanded by remember { mutableStateOf(false) } IconButton( onClick = { expanded = true }, - modifier = Modifier.testTag("SubscriptionExpiration"), + modifier = Modifier.testTag("SubscriptionExpiration") ) { Icon( - painter = painterResource(R.drawable.ic_timer), + painter = painterResource(R.drawable.ic_timer_outline), contentDescription = stringResource(R.string.wallet__payment_request_expires), tint = Colors.White, ) @@ -467,85 +446,56 @@ internal fun SubscriptionProposalSent( } ), ) - Column( - modifier = Modifier.weight( - 1f - ).verticalScroll(rememberScrollState()).testTag("SubscriptionConfirmationBody"), - ) { - VerticalSpacer(16.dp) - Image( - painter = painterResource(R.drawable.check), - contentDescription = null, - modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), - ) - VerticalSpacer(16.dp) - Display( - text = stringResource( - if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - R.string.subscriptions__proposal_sent_headline - } else { - R.string.subscriptions__proposal_queued_headline - } - ) - .withAccent(accentColor = Colors.Purple), - ) - VerticalSpacer(12.dp) - BodyM( - text = stringResource( - if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - R.string.subscriptions__proposal_sent_description - } else { - R.string.subscriptions__proposal_queued_description - } - ), - color = Colors.White64, - ) - VerticalSpacer(16.dp) - PubkyContactRow( - profile = contact, - onClick = {}, - verticalPadding = 16.dp, - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(Colors.Gray6) - .padding(horizontal = 16.dp), - ) - VerticalSpacer(8.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Colors.Gray6) - .padding(16.dp), + BoxWithConstraints(modifier = Modifier.weight(1f)) { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight).testTag("SubscriptionConfirmationBody") ) { - val iconUri = subscription.metadata.iconUri - if (iconUri == null) { - Image( - painter = painterResource(R.drawable.subscription_default_icon), - contentDescription = null, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) - .background(Colors.White).padding(5.dp), - ) - } else { - PubkyImage(uri = iconUri, size = 40.dp) - } - Column(Modifier.padding(start = 16.dp).weight(1f)) { - BodyMSB( - text = subscription.note ?: stringResource(R.string.subscriptions__subscription), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subscription.frequencyTitle(), - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - MoneyCell(sats = subscription.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally) + ) + Display( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_headline + } else { + R.string.subscriptions__proposal_queued_headline + } + ).withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_description + } else { + R.string.subscriptions__proposal_queued_description + } + ), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + PubkyContactRow( + profile = contact, + onClick = {}, + verticalPadding = 16.dp, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(horizontal = 16.dp) + ) + VerticalSpacer(16.dp) + SubscriptionRow( + subscription = subscription, + contact = contact, + faded = false, + subtitle = subscription.subscriptionFrequencyText(), + ) + VerticalSpacer(24.dp) } - VerticalSpacer(24.dp) } PrimaryButton( text = stringResource(R.string.common__ok), @@ -566,17 +516,19 @@ private fun PaykitRecurrenceUnit.creationTitle(): String = stringResource( } ) -@Composable -private fun PaykitSubscription.frequencyTitle(): String = recurrence.unit.creationTitle() - private enum class SubscriptionCreationStep { Details, Amount, Recipient, Sent } -private val creationFrequencies = persistentListOf( - PaykitRecurrenceUnit.Day, - PaykitRecurrenceUnit.Week, - PaykitRecurrenceUnit.Month, - PaykitRecurrenceUnit.Year, -) +private enum class SubscriptionFrequencyOption(val unit: PaykitRecurrenceUnit) : TabItem { + Day(PaykitRecurrenceUnit.Day), + Week(PaykitRecurrenceUnit.Week), + Month(PaykitRecurrenceUnit.Month), + Year(PaykitRecurrenceUnit.Year); + + override val uiText: String + @Composable get() = unit.creationTitle() +} + +private val creationFrequencies = SubscriptionFrequencyOption.entries.toImmutableList() @Preview(showSystemUi = true) @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt new file mode 100644 index 0000000000..82b9491ce0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt @@ -0,0 +1,92 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyImage +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +internal fun SubscriptionRow( + subscription: PaykitSubscription, + contact: PubkyProfile, + subtitle: String, + faded: Boolean, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (faded) 0.5f else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + ) { + SubscriptionAvatar(subscription = subscription, contact = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CaptionB( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.displaySats) + } +} + +@Composable +internal fun SubscriptionDefaultIcon(size: Dp) { + Image( + painter = painterResource(R.drawable.subscription_default_icon), + contentDescription = null, + modifier = Modifier.size(size).clip(RoundedCornerShape(size / 5)) + .background(Colors.White).padding(size / 8) + ) +} + +@Composable +internal fun SubscriptionAvatar( + subscription: PaykitSubscription, + contact: PubkyProfile, + size: Dp, +) { + val iconUri = subscription.metadata.iconUri + if (iconUri == null && subscription.isCreatedByUser) { + SubscriptionDefaultIcon(size = size) + } else if (iconUri == null) { + PubkyContactAvatar(profile = contact, size = size) + } else { + PubkyImage(uri = iconUri, size = size, shape = RoundedCornerShape(size / 5)) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index ca53f773c5..5f5356ea7d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -26,6 +25,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation @@ -74,12 +73,9 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneyCell import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.MoneyMSB import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.PubkyContactAvatar -import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute @@ -174,7 +170,7 @@ internal fun SubscriptionsContent( Column( modifier = Modifier .fillMaxSize() - .gradientBackground() + .background(Colors.Black) .navigationBarsPadding() .testTag("SubscriptionsScreen") ) { @@ -240,17 +236,10 @@ internal fun SubscriptionsContent( } if (selectedTab == SubscriptionTab.Overview) { - PrimaryButton( + SecondaryButton( text = stringResource(R.string.subscriptions__create), onClick = onCreateSubscription, - modifier = Modifier.padding(horizontal = 16.dp).testTag("SubscriptionCreate"), - icon = { - Icon( - painter = painterResource(R.drawable.ic_plus), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, + modifier = Modifier.padding(horizontal = 16.dp).testTag("SubscriptionCreate") ) VerticalSpacer(16.dp) } @@ -272,7 +261,7 @@ private fun LazyListScope.subscriptionSection( SubscriptionRow( subscription = subscription, contact = contacts.contactFor(subscription), - now = now, + subtitle = subscription.rowSubtitle(now), faded = subscription.isExpired(now), onClick = { onSubscription(subscription) }, ) @@ -315,21 +304,21 @@ private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 24.dp) + .padding(horizontal = 16.dp, vertical = 32.dp) ) { FillHeight() Image( - painter = painterResource(R.drawable.subscription_clock), + painter = painterResource(R.drawable.subscription_intro_clock), contentDescription = null, modifier = Modifier .size(256.dp) - .align(Alignment.CenterHorizontally), + .align(Alignment.CenterHorizontally) ) - FillHeight() + VerticalSpacer(32.dp) Display( text = stringResource(R.string.subscriptions__empty_headline).withAccent(accentColor = Colors.Purple), ) - VerticalSpacer(12.dp) + VerticalSpacer(8.dp) BodyM(text = stringResource(R.string.subscriptions__empty_description), color = Colors.White64) } } @@ -338,18 +327,24 @@ private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, createdCount: Int) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.weight(1f)) { Caption13Up(text = stringResource(R.string.subscriptions__monthly_cost), color = Colors.White64) VerticalSpacer(8.dp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(painterResource(R.drawable.ic_calendar), contentDescription = null, tint = Colors.Purple) - MoneyMSB(sats = monthlyCostSats) + Icon( + painterResource(R.drawable.ic_calendar), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(24.dp) + ) + MoneyMSB(sats = monthlyCostSats, showSymbol = true) } } - Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) - Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + VerticalDivider(color = Colors.White16, modifier = Modifier.height(50.dp)) + Column { Caption13Up(text = stringResource(R.string.subscriptions__active), color = Colors.White64) VerticalSpacer(8.dp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -357,75 +352,18 @@ private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, created BodyMSB(text = activeCount.toString()) } } - Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) - Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + VerticalDivider(color = Colors.White16, modifier = Modifier.height(50.dp)) + Column { Caption13Up(text = stringResource(R.string.subscriptions__created), color = Colors.White64) VerticalSpacer(8.dp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(painterResource(R.drawable.ic_plus), contentDescription = null, tint = Colors.Purple) + Icon(painterResource(R.drawable.ic_asterisk), contentDescription = null, tint = Colors.Purple) BodyMSB(text = createdCount.toString()) } } } } -@Composable -private fun SubscriptionRow( - subscription: PaykitSubscription, - contact: PubkyProfile, - now: Instant, - faded: Boolean, - onClick: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .alpha(if (faded) 0.5f else 1f) - .clip(RoundedCornerShape(16.dp)) - .background(Colors.Gray6) - .clickable(onClick = onClick) - .padding(16.dp), - ) { - SubscriptionAvatar(subscription = subscription, contact = contact, size = 40.dp) - Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { - BodyMSB( - text = subscription.note ?: stringResource(R.string.subscriptions__subscription), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subscription.rowSubtitle(now), - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - MoneyCell(sats = subscription.displaySats) - } -} - -@Composable -private fun SubscriptionAvatar( - subscription: PaykitSubscription, - contact: PubkyProfile, - size: Dp, -) { - val iconUri = subscription.metadata.iconUri - if (iconUri == null && subscription.isCreatedByUser) { - Image( - painter = painterResource(R.drawable.subscription_default_icon), - contentDescription = null, - modifier = Modifier.size(size).clip(RoundedCornerShape(size / 5)) - .background(Colors.White).padding(size / 8), - ) - } else if (iconUri == null) { - PubkyContactAvatar(profile = contact, size = size) - } else { - PubkyImage(uri = iconUri, size = size) - } -} - @Composable fun SubscriptionDetailScreen( appViewModel: AppViewModel, @@ -1036,7 +974,7 @@ private fun PaykitSubscription.frequencyValue(): String { } @Composable -private fun PaykitSubscription.subscriptionFrequencyText(): String { +internal fun PaykitSubscription.subscriptionFrequencyText(): String { if (recurrence.every != 1) return cadenceText() return when (recurrence.unit) { PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily_subscription) @@ -1149,7 +1087,7 @@ private fun Instant.formatShortDate(): String = dateTimeFormatterOf("MMMM d") private fun Instant.formatFullDate(): String = dateTimeFormatterOf("MMMM d, yyyy") .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) -private val PaykitSubscription.displaySats: Long +internal val PaykitSubscription.displaySats: Long get() = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() internal fun subscriptionMonthlyCostSats( diff --git a/app/src/main/res/drawable-nodpi/subscription_intro_clock.png b/app/src/main/res/drawable-nodpi/subscription_intro_clock.png new file mode 100644 index 0000000000000000000000000000000000000000..bedb798955a19f97c2ad472f88449d3de6411e86 GIT binary patch literal 31061 zcmd2?Wmgpx4^wQ}aKqwmv_zo}L1x_@44~ zEn2GZ?Ps?{sx1z;T(36Tp9H&zC{RfRJ+F8ALSX-2Tnwb`{FwuwF2kBMWIB$gGdJ1T z+B(|USatjcdA1hOq2-hVnzD63pCzq`G=V>d)5fp~3YW3I8NsEDGf zwA8#G9VEN+^hVFk&7Gc>w)itG?Z#b4XXvo7uy9#1msdF~-?wyMhcnwX9b!@<9QU;Z5(U42zh@C%PL5b?Y= zsvAavOAWE|U;oyzO&^yR@_fm-!|a&0=nOB#e&`M&fhT^<*O?jYdiWnQ6B2`)V?|k@85y=YC~YgxBTz z`gK4i;aTiHWiqd4N%q^fnDo?CE*u;jVIf}L=PQ{65@^b6qY&_ofd;-ee*?F#4Bhsg zZ&>sBT@MijJT936qe-JAYu%1hVY!0wOF3B%j&MO0zkff;n46n#s(zGNUE}pa4G}ta zCaHQ9zLqsD;(pxz^w?U}($GlXT3V7e4KrdJ3ApZ#5GO}?e#e4?(Jn@TQT0U1M0=Eg zJh{5!X$8-%5*NWiPt*?E#MD&$WG>&GufPAz=SGX|FwHa*+J83~T&mF>Kz`sxB@%Go zs}gX(>^%85n&OYP&=MHiMA<2wrJaQ(2d^wG4HKrKvQh?fgV(ETZ5=r&j1VF5?+G@N zPEKE6NFKzCiw7Yd=OnpT_xpE3$DcnHRaG&sudhfJ>`PPq$;rtKXW6KJyK}#1!35LXfc0-MP5b~ zyS1G620x!4mTJCD{S?T+>bDG@o+#<;6daovE4Xh)(%3I<$W)p(Wf!~mYys_BrQnJ6 zuE3}p`dZwUgFu2hUEbVRJ)SodX&I!&MskUbO^u^Ru%n2oQwf>fMJ+0eH54R}AE z9+hmrF>>?psQUQ4aa+PXGjm!_5oHSbJboi4CPvLjOXHry3LORvq2sdX$!_)pe9~@t z9sT@l_j$S`WME+Mg@lAmh3nsC!^Xl=a&TZdFgvPu(`;?qM^zCzOnsJrbFp}S4_?uJ zOF(~tATu*2sMc-@x0udTASS+Wd%d^j_im1LX>6kBVO6vNkU4pEnn3ZEo52OH)n+1e z6VXVhnqn?4EY-8_>TrbQ=H?zZnvchqmY08?JYKAOC~$8Ff`D~wa@(9kFEdr>w>SwD6QE2l(? zS-K!}W1@JVm`d}zU1~Uil=KnL0>RokH77?#dsRF2 z=TE7V6Z~+)UgWV=I8re`ePZw=8eqbjkf9ZXA>v9OPGmo5`AH0TjE#>Evutq0C!Vhi zv<&4qc56HB_J>^wJ}jsjZnWC#ejJQv$SAmxVXfSey@EOgaWct_Dn`*gH>hu9D2xD! zhv<`-BBGFxAvh-iEdcVmE@$Ql7+prj)vE1<1-H_HXB~ciffq%fj+nACIW{)7sYG+LCT(Gb)tEKGN+=WLNzNJxkwuh*>^ zqW;k}S0KCW9{%xp=h4i~{^!e@ZQZ1Z>tU>8b116#_%!%mI4#(2=;`A{^otm+v$#QULw zSs{k0R0)sI&+mUc-B0B4I9?8cszJpq88)`Z{C_KGN+OhH--Ci2yFP}_w!v?2eIxuE zojf6*(fo8CXO4w@zSw-ZV!0EY{dInHX>JB10_>S(^;09~L>6 z8AE?_AsAa2a04mE04=1UH5>>J2GBDI*FR%$S9DC|*TdnE-&Hp*8ax-JguD9} zg`6*Ox3?~a=3efOPHKK{nOMV`d<|y~;8sck985-tK#+K&)qVHX z97s&10TjtRqy2WUvYO$=1(AXghiU)65ot47D?XylNM%hASal*1v5fU~dhJZUrc{ku zJ$2W^i7eJU0S^aQeamb4K&tRZ@DDc!%(|_@Pj}_{+U|!F_@A=kW0}`VSN|ot($Z3S zEu{+|?~cm0w#;-ET{(#miCi+sJx6Qnc7{CyY1oEIonHm$@zieC^eB>{5SC_A#{w@(mnww(zIkvKZs&Rv5YtnqIOmw#r8@13}>S%L}dg%E%dAU z8lJ0LcX>g5u))C<`hVPSxwFB7L72HF|aV~y}`ro%H~T6?(px}O-xK=@_FoT z48MiHcDsClUU*LUgN6YZc4IgFh^(NSyJ?Xp9^KZ?Ry0fps$R)(8*M#~xC>8pvibGp zZzG5R_TIAaW*KJ`8s2DE7neIae5FAo8GmfIW_{mBrM_P^LMuFKwi?9%w5rtK%ImuXk!mIH#X<}>Ad^mB9e&;adLlDOY%!} z5ahFjGG-?AEfNf+M0K2An^RDhSdskB^9{n-$VhgNQD1PrySuya%)&xwb>COi1fU+Z zf#1VoHEo_JDBs}u%Fx&Psa7AK(m6Qj8f`0Ez>?(!!vFO0)WlJ^b91x$@kxuwh%D5K zssGf2HV1bLGjm}#xv`6+|5zWU$hH0i!r>Qpf;)a zMVT!cj}tX_+zhd-1lD*UAJ7E5JJ{MfzA&>QYhP27zFj5mf66bmVFcmT-ma#n(yt!% zOoRlLHQKKR1~GWs>yl`3(kI8Wl=p?Ge)VosxqQ>l?;mgA zs%tI)qkvK-UPaxU+T7gI^#;@bZmq@k4;wlJdu8390?Tw!bp?Qd4L>VRD zKQ_`Aonoo&CHZ$bEZ0&%D~&pLUDNLf<+`8XTf;y`KmBRQ$~skw6|W>`;RB}|mc^w7 z6?65{KDfi_d_~fa>j%a1c2gY`~6;`T>P z2)hlhyTjyd(vpvE1C2Z5E5@7vsx45@$5~GqILW>)*7^Spg^VqsgOL);!q-ksPm(r9 z6JGGk`GqPMf)l);CrRw>^SVOoTq6-n=FbL|`K3+9^Ps+{nruM<;h@gwZWy;fmaG3e zpA|gm$A}+Sz?R^5TK~6;-zN)|szfYduIPpRU;q)O8VURQX6-~LUQ@$d4NrB72?qt? zwmuZ6rlKc*)${o=1@3jWFV4?%Q>_O`An}0UI;ElK-w7%QXNUd>R1#jNzdkTgF-XEI z&17yurlxJW-2x) zE9O_>_XNz4+gq!fU@0!wM|p&Z{`(yJo?RC+EULmGao|z=1>|q#?+z%)D37Y2y=dHe z9fMNeC~sDgU~}I%^IwpD3i*7|(AR$1PFq|w%#4kb>J$uQ+*!@Y(|5Dl{#efUJ+0Jb zh$ro#xG}|G)ou(`S3y5K-HGSP%%n_vEe5V{^^swEGgPf=>k%LY_ftXd?)$JBIus+~ zN?r=$^=i5A;KTQ3IwcsL_P#_Vl4NGuh!+89PwjtiO5fp5n9iGw+t#t)NZeAER6u59 zcq51sj!aCLkV<&l@Rx>)`;uY+K_o_pPg_29SN}#aV;UP9n=Gbs+nXvYtwDUJbUW;U z#%dpYu7|$Iz1@&?LLY;et}Kg&1nJL($t*g}p=FBseJS`h+;GSNnVF-h&GPs3^m;mo z;xe_q(&1#U_xD_K-@YAPN%YI>|EwsL5g(V$P1{Fiqh>2#YSnND?RFWNiD_tN)M<&K z3O|!=?;qv)#KV(5ktjp*ERs9{2jk7CxnzUp5Ql>A+}+%iD0-WuQjIq|9{QRrr)#gS zPOMLl&jm$O;7A}NB|r&6k1M1XEA9IymH{7jC{!;_qUT;Ox7fKA^NXA2XdefU1Q~Yk3>* zNmI^J@!ZQ>CVasZQ3Ks0ec|>DHtZM-?cWxFr+T*n(&zM?U84sI`q z4tEl96G6nt2C%_wkeZ=3CiGqHS3qT&QZha<=0u$ z8B+kk*En>X*L^5r(Jn;|&*|o=bcN^OQLq_C#+C%dtU}Zc! zV!w=?Zp(6TG5Q=y3IV<|+`0m^=zxuljhO=62O#|xXpGT^nw*g_Jx5HCIaUKGRw7P0 zJ!74nm{TFip(tiZ+|xwNCGAE`wJR3c12@jMA$>a_@cd-ZhNyzagQ5G|VR8%H5zdRe ztt(ep@{gg9jeok>a%K^6u74lVWD z1dI5EPY~|Mc8w|8xqqfDICd?15OOP`G9h?Tjs~ClNS$_efMyX}saaWG%}q^rSPDk+ zUNC`j-H=xo0VlJhpUY6b+WL`A*fvLd?i2c)}Qrb23 zH|*_XH)78ztCb$ifW43}fMDq?jz~i_SkpxcwjhizDd$hf69PCfv&|>-tGVJrL$VwK z`@rc)AL?5D@$M`zo`;4A>RpSCja}~O=&1b|T*gz)2HI_PdTqU3^*n(RNWA_*A|oR^ z5BPf4C*mB>gkU?dt%}BGvPC`isRr7h_D_c`Ok-@i^jVSZvIPl6i3sjQ^l=8YL-lLU z2?{Z9TIrbdL|j~4*op6S7asw~>M+@Nd!gjy!;TC#s*QwGdXcOv;LT~*~-=hG4V$3zRkMVI%=P)*oPO?L%G4V4!uwQhEhF(|A9UiaXPNA4-<|4| z2%9FF2;9~b7r5WH0;kz?Hw87s-lyrGJQ=EeNtKZr`?GX4h)@tYZ?CTtf(P)y^uqw~ z@;up-h(PHA{Yz2YLTX_nVKbfyKtYRR(5Y-M4TFKj^yoJJE-u~&IqCd9A-@$b#hN_( z*WwrCQ(a~IsB9ucFU&0>Vp|WOgL%~zX>NX=uN+c8YiGG#N0b)}2YaqL8AGSbf?Hip zZAaj4VE0+8=OPxexHOS#B}A0-q? zLdGh@gDZ5=sKssX=vW+s%UF5EkO6zQ6RLETv+5=xMTQk1Yqnrh@C|JzWevy$IRTqW zH1Mygs;0~44#gFQX5)O-b>(-Um)r3SxfMYScmV5NU0s;J+&stF0yqD7SYCj?<`n~8 zn*`2IPxsb9vMkz-Zc+F59*uXhUa^?axl!l4)9B8AST|}K>dLCBt}0VkeazxBa?mf( zc0+|0fVAY9ZeHJG&T`BHg+U_P+(6g|eY!lu5qHh#wQZl8X$75nhLz?wisLnXWQ!JkB?P7}xPC{<-z zzs@#Z?xdTLQI}y7M{4Zt)l^jM+;Cx1EM*)LxL+b9qGs^=chQ-D;Gi0jjKKH$2EzyA zIE`?M1}hmDY<+-}ApE?PRc71MLl7h^N(m9a+wnH3-~Frrt3fAT#&i3?c{FyjQ=jkY z*@Y?n%w`&rVo&6SE98V;MX_~mpU34!J39B{bs}%?j52QtjzUkR)X!_wkJi>!5ublaG9Rbs zt4+=UjNT8%U4IloQ&&!BbZB<=3dAk?-LHvgfnULukbd!lXiBB3wpNaA;ot6Q!P0l8 zk?{=wO!+St1-q+Jn#|nkba$-G*tSIdAUXy*Bh?+PmxgvPZS6Pyjt&~hzeYRWEGI(F zpl95CaP^UiClMle@}STQX|p#rM)&);%P$XxXn?a6^Jnon*yZjfFiOT}u4`=adN2rm zO2!tyXY&^QtBD=L2HVKNS^2ADP3)L$9D~|FlVpaWP(k!;z_(XWAmF33r=gv%=g*(F zo=NG`{fM)4U}}20@4w|9ulKAAf!2t@^w956)R5$HS#HRr(Jgx(8^Hu3R97KZ+Vn^Nr z4GTIuJGs!{Kpo}f<#|K+%hZsbuP5b>TkWpPWf_M4e2fP2#P%ge6Tq7%=U~*%elWGT zlQ@anb1gMCGGR0i$QIN0yQmO+AoE^aN138xZ544QOIJ!z5d-2-!8Z<|PQ2&UUcNr)E4dN6_} zx!;nBuDBg4KDc`)eZv#DLK}bIR1C;gl{%Wplb{4PPPa*NQwfend8P>I5iTsKxi%_N zVeGOI`wA~ZTvHcVLf#70pJM7H7azRBiae7CfP?pnSyUp*dFaPqfcyCh`bLZE(WlqP z%aLOw?r@lyL9-RQ=5-&9h2z~*^YdQi;dln~(KX?i7kW+{Zp1d2Xst15$@YJ2%5Q*ukvk2bBlv zB8$o;Z5V!eQeL;wY}7K~b-w)LVx?ifwu(52Ehz_Cd`M!K_Y*tYFP@4J>1>mH6|(DS zUS(d=rxu;)2qu&wDITE(YuU5q=!T7>`S;t-ttRnl z3QEsHy)0|jn!2T0W<`BL;yWDAmM5i8l8~MN61Z0Pf9Jw6z3L?PM2UKaRf^tNUHsbR z_r5`kfvj)?s{k19u4y;6_f}Tkh2)CQyhqwO68ZFcWX$;@^Z=G=Cfrr$Z_qNB4TnbY zHaCS9R#sA#FlC`b-DZ3-B(sy!^Xj31kaTnm1wYP~XN$rRwdFRP29Q&)*n@1twU~yg z;E_>~@vyV5mj-B^rn{bIuKZ6`H!haDc}K0tG{R3UXvq#?p`daggu1G^(^KW&$ja#B zFoRQs=Igp!A3r-kh6v9#?YsnenI@vPUam)qB(aFiF`<_>$ET)teSx9LzkV@bQuv20 zC^2|sK&uAX>*#9AjHk06kvtnf%(jR*IqlNpdOtt#FmdQ^Y_@GCmzXZ4tzCGLZhH7rvIbxHZ*U4Z*lA_peY? zxP3;6>Yv!->GR14mq^4n)m@{h5C{abvlr;?UB18&Op$HBWE^UdPIq1nC;xg@&u(9cv z*cCI*fF+@mG=-d*ImTU@>WLK}Z!06E1q!25ljouWcrsLKTG})mR)hN}aB%`1d6vM? z_OA^gvvxn59Cv|(@*K>sX75Tbm}p;m`Gyz=6i%uZDR93B0jh%eR*$FihX|lcTsdaf z=4jT`EX|a9N&lS=Ue>bYMS^wQeQr1c>8$kgzNiGhbJP2lI{&m_EemGO6vFVGtq;(u z<;f(^b_u8D^tFw|D1=(TKcbzq3_esSUUInc772mL@o`-+a7mqASl~MJ+o7R`1WR;2 z&uN+lFE#P;C^FYL$@ryt4O*ezKF~L`IEFWJa#x3ov(x0ii%V#t2Re{#QVKPhDp<5p}<5bQP*oJS0CqzyH;urlEd9QM4yX z0IxRMZPhQijZ2yDA}X$ZeX8Q*Hj*dD+|@_J4|zg9>9nQOxI3rppdcW4Atlet4D6!i z@G>}D81GSHL@6;597vg68@%^11=jcHj!lgjd?BHEDKQ4*@HpnZZ@5msJN^#OOPm7h zL6)N%B5WfJ{N9(;5Q{SsyiqEvU%A%00h%G;ey3>}?^LAj2P_R5oYHp(9Fw7DyH%I& zFQG7f1%mRvlEumd|2Y0dGRdUZN$g=0=-cq-_V_=Xve%*9E2f9${{%CG72twD(~WAg zh-|x5|3rjTP+Pk+dw604=+}(-5xAoyz=p#MMk0NH`eW)$z(+mN<2^p-aqXYWe;TT6 z-)Ka+DQKe(0s%U-)5#dIA2VqT((N?kS^mw9TVtvG{J)qn7$4r5} zcH5=uT{*!DmHL1 z*xaxk*XAY|8j+rsH35;4?7d+}qR3q&GddB3e-3~eYOE!dKc85gpHnXRd>O^P=2X>b zvf%dHSwgM%sy8{f4Morp)brWVmM&(u0F0efOh7yXVIp9xos4>MQOaZ6<#=}Sdq3Xa zNY}s;1ks=0I2RrDFzbo(jTQ49uT-}Msi)I%lp*2|iuawxUEz(z2mR>BGP!WZl=;Re zz4VzGnxbdRb>*esGMtXcpCJY1?FLQQKVoEM$e_}U5YPz-ur9_B5uy)A)hC9KFtdSz zZ|6;};P5XGuvL5(8<;HB>iD*%SpU+H?NAgr7$JdPSD1=r<6yJP(9-}#GkUXspeKZL z4LdR%tC$HeLK&lnJM?(k?D>0R9-5grb!u#a84&xq1B2GjM56%l1sMkR#y94QV1K4F zfWqUM5A3J`_jLj}wwkjITLe@Ty!7I9^;s!}uSbUk#fk*$?@H57TQ{QW>d3e(&efR$ zvT&24+$n+5Xb8}JM4nHZ9?EoBWnjIu$#iC5P*R=)umVcTtc32x1&D>e96r63EwgHK zlo1EhFhM@=q6W0t%$I2jTPRKsVkjnhN=Tr)PxOgp#FpUkzSlcJ-u5e(hGFg5W)XiO zO0gl}qZ&1dK0`tT?SNeuQ|v$8FkM4iB}rRFkW;bA@7C6S0f-Dsx&%02k;%p&WFwb#VI)-oor2=%MnXKhZ;r6 z&HeL8oC9!?d=Y>Gj4mCeNWzm!V+B|MlEX}9x8~;NAhJ)9cJ}_pJe_Z}-%0=3M@=C_ zg@(qKEeiC@v(jPV?P`4M1CTwOoep_)!RSe7uPTGV0c`b-zqHNgmj()f;|aW^xrrNOff!J8h-D8c9Qj0 z`!@qs)f8%Gj6AQBw|?DdCK29`H@*b|KUy#b=F=&NZH$zdFw3Rh2ael-^=xF~~ zTOji37{sjU3BHv;wWI7k#t~*E?fRK0FbM)i$phf{4-d^Y~l+2?Pg___-kf@y-%DJ`z~T16$$W{oNo9hoG7`f z>o-nl$lyfEb408{vA<9&RHE!rAYzXVFIr}uwm`zP#$v9o7W=9(xOGc|QY83Sam%2r+X>#1SO@87zEni}pn!|&>^RAVOVDLc;tUTK@C zD)?1y+ev_S*kkC(BC_6TOew2m>14E9i0iy}pco3g|M39nt}uj^UDRKCAw6R7E5pEH zLonL&vP5)2MDO?ijjH~;`>Qp1G{cVBL)6O$EW-gXV`lg6NyQw8 zkb=m}%Pd_&l`fW&^c&iKfcfC0+cDF~Ci4J!Ko% zJR#EZEr9BMg6?OZu?SmW>$v^GA~z_QgfeLC5i+Pi)2DIWhZ+(V_0m#7wBpv}mJTmU zEb-$U3#tBBRO3O;(zYacmSN2;EUsT6NTg9oaj&RoDPWCF10{mk>0iRYBJ1vYv{Z5T z!otE#su`f~x?53AO|81oG2&u6ONHYM_2xyA9LIOWXf#1d$6nulZlTKzLJ0Zw?Tz$})P#6g8JH@K8Q6pIu^%=YC|eMWf-A6inPikd?PqYoB&clWNS{ZwFs z*;(%}AJ<%4TkChHk89aT5}BOqsbQwq_iSJh)DagmA3_l0y^@3?N z!>9&U43a6+0(t=cxF7(SaU7m?j2GDUhL)g=n5ONo8V&Sh>4yb4L5ZHY$|3GpkSk{b zX7kMCuSbUfBtEyJ$VXF#ZGXG{z6(OOq10gqQ?#4$XNEM3^XHgZ)T- zMk+RNwb;G6GdUzE!hjU2(-b68DQI7c@d+acmv=rhP8RLX%*#KY!gA9iJ2N|0SBq+4 zic5M_kQ=w(XduM=RBr;L?)zEEz|!QqaK58Fu`(uw7HF<%I-q4&rK_|@A&ERp z&ziS&W(xo2^RP_%^!&7}tjwartul|A7w+Kw{jJ?~+NpP7M?*IVP=}r$Yt`QZK{E{I zUXH&RdXJ=%DG|RjaLU@Lt_H8f>l@*-LMRf~a{-<|NJPZ!Ag&BA02Qr^YKN6fAVkI3Q> z(AS{U^$#MWf?XP*BXAKi%UAhEu0S(!ykF^X zBuWF3EMI)V$PVK_q4xxr^&>dT%*LC6S-UfI;MWX7{e=!wEW0*)Vu=q>H632xv>6WA zkEQeiKuD4;S-g5$p2aiK!|&Zs5c!A#TULsQ8=*%`1|$#pPlA9l=6QP)#g$M{^P*#{ zyzE?X)t8+3f!p5KGsYc9zc0C)=F><;88MQ@s44ejX^Any*b*-$(Kemmh}~|2wHBaa z51xdd_$+WL1O=VMgaRWy0vO%x@tzbv-FI>dbNd!FE^dff9*E4*N6`s;Pe$?UJ-08#l) z`}#f|kS7dEN|*s7gxG%-{d?jcQ{5lkB^7-s@P$f|%Neq6Qrh&Z$YmvJ-S3aSlD(HD4oVLl7ykU`|je zPR?`N@&b5r1p?A3iOGyq?eFnF`^j}wbtPEe_i*oD&(@Czqf{pZ?+_8Jz|ugo`-RRF zJizoPdvU5T*xgvPN$~C6@2X4jY4Zua!}%hv+2jDh#?DHTRNC9)%78y$7XlOFLn+sd z8CNQc4t&UFJ4RA|Z$y(F^R=$cs_bJx&U_1#q)@UpRTYOEnUMed2NtMAOix=N#xq#= z*>GGw1pe^1&pQ?cV$v`&=5Wl-S2R_{@bibGVt?n_G4Q=r%`e$2qWB(-VGA9JBr2Z) zws*})p!!9DWu$)N?yW77bkM~`MrC7TbOx5KgTrpC@hacyxjZGG%6&025<;xV%K! zx#Vub*;8bk>~sEqPfh7;Y;4yc@YvmFAEp4^paa(O^X-70d;tLg+>>=gL`1#^&6;$G zO3qx(0II+RnKV6V3;u00HIPC{QGOD@zL>{uMYKwz&Z5X11`Um+`yKXio~Qc;ncwfU z`RuA+Qu@J6(*w6gt2_9RRZG;``h?5xm20i#j2NLbR0|@A4@$y#)onr1x{{oM`RC}` zz$v$_7Uw{hJaR;q?2aciX4@78JlG8N^@J1HKQ%eI>r(>)P{=dl@qA!1iWH`UMOQl` zqn#cwZuW?*^}yWOXm@QQAk4XR`}4vefPm6#hIL%=SBPzP_N!GaCvytKM#|G;myF?a z$ac9f-Mjd;s;LCz`}_90P#rBC9c%$H*+TsI#kqti4Z&Q2k z8Tk5vS(%9-G8a)^i!@9nY$&PKuQ7goMr;Ie(!-r3etLMlqbal^TwGjMqi?8H6C$hB zrGLt4jZL7JCq=gclsrvS8f<1fcURb(5w6Ms-~4-J9J^j%sy-8ksx<2`+A*A?OO@<# zkjk9suwwT>?J*N*JAobhkLy`qqn*+wXcJeZGb=I&oe7Ye)lgQGPd zCkSDZkhBFG#bh9AEUVpn^{?qB>b{5fuz-j4Q-;6j9`gfbO@OI3JDwwx6euHdm*Uc51qf6>3di6 zKj%$LO?4_GC$g?D9sbjPY6bTAkHycLiB1U6LZZ!FHrd^R9iG|XN5)|N@e>Nk=sOjL zw%vAQ)BrMunT;7oKO5Y85PU&9mb4eLQ|_ z-Q&C8;JQRJz8TG{z)15<#y*WV()H0K+J0VNw4_PAX!&b9~0_D)e-eLrSm5raNs zX*2Ww>6!I)nTLlTYK8&U{kG5&5+?1Jl70BUT(tR6-jMexm8mzAv`T8hhX(^&U;-JO z5iLg_AoLZrG6TtngPBOW?nivzuOA{?xE`gLMV#cSSNn&eBxp*FndCs{tWMvX&Cutc`k`2ZqfM&jDnFd1_nb<#UMzxrC}eHQ2@S=EZM&VRTKf{2P&^J z9frV{CGy@I6M)C+cw$;{y!uXsY=FW-6H-(r22PA`0U0F^YeMPo>a>bv?lavL~Z*BZ8 zcj=RMzlT!vz$|02jQ>3xvvzA}hrplXmSz9H<#sj>g>?ZJRYWA*zu+GN;%qVRaG$8g z@@!XUf@KhxW%Ow??Y;RqSX8t6nDjeSFFWtP^ZSE%iG_VLr}9OHk8XAJY<_B7N z=|iwwmZCwA{~Z~zW~RIaGUW+JF<~-*7hpc1nZJ#Dc5$-ybw9Ad0&wi*UuAn*SDi)@ z9X&)EMZuhFL1oel3kZ&=L81xiCt#TYG{i&`Nc@e1O?foewLCKzg=1>m46~-|Z>3)2 zM{T-Ho+$d&ksU=#6#NCV^v_jC&Id^GGd3?^7y5Ck>Z*S_u({`?EazC}^MmsHb&$8u zPOq4ozQwxV;e-m<5Ni(V_85aD1NYX(m3OXqhz53gIc9#pp6O_Ebv~RjRS;C@cvv%f z90w)|k3sEsZpS<1{9dP2mcC%?Ukv{mtE&Dxbv%_`)dmDM!qAH`OQm3V=A+#X;n(mb z_&}gbUO0_xYI?<3P*O?UOFX-Oeos$RQ+aS10J4qv-eFryPmSq(i>`A~Vn7%!M5*+Y z7?sXlS>2J2NgRazrLxggU2loZn{RZp5VM(xC2GCI>+4$fzhzOi#o_F^Su#ZUVm7)vR%E79L zXWKL63Yy3jRp=f~ae=YFf-SYVHWLCu%X5Iy-~|bD_{cK84@sQ5=bzbulR8n}-7WU` zs)w?M!BFW`3paOaM6DDeE(fM)9!ztvB)2GW{>Q!LaUbkw<`rrvBA0xq(mIP_*?7i! zMa>0=Z9t^BaR7xs0Av71I6L@vA$|dZ6@>2pQ!7@*Z1~8EYYi-+@E(H0-v~fjU-h{& zMK>)KhSj*?-2ojD4=YjH|1Sn*{z0bl&Y$#1J0`;T!LF&Ss?0<1b=ltiXB#u-!UDk8 zJ!c!wI8S1kMo${=2-B>@uZLAyCZ4El-QkY{3n~qfv*B|;?(u)-vu|F$t#6F=e#={% zZo7L@8bA8T4kvy}6o*;Ae+vjq{V>@?bZ#*d#Q9rcN;E6FKNo8RV`%8_AY5nUwhrr1 zj7iI|;_P{PPoD=ngR`ciTiIG0Puv$}1Koxj@LIlI!fm^B-FZriSJ1sB>@PC2Eu##jH^C7#f)N8v9eZCGX*u#dk&!dCtIg)#1#1BC?GZkg>yclO?Z zqWb8ihk#WUykvc?g4bPujW8xc4pjdvI8P>&Cl8p(zP3VtwCl~;@)kgKdWSKtAPi-a zhwvh?Ce@M^IP^4qv6_W=u5lx{iE=9wZ?=4Lc%94W{&#%Clgq`*%2;mrahpNZ&B#Po z;B)!HJc?C^p|Yv@8F{988e{}zclGO+WXMUsBASs!po7#iGTY};ZC4VDhQ{1zGy;Q& zdu&T!aKHF>mEe3pew_w`L{o3HirwqMKRk4t*%%QyNC`-;1(6Y***Dln+FV8ooyE!t z5oDJ<{6t5o8d0}=H&nf4B!I2vw$Zj01#Vq076c?-t!~^%Vfe$~WqI4airHa)^q+M< zZ~k|}x`8b0S9giUGxhEpRRm7NW1QDzg53ZYqw%qube-BqrpmIJS@HB;l~Jsm(3AlgRSK4 zRh??MO(iRBn6FPvXVRJAhO?Z?D+K#vUBW4G?4`VUCmQbDThIj1J%c5{*Up=bO!q!wcI&>fAQt@n9Wh$(rfUM2$VokAObd zg({_^v{oY|9?7mE-*?|V>>ktM$BX-%5C7Mt-`#(ck=+?x-qDed&n`-r#%s>a#95)K ze)oPz#a#dWmhQyz*%I6bw_sV*)#{udtINs->t7}&680{}z=WXu-QY_(U7cQgGy|)~ zTvD+NZ#iHBWetLXPk9IGtI(W^d8~SJS9SG7<236)3j;!0P~pST?Q5oFpgK zY`g2D@&in6{4*m}aDoY(GelhN*9hiWiTr#*9aMj4<8Gbf@Vb?Zh&!s{knaO*{P4Le z%zUhtXgzlCd0h3Z!t7Dt_`BCVrU#U1n{nrlBdnlFW)gb$pVAby{~U_@owLu36jG9* z%{J*-IB5&4YR2pl`-g9R0daW<8h8Bv8%k zIr@}U-(+}eF&x9MbA;h(?_1}>D(wVYt|G2RAYAs^Wslytv6Iuamagu4+sdb@KJv4s zq}eDyvIv<9lZF8gu<*z83*NB0I(FLjG$V975Gny4BAx{Y@YON`zbb!>gd0v=US8sQ zEpjmNe9d-3+K!-oHBy@c<&Vx)(V=Y)GZd4s_G*LKC`=ANEU5pX(roc~b^ z?A_;D?p6-1Pz$)HTdo`a3up2GV9jC97@c!%P>@nP=+qX8Nw5IMwkmi3h=MtuTe~$N zHqINk)&3F~iOl+O@a2iKGAh*f4Q#Y?zb$ODw4#%{|^dvLh#Ty{qdvJ;;_kub4TQx_AVTw~0GDC$3ZvTItaUw@v z{OV<(6iTVHe5_t=eheo(0fpVEIfL+i^7~~!g*4Dh1GZ&5y@JPvYN=Xejy3|Uvgf`M zlOA#otXNU$fOnd-zgG4FzKk%5BB~`vd&}PB@(T}k_OWZbz&-GgBtDcou<_^_L8RE= z!HihNVm-omok&tgSP#~IK_JZK(Tdaw2@|VODjXFfx>M$Z&|8E*6LVgP1t}CYgeK{d z$g|_cxY*r{iT~Obr|L(-MXdS4{l(>1EnI?e6a$kQy#{soJ+yJ3D+)6O5z1_JRudhM zNJ5!n&Xf%+XWX6%3F@BOx7zhP^x9C*vgmI8*6G9M%TACWi-hzLB?Z)oi)6^WtL5~~ zk6tG6@RA%qLp8mL{S+Vz-T;3}r`(+rQtzt(c#~S*Ky$hC;BlUk2|8f-=Wc10w$)g&Y2%}+8oeIbNh}f6Elh;~@02?b9z1N?0M;2=DwW2`MhOLArBcN$;3LBZ7+L+O# zXYNj8I`fZEc_{~^PKyoprpLA)csJlzFeSN*M%PT}`SEZ~l7q<1{I%g7C>x;mlYl1H zysXO0T--{fd#kVSlHLB3I}gby>78YV#;9NND^q-AtELfzgOYn%A;z%s*||-R3(H9Y zVE7koXtswBb}ov9w{55uxqpVe+=}l08_Q|U!N#ja8t2aUjd;~-LJ!QoMkx+{J4;7| z%NW!}8Uwx-*Y|(#9wL zs*5AkwYSZ1C9vsCjn0*@-poGZAKvDJy({1XD<9ot$IV4DE}Oyb zocYVIdd14~ORPe8>)%SOXl3#7U?IDBvjBjhfIQ+XJv}pX8Ui8$U-Ir@{k+JTKN7L` zu_M(Bz{>yiP}fnJW9LXIuYwOKITbct7=iyJ8(!}xY^W$BGhAm4VPqEm!(=2$8Gtww zNt(%;Ulv~V{5Km#_aL> zOM@zjDYcDhhQgx8x&m^)%grI)a21DZ_0!+)|5w{Nc1PB9VYp-4wll%RHYc7K6LaE; zZQB!bVsvcVwr!hlKR@DKtG}K8Q0JUlU0u7b``RVoOixUk7fyL!Zg2f|oZByI z&{qTl(zpjO-88BVLfQdXA;thdZFx2KkkC7@Yd5VvTj_xk1Xc#}1(W3Dn9RgQ{>5#^ zL34)+a&!$SZN7%U*8?Ai%zE~Hri(zIi&2H9>=Eu*g6DS5?s)UAoT_-nHE)MCYu%y? zuhRmP>XeID&BB>{TG#<0dhtv~>-FY%1yxGs1HrI%$odbLX!SH7%$u2c4{|ZF;&m3Ov8xu+x(qp+ENaW%7%Z1_MvLSmDsZM*lL0%}e^FXH`$Az9>`3?cp-?gypr~ z|3NS3(X_4GKyr{thmc=0UcP-j*?+hZa`ZFpT>16T7xi@e#3N7diP`NgR7sMvL`@C}k*2JP~mw_hTJm~d-}f4Aez+j!OX z9-Oms9a2|s{`1ky&WlGhebo?V872czNY8WmT}nUFcZ;LKhx1~@9Q_^={ACzlXx zU0+{+|Cug~ivYb%3kFTr=T6=q3aZgT0Pgn_-NGdE*>S^%=a;S10N8_3#shs5CEb z*HIkqOw(Lv*DVl~HF5JELiq;MwEZ}vQ0b%#TK-6A-!1^;Ai=q`5PV))TpJF7ooG=* zzS5YdXJ?*WwY9e_@}!hi`g(XN4`1_QNBw9vI_*?{ZP)a4lux^^L{qst2^pl(Ju9Ug2Fj4lj$8Ibs% zn(Gh~r89%fqv=+k%DFo}9yNVp3(965dnBVCe;nOKLLm``w%XL3&^)rWwWk);d%_)& zVy{+IRuZTol8j|{B^TC^_$BG9*)n-gq|i$i$?r(8Lvs-Lf(TUpo!j9mPw4H_SyP%= zNpY7PfGmey%LDf7AXk?R*H*oML`1|1C`fpaq_`qA;h@lcGB{U8c3wb6$5W0%@0Gh%MqhcDw~u{t-{k`ve5nPu zOZzWb)kuQszDrlslS^gyOLleNBNjb=zMa?$b3IrgRjkQM^Fq1CA3op=OVrNJUT3iE zYxy*L)kZP8FQTXHE3|ZB;nUMoPAh6#O)xcX6$gZYq{Ws6_bvP0&*Up|8w@0-uKQ^* z-OjR1VcKI*qS&N2_6T-AqA8DzVopxZey)qOFgHz)Ty|R&$TV-9k%9dEr(lFzaqVvg zmu!Py#jPZ_9v53>zoA@`wZ42(w{rCm6@MuQl+U#|u)aqoR}aVw^*CfgTMI2N=jwI+ za}PU9&1pD`4{YdLa67YPg`VV3;wrEU+DN6I%^DD~r|h+M1H}aCw4~*c>OQK?Dayhy zavN`?a;OqHX6NVcja#MRT!{W=A_BmC5ri|9X4IZk*XywuQsziLcSvrGaNO)i%OhNr zK%m$^lK1P-by1IIEc@$gWjYIVA~eLm#&>N0^4_J)%~vec$X@?#R!DcT=3}F4`DzDle8UQ(rlsbP5LB zyqZi7(+Fsfg1>>%MP%&!IkB(x(z60jn_^d&-l+*tUApi{;4=GnW2dUABNUAhVOYNl z0s}d0Y-~#23rYt8*O)|1x)gt8HyCqbFPrxiUi*YzDLxT>yZNr##Xu*L+#jbJU9~1~ z`4$%3xyM0yjS|UFaqET>_|yXJ(1amg;)8*=dvU%^`YbU zy)_%Tc|mDw*G*UBjEVc6b4v?r7OPJ*jx=T7z)km|j#+iMoSA2+_sj=#Szij^(6iJ8 z-WasNHi9oNRf;yD#WW9|2o(ijp0bl2~44kh24XXq0VB4u(fY9pUnP~o9Xf17CuQ>wYWK{lcuvmEUSST&W|5?nTO zqbq2BD2y;wjqy$r?t0nThF4dHO%1j+8_&lAZDVrI_m|Cx^JHwF&Xl?qo(=E{e)!t7 z@#mDjXrc@_Sg7QOd4tfgNPc~ib$PoueqoOz(gpqU5)k2rb7F>`P{G)w>a)(uB8-X33QVCB z-)OQ~11q7#Z2Rp>o|()O?4rIMMk?ek7Mx%0{iA48uyBrg#pg3j7dPFoUF(kY)Ao($ zqi`sXpMD%_nOYn&SSzsE`}pqIliw#L_fGFC?zAuOe8B)9AMCAhvDuLYu!_A>gb$Ul z0UtQ(Grq_#rw(oEfxsI(&{(C4ZFS8N0Z)S*_h1BaAdoqFQHa3+W4_cN;yv+O>Yo2l zfU-RWG)G?HC^ej_NPHRVi>ga*K7PwaX1!T*cA&jeeoV_>6nrpgoBmQlf_;MySpo;{ zaMdn5kL90wb1ab78+qPw8P@4ey1T9xn!JfXy;$eC9`2C?u8b}{6Pxc|{T+yEOn`SO zF1zNxbKVzaLP0|y`0a(SW_;NKRr0qx<%e`4dIFI)6-<0|T#ki$`5}{QAu@DHOX*2} zcXxMC$qR`hv>(l()t5mqe%5xLS&KaCCbe2wu71T!=w#Q8pS8#{oIS1;IA$IldE+>T z1PNz@Mh&ua<^_TYiWq~OrD-_ts2;NIPOl*2EARq8`m7NnUc~A3#hP&R#GiFbYtnQ{ zCsS_JN6@<~`;56t!iuHKHAa6|q z)ZXAXKveGy4kC2$g`JZqr}6uv&ZxHEJ%XQBKY6^gtLvM-g6h@wR06Z+$#`>PuNsBEFyj%#;p6SgrVR=go2<z(O zuH@=oY+9JlHgUfM@JoCCdfzlhr+)pUGs+gSBW`*I7psAgF>P^oUw5!z|M>D|j4Rwz zx6+S+R4<9ke$4hj07rDWs#*&HwZD8fT!OG@r+f9;TJK#)di8fi5n zuU5fGfMS2^2WJqlZ5NiICV7ClWLBNFGw6z2*2*dxpKXMzU|E72DZ|KG>{J)dw)Da@ z1jjHyXzo>zOdYjXksjvajJ*paG$Jl1IdN3p%|6koAQ^WuT+;(yhozn2&`O?}1T9{? zb^x+i?@)r%au-_BEodWxmCtFJ=<_Y-WJ^A7P0dgXM8Y(Z;s170PfX(o0!}_s-O1Mj z(Gf-2sV;uo*nGT&PhZ*Pvj8CPjAH~($$-0&E2j*f=u0KXeYgXVyDtd+nn@5DL3Fj? zm5?T~j&q8+kkQ8DEXYh0UKSMKS<@kF)Pkc|M}e;S~h zQ;WUpK6UnO_6%=)VjlxHeEe6bHbqA2xzvuDYq;vc}?jK1+ULZ#9 zX}LShyJM3D+MN+bM~WB%c;1#v>%;7f<|e57X`Q z4Nrg`4PufPYF)TK$OYo)i-Aq}J6MUu&Gg2`h_;wqRRqQH@VPs4?l^ck#Kbz2S+fDW zdUcyayHDB$VG7!*my5-b$-q|TN8`pLwxJ8HRsDCCHP{=gmlh_Kv@F6#hz1eXi@Lni zF^AUKjh1sC!2DO;z=32{kh)U~~F4%4R=5xQ0B z$5}6f%554Z>=HH+sYY0+`;1!h^mU9MjI4`5XP3eS(Ky8A`Km2`%*pWGoT{FpwIJ=`wm&?t^+#io$@ zi{k>WVc>v$u>6y6er4i=6Wxh|UPAabK~H9}$Ar<7W6+RhpS$LKK34f^BPW{N{!e=r zUK)ix6Ds8|kjt-U3Y|=VGU|lk1E915L^*@Z)fd;hI2QE*Ircy~L8iszZWX8WiR!}k z9vH+*fL!;~Z>B}{r4B*w*Lw}i1Vct#!0a0%0;%Irw)DJ3d116K*y3*nY!hA+CK0R8$(K~`#9H6noi(^}m&K^uyRrMt zW61l#tR+zS^8us#EU+3mC4)gReXvQKxiL#IwXtZB%u^Q9&TZ0*usuwaHtV7}eJ*-p zTZguA#xg9hi7X z2(epA+~x4z&~A1vENK2j;=<0}baUyL1?OIzN)YI*E^2B@1!WA~ zXGPYO#>99s{S(|ITFd{k8N7#!n7vuQ?Q_yiu61eaZO@sjblmUjw!^mmY%!5AXCkA`tY+F8uL8;&~HrT{i z)?O9`15r{oIaatwIB6N%!bok2iaerY-zrFB9_fTSa)5~ZWq)-&1dPpEm_A}1k$EV8 z>f=bRl)K;n=Kb$TJwrXfP{Y8-O$46B$;_)n30dQo(Yud+l#Tl@v@|bv5PIzKywjDt zX4jQ9%N8_5@&;sliNa&E*lN4d=^~a920`lT03?*ZJ_VIuiZU{W=C$P@KJtbyh~!tXpPB9U8#A8TWInx0{UBglvpXyaiasg%9W!tY(g2N@ z#E&9)67;uwy?ie#tH=l0ZOo9R4+QMD{iudWrr`TyKjW5>LrLX=pT|exP#r+hNIpCy zXi_S^DGcoBWwY<#z>TTZ-^Rwq<5y3HRDBy{keOM4XuNNQl{QiC zx#Va^lnWfNd_CNU^&&%qV4Ue3O*&J1_T19nhQ1GTirO?{blvgka=_qZ_dQAbF#n1r z;v3|A13^6Q3f(Y43Z1l%c!c=5eWA!Ygx#c~7Eqws^p4kyqtNR%HqZigB(3-()wB~# zjGGo5$Ewo+@-^rCkKC+huTDMC7q?E2k8$1edoaxbU6oC+LldDII9%A3?Vg?#4cu7xG@Z-Llx$pbtv5!)9XFo+L1MG0QltwE+gMM^$g=%0Tex!rQ#$!+r`dXgmVw9_-4kMPbD= z*m77GqVDyA2>gfV_c(TpU;!e}xjDv5^KV)q&ca4$h0P@Y$z7x3>4N1vpGf|oS)pG? zzJ}&lX;G%Jt7)7!+jSr;EA4(iokwy@2B(HEZspHZayEy=F8~r-zU+_UTZ>;-#cNJr zwn(hmUDo|-Q+b;Bb}onTOvlC+iL(mu&k^z!F?o7;oCP^vM~J^*NW9{hblZoAgb#+$ zcS%K2=^WpsZr_O%x}xNK(Dn4P<~NyO+o}~Y<3wmn8*j@R+L3^uTT`J#f6>3>a=bOH z!0SFrCIKWe2r=iFC8ek^Qro(4sd#d*0oIqkr4m@-6~0Qp0|3oll4j6~oYF-6ym^U-K+`FL7o&J?GOQw(nk$B@1R0$6CDf3>fhM>#t3yGqMd}zkX3VkoJd$&n(T2 zakkL{F^x=oZ#yC}f81rj8ZP=sACk5B0g(7R0F@WtYO@>IPTHjE8e3`s{8 zOnqZK<`#ZuaLvsC0;T2zt1QrEhB9uHl`2iUE~4M}3HMl#tS9&$Set)cOYUX*C;=$>*?U<*-=^<iIbxhYx9P)rXT`@v#)59ZV>Wy>pY?@Hi$5Mzz(i^ApPs08M>PV*c(WW zaN$_|Rlr4DK65quoz}zu(6uew$-JrV3x4N@n_g}HiPknrc`nr~WAmX8GqXDQHz!kf zKzoOoYjtQS99z43WfV0?%jAs2VCH3KNu)@R_iM+!*C#Y26j6A2HSUzRyD*heOEg#_ zSd{Pl@*l`HvhqlRr224dF#$!u3v$;i!N9<{&N+vGI26>QoS)@Tc|IPK>n%yie=V1F zGH(&g4dkS)>e!0Xa!QQk`LhY`y`wS}%&!KS zD2eG!;Y6=06zAOYIz|IzvuPjE8<6&7K6z>c`*s_@GlyPnkg0dkS?!HcZPzCL&{a1R z6aZy>#%S_x|BMS@8r2~+3gUi%d|ZQ6ZPL3lU_WgJMw2r!|AmnlOX9x(jSIDn2>Szi zw!0+B<4NY6!$2cvS-n!;5td6#GVnW`(CI#CBXyHQv;gm2!rnvQNx?2=7Up@{mS|iH zq@o(QkGAtlEQ%OVM2!Rv`mvC6A&2*_1U4EP_4c+gst6{#3m41+7_F-Nj%dP{lczJ8 zM7=9G$DIQ|bQPLWOOlO`nI9*xZAM!4m)}z^ZXw##y#>`@MBYb>|J=^g`IyDM51%z@ zJoGT?g3g3v!2&~EiHW?g38gU36tUlZJgCvbKZO!Y2Yt!e0CFhD&-R75e~5R4sV!O} zn8Pr0lKWuo%P&H({j|Y1j3fVa+|Q2vXMm`gh?O9BYCr=;N^)TgRjVktF?)clE~t+2 zq3XzGVL$g_`^}*pF6@3(Y*A9m8`>+S;L=l-N5f9A_F+^CekYDEgBXu}<$Dl3{QThb z4FJZ|=`U}%acU~VXyf1VPS<*_)?3PLUv<7!QH&#WAwT9(;!MHyqezj434yH%ptTc6 z&frwj6mPk)LjsNY(DDNJ5WV~${j&5`Rr@Ce8c-qlJd6%kuZO!G#}GgTZxc1NNl~{* zU@xk*6zaIwB)f6x*WjIO0R03+2~<^r19>3 zK`v3&k+B1dpYmZpyiTJ(KPLac-Su15on3Jd^_qs|(KVNn+afADPQ-bIH0Lxgk$Uk@ zEtIP(yC@)EthdaOO~gY-ZGPyE3a}3YpI%>iZO=G}`>*a^n1cFts_!2jc*1A;GNx+8($jwGDinNP#iC?q3Hi?s`d zw6o9yE0Dng4H+hfb932liI&m%!BXzM+123O{?Q+8-C>9*Q#A73_amJJdBJD+0>|es z>~GKER*0Qg7pfeS?xQ;n7;yh7WJJWIIp-J<1PNHB{$LG)5)|gG&}~uS{s^)idF-fp z8|WMA7cDh!`T+|IM-DQB9moU~#(8!cu*5x^bvEO{Cmfu~#b`N{^@lfP*cnJ+EEI$Z1 zcbUfQc4PG$Q-RQoLP=vub zR_HZ?ld^M{TSubDAtV%P34j3r0C(iS9s!4&5?tS+vs{;B<9M&CTp)?3;Y4^NXmpdkK=_t%HG2R(YB2yyZZ%Ls#ZTtK#UIU=0nAWGo} z$c*83!mI#&`_dw9A$<2)K!}@Hn6jvzZJj2p!)f=AYS!g_(zR1;>to56i1p@7E#%}x z)Y%kz`!1YaZ%BF#n{NIu4ed#_XdsA^2P!AnTc`Dt%2U;x_qs^ci!_QX{S7(@I(^#( zffqLnAx1Qbl;!9D()z^>(`|GhP`R5I2RjQTV zdrLyEq@C8Rb%UG3^aAu^3fuZwfI;`0G{gSdodVfv2+5vbm%1if_Tblbn6f+YVQ?e= zI2NS=vD+2eBhdP3+)NLKA`t>(|}3d_<6 zQlnZ@^D><~BRMEGgk4R3UNAh>N61=(ThG=<-8%1P^Y=z^-Z&Bq61VuRrj|TELw4@L zeXCU2WVa$M!()6gBUv3Q`*R~Kl0lYX*(-G&E%rb@TF7F9DO2@8<41Jtg9j2v7A3yQ3l zr|<{kz2lH(fAmHe+EB5)hq6)thC zWw>eG`Dtr1S{mYj2R_y&2EhW4y2Mn5?O7vd`jhqVa==5DmPd8kv=81@qfjz#&5#pT!$>F}B>hO54vG_e+ z#buBT+=Kl|Z4u*vmZL%bXuUxibn9mH2rL{F-#iivnd;ooi~_qjb(~??)sdG=OI#_b zPex{Eeo9{0Bq)WQ!0R6Cna1Cn@9y`Tolm+14x*>bTHuKW(4No1qg!J^vF1Vb+hozt z)m~c($e2&>8=y3-^W3~~EgJxUMTT1EYN%b#C?KcZPy#~_sm4We`Bc5K#j~=KDYUsc zb1A#e4U+~#{bb<{4nU97UR#aw3G#^XyTo9DRqI?LlSBKc8zXhRJo-Y+(mSttpukrr z5WegOB}FCmUq&^!b#6f#E^~95A71W^<-6CZ;sx_tOG9rW_ww=+*42BRd6qQkj=Ig7 zGDD+dL<3s(h@z(jGYpsUw_V%1EN5}uR4Hx{5K3bW(L5gyZ$ICC(eNU?wkSSt%AxEb z%z7!r@A<^*i3wszgeE72lgURDsRH=#P$g6-YQdFQz|I5v{R_%=`{9U+ZgSe{U3-r^90t^bV6cwfUTJVx27|gQZFb%*Ux$ zXpW2{JSDO{&d#m$i<~*9@wmm_1=51fDx-1vVewo9eV9jGZr(Qhh@&-gA@Bpf)8rnC zCGIVMqNag7FxGccH3kUL9Ic{x)2T4f(4T7*pW#EGRV=~xhxk+;CuE}O)nB@$Taf@jq%a=jeE@&9OBjKtJk2mcs9H zgl%^*IIFt#L1Sf#nfxLk%b!C>j?xp_95ZA8F5#hQ1?j6jH3P{RZYykhoukRLT!+++>c#=N(w zf}cg+?5=a6Ua-^lY7H+UIW+fCZP8e>r@V$@lkUi}Q&p*uAx0#+iCaejD+}3O<|PmG zzq)CgHpR#};|qw223UF6S=`AV)?H^+ZOvJnX&AGP%(~6PmCYAVu!rF2JI_*xZMMeJ{ZjlH5X09EzDl2DOXwW^kui4bi@I5mTpabpv`4 z>==}si5w-8-#)}E8rp`4ILQFC^x7`S1lj5Y2bB+&T_dU;0WUHSpKF4d?LeKIrX;ge zq1Bfz+^&{kPi#^n(lJ0Xzc$mD0p87DQ}vGV^#J*!AC$Ae^4Jak$_Hg(Ow`wMMM$3= zj8^CttRdZDM2!J?jwIlJdBTLaiIBpp&51|XA@zQ5tDS#P)2KqpN!O{_EJFT-{qAro zS4}FE!^b94CrT`o7MM{u$k9tFJOMq4L4>Z2RLil()Z|EDM|Gfep)n#5?*M%A4+Pcg zQIL?4iCbp7OC!Pu0f6!GP}96=a=A>`r^}-JUT}JP#_!bB7-3t?5lRMoW4mZ*F&KZ% zOIZ59|7>t>s9FUy_N+jKLrrmp!8cx7;Psdby?Ks1v8;@Zj+VBRzI-*hezH2MsS$QP zbvD%{wPtPDy3(D|W0A@0B%?Ei;QErL+y0)DmQI4i5W#>OB`H+KFgBtTiDq5&rDSDQ ze(!_iD0z=7Bvn#^XejMZ>JL-W)@dBP5M9~0YkW8LjR6OKcn1<5+{Fit6S? z1Ish+0LzX^9@9tr2ugHJUxmnXD0lZE@mEq`?0T^_;BdSzPPu-z6U@sT`uOF4i!u*v z$thzGs3tjlApC(SczXj<>#fny(3sn@QZfF90@Q%L3l_~+E4?7}fE=Xe^TU&9-)}~u zq(~Vht>k{L{Ty2|(|N1uEO=GsB6L_rzVF>GQs#zl0jkC5)*BfL8>d!25g1H35 zV%Iza8ix(W)d#UHAtcoN;OWOxcFA}>JYpgS3rC3Xe;;LJF_sSyz4l6=~{s)jndY&8*qVPmwn$BP0_+LBP;FBoUDb>up35~d2ce+_;;?Igs+|Il^W zb^3b!g?IVU6#dItGSGu|`+jTYk#z=a;Z#?KI;+^WmqIUHuf2kmlX5T4AlEMr)iU;x z#`d9~>)^rrRo`AQ6mhn~i4q50kI;M-3cAnHD9xJw*`3TOX8lD+J)>^T0*_-gCp*8@ z^{Y~UOKQ%lrgf^J5Aic(gj4gI;b|ZJa;%~oetOz?sIYgh3?Mjaxv zo^{-Va1x220QpOM@dl`~l8oli(BR2a_b5n@1NJalUcJx2&Ik!B@cw4ikQ$Wz4GI0- zL%?oucbBMbmnKP;5$0{R{fL%N)R6^5q4uw>z8E*m(!mS2*!Jkt z76}gh3_=}l-3cKWeyR=)k#kV;_BpjMho<`}`k4~a*0Bj8Tp@DC`Z(Q~ zz#b(u+ZJsy^EtF|xrczjuXy|H*fT(pzcrMcSG#YEaCo4bA!^A;DIGk$c5uYjX!>ho zO3!D?eMY0h0@q+B%b*usjIrEb+bNj}KuQ~e675^ESTlC^8OxW742)FYIkW zv!rL~cQ|xNTQ=EKSa#L80QTg8#c^XA(Q!Z!UKBxu@iuXH)ZzP!_4#T;hKliaUZ{`M z4VbMo{y^1Wv0@@~3=$nMgH;#BI#NQ%s9H;`Hwdm@Ndzz5F|(*Q*-2L}a2J1`XPLU> zE^w|eFc6}w+D_o*dA(t{cPs@xShQA;jXI|^ER$FK>8y9xuVe6(-6RWXT$Ku* zcE|md?JS_m1qS7JM~M*d?y5?P{ps!3L*JnNtJM6%SM^6qfEU$4z2@bF=(Kj${Hhn1 z{47j>$${-6Fv%$aL1~a{)Qe4x?zLqGSJ+>u@U?2$2?`790Lx;M3P8A?KXgt#?#$X2 z!IySnzU2}5y5bP4h9}QigR3MFW{6u!kBmd$W!Fr>a)JigFJR07Z^NGB?X~HLmGTpJ zMU?35=?}|7)idKeB}80_hNBZw$04*S?bk(Z?$#zCPw`ed^7jE35I^)Hkc5fgj7hu5D_gy$Q3R+vV8{4uB zN#j*yd1r`X7DYtrKBpW(q8~NHkV6oS@tcCNjwbYaM9$bIo=wz$=AIV5)8H z;L&uUYuKq4oRmI!^sSfWy+y?eMhFH2_v1_x&q2i45lII;bFfz3HxKgJpAhUr;9gI z>S?o|ByyIDB&WBnm&kqB`bL+`L<|aG-0!kQwQIl*DiAlO836aXpm8~ZXrLdk{34ElcE zTV{ow0kZRmR*(UbmrLP<1Hp&xW2m&tl=N9ziBKj+wRkOE0EN37pAbc|UL}>RG2QlNji0 z`=#trsRrVk6*IiBWrxhDH1V4KOnW&HPok2DSCb8Q^@s2GB%;o{&fPa-%N9-p%|WJt zuJ|9l`M@$vz}(uJrwuBk>wrT$P0Zdphrx6d8w-o@B3MAkvR)YPjzw-UmMm>Z@4734 zn+(}>2Zjg3`y@WC7MPc=EtNfBsv`zVIuI&=G~1JewrJR<*5Cbej50U1pdluj)%VH! zi*I9NBb1t&I;B%XJ1tSI6p&`s;9;8nuL<;!UuYvZi|LG1qH}R!OuI@t#bnW*o@co2 zHL#mo0>YxHy;q88p&n?l{x*0CMJ>f7hCvdkd858_Dy-Rrvr=Dsf8`#z3zaWe#Y!{z zzSNds0qQ^<1vf#upIB%SdmT&(&t4YY#610GDB8XOBrtJJg!=H3P~f^t5>7>M{^+zI zlP(zYBJesjz_Us;9x5Zy<$CYJR)%&=?^o}nQgqNv^^4x8NOBnoXsEYV8xBp0s8G}Q zo*>REVLQ4sM7>u;Ft|@clZ4pN0AD8o?Ft$hb2nV+}>AyRSzuU0d9^>HO{I zile23zAb|%TNMjEdp6Ix=O={4G7m_5JXf6OM6vVjTN1JldW z4oNkz&fKNVI@q6m^)dJKUhK8?W!a$pF;)5#)NRomh8*7DzpfE2=0-+Fb*zVnlroPd zmL6)j5~;oXu|`qkQpuaQaGiSA>yu*3YERCU55}^kTh?oh7tA1yfflDF^iD4-lzlcL z3QDz1fI;NRjLr96zleK~m@C6!W;xYBUm9#6Dp@*DJ^k0hG%(C^blFLb z01Iy_gMF27Hv1{5_~8!*1_rY%rtbkc{PwOPs7I&=Xga5`APO0I-LWQ{X_)_AP5M`w zV_FK71?+|f5LLn0xAt>j)G{~9~B#$yF zCRFf^eWwlKZy2JO7~=q!s1_~&ubCplwaoHY-+@{EtyI2@tM~J-U{$?`+S$2;@jHgk zgm2!gJ`WEsZ-iDP`E>v(?Em#+Mg0XckaV}N$B%$f_)6UGv)zHxtAZclC VW0(z0F$P@$$Ve!NSBV+~{0|w52Pyyn literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_asterisk.xml b/app/src/main/res/drawable/ic_asterisk.xml new file mode 100644 index 0000000000..b2dcd8a28b --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_timer_outline.xml b/app/src/main/res/drawable/ic_timer_outline.xml new file mode 100644 index 0000000000..17b96cc4bc --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_outline.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2feb8fbd0e..9dde74c744 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1048,7 +1048,7 @@ Icon Tap to upload a custom icon Daily - Daily Subscription + Daily subscription Delete Subscription Description What is this subscription for? @@ -1069,7 +1069,7 @@ Could not load this image. Choose another image and try again. Monthly Monthly Cost - Monthly Subscription + Monthly subscription More Info Subscription Name Subscription name @@ -1087,7 +1087,7 @@ Proposal]]> Proposal queued Queued - You have sent a subscription proposal. + You have sent a subscription proposal to Proposal]]> Proposal sent Proposals @@ -1110,9 +1110,9 @@ Unsupported Frequency This subscription uses payment details that Bitkit does not support yet. Weekly - Weekly Subscription + Weekly subscription Yearly - Yearly Subscription + Yearly subscription %1$s sats Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). LOOKING FOR FUNDS... From b7bfe11952b532d0d2e7082a396e91bac8d10ad9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 17:31:21 +0300 Subject: [PATCH 5/7] chore: rename changelog fragment --- .../next/{subscription-requests.added.md => 1239.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{subscription-requests.added.md => 1239.added.md} (100%) diff --git a/changelog.d/next/subscription-requests.added.md b/changelog.d/next/1239.added.md similarity index 100% rename from changelog.d/next/subscription-requests.added.md rename to changelog.d/next/1239.added.md From baedbc72264a487b854da6d5f0dc947a1e21f669 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 16:18:06 +0100 Subject: [PATCH 6/7] fix: retain creator subscription payment history --- .../bitkit/repositories/PaykitSubscription.kt | 3 +- ...aykitPaymentRequestRepoSubscriptionTest.kt | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index ff7fb2430a..414a63c52d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -212,7 +212,8 @@ data class PaykitSubscription( fun isActive(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && recurrence.endsAt?.let { it > now } != false - fun isCreatedVisible(now: Instant): Boolean = isCreatedByUser && (isProposalVisible(now) || isActive(now)) + fun isCreatedVisible(now: Instant): Boolean = + isCreatedByUser && (isProposalVisible(now) || isActive(now) || paidPeriods.isNotEmpty()) fun canCancel(now: Instant): Boolean = recurrence.endsAt == null && if (isCreatedByUser) { isProposalVisible(now) || isActive(now) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index a3967884d1..299799e598 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -333,6 +333,47 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) } assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) + assertFalse(sut.subscriptions.value.single().isCreatedVisible(clock.now())) + } + + @Test + fun `deleting a paid creator subscription keeps received history accessible`() = test { + val period = BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") + val proof = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val active = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(proof), + ) + val canceled = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.CANCELED, + paymentProofs = listOf(proof), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), listOf(canceled)) + whenever( + paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + ).thenReturn(canceled) + sut.refresh().getOrThrow() + val subscription = sut.subscriptions.value.single() + val received = subscription.receivedPaymentRequests() + assertEquals(1, received.size) + + sut.cancel(subscription).getOrThrow() + sut.refresh().getOrThrow() + + val retained = sut.subscriptions.value.single() + assertTrue(retained.isCreatedVisible(clock.now())) + assertTrue(retained.isExpired(clock.now())) + assertFalse(retained.isActive(clock.now())) + assertFalse(retained.canCancel(clock.now())) + assertFalse(retained.copy(role = PaykitSubscriptionRole.Payer).isCreatedVisible(clock.now())) + assertEquals(received, retained.receivedPaymentRequests()) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) } @Test From 8f1e6f7eb90472544db1ffe6524f6b6542b20f30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 11 Sep 2026 19:50:09 +0100 Subject: [PATCH 7/7] fix: preserve subscription sheet top spacing --- .../ui/screens/subscriptions/CreateSubscriptionScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index f98a81ffbc..0b08f6c270 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -147,6 +147,7 @@ fun CreateSubscriptionSheet( } }, onChooseRecipient = { step = SubscriptionCreationStep.Recipient }, + modifier = Modifier.sheetHeight() ) SubscriptionCreationStep.Amount -> PaymentRequestAmountContent( amountInputViewModel = amountInputViewModel, @@ -200,6 +201,7 @@ fun CreateSubscriptionSheet( PubkyPublicKeyFormat.matches(contact.publicKey, it.counterparty) } ?: PubkyProfile.placeholder(it.counterparty), onDone = appViewModel::hideSheet, + modifier = Modifier.sheetHeight() ) } } @@ -428,9 +430,10 @@ internal fun SubscriptionProposalSent( subscription: PaykitSubscription, contact: PubkyProfile, onDone: () -> Unit, + modifier: Modifier = Modifier, ) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding()