Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6f50ef8
fix: pay first onchain subscription period in one swipe
jvsena42 Oct 6, 2026
0a63347
fix: match subscription review to figma and keep it while paying
jvsena42 Oct 6, 2026
6637204
docs: update subscribe journey for single swipe
jvsena42 Oct 6, 2026
9101554
chore: add subscription single swipe changelog fragment
jvsena42 Oct 6, 2026
8cee06b
fix: start first subscription payment after send setup and fee
jvsena42 Oct 8, 2026
c6a8d1c
fix: show one subtitle line on subscription review card
jvsena42 Oct 8, 2026
5d979ab
chore: name subscription changelog fragment after pr
jvsena42 Oct 8, 2026
cb85d60
fix: open manual confirmation when fee rate is missing
jvsena42 Oct 8, 2026
08f4786
docs: keep subscription journeys valid with single swipe
jvsena42 Oct 8, 2026
570c54e
fix: open coin selection for first payment with manual coins
jvsena42 Oct 12, 2026
6836743
fix: pick hardware funding after lightning falls back to onchain
jvsena42 Oct 12, 2026
6e02959
chore: log why first subscription payment needs confirmation
jvsena42 Oct 12, 2026
f907ff9
fix: wait for a swipe after choosing coins for first payment
jvsena42 Oct 12, 2026
3cfc918
fix: treat send setup as pending until it has run
jvsena42 Oct 13, 2026
b2ae7e1
fix: keep first payment automatic while the fee rate loads
jvsena42 Oct 13, 2026
3758d9c
fix: wait for wallet switch before first payment starts
jvsena42 Oct 14, 2026
190d434
fix: reload fee rate when retrying first subscription payment
jvsena42 Oct 14, 2026
9cd6c3e
fix: start first payment from wallet switch observer
jvsena42 Oct 14, 2026
608930d
fix: keep stale retry from setting a newer send's fee rate
jvsena42 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions Bitkit/Components/SwipeButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ struct SwipeButton: View {
var isDisabled = false
/// Blocks interaction and shows the knob spinner while an operation is running.
var isLoading = false
/// Shows the knob at the end of the track, as after a completed swipe.
var isConfirmed = false
/// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent.
var swipeProgress: Binding<CGFloat>?
let onComplete: () async throws -> Void
Expand All @@ -28,9 +30,10 @@ struct SwipeButton: View {
var body: some View {
GeometryReader { geometry in
let maxOffset = max(1, geometry.size.width - buttonHeight)
let clampedOffset = max(0, min(offset, geometry.size.width - buttonHeight))
let knobOffset = isConfirmed ? maxOffset : offset
let clampedOffset = max(0, min(knobOffset, geometry.size.width - buttonHeight))
let trailWidth = max(0, min(clampedOffset + (buttonHeight - innerPadding), geometry.size.width - innerPadding))
let textProgress = offset / maxOffset
let textProgress = knobOffset / maxOffset
let halfWidth = geometry.size.width / 2

ZStack(alignment: .leading) {
Expand Down Expand Up @@ -69,13 +72,13 @@ struct SwipeButton: View {
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.gray7)
.opacity(Double(1.0 - (offset / halfWidth)))
.opacity(Double(1.0 - (knobOffset / halfWidth)))

Image("check-mark")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.gray7)
.opacity(Double(max(0, (offset - halfWidth) / halfWidth)))
.opacity(Double(max(0, (knobOffset - halfWidth) / halfWidth)))
}
}
)
Expand Down
1 change: 0 additions & 1 deletion Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1598,7 +1598,6 @@
"subscriptions__expires" = "Expires";
"subscriptions__first_payment_failed" = "First Payment Failed";
"subscriptions__first_payment_failed_description" = "You’re subscribed, but your first payment wasn’t sent.";
"subscriptions__first_period_ends" = "First billing period ends {date}. Each period is charged in full.";
"subscriptions__expires_date" = "Expires {date}";
"subscriptions__frequency" = "Frequency";
"subscriptions__more_info" = "More Info";
Expand Down
136 changes: 69 additions & 67 deletions Bitkit/Views/Subscriptions/SubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -643,62 +643,24 @@ struct SubscriptionSheet: View {
}

private func review(_ subscription: PaykitSubscription) -> some View {
let payOnAcceptance = subscription.paymentDueOnAcceptance(at: now, acceptedAt: Date()) != nil
return VStack(spacing: 0) {
SheetHeader(title: t("subscriptions__review_and_subscribe"))
SubscriptionAmountHeader(subscription: subscription)
SubscriptionProviderCard(subscription: subscription, showsCounterparty: true) {
SubscriptionReviewContent(
subscription: subscription,
now: now,
isLoading: isAccepting || paymentRequests.isProcessingSubscription,
onDetails: {
guard !isAccepting else { return }
previousRoute = route
route = .details(subscription)
}
.allowsHitTesting(!isAccepting)

if let period = subscription.paymentDueOnAcceptance(at: now, acceptedAt: Date())?.billingPeriod {
BodySText(
t("subscriptions__first_period_ends", variables: ["date": period.endsAt.formatted(date: .abbreviated, time: .shortened)]),
textColor: .white64
)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
}

if !subscription.recurrence.unit.isSupported {
BodyMText(t("subscriptions__unsupported_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
} else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty {
BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
}

Spacer()
Image("subscription-clock")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 256, height: 256)
.accessibilityHidden(true)
Spacer()

if subscription.isProposalActionable(at: now) {
SwipeButton(
title: payOnAcceptance
? t("subscriptions__swipe_to_subscribe_and_pay")
: t("subscriptions__swipe_to_subscribe"),
accentColor: .brandAccent,
isLoading: isAccepting || paymentRequests.isProcessingSubscription
) {
do {
try await accept(subscription)
} catch {
app.toast(error)
throw error
}
},
onSubscribe: {
do {
try await accept(subscription)
} catch {
app.toast(error)
throw error
}
}
}
.padding(.horizontal, 16)
)
}

private func success() -> some View {
Expand Down Expand Up @@ -944,6 +906,62 @@ struct SubscriptionSuccessView: View {
}
}

struct SubscriptionReviewContent: View {
/// Figma draws the review clock at 288pt inside its 256pt slot, rotated 15 degrees and shifted 7pt left.
private static let clockScale: CGFloat = 288.0 / 256.0
private static let clockRotation = Angle.degrees(15)
private static let clockOffsetX: CGFloat = -7

let subscription: PaykitSubscription
var now = SubscriptionClock.subscriptionNow()
var isLoading = false
/// Shows the swipe as already completed, for the first payment that follows acceptance.
var isPaying = false
var onDetails: () -> Void = {}
var onSubscribe: () async throws -> Void = {}

var body: some View {
VStack(spacing: 0) {
SheetHeader(title: t("subscriptions__review_and_subscribe"))
SubscriptionAmountHeader(subscription: subscription)
SubscriptionProviderCard(subscription: subscription, action: onDetails)
.allowsHitTesting(!isLoading && !isPaying)

if !subscription.recurrence.unit.isSupported {
BodyMText(t("subscriptions__unsupported_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
} else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty {
BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
}

Spacer()
Image("subscription-clock")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 256, height: 256)
.scaleEffect(Self.clockScale)
.rotationEffect(Self.clockRotation)
.offset(x: Self.clockOffsetX)
.accessibilityHidden(true)
Spacer()

if isLoading || isPaying || subscription.isProposalActionable(at: now) {
SwipeButton(
title: t("subscriptions__swipe_to_subscribe"),
accentColor: .brandAccent,
isLoading: isLoading || isPaying,
isConfirmed: isPaying,
onComplete: onSubscribe
)
}
}
.padding(.horizontal, 16)
}
}

private struct SubscriptionAmountHeader: View {
let subscription: PaykitSubscription

Expand All @@ -954,17 +972,10 @@ 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) {
Expand All @@ -984,15 +995,6 @@ 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import SwiftUI

struct InitialSubscriptionPaymentProgress: View {
@EnvironmentObject private var app: AppViewModel
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests

private var subscription: PaykitSubscription? {
guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return nil }
return paymentRequests.subscriptions.first {
$0.paymentRequestId == request.paymentRequestId && $0.counterparty == request.counterparty
}
}

var body: some View {
Group {
if let subscription {
SubscriptionReviewContent(subscription: subscription, isPaying: true)
} else {
progressIndicator
}
}
.sheetBackground()
}

private var progressIndicator: some View {
VStack(spacing: 0) {
SheetHeader(
title: t("subscriptions__review_and_subscribe"),
Expand All @@ -14,7 +35,6 @@ struct InitialSubscriptionPaymentProgress: View {
Spacer()
}
.padding(.horizontal, 16)
.sheetBackground()
}
}

Expand Down
Loading
Loading