diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json new file mode 100644 index 000000000..3561b11b1 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "subscription-default-icon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png new file mode 100644 index 000000000..830189723 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json new file mode 100644 index 000000000..a53c8c713 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "subscription-intro-clock.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png new file mode 100644 index 000000000..bedb79895 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png differ diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json new file mode 100644 index 000000000..c3aa265df --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "asterisk.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg new file mode 100644 index 000000000..ca8d20d1f --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json new file mode 100644 index 000000000..68f946750 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "timer-outline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg new file mode 100644 index 000000000..192285d6f --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Bitkit/Components/MoneyCell.swift b/Bitkit/Components/MoneyCell.swift index 14fd219d6..d8aa5a4a7 100644 --- a/Bitkit/Components/MoneyCell.swift +++ b/Bitkit/Components/MoneyCell.swift @@ -5,6 +5,7 @@ struct MoneyCell: View { let sats: Int let prefix: String var enableHide: Bool = false + var symbol: Bool? var body: some View { VStack(alignment: .trailing, spacing: 2) { @@ -12,6 +13,7 @@ struct MoneyCell: View { sats: sats, unitType: .primary, size: .bodyMSB, + symbol: symbol, enableHide: enableHide, prefix: prefix, color: .textPrimary, diff --git a/Bitkit/Components/MoneyText.swift b/Bitkit/Components/MoneyText.swift index 720baa80b..b96686a48 100644 --- a/Bitkit/Components/MoneyText.swift +++ b/Bitkit/Components/MoneyText.swift @@ -30,6 +30,7 @@ struct MoneyText: View { var color: Color = .textPrimary var symbolColor: Color? var testIdentifier: String? + var fillsWidth: Bool = true @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @@ -105,7 +106,8 @@ extension MoneyText { case .display: DisplayText( // Cap symbol font weight to ExtraBold - text, textColor: color, accentColor: symbolColor ?? .textSecondary, accentFont: size == .display ? Fonts.extraBold : nil + text, textColor: color, accentColor: symbolColor ?? .textSecondary, + accentFont: Fonts.extraBold, fillsWidth: fillsWidth ) case .title: TitleText(text, textColor: color, accentColor: symbolColor ?? .textSecondary) diff --git a/Bitkit/Components/NoteTextEditor.swift b/Bitkit/Components/NoteTextEditor.swift index 33bc657b6..c12dccda3 100644 --- a/Bitkit/Components/NoteTextEditor.swift +++ b/Bitkit/Components/NoteTextEditor.swift @@ -5,6 +5,9 @@ struct NoteTextEditor: View { let placeholder: String let testIdentifier: String let isFocused: FocusState.Binding + var minHeight: CGFloat = 30 + var maxHeight: CGFloat = 50 + var backgroundColor: Color = .white06 var body: some View { ZStack(alignment: .topLeading) { @@ -20,13 +23,13 @@ struct NoteTextEditor: View { .submitLabel(.done) .scrollContentBackground(.hidden) .padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5)) - .frame(minHeight: 30, maxHeight: 50) + .frame(minHeight: minHeight, maxHeight: maxHeight) .dismissKeyboardOnReturn(text: $text, isFocused: isFocused) .accessibilityValue(text) .accessibilityIdentifier(testIdentifier) } .padding() - .background(Color.white06) + .background(backgroundColor) .cornerRadius(8) } } diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift index 919060b9c..534c29bab 100644 --- a/Bitkit/Components/PubkyContactRow.swift +++ b/Bitkit/Components/PubkyContactRow.swift @@ -15,8 +15,8 @@ struct PubkyContactRow: View { HStack(spacing: 16) { PubkyContactAvatar(contact: contact, size: 48) - VStack(alignment: .leading, spacing: 4) { - CaptionText(contact.profile.truncatedPublicKey.localizedUppercase) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64) .lineLimit(1) BodyMSBText(contact.displayName) diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index ab0e7b4cc..6c1a72003 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -7,6 +7,7 @@ import SwiftUI struct PubkyImage: View { let uri: String let size: CGFloat + var cornerRadius: CGFloat? @State private var uiImage: UIImage? @State private var hasFailed = false @@ -24,7 +25,7 @@ struct PubkyImage: View { } } .frame(width: size, height: size) - .clipShape(Circle()) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius ?? size / 2)) .accessibilityLabel(Text("Profile photo")) .task(id: uri) { await loadImage() @@ -32,7 +33,7 @@ struct PubkyImage: View { } private var placeholder: some View { - Circle() + Rectangle() .fill(Color.gray5) .overlay { Image("user-square") diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index 2f1a1b8c0..551674a18 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -4,11 +4,13 @@ struct TabItem { let tab: T let activeColor: Color? let badge: Int? + let accessibilityIdentifier: String? - init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil) { + init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) { self.tab = tab self.activeColor = activeColor self.badge = badge + self.accessibilityIdentifier = accessibilityIdentifier } } @@ -16,18 +18,21 @@ struct SegmentedControl: View { @Binding var selectedTab: T private let tabItems: [TabItem] private let defaultActiveColor: Color + private let inactiveColor: Color? @Namespace private var underlineNamespace - init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary) { + init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary, inactiveColor: Color? = nil) { _selectedTab = selectedTab tabItems = tabs.map { TabItem($0) } defaultActiveColor = activeColor + self.inactiveColor = inactiveColor } - init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary) { + init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary, inactiveColor: Color? = nil) { _selectedTab = selectedTab self.tabItems = tabItems self.defaultActiveColor = defaultActiveColor + self.inactiveColor = inactiveColor } var body: some View { @@ -40,7 +45,7 @@ struct SegmentedControl: View { }) { VStack(spacing: 8) { HStack(spacing: 8) { - CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : .secondary) + CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary) if let badge = tabItem.badge, badge > 0 { CaptionBText("\(badge)", textColor: .black) @@ -54,7 +59,7 @@ struct SegmentedControl: View { ZStack { Rectangle() .frame(height: 2) - .foregroundColor(Color.white64) + .foregroundColor(inactiveColor ?? .white64) if selectedTab == tabItem.tab { Rectangle() @@ -68,7 +73,7 @@ struct SegmentedControl: View { .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("Tab-\(tabItem.tab.description.lowercased())") + .accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())") } } .frame(maxWidth: .infinity) diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift index e4277552e..20c1dc49f 100644 --- a/Bitkit/Components/TextField.swift +++ b/Bitkit/Components/TextField.swift @@ -7,6 +7,7 @@ struct TextField: View { let axis: Axis let testIdentifier: String? let submitLabel: SubmitLabel + let contentPadding: CGFloat @Binding var text: String init( @@ -16,7 +17,8 @@ struct TextField: View { font: Font = .custom(Fonts.semiBold, size: 15), axis: Axis = .horizontal, testIdentifier: String? = nil, - submitLabel: SubmitLabel = .return + submitLabel: SubmitLabel = .return, + contentPadding: CGFloat = 16 ) { self.placeholder = placeholder self.backgroundColor = backgroundColor @@ -24,6 +26,7 @@ struct TextField: View { self.axis = axis self.testIdentifier = testIdentifier self.submitLabel = submitLabel + self.contentPadding = contentPadding _text = text } @@ -41,7 +44,7 @@ struct TextField: View { .submitLabel(submitLabel) .accessibilityIdentifierIfPresent(testIdentifier) } - .padding() + .padding(contentPadding) .background(backgroundColor) .cornerRadius(8) } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9dd8d3083..478287fd8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1538,9 +1538,22 @@ "subscriptions__active" = "Active"; "subscriptions__cancel" = "Cancel"; "subscriptions__cancel_subscription" = "Cancel Subscription"; +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"subscriptions__content_too_long" = "Shorten the subscription name or description and try again."; +"subscriptions__icon_error" = "Could not load this image. Choose another image and try again."; +"subscriptions__create_subscription" = "Create Subscription"; +"subscriptions__created" = "Created"; +"subscriptions__created_summary" = "1 subscriber · {count} payments"; +"subscriptions__created_summary_single_payment" = "1 subscriber · 1 payment"; +"subscriptions__custom_icon" = "Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__daily" = "Daily"; -"subscriptions__daily_subscription" = "Daily Subscription"; +"subscriptions__daily_subscription" = "Daily subscription"; "subscriptions__details" = "Subscription Details"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; "subscriptions__empty_description" = "At the moment, you don’t have any active subscriptions from any providers."; "subscriptions__empty_headline" = "Welcome To\nSubscriptions"; "subscriptions__every_days" = "Every {count} days"; @@ -1557,7 +1570,9 @@ "subscriptions__more_info" = "More Info"; "subscriptions__monthly" = "Monthly"; "subscriptions__monthly_cost" = "Monthly Cost"; -"subscriptions__monthly_subscription" = "Monthly Subscription"; +"subscriptions__monthly_subscription" = "Monthly subscription"; +"subscriptions__name" = "Subscription Name"; +"subscriptions__name_placeholder" = "Subscription name"; "subscriptions__ongoing" = "Ongoing"; "subscriptions__overview" = "Overview"; "subscriptions__payment_due_description" = "Open Bitkit to review a subscription payment."; @@ -1567,6 +1582,15 @@ "subscriptions__per_month" = "per month"; "subscriptions__per_week" = "per week"; "subscriptions__per_year" = "per year"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_headline" = "Queued\nProposal"; +"subscriptions__proposal_queued_title" = "Queued"; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal to"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__proposals" = "Proposals"; "subscriptions__renews" = "Renews"; "subscriptions__retry_payment" = "Retry Payment"; @@ -1575,7 +1599,9 @@ "subscriptions__status" = "Status"; "subscriptions__subscribed" = "Subscribed"; "subscriptions__subscription" = "Subscription"; +"subscriptions__subscribers" = "Subscribers"; "subscriptions__swipe_to_cancel" = "Swipe To Cancel"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; "subscriptions__swipe_to_subscribe" = "Swipe To Subscribe"; "subscriptions__swipe_to_subscribe_and_pay" = "Swipe To Subscribe & Pay"; "subscriptions__title" = "Subscriptions"; @@ -1584,9 +1610,9 @@ "subscriptions__unsupported_frequency" = "Unsupported frequency"; "subscriptions__unsupported_payment_description" = "This subscription uses payment details that Bitkit does not support yet."; "subscriptions__weekly" = "Weekly"; -"subscriptions__weekly_subscription" = "Weekly Subscription"; +"subscriptions__weekly_subscription" = "Weekly subscription"; "subscriptions__yearly" = "Yearly"; -"subscriptions__yearly_subscription" = "Yearly Subscription"; +"subscriptions__yearly_subscription" = "Yearly subscription"; "wallet__instant_payment_received" = "Received Instant Bitcoin"; "wallet__error_create_tx" = "Transaction Creation Failed"; "wallet__error_create_tx_msg" = "An error occurred. Please try again {raw}"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 47c82228a..288ffd605 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,6 +1,8 @@ import Foundation +import ImageIO import LDKNode import Paykit +import UIKit struct PaykitPaymentRequest: Identifiable, Hashable { enum Direction: Hashable { @@ -183,7 +185,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable { subscription: PaykitSubscription, billingPeriod: PaykitBillingPeriod, lifecycleState: Paykit.PaymentRequestLifecycleState, - paymentProofKind: PaykitPaymentProofKind? = nil + paymentProofKind: PaykitPaymentProofKind? = nil, + direction: Direction = .incoming ) { paymentRequestId = subscription.paymentRequestId counterparty = subscription.counterparty @@ -195,7 +198,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { expiresAt = nil acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers deliveryStatus = nil - direction = .incoming + self.direction = direction self.lifecycleState = lifecycleState self.billingPeriod = billingPeriod self.paymentProofKind = paymentProofKind @@ -324,6 +327,26 @@ struct PaykitPaymentRequestDraft: Hashable { let expiresAt: Date } +struct PaykitSubscriptionDraft: Hashable { + var amountSats: UInt64 + var name: String + var description: String + var frequency: PaykitSubscriptionRecurrence.Unit + var expiresAt: Date + var iconData: Data? + + static var empty: PaykitSubscriptionDraft { + PaykitSubscriptionDraft( + amountSats: 0, + name: "", + description: "", + frequency: .month, + expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60), + iconData: nil + ) + } +} + struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] @@ -345,6 +368,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { case requestExpired case operationInProgress case amountMismatch + case subscriptionTooLong var errorDescription: String? { switch self { @@ -356,6 +380,8 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { t("wallet__payment_request_in_progress") case .amountMismatch: t("wallet__payment_request_mismatch") + case .subscriptionTooLong: + t("subscriptions__content_too_long") } } } @@ -373,6 +399,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord + func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -428,7 +455,7 @@ struct PaykitPaymentRequestService { let history = records.compactMap { PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) } - let subscriptions = records.compactMap(PaykitSubscription.init) + let subscriptions = records.compactMap { PaykitSubscription(record: $0) } return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, @@ -525,6 +552,116 @@ struct PaykitPaymentRequestService { ) } + func proposeSubscription( + _ draft: PaykitSubscriptionDraft, + to target: PaykitPaymentRequestTarget, + savedPublicKeys: [String], + expectedIdentity: String, + validateBeforeProposing: @MainActor () throws -> Void + ) async throws -> PaykitSubscription { + let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() + let validationDate = now() + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard draft.amountSats > 0, + !name.isEmpty, + draft.frequency.isSupported, + !acceptedPaymentEndpointIdentifiers.isEmpty + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard draft.expiresAt > validationDate else { + throw PaykitPaymentRequestError.requestExpired + } + guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + throw PaykitPaymentRequestError.requestUnavailable + } + + try PaykitSubscriptionProposal.validate(subscriptionTerms( + draft, + iconURI: draft.iconData == nil ? nil : PaykitSubscriptionProposal.reservedIconURI, + endpoints: acceptedPaymentEndpointIdentifiers, + proposalDate: validationDate + )) + let iconURI: String? = if let iconData = draft.iconData { + try await sdk.uploadProfileAvatar( + bytes: Self.compressedSubscriptionIcon(iconData), + contentType: "image/jpeg", + expectedIdentity: expectedIdentity + ) + } else { + nil + } + guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await validateBeforeProposing() + guard Self.acceptedPaymentEndpointIdentifiers() == acceptedPaymentEndpointIdentifiers else { + throw PaykitPaymentRequestError.requestUnavailable + } + let proposalDate = now() + guard draft.expiresAt > proposalDate else { + throw PaykitPaymentRequestError.requestExpired + } + let terms = try subscriptionTerms( + draft, + iconURI: iconURI, + endpoints: acceptedPaymentEndpointIdentifiers, + proposalDate: proposalDate + ) + try PaykitSubscriptionProposal.validate(terms) + let record = try await sdk.proposePaymentRequest( + counterparty: target.publicKey, + counterpartyReceiverPath: target.receiverPath, + terms: terms, + expectedIdentity: expectedIdentity + ) + let reports = await (try? processPendingMessages()) ?? [] + let deliveryStatus = proposalWasSent(record, reports: reports) + ? PaykitPaymentRequest.DeliveryStatus.sent + : .queued + guard let subscription = PaykitSubscription(record: record, deliveryStatusOverride: deliveryStatus) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return subscription + } + + private func subscriptionTerms( + _ draft: PaykitSubscriptionDraft, + iconURI: String?, + endpoints: [String], + proposalDate: Date + ) throws -> Paykit.PaymentRequestTerms { + var subscriptionMetadata: [String: Any] = [ + "version": 1, + "description": draft.description.trimmingCharacters(in: .whitespacesAndNewlines), + "benefits": [], + ] + if let iconURI { + subscriptionMetadata["icon_uri"] = iconURI + } + let metadataData = try JSONSerialization.data(withJSONObject: [ + "note": draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + "subscription": subscriptionMetadata, + ]) + let metadataText = String(decoding: metadataData, as: UTF8.self) + let timestamp = Self.timestamp(proposalDate) + let recurrence = Paykit.PaymentRequestRecurrence( + every: 1, + unit: draft.frequency.rawValue, + startsAt: timestamp, + anchor: timestamp, + endsAt: nil + ) + return try Paykit.PaymentRequestTerms( + amount: Paykit.PaymentRequestAmount(value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), asset: "btc"), + paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"), + proposalExpiresAt: Self.timestamp(draft.expiresAt), + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + metadata: Paykit.PrivateJsonObject(text: metadataText) + ) + } + func accept(_ request: PaykitPaymentRequest) async throws { guard !request.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired @@ -580,7 +717,7 @@ struct PaykitPaymentRequestService { } func cancel(_ subscription: PaykitSubscription) async throws -> PaykitSubscription { - guard subscription.isActive(at: now()) else { + guard subscription.canCancel(at: now()) else { throw PaykitPaymentRequestError.requestUnavailable } @@ -615,6 +752,22 @@ struct PaykitPaymentRequestService { return formatter.string(from: date) } + static func compressedSubscriptionIcon(_ data: Data) throws -> Data { + guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 400, + ] as CFDictionary) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard let compressed = UIImage(cgImage: thumbnail).jpegData(compressionQuality: 0.8) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return compressed + } + @discardableResult private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { @@ -645,6 +798,7 @@ struct PaykitPaymentRequestService { _ record: Paykit.PaymentRequestRecord, reports: [Paykit.OutboundPrivateCounterpartySendReport] ) -> Bool { + if case .sent? = record.proposalOutboundStatus { return true } guard let messageId = record.proposalOutboundMessageId else { return false } return reports.contains { report in PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && @@ -922,6 +1076,56 @@ final class PaykitPaymentRequestManager { return request } + func proposeSubscription( + _ draft: PaykitSubscriptionDraft, + to target: PaykitPaymentRequestTarget + ) async throws -> PaykitSubscription { + guard draft.amountSats > 0, + isAvailable(), + let activeIdentity, + eligibleTargets.contains(target) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard !isCreatingRequest else { + throw PaykitPaymentRequestError.operationInProgress + } + + let actionGeneration = stateGeneration + let savedPublicKeysSnapshot = savedPublicKeys + isCreatingRequest = true + defer { + if actionGeneration == stateGeneration { + isCreatingRequest = false + } + } + let subscription = try await service.proposeSubscription( + draft, + to: target, + savedPublicKeys: savedPublicKeysSnapshot, + expectedIdentity: activeIdentity, + validateBeforeProposing: { + guard actionGeneration == self.stateGeneration, + self.isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity), + self.savedPublicKeys.contains(where: { PubkyPublicKeyFormat.matches($0, target.publicKey) }) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + } + ) + if actionGeneration == stateGeneration, + isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + { + invalidateRefresh() + subscriptions.removeAll { $0.id == subscription.id } + subscriptions.append(subscription) + discardExpiredRequests() + } + return subscription + } + func refresh() async { await refresh(excludingProtectedRequestId: nil) } @@ -1022,6 +1226,7 @@ final class PaykitPaymentRequestManager { func requestSubscriptionPresentation(_ subscription: PaykitSubscription) { guard subscriptions.contains(where: { $0.id == subscription.id }), + subscription.isPayer, subscription.isProposalVisible(at: now()), !isProcessingSubscription else { return } @@ -1031,11 +1236,11 @@ final class PaykitPaymentRequestManager { func subscriptionProposalForPresentation() -> PaykitSubscription? { if let requestedSubscriptionProposalId { return subscriptions.first { - $0.id == requestedSubscriptionProposalId && $0.isProposalVisible(at: now()) + $0.id == requestedSubscriptionProposalId && $0.isPayer && $0.isProposalVisible(at: now()) } } return subscriptions.first { - $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id) + $0.isPayer && $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id) } } @@ -1076,6 +1281,7 @@ final class PaykitPaymentRequestManager { guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } guard let current = subscriptions.first(where: { $0.id == subscription.id }), current == subscription, + current.isPayer, current.isProposalActionable(at: now()), let activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable } @@ -1107,7 +1313,7 @@ final class PaykitPaymentRequestManager { func cancel(_ subscription: PaykitSubscription) async throws { guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } guard let current = subscriptions.first(where: { $0.id == subscription.id }), - current.isActive(at: now()), + current.canCancel(at: now()), let activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable @@ -1121,12 +1327,14 @@ final class PaykitPaymentRequestManager { } } - let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id) - guard actionGeneration == stateGeneration, - PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return } - guard protectedRequestIds.isEmpty else { - throw PaykitPaymentRequestError.operationInProgress + if current.isPayer { + let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id) + guard actionGeneration == stateGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + guard protectedRequestIds.isEmpty else { + throw PaykitPaymentRequestError.operationInProgress + } } let canceledSubscription = try await service.cancel(current) @@ -1342,15 +1550,18 @@ final class PaykitPaymentRequestManager { else { return } let refreshDate = now() subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) } - let visibleProposalIds = Set(subscriptions.filter { $0.isProposalVisible(at: refreshDate) }.map(\.id)) + let visibleProposalIds = Set(subscriptions.filter { + $0.isPayer && $0.isProposalVisible(at: refreshDate) + }.map(\.id)) presentedSubscriptionProposalIds.formIntersection(visibleProposalIds) for subscription in subscriptions - where subscription.wasAccepted && + where subscription.isPayer && + subscription.wasAccepted && subscriptionAcceptedAt[subscription.id] == nil { subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? refreshDate } - let recurringRequestsBySubscription = subscriptions.map { subscription in + let recurringRequestsBySubscription = subscriptions.filter(\.isPayer).map { subscription in let requests: [PaykitPaymentRequest] = if let acceptedAt = subscriptionAcceptedAt[subscription.id] { subscription.requests(through: refreshDate, acceptedAt: acceptedAt) } else { @@ -1435,6 +1646,19 @@ final class PaykitPaymentRequestManager { subscriptions.removeAll { $0.id == subscription.id } subscriptions.append(subscription) + guard subscription.isPayer else { + await subscriptionNotificationScheduler.synchronize( + subscriptions.filter(\.isPayer), + acceptedAt: subscriptionAcceptedAt, + pendingRequestIds: Set(pendingRequests.map(\.id)), + payerIdentity: activeIdentity, + notificationsEnabled: SettingsViewModel.shared.enableNotifications, + now: date + ) + discardExpiredRequests() + return + } + let recurringRequests = subscriptionAcceptedAt[subscription.id].map { subscription.requests(through: date, acceptedAt: $0) } ?? [] @@ -1526,7 +1750,7 @@ final class PaykitPaymentRequestManager { pendingRequests.removeAll { $0.isExpired(at: date) } subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: date) } presentedSubscriptionProposalIds.formIntersection( - Set(subscriptions.filter { $0.isProposalVisible(at: date) }.map(\.id)) + Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: date) }.map(\.id)) ) persistSubscriptionState() if requestedSubscriptionProposalId.map({ id in diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 38e867b83..73e5aa5e4 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -11,17 +11,17 @@ private struct PaykitPreciseInstant: Comparable, Hashable { Date(timeIntervalSince1970: Double(seconds) + Double(nanoseconds) / 1_000_000_000) } + var wholeSecondDate: Date { + Date(timeIntervalSince1970: TimeInterval(seconds)) + } + init?(timestamp: String) { let canonical = PaykitSubscriptionTimestamp.canonical(timestamp) let fraction = PaykitSubscriptionTimestamp.fractionalSeconds(from: canonical) ?? "" let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var parsedDate = formatter.date(from: canonical) - if parsedDate == nil { - formatter.formatOptions = [.withInternetDateTime] - parsedDate = formatter.date(from: canonical) - } - guard let date = parsedDate else { return nil } + formatter.formatOptions = [.withInternetDateTime] + let wholeSecondTimestamp = fraction.isEmpty ? canonical : canonical.replacingOccurrences(of: ".\(fraction)", with: "") + guard let date = formatter.date(from: wholeSecondTimestamp) else { return nil } seconds = Int64(floor(date.timeIntervalSince1970)) nanoseconds = Int(fraction.padding(toLength: 9, withPad: "0", startingAt: 0)) ?? 0 @@ -114,6 +114,7 @@ struct PaykitBillingPeriod: Codable, Hashable { struct PaykitSubscriptionMetadata: Hashable { let description: String? let benefits: [String] + let iconURI: String? init(_ metadata: Paykit.PrivateJsonObject) { guard let data = metadata.exportText().data(using: .utf8), @@ -123,6 +124,7 @@ struct PaykitSubscriptionMetadata: Hashable { else { description = nil benefits = [] + iconURI = nil return } @@ -130,6 +132,8 @@ struct PaykitSubscriptionMetadata: Hashable { benefits = (subscription["benefits"] as? [String] ?? []) .prefix(8) .compactMap { Self.trimmed($0, limit: 160) } + iconURI = Self.trimmed(subscription["icon_uri"] as? String, limit: 512) + .flatMap { $0.hasPrefix("pubky://") ? $0 : nil } } private static func trimmed(_ value: String?, limit: Int) -> String? { @@ -198,8 +202,18 @@ struct PaykitSubscriptionRecurrence: Hashable { } func periods(through date: Date, acceptedAt: Date) -> [PaykitBillingPeriod] { - let preciseDate = PaykitPreciseInstant(date: date) - let preciseAcceptedAt = PaykitPreciseInstant(date: acceptedAt) + periods(through: PaykitPreciseInstant(date: date), acceptedAt: PaykitPreciseInstant(date: acceptedAt)) + } + + func contains(_ period: PaykitBillingPeriod) -> Bool { + guard let start = PaykitPreciseInstant(timestamp: period.sdkValue.startsAt) else { return false } + return periods(through: start, acceptedAt: start).first == period + } + + private func periods( + through preciseDate: PaykitPreciseInstant, + acceptedAt preciseAcceptedAt: PaykitPreciseInstant + ) -> [PaykitBillingPeriod] { guard unit.isSupported, preciseStartsAt <= preciseDate else { return [] } var periods: [PaykitBillingPeriod] = [] @@ -295,17 +309,17 @@ struct PaykitSubscriptionRecurrence: Hashable { let boundaryDate: Date? = switch unit { case .minute: - calendar.date(byAdding: .minute, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .minute, value: value, to: preciseAnchor.wholeSecondDate) case .hour: - calendar.date(byAdding: .hour, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .hour, value: value, to: preciseAnchor.wholeSecondDate) case .day: - calendar.date(byAdding: .day, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .day, value: value, to: preciseAnchor.wholeSecondDate) case .week: - calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.wholeSecondDate) case .month: - Self.monthBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + Self.monthBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar) case .year: - Self.yearBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + Self.yearBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar) } guard let boundaryDate else { return nil } return PaykitPreciseInstant(seconds: Int64(floor(boundaryDate.timeIntervalSince1970)), nanoseconds: preciseAnchor.nanoseconds) @@ -316,17 +330,17 @@ struct PaykitSubscriptionRecurrence: Hashable { calendar.timeZone = TimeZone(secondsFromGMT: 0)! let result: Date? = switch unit { case .minute: - calendar.date(byAdding: .minute, value: every, to: date.date) + calendar.date(byAdding: .minute, value: every, to: date.wholeSecondDate) case .hour: - calendar.date(byAdding: .hour, value: every, to: date.date) + calendar.date(byAdding: .hour, value: every, to: date.wholeSecondDate) case .day: - calendar.date(byAdding: .day, value: every, to: date.date) + calendar.date(byAdding: .day, value: every, to: date.wholeSecondDate) case .week: - calendar.date(byAdding: .weekOfYear, value: every, to: date.date) + calendar.date(byAdding: .weekOfYear, value: every, to: date.wholeSecondDate) case .month: - calendar.date(byAdding: .month, value: every, to: date.date) + calendar.date(byAdding: .month, value: every, to: date.wholeSecondDate) case .year: - calendar.date(byAdding: .year, value: every, to: date.date) + calendar.date(byAdding: .year, value: every, to: date.wholeSecondDate) } guard let result else { return nil } return PaykitPreciseInstant(seconds: Int64(floor(result.timeIntervalSince1970)), nanoseconds: date.nanoseconds) @@ -379,6 +393,11 @@ struct PaykitSubscriptionRecurrence: Hashable { } struct PaykitSubscription: Identifiable, Hashable { + enum LocalRole: Hashable { + case payer + case payee + } + struct ID: Codable, Hashable { let paymentRequestId: String let counterparty: String @@ -402,6 +421,8 @@ struct PaykitSubscription: Identifiable, Hashable { let metadata: PaykitSubscriptionMetadata let acceptedPaymentEndpointIdentifiers: [String] let wasAccepted: Bool + let localRole: LocalRole + let deliveryStatus: PaykitPaymentRequest.DeliveryStatus? var lifecycleState: Paykit.PaymentRequestLifecycleState let payments: [Payment] @@ -421,6 +442,14 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .proposed } + var isPayer: Bool { + localRole == .payer + } + + var isCreatedByUser: Bool { + localRole == .payee + } + func isProposalActionable(at date: Date) -> Bool { isProposalVisible(at: date) && recurrence.unit.isSupported && @@ -438,6 +467,22 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true } + func isCreatedVisible(at date: Date) -> Bool { + isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date)) + } + + func isExpiredVisible(at date: Date) -> Bool { + isExpired(at: date) && (isPayer ? wasAccepted : !payments.isEmpty) + } + + func canCancel(at date: Date) -> Bool { + guard recurrence.endsAt == nil else { return false } + if isCreatedByUser { + return isProposalVisible(at: date) || isActive(at: date) + } + return isActive(at: date) + } + func isExpired(at date: Date) -> Bool { lifecycleState == .canceled || lifecycleState == .rejected || lifecycleState == .proposalExpired || (isProposal && proposalExpiresAt.map { $0 <= date } ?? false) || @@ -453,9 +498,21 @@ struct PaykitSubscription: Identifiable, Hashable { return subscription } - init?(record: Paykit.PaymentRequestRecord) { - guard record.localRole == .payer, - let terms = record.terms, + init?( + record: Paykit.PaymentRequestRecord, + deliveryStatusOverride: PaykitPaymentRequest.DeliveryStatus? = nil + ) { + let localRole: LocalRole + switch record.localRole { + case .payer?: + localRole = .payer + case .payee?: + localRole = .payee + case .unknown?, nil: + return nil + } + + guard let terms = record.terms, let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init), terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), @@ -482,18 +539,27 @@ struct PaykitSubscription: Identifiable, Hashable { network: Env.network ) wasAccepted = record.acceptedEventId != nil || record.state == .activeRecurring || !record.paymentProofs.isEmpty + self.localRole = localRole + deliveryStatus = localRole == .payee + ? deliveryStatusOverride ?? Self.deliveryStatus(from: record.proposalOutboundStatus) + : nil lifecycleState = record.state - payments = record.paymentProofs.compactMap { proof in - guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init) else { return nil } - return Payment( + var paymentsByPeriod: [PaykitBillingPeriod: Payment] = [:] + for proof in record.paymentProofs { + guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init), + recurrence.contains(billingPeriod) + else { continue } + paymentsByPeriod[billingPeriod] = Payment( billingPeriod: billingPeriod, proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier) ) } + payments = paymentsByPeriod.values.sorted { $0.billingPeriod.startsAt < $1.billingPeriod.startsAt } } func requests(through date: Date, acceptedAt: Date) -> [PaykitPaymentRequest] { - recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in + guard isPayer else { return [] } + return recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in let payment = payments.last { $0.billingPeriod == period } return PaykitPaymentRequest( subscription: self, @@ -505,9 +571,32 @@ struct PaykitSubscription: Identifiable, Hashable { } func paymentDueOnAcceptance(at date: Date) -> PaykitPaymentRequest? { + guard isPayer else { return nil } guard let period = recurrence.periods(through: date, acceptedAt: date).first else { return nil } return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring) } + + func receivedPaymentRequests() -> [PaykitPaymentRequest] { + guard isCreatedByUser else { return [] } + return payments.map { + PaykitPaymentRequest( + subscription: self, + billingPeriod: $0.billingPeriod, + lifecycleState: .proofSubmitted, + paymentProofKind: $0.proofKind, + direction: .outgoing + ) + } + } + + private static func deliveryStatus( + from status: Paykit.OutboundPrivateMessageStatus? + ) -> PaykitPaymentRequest.DeliveryStatus { + if case .sent? = status { + return .sent + } + return .queued + } } struct PaykitSubscriptionState: Codable, Equatable { @@ -588,7 +677,8 @@ actor PaykitSubscriptionNotificationScheduler { let currentGeneration = generation let notifications: [(PaykitSubscription, PaykitBillingPeriod)] = notificationsEnabled ? Array(subscriptions .filter { - $0.isActive(at: now) && + $0.isPayer && + $0.isActive(at: now) && $0.recurrence.unit.isSupported && acceptedAt[$0.id] != nil } diff --git a/Bitkit/Services/PaykitSubscriptionProposal.swift b/Bitkit/Services/PaykitSubscriptionProposal.swift new file mode 100644 index 000000000..675a822a0 --- /dev/null +++ b/Bitkit/Services/PaykitSubscriptionProposal.swift @@ -0,0 +1,43 @@ +import Foundation +import Paykit + +enum PaykitSubscriptionProposal { + // Paykit's proposal wire format is carried in a pubky-noise message capped at 1,000 UTF-8 bytes. + static let maximumMessageBytes = 1000 + static let reservedIconURI = "pubky://" + String(repeating: "x", count: 122) + + static func validate(_ terms: Paykit.PaymentRequestTerms) throws { + guard try encodedSize(terms) <= maximumMessageBytes else { + throw PaykitPaymentRequestError.subscriptionTooLong + } + } + + static func encodedSize(_ terms: Paykit.PaymentRequestTerms) throws -> Int { + guard let recurrence = terms.recurrence else { + throw PaykitPaymentRequestError.requestUnavailable + } + let metadata = try JSONSerialization.jsonObject(with: Data(terms.metadata.exportText().utf8)) + let uuid = "00000000-0000-0000-0000-000000000000" + let wire: [String: Any] = [ + "version": 1, + "kind": "paykit.payment_request", + "event_id": uuid, + "payment_request_id": uuid, + "request": [ + "amount": ["value": terms.amount.value, "asset": terms.amount.asset], + "payment_reference": terms.paymentReference.exportText(), + "proposal_expires_at": terms.proposalExpiresAt as Any? ?? NSNull(), + "recurrence": [ + "every": recurrence.every, + "unit": recurrence.unit, + "starts_at": recurrence.startsAt, + "anchor": recurrence.anchor, + "ends_at": recurrence.endsAt as Any? ?? NSNull(), + ], + "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers, + "metadata": metadata, + ], + ] + return try JSONSerialization.data(withJSONObject: wire, options: [.withoutEscapingSlashes]).count + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 8b3bbaa8a..c8ac03ddb 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -576,9 +576,15 @@ actor PaykitSdkService { } } - func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String { + func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { let record = try await withStateRevisionTracking { sdk in - try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) + if let expectedIdentity { + guard let identity = try await sdk.identityStatus(), + identity.liveSessionAvailable, + PubkyPublicKeyFormat.matches(identity.publicKey, expectedIdentity) + else { throw PaykitPaymentRequestError.requestUnavailable } + } + return try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) } return record.uri } diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 403564a8f..574999f72 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -12,6 +12,7 @@ struct DisplayText: View { var accentColor: Color = .brandAccent var accentFont: ((CGFloat) -> Font)? var accentAction: (() -> Void)? + var fillsWidth: Bool private let fontSize: CGFloat = 44 @@ -20,13 +21,15 @@ struct DisplayText: View { textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + fillsWidth: Bool = true ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + self.fillsWidth = fillsWidth } var body: some View { @@ -42,7 +45,7 @@ struct DisplayText: View { .environment(\._lineHeightMultiple, 0.83) .textCase(.uppercase) .padding(.bottom, -9) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) .dynamicTypeSize(...DynamicTypeSize.xxLarge) } } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index c3fa47c91..5cbc7bfb0 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -123,10 +123,32 @@ struct RequestOrPayView: View { } struct PaymentRequestRecipientView: View { + let onSelect: (PaykitPaymentRequestTarget) -> Void + + var body: some View { + PaykitRecipientPicker( + selectedTarget: nil, + onSelect: onSelect, + accessibilityIdentifier: "PaymentRequestRecipient", + testIdentifierPrefix: "PaymentRequest" + ) { + SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true) + } footer: { + EmptyView() + } + } +} + +struct PaykitRecipientPicker: View { @EnvironmentObject private var contactsManager: ContactsManager @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + let selectedTarget: PaykitPaymentRequestTarget? let onSelect: (PaykitPaymentRequestTarget) -> Void + let accessibilityIdentifier: String + let testIdentifierPrefix: String + @ViewBuilder let header: () -> Header + @ViewBuilder let footer: () -> Footer @State private var recipientQuery = "" @@ -145,26 +167,29 @@ struct PaymentRequestRecipientView: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true) + header() recipientInput - .padding(.bottom, 16) + .padding(.bottom, 32) ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 0) { CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) - .padding(.vertical, 16) + .padding(.bottom, 16) CustomDivider() ForEach(recipientTargets) { target in recipientRow(target) } } } + + footer() } .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) - .accessibilityIdentifier("PaymentRequestRecipient") + .accessibilityElement(children: .contain) + .accessibilityIdentifier(accessibilityIdentifier) } private var recipientInput: some View { @@ -176,9 +201,10 @@ struct PaymentRequestRecipientView: View { t("wallet__payment_request_enter_pubky"), text: $recipientQuery, backgroundColor: .clear, - font: .custom(Fonts.regular, size: 17), - testIdentifier: "PaymentRequestRecipientFilter" + testIdentifier: "\(testIdentifierPrefix)RecipientFilter", + contentPadding: 0 ) + .frame(minHeight: 20) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) @@ -194,14 +220,15 @@ struct PaymentRequestRecipientView: View { .scaledToFit() .frame(width: 16, height: 16) .accessibilityHidden(true) - BodyMSBText(t("common__paste")) + CaptionBText(t("common__paste"), textColor: .textPrimary) } + .padding(.horizontal, 8) } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestRecipientPaste") + .accessibilityIdentifier("\(testIdentifierPrefix)RecipientPaste") } .padding(16) - .background(Color.white08) + .background(Color.white10) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -217,10 +244,10 @@ struct PaymentRequestRecipientView: View { @ViewBuilder private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { if let contact = contact(for: target) { - PubkyContactRow(contact: contact, verticalPadding: 20) { + PubkyContactRow(contact: contact, verticalPadding: 24, isSelected: selectedTarget == target) { onSelect(target) } - .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") + .accessibilityIdentifier("\(testIdentifierPrefix)Contact\(contact.publicKey)") } else { Button { onSelect(target) @@ -229,12 +256,19 @@ struct PaymentRequestRecipientView: View { ContactAvatarLetter(source: target.publicKey, size: 48) BodyMSBText(PubkyPublicKeyFormat.displayTruncated(target.publicKey)) Spacer() + if selectedTarget == target { + Image("check-mark") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.brandAccent) + .accessibilityHidden(true) + } } - .padding(.vertical, 20) + .padding(.vertical, 24) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestTarget-\(target.id)") + .accessibilityIdentifier("\(testIdentifierPrefix)Target-\(target.id)") CustomDivider() } } @@ -245,8 +279,10 @@ struct PaymentRequestAmountView: View { @EnvironmentObject private var currency: CurrencyViewModel let initialDraft: PaykitPaymentRequestDraft - let target: PaykitPaymentRequestTarget + let target: PaykitPaymentRequestTarget? let onContinue: (PaykitPaymentRequestDraft) -> Void + var onBack: (() -> Void)? + var testIdentifierPrefix = "PaymentRequest" @State private var amountViewModel = AmountInputViewModel() @@ -255,14 +291,15 @@ struct PaymentRequestAmountView: View { SheetHeader( title: t("wallet__payment_request_amount"), showBackButton: true, - action: AnyView(targetAvatar) + action: target == nil ? nil : AnyView(targetAvatar), + onBack: onBack ) NumberPadTextField( viewModel: amountViewModel, showEditButton: false, isFocused: true, - testIdentifier: "PaymentRequestAmountField" + testIdentifier: "\(testIdentifierPrefix)AmountField" ) Spacer() @@ -283,7 +320,7 @@ struct PaymentRequestAmountView: View { ) ) } - .accessibilityIdentifier("PaymentRequestAmountContinue") + .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue") } .padding(.horizontal, 16) .sheetBackground() @@ -295,10 +332,12 @@ struct PaymentRequestAmountView: View { @ViewBuilder private var targetAvatar: some View { - if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) { - PubkyContactAvatar(contact: contact, size: 24) - } else { - ContactAvatarLetter(source: target.publicKey, size: 24) + if let target { + if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) { + PubkyContactAvatar(contact: contact, size: 24) + } else { + ContactAvatarLetter(source: target.publicKey, size: 24) + } } } } diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift new file mode 100644 index 000000000..6284a3cc1 --- /dev/null +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -0,0 +1,373 @@ +import PhotosUI +import SwiftUI + +struct CreateSubscriptionView: View { + @EnvironmentObject private var app: AppViewModel + + @Binding var draft: PaykitSubscriptionDraft + let onEditAmount: () -> Void + let onChooseRecipient: () -> Void + + @State private var selectedPhotoItem: PhotosPickerItem? + @FocusState private var isNameFocused: Bool + @FocusState private var isDescriptionFocused: Bool + + private var isLoadingIcon: Bool { + selectedPhotoItem != nil + } + + private var iconImage: UIImage? { + draft.iconData.flatMap { UIImage(data: $0) } + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__create_subscription")) + + ScrollViewReader { proxy in + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + amount + frequency + name + description + .id("SubscriptionDescription") + customIcon + } + .padding(.bottom, 24) + } + .onChange(of: isDescriptionFocused ? geometry.size.height : nil) { _, focusedHeight in + if focusedHeight != nil { + withAnimation { + proxy.scrollTo("SubscriptionDescription", anchor: .bottom) + } + } + } + } + } + + CustomButton( + title: t("subscriptions__choose_recipient"), + isDisabled: draft.amountSats == 0 || + draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + isLoadingIcon + ) { + onChooseRecipient() + } + .buttonBottomPadding(isFocused: isNameFocused || isDescriptionFocused) + .accessibilityIdentifier("SubscriptionChooseRecipient") + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .onChange(of: draft.name) { _, value in + if value.count > 256 { + draft.name = String(value.prefix(256)) + } + } + .onChange(of: draft.description) { _, value in + if value.count > 1024 { + draft.description = String(value.prefix(1024)) + } + } + .task(id: selectedPhotoItem) { + await loadIcon(selectedPhotoItem) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("CreateSubscription") + } + + private var amount: some View { + VStack(alignment: .leading, spacing: 12) { + CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64) + Button(action: onEditAmount) { + HStack(spacing: 8) { + MoneyText( + sats: Int(clamping: draft.amountSats), + unitType: .primary, + size: .display, + symbol: true, + color: .textPrimary, + symbolColor: .textSecondary, + fillsWidth: false + ) + .lineLimit(1) + .minimumScaleFactor(0.5) + Image("pencil") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.textPrimary) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("SubscriptionEditAmount") + } + } + + private var frequency: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__frequency").localizedUppercase, textColor: .white64) + SegmentedControl( + selectedTab: Binding( + get: { SubscriptionFrequencyOption.allCases.first { $0.unit == draft.frequency } ?? .month }, + set: { draft.frequency = $0.unit } + ), + tabItems: SubscriptionFrequencyOption.allCases.map { + TabItem($0, accessibilityIdentifier: "Tab-\($0.rawValue)") + }, + inactiveColor: .white.opacity(0.5) + ) + } + } + + private var name: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__name").localizedUppercase, textColor: .white64) + TextField( + t("subscriptions__name_placeholder"), + text: $draft.name, + testIdentifier: "SubscriptionName" + ) + .focused($isNameFocused) + } + } + + private var description: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__description").localizedUppercase, textColor: .white64) + NoteTextEditor( + text: $draft.description, + placeholder: t("subscriptions__description_placeholder"), + testIdentifier: "SubscriptionDescription", + isFocused: $isDescriptionFocused, + minHeight: 60, + maxHeight: 60, + backgroundColor: .white10 + ) + } + } + + private var customIcon: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__custom_icon").localizedUppercase, textColor: .white64) + PhotosPicker(selection: $selectedPhotoItem, matching: .images) { + HStack(spacing: 16) { + Group { + if let iconImage { + Image(uiImage: iconImage) + .resizable() + .scaledToFill() + } else { + SubscriptionDefaultIcon(size: 40) + } + } + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + CaptionBText(t("subscriptions__custom_icon_description"), textColor: .white64) + Spacer() + } + .padding(16) + .background(Color.white10) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(isLoadingIcon) + .accessibilityIdentifier("SubscriptionIconPicker") + } + } + + private func loadIcon(_ item: PhotosPickerItem?) async { + guard let item else { return } + defer { if selectedPhotoItem == item { selectedPhotoItem = nil } } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try Task.checkCancellation() + let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(data) + guard selectedPhotoItem == item else { return } + draft.iconData = compressed + } catch is CancellationError { + return + } catch { + app.toast(type: .error, title: t("common__error"), description: t("subscriptions__icon_error")) + } + } +} + +struct SubscriptionAmountView: View { + let initialAmountSats: UInt64 + let onBack: () -> Void + let onContinue: (UInt64) -> Void + + var body: some View { + PaymentRequestAmountView( + initialDraft: PaykitPaymentRequestDraft(amountSats: initialAmountSats, note: "", expiresAt: .distantFuture), + target: nil, + onContinue: { onContinue($0.amountSats) }, + onBack: onBack, + testIdentifierPrefix: "PaymentRequest" + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SubscriptionAmount") + } +} + +struct SubscriptionRecipientView: View { + @EnvironmentObject private var app: AppViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + @Binding var draft: PaykitSubscriptionDraft + @Binding var selectedTarget: PaykitPaymentRequestTarget? + let onBack: () -> Void + let onSent: (PaykitSubscription) -> Void + + var body: some View { + PaykitRecipientPicker( + selectedTarget: selectedTarget, + onSelect: { selectedTarget = $0 }, + accessibilityIdentifier: "SubscriptionRecipient", + testIdentifierPrefix: "Subscription" + ) { + SheetHeader( + title: t("subscriptions__choose_recipient"), + showBackButton: true, + action: AnyView(expirationMenu), + onBack: onBack + ) + } footer: { + CustomButton( + title: t("subscriptions__propose_subscription"), + isDisabled: selectedTarget == nil, + isLoading: paymentRequests.isCreatingRequest + ) { + await propose() + } + .padding(.bottom, 16) + .accessibilityIdentifier("SubscriptionPropose") + } + .interactiveDismissDisabled(paymentRequests.isCreatingRequest) + } + + private var expirationMenu: some View { + Menu { + ForEach(PaymentRequestExpiration.allCases) { expiration in + Button(expiration.description) { + draft.expiresAt = expiration.date(from: Date()) + } + } + } label: { + Image("timer-outline") + .resizable() + .frame(width: 18, height: 21) + .frame(width: 24, height: 24, alignment: .top) + .foregroundColor(.textPrimary) + } + .accessibilityLabel(t("wallet__payment_request_expires")) + .accessibilityIdentifier("SubscriptionExpiration") + } + + private func propose() async { + guard let selectedTarget else { return } + do { + let subscription = try await paymentRequests.proposeSubscription(draft, to: selectedTarget) + guard paymentRequests.subscriptions.contains(where: { $0.id == subscription.id }) else { return } + onSent(subscription) + } catch { + app.toast(error) + } + } +} + +struct SubscriptionProposalSentView: View { + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var sheets: SheetViewModel + + let subscription: PaykitSubscription + + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t(subscription.deliveryStatus == .sent + ? "wallet__payment_request_sent_title" + : "subscriptions__proposal_queued_title")) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + DisplayText(t(subscription.deliveryStatus == .sent + ? "subscriptions__proposal_sent_headline" + : "subscriptions__proposal_queued_headline"), accentColor: .purpleAccent) + .padding(.bottom, 8) + BodyMText( + subscription.deliveryStatus == .sent + ? t("subscriptions__proposal_sent_description") + : t("subscriptions__proposal_queued_description"), + textColor: .white64 + ) + .padding(.bottom, 16) + if let contact { + PubkyContactRow(contact: contact, verticalPadding: 16, showsDivider: false) {} + .padding(.horizontal, 16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .allowsHitTesting(false) + } + SubscriptionRow( + subscription: subscription, + now: Date(), + subtitle: subscription.recurrence.subscriptionFrequencyLabel + ) + .padding(.top, 16) + Spacer().frame(height: 24) + } + .frame(minHeight: geometry.size.height, alignment: .bottom) + } + } + CustomButton(title: t("common__ok")) { + sheets.hideSheet(reason: "Subscription proposal created") + } + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheetBackground() + .navigationBarHidden(true) + .allowSwipeBack(false) + .accessibilityIdentifier("SubscriptionProposalSent") + } +} + +private enum SubscriptionFrequencyOption: String, CaseIterable, CustomStringConvertible { + case day + case week + case month + case year + + var unit: PaykitSubscriptionRecurrence.Unit { + switch self { + case .day: .day + case .week: .week + case .month: .month + case .year: .year + } + } + + var description: String { + switch self { + case .day: t("subscriptions__daily") + case .week: t("subscriptions__weekly") + case .month: t("subscriptions__monthly") + case .year: t("subscriptions__yearly") + } + } +} diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index cdafd057c..2b77f7360 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -3,6 +3,10 @@ import SwiftUI struct SubscriptionSheetItem: SheetItem { enum Route: Hashable { + case create + case createAmount + case createRecipient + case proposalSent(PaykitSubscription) case review(PaykitSubscription) case success case details(PaykitSubscription) @@ -42,21 +46,23 @@ struct SubscriptionsView: View { } private var proposals: [PaykitSubscription] { - paymentRequests.subscriptions.filter { $0.isProposalVisible(at: now) } + paymentRequests.subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: now) } } private var active: [PaykitSubscription] { - paymentRequests.subscriptions.filter { $0.isActive(at: now) } + paymentRequests.subscriptions.filter { $0.isPayer && $0.isActive(at: now) } } private var expired: [PaykitSubscription] { - paymentRequests.subscriptions.filter { - $0.isExpired(at: now) && $0.wasAccepted - } + paymentRequests.subscriptions.filter { $0.isExpiredVisible(at: now) } + } + + private var created: [PaykitSubscription] { + paymentRequests.subscriptions.filter { $0.isCreatedVisible(at: now) } } private var hasVisibleSubscriptions: Bool { - !proposals.isEmpty || !active.isEmpty || !expired.isEmpty + !proposals.isEmpty || !active.isEmpty || !expired.isEmpty || !created.isEmpty } var body: some View { @@ -67,7 +73,8 @@ struct SubscriptionsView: View { tabItems: [ TabItem(.overview), TabItem(.payments, badge: paymentRequests.pendingRequests.count), - ] + ], + inactiveColor: .white.opacity(0.5) ) if selectedTab == .payments { @@ -81,10 +88,22 @@ struct SubscriptionsView: View { section(t("subscriptions__proposals"), subscriptions: proposals) section(t("subscriptions__active"), subscriptions: active) section(t("subscriptions__expired"), subscriptions: expired) + section(t("subscriptions__created"), subscriptions: created) } .padding(.top, 32) - .padding(.bottom, 120) + .padding(.bottom, 32) + } + } + + if selectedTab == .overview { + CustomButton( + title: t("subscriptions__create"), + variant: .secondary + ) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } + .padding(.bottom, 16) + .accessibilityIdentifier("SubscriptionCreate") } } .padding(.horizontal, 16) @@ -117,21 +136,21 @@ struct SubscriptionsView: View { VStack(alignment: .leading, spacing: 0) { Spacer() - Image("subscription-clock") + Image("subscription-intro-clock") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 256, height: 256) .frame(maxWidth: .infinity) .accessibilityHidden(true) - Spacer() + Spacer().frame(height: 32) DisplayText(t("subscriptions__empty_headline"), accentColor: .purpleAccent) - Spacer().frame(height: 12) + Spacer().frame(height: 8) BodyMText(t("subscriptions__empty_description"), textColor: .white64) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, 24) + .padding(.bottom, 32) } private var metrics: some View { @@ -141,17 +160,28 @@ struct SubscriptionsView: View { sats: monthlyCostSats, unitType: .primary, size: .bodyMSB, - prefix: "", + symbol: true, color: .textPrimary, symbolColor: .textSecondary ) + .lineLimit(1) + .minimumScaleFactor(0.5) } + .frame(maxWidth: .infinity, alignment: .leading) Rectangle() .fill(Color.white16) .frame(width: 1, height: 50) SubscriptionMetric(title: t("subscriptions__active"), icon: "arrows-clockwise") { BodyMSBText("\(active.count)") } + .fixedSize(horizontal: true, vertical: false) + Rectangle() + .fill(Color.white16) + .frame(width: 1, height: 50) + SubscriptionMetric(title: t("subscriptions__created"), icon: "asterisk") { + BodyMSBText("\(created.count)") + } + .fixedSize(horizontal: true, vertical: false) } } @@ -162,7 +192,7 @@ struct SubscriptionsView: View { CaptionMText(title.localizedUppercase, textColor: .white64) ForEach(subscriptions) { subscription in Button { - if subscription.isProposalVisible(at: now) { + if subscription.isPayer && subscription.isProposalVisible(at: now) { paymentRequests.requestSubscriptionPresentation(subscription) sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) } else { @@ -194,7 +224,7 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date) } } let maximum = NSDecimalNumber(value: Int.max) - return subscriptions.filter { $0.isActive(at: now) }.reduce(into: 0) { total, subscription in + return subscriptions.filter { $0.isPayer && $0.isActive(at: now) }.reduce(into: 0) { total, subscription in var monthlyCost = Decimal(subscription.amountSats) * annualPeriods(subscription.recurrence.unit) / Decimal(subscription.recurrence.every) / 12 var roundedMonthlyCost = Decimal() @@ -222,7 +252,7 @@ private struct SubscriptionMetric: View { @ViewBuilder let content: () -> Content var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { CaptionMText(title.localizedUppercase, textColor: .white64) HStack(spacing: 8) { Image(icon) @@ -232,28 +262,28 @@ private struct SubscriptionMetric: View { content() } } - .frame(maxWidth: .infinity, alignment: .leading) } } struct SubscriptionRow: View { let subscription: PaykitSubscription let now: Date + var subtitle: String? var body: some View { HStack(spacing: 16) { SubscriptionAvatar(subscription: subscription, size: 40) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { BodyMSBText(subscription.note ?? t("subscriptions__subscription")) .lineLimit(1) - CaptionText(subscription.rowSubtitle(at: now), textColor: .white64) + CaptionBText(subtitle ?? subscription.rowSubtitle(at: now), textColor: .white64) .lineLimit(1) } Spacer(minLength: 8) - MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "") + MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "", symbol: true) } .padding(16) .background(Color.gray6) @@ -277,7 +307,11 @@ struct SubscriptionAvatar: View { } var body: some View { - if let contact { + if let iconURI = subscription.metadata.iconURI { + PubkyImage(uri: iconURI, size: size, cornerRadius: size / 5) + } else if subscription.isCreatedByUser { + SubscriptionDefaultIcon(size: size) + } else if let contact { PubkyContactAvatar(contact: contact, size: size) } else { ContactAvatarLetter(source: subscription.counterparty, size: size) @@ -285,6 +319,20 @@ struct SubscriptionAvatar: View { } } +struct SubscriptionDefaultIcon: View { + let size: CGFloat + + var body: some View { + Image("subscription-default-icon") + .resizable() + .scaledToFit() + .padding(size / 8) + .frame(width: size, height: size) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: size / 5)) + } +} + struct SubscriptionDetailView: View { @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @@ -358,7 +406,7 @@ struct SubscriptionDetailView: View { LabeledDetailCell(title: t("subscriptions__frequency"), value: subscription.recurrence.frequencyValue, icon: "arrows-clockwise") LabeledDetailCell( title: t("subscriptions__status"), - value: subscription.isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired"), + value: subscription.statusLabel(at: now), icon: "check-mark" ) if subscription.isActive(at: now) || subscription.recurrence.endsAt != nil { @@ -368,23 +416,35 @@ struct SubscriptionDetailView: View { icon: "calendar" ) } + if subscription.isCreatedByUser { + LabeledDetailCell( + title: t("subscriptions__subscribers"), + value: subscription.wasAccepted ? "1" : "0", + icon: "users" + ) + LabeledDetailCell( + title: t("subscriptions__payments"), + value: "\(subscription.payments.count)", + icon: "arrow-down" + ) + } } } @ViewBuilder private func footer(_ subscription: PaykitSubscription) -> some View { let hasMoreInfo = subscription.metadata.description != nil || !subscription.metadata.benefits.isEmpty - let canCancel = subscription.isActive(at: now) && subscription.recurrence.endsAt == nil + let canCancel = subscription.canCancel(at: now) && subscription.recurrence.endsAt == nil if hasMoreInfo || canCancel { HStack(spacing: 16) { - if hasMoreInfo { + if hasMoreInfo && !subscription.isCreatedByUser { CustomButton(title: t("subscriptions__more_info"), variant: .secondary) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .details(subscription))) } } if canCancel { CustomButton( - title: t("subscriptions__cancel"), + title: subscription.isCreatedByUser ? t("common__delete") : t("subscriptions__cancel"), icon: Image("x-mark").resizable().frame(width: 16, height: 16) ) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .cancel(subscription))) @@ -397,9 +457,9 @@ struct SubscriptionDetailView: View { @ViewBuilder private func payments(_ subscription: PaykitSubscription) -> some View { - let payments = paymentRequests.historyRequests.filter { - $0.belongs(to: subscription) - } + let payments = subscription.isCreatedByUser + ? subscription.receivedPaymentRequests() + : paymentRequests.historyRequests.filter { $0.belongs(to: subscription) } if !payments.isEmpty { VStack(alignment: .leading, spacing: 12) { CaptionMText(t("subscriptions__payments").localizedUppercase, textColor: .white64) @@ -408,7 +468,7 @@ struct SubscriptionDetailView: View { request: payment, subtitleOverride: payment.createdAt.map(Self.dateFormatter.string), isHighlighted: false, - paymentDirection: .incoming + paymentDirection: payment.direction ) } } @@ -460,6 +520,8 @@ struct SubscriptionSheet: View { @State private var previousRoute: SubscriptionSheetItem.Route? @State private var now = Date() @State private var isAccepting = false + @State private var creationDraft = PaykitSubscriptionDraft.empty + @State private var selectedCreationTarget: PaykitPaymentRequestTarget? init(config: SubscriptionSheetItem) { self.config = config @@ -470,6 +532,30 @@ struct SubscriptionSheet: View { var body: some View { Sheet(id: .subscription, data: config) { switch route { + case .create: + CreateSubscriptionView( + draft: $creationDraft, + onEditAmount: { route = .createAmount }, + onChooseRecipient: { route = .createRecipient } + ) + case .createAmount: + SubscriptionAmountView( + initialAmountSats: creationDraft.amountSats, + onBack: { route = .create }, + onContinue: { + creationDraft.amountSats = $0 + route = .create + } + ) + case .createRecipient: + SubscriptionRecipientView( + draft: $creationDraft, + selectedTarget: $selectedCreationTarget, + onBack: { route = .create }, + onSent: { route = .proposalSent($0) } + ) + case let .proposalSent(subscription): + SubscriptionProposalSentView(subscription: subscription) case let .review(subscription): review(subscription) case .success: @@ -512,7 +598,7 @@ struct SubscriptionSheet: View { } now = Date() } - .interactiveDismissDisabled(isAccepting) + .interactiveDismissDisabled(isAccepting || paymentRequests.isCreatingRequest) } private func review(_ subscription: PaykitSubscription) -> some View { @@ -520,7 +606,7 @@ struct SubscriptionSheet: View { return VStack(spacing: 0) { SheetHeader(title: t("subscriptions__review_and_subscribe")) SubscriptionAmountHeader(subscription: subscription) - SubscriptionProviderCard(subscription: subscription) { + SubscriptionProviderCard(subscription: subscription, showsCounterparty: true) { guard !isAccepting else { return } previousRoute = route route = .details(subscription) @@ -737,7 +823,11 @@ struct SubscriptionSheet: View { private func cancel(_ subscription: PaykitSubscription) -> some View { VStack(spacing: 0) { - SheetHeader(title: t("subscriptions__cancel_subscription")) + SheetHeader( + title: subscription.isCreatedByUser + ? t("subscriptions__delete_subscription") + : t("subscriptions__cancel_subscription") + ) SubscriptionAmountHeader(subscription: subscription) SubscriptionProviderCard(subscription: subscription, subtitle: subscription.rowSubtitle(at: now)) { previousRoute = route @@ -753,7 +843,9 @@ struct SubscriptionSheet: View { Spacer() SwipeButton( - title: t("subscriptions__swipe_to_cancel"), + title: subscription.isCreatedByUser + ? t("subscriptions__swipe_to_delete") + : t("subscriptions__swipe_to_cancel"), accentColor: .redAccent, isLoading: paymentRequests.isProcessingSubscription ) { @@ -821,10 +913,17 @@ private struct SubscriptionAmountHeader: View { } private struct SubscriptionProviderCard: View { + @EnvironmentObject private var contactsManager: ContactsManager + let subscription: PaykitSubscription + var showsCounterparty = false var subtitle: String? var action: (() -> Void)? + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } + } + var body: some View { if let action { Button(action: action) { @@ -844,6 +943,15 @@ private struct SubscriptionProviderCard: View { .lineLimit(1) CaptionText(subtitle ?? subscription.recurrence.subscriptionFrequencyLabel, textColor: .white64) .lineLimit(1) + if showsCounterparty { + if let contact { + CaptionText(contact.displayName, textColor: .white64) + .lineLimit(1) + } + CaptionText(PubkyPublicKeyFormat.displayTruncated(subscription.counterparty), textColor: .white64) + .lineLimit(1) + .accessibilityIdentifier("SubscriptionCounterparty") + } } Spacer() if showsChevron { @@ -910,7 +1018,27 @@ extension PaykitSubscriptionRecurrence { } private extension PaykitSubscription { + func statusLabel(at now: Date) -> String { + if isProposalVisible(at: now) { + return t("subscriptions__pending") + } + return isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired") + } + func rowSubtitle(at now: Date) -> String { + if isCreatedByUser { + if isProposalVisible(at: now) { + return deliveryStatus == .sent + ? t("subscriptions__proposal_sent_status") + : t("subscriptions__proposal_queued_status") + } + if isActive(at: now) { + let key = payments.count == 1 + ? "subscriptions__created_summary_single_payment" + : "subscriptions__created_summary" + return t(key, variables: ["count": "\(payments.count)"]) + } + } if isProposalVisible(at: now) || !recurrence.unit.isSupported { return recurrence.subscriptionFrequencyLabel } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 95be12342..389a5bb3a 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2,6 +2,7 @@ import BitkitCore import Foundation import Paykit +import UIKit import UserNotifications import XCTest @@ -319,6 +320,373 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(request.billingPeriod?.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))) } + func testRefreshKeepsCreatorSubscriptionWithoutGeneratingPayerPayment() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord( + id: "creator-recurring", + state: .activeRecurring, + role: .payee, + recurrence: recurrence, + metadata: #"{"note":"Creator plan","subscription":{"version":1,"description":"Monthly support","benefits":[],"icon_uri":"pubky://creator/icon"}}"# + ) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertTrue(subscription.isCreatedByUser) + XCTAssertEqual(subscription.note, "Creator plan") + XCTAssertEqual(subscription.metadata.description, "Monthly support") + XCTAssertEqual(subscription.metadata.iconURI, "pubky://creator/icon") + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + } + + func testCreatorPaymentsAggregateDuplicateProofEventsForOneBillingPeriod() throws { + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let first = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + ) + var second = first + second.eventId = "850e8400-e29b-41d4-a716-446655440000" + var offSchedule = first + offSchedule.eventId = "950e8400-e29b-41d4-a716-446655440000" + offSchedule.billingPeriod = BillingPeriod(startsAt: period.startsAt, endsAt: "2027-02-02T08:00:00Z") + let record = try paymentRequestRecord( + state: .activeRecurring, + role: .payee, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: nil + ), + paymentProofs: [first, second, offSchedule] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + XCTAssertEqual(subscription.payments.count, 1) + let received = subscription.receivedPaymentRequests() + XCTAssertEqual(received.count, 1) + XCTAssertEqual(received.first?.direction, .outgoing) + XCTAssertEqual(received.first?.lifecycleState, .proofSubmitted) + XCTAssertEqual(received.first?.paymentProofKind, .lightning) + } + + func testExpiredCreatorSubscriptionsKeepPaidHistoryAccessible() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z")) + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let proof = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + ) + for state in [PaymentRequestLifecycleState.canceled, .activeRecurring] { + for hasPayments in [false, true] { + let record = try paymentRequestRecord( + state: state, + role: .payee, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: state == .activeRecurring ? period.endsAt : nil + ), + paymentProofs: hasPayments ? [proof] : [] + ) + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), clock: PaymentRequestTestClock(now)) + await manager.refresh() + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertFalse(subscription.isCreatedVisible(at: now)) + XCTAssertEqual(subscription.isExpiredVisible(at: now), hasPayments) + XCTAssertEqual(subscription.receivedPaymentRequests().count, hasPayments ? 1 : 0) + XCTAssertFalse(subscription.canCancel(at: now)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + } + } + + func testFractionalBillingProofsRemainPaidForCreatorAndPayer() throws { + for fraction in ["123", "123456789", "999999999"] { + let first = BillingPeriod( + startsAt: "2027-01-15T08:00:00.\(fraction)Z", + endsAt: "2027-02-15T08:00:00.\(fraction)Z" + ) + let second = BillingPeriod( + startsAt: first.endsAt, + endsAt: "2027-03-15T08:00:00.\(fraction)Z" + ) + let proofs = try [first, second].map { + try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: $0 + ) + } + for role in [PaymentRequestLocalRole.payee, .payer] { + let record = try paymentRequestRecord( + state: .activeRecurring, + role: role, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: first.startsAt, + anchor: first.startsAt, + endsAt: nil + ), + paymentProofs: proofs + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + XCTAssertEqual(subscription.payments.count, 2) + let requests = if role == .payee { + subscription.receivedPaymentRequests() + } else { + try subscription.requests( + through: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-16T08:00:00Z")), + acceptedAt: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:01Z")) + ) + } + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { $0.lifecycleState == .proofSubmitted }) + } + } + } + + func testCreatorProposalBuildsRecurringTermsAndStaysQueuedUntilDelivery() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let expiresAt = now.addingTimeInterval(60) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "creator-proposal", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee + )) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + + let subscription = try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 100_000, + name: " Monthly support ", + description: " Thank you ", + frequency: .month, + expiresAt: expiresAt, + iconData: nil + ), + to: XCTUnwrap(manager.eligibleTargets.first) + ) + + let snapshot = await sdk.snapshot() + let proposed = try XCTUnwrap(snapshot.proposedRequests.first) + XCTAssertEqual(proposed.amount, "0.001") + XCTAssertEqual(proposed.expiresAt, timestamp(expiresAt)) + XCTAssertEqual(proposed.recurrence?.every, 1) + XCTAssertEqual(proposed.recurrence?.unit, "month") + XCTAssertEqual(proposed.recurrence?.startsAt, timestamp(now)) + XCTAssertEqual(proposed.recurrence?.anchor, timestamp(now)) + XCTAssertNil(proposed.recurrence?.endsAt) + let metadataData = try XCTUnwrap(proposed.metadata.data(using: .utf8)) + let metadata = try XCTUnwrap(JSONSerialization.jsonObject(with: metadataData) as? [String: Any]) + XCTAssertEqual(metadata["note"] as? String, "Monthly support") + let subscriptionMetadata = try XCTUnwrap(metadata["subscription"] as? [String: Any]) + XCTAssertEqual(subscriptionMetadata["description"] as? String, "Thank you") + XCTAssertTrue(subscription.isCreatedByUser) + XCTAssertEqual(subscription.deliveryStatus, .queued) + XCTAssertEqual(manager.subscriptions, [subscription]) + } + + func testCreatedSubscriptionSurvivesContactChangesButNotSessionChanges() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let otherKey = "pubky\(String(repeating: "a", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + + for change in ["reordered", "removed", "added", "cleared", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + await sdk.pauseNextProcess() + let proposal = Task { + try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: now.addingTimeInterval(60), iconData: nil + ), + to: target + ) + } + try await waitUntil { await sdk.processIsPaused() } + switch change { + case "reordered": await manager.refreshEligibleTargets(savedPublicKeys: [otherKey, publicKey]) + case "removed": await manager.refreshEligibleTargets(savedPublicKeys: []) + case "added": await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey, String(repeating: "o", count: 52)]) + case "cleared": manager.clear() + default: manager.activate(identity: otherKey) + } + await sdk.resumeProcess() + let subscription = try await proposal.value + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1, change) + XCTAssertEqual(manager.subscriptions, ["cleared", "identity"].contains(change) ? [] : [subscription], change) + XCTAssertFalse(manager.isCreatingRequest, change) + } + } + + func testSubscriptionRevalidatesAfterIconUploadBeforeEnqueue() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.purple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + let iconData = try XCTUnwrap(image.pngData()) + let optionKey = PublicPaykitService.lightningPaymentOptionEnabledKey + let previousOption = UserDefaults.standard.object(forKey: optionKey) + defer { UserDefaults.standard.set(previousOption, forKey: optionKey) } + + for change in ["unchanged", "expired", "removed", "unlinked", "unsupported", "endpoint", "cleared"] { + UserDefaults.standard.set(true, forKey: optionKey) + let clock = PaymentRequestTestClock(now) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + await sdk.pauseNextUpload() + let proposal = Task { + try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: now.addingTimeInterval(60), iconData: iconData + ), + to: target + ) + } + try await waitUntil { await sdk.uploadIsPaused() } + switch change { + case "expired": clock.advance(by: 60) + case "removed": await manager.refreshEligibleTargets(savedPublicKeys: []) + case "unlinked": await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]) + case "unsupported": + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [:] + ) + case "endpoint": UserDefaults.standard.set(false, forKey: optionKey) + case "cleared": manager.clear() + default: break + } + await sdk.resumeUpload() + do { + _ = try await proposal.value + XCTAssertEqual(change, "unchanged") + } catch { + XCTAssertNotEqual(change, "unchanged") + XCTAssertEqual(error as? PaykitPaymentRequestError, change == "expired" ? .requestExpired : .requestUnavailable, change) + } + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.uploadCount, 1, change) + XCTAssertEqual(snapshot.proposedRequests.count, change == "unchanged" ? 1 : 0, change) + XCTAssertEqual(manager.subscriptions.count, change == "unchanged" ? 1 : 0, change) + XCTAssertFalse(manager.isCreatingRequest, change) + } + } + + func testOversizedCreatorProposalIsRejectedBeforeIconUploadOrEnqueue() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + for iconData in [nil, Data([0, 1, 2])] as [Data?] { + do { + _ = try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, + name: "Support", + description: String(repeating: "💜", count: 256), + frequency: .month, + expiresAt: now.addingTimeInterval(60), + iconData: iconData + ), + to: target + ) + XCTFail("Oversized proposals must be rejected before external writes") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) + } + } + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.uploadCount, 0) + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + XCTAssertTrue(manager.subscriptions.isEmpty) + XCTAssertFalse(manager.isCreatingRequest) + } + + func testCreatorPendingProposalCanBeDeletedButFixedEndProposalCannot() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let openEnded = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + role: .payee, + expiresAt: timestamp(now.addingTimeInterval(60)), + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: timestamp(now), + anchor: timestamp(now), + endsAt: nil + ) + ))) + let fixedEnd = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + role: .payee, + expiresAt: timestamp(now.addingTimeInterval(60)), + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: timestamp(now), + anchor: timestamp(now), + endsAt: timestamp(now.addingTimeInterval(3600)) + ) + ))) + + XCTAssertTrue(openEnded.canCancel(at: now)) + XCTAssertFalse(fixedEnd.canCancel(at: now)) + } + func testEndedSubscriptionKeepsItsUnpaidPeriodAvailable() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -2370,6 +2738,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var receiverPathsByPublicKey: [String: [String]] = [:] private var liveSessionAvailable = true private var proposalResult: PaymentRequestRecord? + private var uploadCount = 0 + private var shouldPauseNextUpload = false + private var isUploadPaused = false + private var uploadContinuation: CheckedContinuation? private var processCallCount = 0 private var receiveCallCount = 0 private var processFailuresRemaining = 0 @@ -2455,6 +2827,33 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathsByPublicKey[publicKey] ?? [] } + func pauseNextUpload() { + shouldPauseNextUpload = true + } + + func uploadIsPaused() -> Bool { + isUploadPaused + } + + func resumeUpload() { + uploadContinuation?.resume() + uploadContinuation = nil + } + + func uploadProfileAvatar(bytes _: Data, contentType _: String, expectedIdentity: String?) async throws -> String { + if shouldPauseNextUpload { + shouldPauseNextUpload = false + isUploadPaused = true + await withCheckedContinuation { uploadContinuation = $0 } + isUploadPaused = false + } + guard expectedIdentity == nil || PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity ?? "") else { + throw PaykitPaymentRequestError.requestUnavailable + } + uploadCount += 1 + return "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg" + } + func proposePaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -2681,6 +3080,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { func snapshot() -> PaymentRequestSdkSnapshot { PaymentRequestSdkSnapshot( + uploadCount: uploadCount, processCallCount: processCallCount, receiveCallCount: receiveCallCount, acceptedRequests: acceptedRequests, @@ -2706,6 +3106,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { } private struct PaymentRequestSdkSnapshot { + let uploadCount: Int let processCallCount: Int let receiveCallCount: Int let acceptedRequests: [PaymentRequestInvocation] diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift new file mode 100644 index 000000000..8b2f549b1 --- /dev/null +++ b/BitkitTests/PaykitSubscriptionProposalTests.swift @@ -0,0 +1,64 @@ +@testable import Bitkit +import Paykit +import UIKit +import XCTest + +final class PaykitSubscriptionProposalTests: XCTestCase { + func testTransportLimitIncludesEnvelopeEndpointsAndPublicIcon() throws { + let empty = try terms(description: "", iconURI: PaykitSubscriptionProposal.reservedIconURI) + let available = try PaykitSubscriptionProposal.maximumMessageBytes - (PaykitSubscriptionProposal.encodedSize(empty)) + XCTAssertGreaterThan(available, 0) + let full = try terms(description: String(repeating: "a", count: available), iconURI: PaykitSubscriptionProposal.reservedIconURI) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(full), 1000) + XCTAssertNoThrow(try PaykitSubscriptionProposal.validate(full)) + XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(terms( + description: String(repeating: "a", count: available + 1), + iconURI: PaykitSubscriptionProposal.reservedIconURI + ))) { error in + XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) + } + } + + func testSizeCountsUTF8AndJSONEscapingRatherThanCharacters() throws { + let base = try PaykitSubscriptionProposal.encodedSize(terms(description: "")) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "💜")), base + 4) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "\"\n\\")), base + 6) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "https://example.com")), base + 19) + } + + @MainActor + func testSubscriptionIconIsDownsampledAndInvalidDataIsRejected() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1200), format: format).image { context in + UIColor.purple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1200)) + } + let input = try XCTUnwrap(image.pngData()) + let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(input) + let output = try XCTUnwrap(UIImage(data: compressed)?.cgImage) + XCTAssertEqual(output.width, 400) + XCTAssertEqual(output.height, 200) + XCTAssertThrowsError(try PaykitPaymentRequestService.compressedSubscriptionIcon(Data([0, 1, 2]))) + } + + private func terms(description: String, iconURI: String? = nil) throws -> PaymentRequestTerms { + var subscription: [String: Any] = ["version": 1, "description": description, "benefits": []] + subscription["icon_uri"] = iconURI + let data = try JSONSerialization.data(withJSONObject: ["note": "Support", "subscription": subscription]) + return try PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.001", asset: "btc"), + paymentReference: PaymentReference(text: "bitkit-00000000-0000-0000-0000-000000000000"), + proposalExpiresAt: "2027-01-22T08:00:00.000Z", + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-15T08:00:00.000Z", + anchor: "2027-01-15T08:00:00.000Z", + endsAt: nil + ), + acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"], + metadata: PrivateJsonObject(text: String(decoding: data, as: UTF8.self)) + ) + } +} diff --git a/changelog.d/next/736.added.md b/changelog.d/next/736.added.md new file mode 100644 index 000000000..6da1146f7 --- /dev/null +++ b/changelog.d/next/736.added.md @@ -0,0 +1 @@ +Bitkit can now propose recurring subscriptions to Paykit contacts.