From 9626a1cb5223713c737b3eef6106436122cb40e7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 12:47:45 +0300 Subject: [PATCH 1/8] feat: add subscription requests --- .../Localization/ar.lproj/Localizable.strings | 23 ++ .../Localization/ca.lproj/Localizable.strings | 23 ++ .../Localization/cs.lproj/Localizable.strings | 23 ++ .../Localization/de.lproj/Localizable.strings | 23 ++ .../Localization/el.lproj/Localizable.strings | 23 ++ .../Localization/en.lproj/Localizable.strings | 22 ++ .../es-419.lproj/Localizable.strings | 23 ++ .../Localization/es.lproj/Localizable.strings | 23 ++ .../Localization/fr.lproj/Localizable.strings | 23 ++ .../Localization/it.lproj/Localizable.strings | 23 ++ .../Localization/nl.lproj/Localizable.strings | 23 ++ .../Localization/pl.lproj/Localizable.strings | 23 ++ .../pt-BR.lproj/Localizable.strings | 23 ++ .../Localization/pt.lproj/Localizable.strings | 23 ++ .../Localization/ru.lproj/Localizable.strings | 23 ++ .../PaykitPaymentRequestService.swift | 221 ++++++++++- Bitkit/Services/PaykitSubscription.swift | 82 +++- .../CreatePaymentRequestView.swift | 57 ++- .../CreateSubscriptionView.swift | 370 ++++++++++++++++++ .../Subscriptions/SubscriptionsView.swift | 130 +++++- .../PaykitPaymentRequestServiceTests.swift | 114 ++++++ .../next/subscription-requests.added.md | 1 + 22 files changed, 1270 insertions(+), 49 deletions(-) create mode 100644 Bitkit/Views/Subscriptions/CreateSubscriptionView.swift create mode 100644 changelog.d/next/subscription-requests.added.md diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 03065b3f0..7b3d0d1e7 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -69,3 +69,26 @@ "widgets__weather__next_block" = "تضمين الكتلة التالية"; "wallet__payment_request" = "طلب دفع"; "wallet__send_error_support" = "الدعم"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 2a33bf72d..b3c4e4870 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1025,3 +1025,26 @@ "widgets__weather__condition__average__short_title" = "Mitjanes"; "widgets__weather__condition__poor__short_title" = "Dolentes"; "wallet__send_error_support" = "Contacta amb el suport"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index d24013cb8..1c3d40710 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1183,3 +1183,26 @@ "widgets__weather__condition__good__short_title" = "Příznivé"; "widgets__weather__condition__average__short_title" = "Průměrné"; "widgets__weather__condition__poor__short_title" = "Špatné"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 58d0dbb5b..c701e24dd 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1179,3 +1179,26 @@ "widgets__weather__condition__good__short_title" = "Günstig"; "widgets__weather__condition__average__short_title" = "Durchschnittlich"; "widgets__weather__condition__poor__short_title" = "Schlecht"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 81eb72f6c..7f322d0e5 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -887,3 +887,26 @@ "widgets__weather__condition__average__short_title" = "Μέσες"; "widgets__weather__condition__poor__short_title" = "Κακές"; "wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 04eae63cd..3ce580a91 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1532,9 +1532,20 @@ "subscriptions__active" = "Active"; "subscriptions__cancel" = "Cancel"; "subscriptions__cancel_subscription" = "Cancel Subscription"; +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__daily" = "Daily"; "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"; @@ -1552,6 +1563,8 @@ "subscriptions__monthly" = "Monthly"; "subscriptions__monthly_cost" = "Monthly Cost"; "subscriptions__monthly_subscription" = "Monthly Subscription"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; "subscriptions__ongoing" = "Ongoing"; "subscriptions__overview" = "Overview"; "subscriptions__payment_due_description" = "Open Bitkit to review a subscription payment."; @@ -1561,6 +1574,13 @@ "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_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__proposals" = "Proposals"; "subscriptions__renews" = "Renews"; "subscriptions__retry_payment" = "Retry Payment"; @@ -1569,7 +1589,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"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 1518392f1..3850ff053 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1195,3 +1195,26 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Promedio"; "widgets__weather__condition__poor__short_title" = "Malas"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 2e012ae75..5f19f81c4 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1054,3 +1054,26 @@ "widgets__weather__condition__average__short_title" = "Promedio"; "widgets__weather__condition__poor__short_title" = "Desfavorables"; "wallet__send_error_support" = "Contactar con el servicio de asistencia"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index c0be9761c..10b74e169 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1209,3 +1209,26 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Moyennes"; "widgets__weather__condition__poor__short_title" = "Mauvaises"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 7e6b09c3f..900b26e9f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1163,3 +1163,26 @@ "widgets__weather__condition__good__short_title" = "Favorevoli"; "widgets__weather__condition__average__short_title" = "Medie"; "widgets__weather__condition__poor__short_title" = "Sfavorevoli"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index a52d6fd05..8caca8ea8 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1203,3 +1203,26 @@ "widgets__weather__condition__good__short_title" = "Gunstig"; "widgets__weather__condition__average__short_title" = "Gemiddeld"; "widgets__weather__condition__poor__short_title" = "Slecht"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 7f1c1fbd9..29252d1cb 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1210,3 +1210,26 @@ "widgets__weather__condition__good__short_title" = "Korzystne"; "widgets__weather__condition__average__short_title" = "Średnie"; "widgets__weather__condition__poor__short_title" = "Niekorzystne"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 47500e956..824acf630 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1211,3 +1211,26 @@ "widgets__weather__condition__good__short_title" = "Favoráveis"; "widgets__weather__condition__average__short_title" = "Normais"; "widgets__weather__condition__poor__short_title" = "Ruins"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 89177e45b..43d32639e 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -47,3 +47,26 @@ "widgets__weather__current_fee" = "Tarifa média atual"; "widgets__weather__next_block" = "Inclusão no próximo bloco"; "wallet__payment_request" = "Pedido de pagamento"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 80c6456c8..cddb23f94 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1197,3 +1197,26 @@ "widgets__weather__condition__good__short_title" = "Благоприятные"; "widgets__weather__condition__average__short_title" = "Средние"; "widgets__weather__condition__poor__short_title" = "Плохие"; + +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"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" = "Custom Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; +"subscriptions__name" = "Name"; +"subscriptions__name_placeholder" = "Subscription name"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal."; +"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; +"subscriptions__subscribers" = "Subscribers"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 2b9be7d7b..73fb055e8 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,5 +1,6 @@ import Foundation import Paykit +import UIKit struct PaykitPaymentRequest: Identifiable, Hashable { enum Direction: Hashable { @@ -180,7 +181,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 @@ -192,7 +194,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 @@ -336,6 +338,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] @@ -385,6 +407,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord + func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -440,7 +463,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, @@ -534,6 +557,87 @@ struct PaykitPaymentRequestService { ) } + func proposeSubscription( + _ draft: PaykitSubscriptionDraft, + to target: PaykitPaymentRequestTarget, + savedPublicKeys: [String], + expectedIdentity: String + ) async throws -> PaykitSubscription { + let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() + let validationDate = now() + let name = String(draft.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(256)) + let description = String(draft.description.trimmingCharacters(in: .whitespacesAndNewlines).prefix(1024)) + 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 + } + + let iconURI: String? = if let iconData = draft.iconData { + try await sdk.uploadProfileAvatar( + bytes: Self.compressedSubscriptionIcon(iconData), + contentType: "image/jpeg" + ) + } else { + nil + } + let proposalDate = now() + guard draft.expiresAt > proposalDate else { + throw PaykitPaymentRequestError.requestExpired + } + var subscriptionMetadata: [String: Any] = [ + "version": 1, + "description": description, + "benefits": [], + ] + if let iconURI { + subscriptionMetadata["icon_uri"] = iconURI + } + let metadataData = try JSONSerialization.data(withJSONObject: [ + "note": name, + "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 + ) + let terms = 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: acceptedPaymentEndpointIdentifiers, + metadata: Paykit.PrivateJsonObject(text: metadataText) + ) + 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 + } + func accept(_ request: PaykitPaymentRequest) async throws { guard !request.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired @@ -589,7 +693,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 } @@ -624,6 +728,26 @@ struct PaykitPaymentRequestService { return formatter.string(from: date) } + private static func compressedSubscriptionIcon(_ data: Data) throws -> Data { + guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else { + throw PaykitPaymentRequestError.requestUnavailable + } + let maximumDimension = CGFloat(400) + let scale = min(maximumDimension / image.size.width, maximumDimension / image.size.height, 1) + let size = CGSize( + width: max(1, image.size.width * scale), + height: max(1, image.size.height * scale) + ) + let renderer = UIGraphicsImageRenderer(size: size) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + guard let compressed = resized.jpegData(compressionQuality: 0.8) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return compressed + } + @discardableResult private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { @@ -654,6 +778,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) && @@ -931,6 +1056,48 @@ 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 + ) + if actionGeneration == stateGeneration, + isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity), + savedPublicKeysSnapshot == savedPublicKeys + { + invalidateRefresh() + subscriptions.removeAll { $0.id == subscription.id } + subscriptions.append(subscription) + discardExpiredRequests() + } + return subscription + } + func refresh() async { await refresh(excludingProtectedRequestId: nil) } @@ -1031,6 +1198,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 } @@ -1040,11 +1208,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) } } @@ -1085,6 +1253,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 } @@ -1116,7 +1285,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 @@ -1130,12 +1299,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) @@ -1351,15 +1522,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 { @@ -1444,6 +1618,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) } ?? [] @@ -1535,7 +1722,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 cdc09229a..1d4132043 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -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? { @@ -379,6 +383,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 +411,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 +432,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 +457,18 @@ 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 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 +484,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 == "btc", let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), @@ -479,6 +522,10 @@ struct PaykitSubscription: Identifiable, Hashable { terms.acceptedPaymentEndpointIdentifiers ) 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 } @@ -490,7 +537,8 @@ struct PaykitSubscription: Identifiable, Hashable { } 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, @@ -502,9 +550,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 { @@ -585,7 +656,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/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index c3fa47c91..a3a30662e 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,7 +167,7 @@ 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) @@ -160,11 +182,13 @@ struct PaymentRequestRecipientView: View { } } } + + footer() } .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) - .accessibilityIdentifier("PaymentRequestRecipient") + .accessibilityIdentifier(accessibilityIdentifier) } private var recipientInput: some View { @@ -177,7 +201,7 @@ struct PaymentRequestRecipientView: View { text: $recipientQuery, backgroundColor: .clear, font: .custom(Fonts.regular, size: 17), - testIdentifier: "PaymentRequestRecipientFilter" + testIdentifier: "\(testIdentifierPrefix)RecipientFilter" ) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -198,7 +222,7 @@ struct PaymentRequestRecipientView: View { } } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestRecipientPaste") + .accessibilityIdentifier("\(testIdentifierPrefix)RecipientPaste") } .padding(16) .background(Color.white08) @@ -217,10 +241,20 @@ struct PaymentRequestRecipientView: View { @ViewBuilder private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { if let contact = contact(for: target) { - PubkyContactRow(contact: contact, verticalPadding: 20) { - onSelect(target) + ZStack(alignment: .trailing) { + PubkyContactRow(contact: contact, verticalPadding: 20) { + onSelect(target) + } + if selectedTarget == target { + Image("check-mark") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.purpleAccent) + .padding(.trailing, 16) + .accessibilityHidden(true) + } } - .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") + .accessibilityIdentifier("\(testIdentifierPrefix)Contact-\(contact.publicKey)") } else { Button { onSelect(target) @@ -229,12 +263,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(.purpleAccent) + .accessibilityHidden(true) + } } .padding(.vertical, 20) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestTarget-\(target.id)") + .accessibilityIdentifier("\(testIdentifierPrefix)Target-\(target.id)") CustomDivider() } } diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift new file mode 100644 index 000000000..b22184a16 --- /dev/null +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -0,0 +1,370 @@ +import PhotosUI +import SwiftUI + +struct CreateSubscriptionView: View { + @EnvironmentObject private var currency: CurrencyViewModel + + @Binding var draft: PaykitSubscriptionDraft + let onEditAmount: () -> Void + let onChooseRecipient: () -> Void + + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var iconImage: UIImage? + @State private var isLoadingIcon = false + @FocusState private var isDescriptionFocused: Bool + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__create_subscription")) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + amount + frequency + name + description + customIcon + } + .padding(.bottom, 24) + } + + CustomButton( + title: t("subscriptions__choose_recipient"), + icon: Image("user-plus").resizable().frame(width: 16, height: 16), + isDisabled: draft.amountSats == 0 || + draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + isLoadingIcon + ) { + onChooseRecipient() + } + .buttonBottomPadding(isFocused: 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)) + } + } + .onChange(of: selectedPhotoItem) { _, item in + Task { await loadIcon(item) } + } + .accessibilityIdentifier("CreateSubscription") + } + + private var amount: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(currency.convert(sats: draft.amountSats)?.formatted ?? "", textColor: .white64) + Button(action: onEditAmount) { + HStack(spacing: 8) { + MoneyText( + sats: Int(clamping: draft.amountSats), + unitType: .primary, + size: .display, + symbol: true, + color: .textPrimary, + symbolColor: .textSecondary + ) + 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) + HStack(spacing: 8) { + ForEach(SubscriptionFrequencyOption.allCases) { option in + Button { + draft.frequency = option.unit + } label: { + VStack(spacing: 8) { + CaptionBText(option.description, textColor: draft.frequency == option.unit ? .white : .secondary) + .frame(maxWidth: .infinity) + Rectangle() + .fill(draft.frequency == option.unit ? Color.white : Color.white16) + .frame(height: 2) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("SubscriptionFrequency-\(option.rawValue)") + } + } + } + } + + 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" + ) + } + } + + 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 + ) + } + } + + 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 { + Image("house") + .resizable() + .scaledToFit() + .padding(10) + .foregroundColor(.purpleAccent) + } + } + .frame(width: 48, height: 48) + .background(Color.white08) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(t("subscriptions__custom_icon")) + CaptionText(t("subscriptions__custom_icon_description"), textColor: .white64) + } + Spacer() + Image("chevron") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.white64) + } + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .accessibilityIdentifier("SubscriptionIconPicker") + } + } + + private func loadIcon(_ item: PhotosPickerItem?) async { + guard let item else { return } + isLoadingIcon = true + defer { + isLoadingIcon = false + selectedPhotoItem = nil + } + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) + else { return } + iconImage = image + draft.iconData = data + } +} + +struct SubscriptionAmountView: View { + @EnvironmentObject private var currency: CurrencyViewModel + + let initialAmountSats: UInt64 + let onBack: () -> Void + let onContinue: (UInt64) -> Void + + @State private var amountViewModel = AmountInputViewModel() + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__payment_request_amount"), showBackButton: true, onBack: onBack) + NumberPadTextField( + viewModel: amountViewModel, + showEditButton: false, + isFocused: true, + testIdentifier: "SubscriptionAmountField" + ) + Spacer() + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) { + onContinue(amountViewModel.amountSats) + } + .accessibilityIdentifier("SubscriptionAmountContinue") + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .task { + amountViewModel.updateFromSats(initialAmountSats, currency: currency) + } + .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"), + icon: Image("airplane").resizable().frame(width: 16, height: 16), + 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") + .resizable() + .frame(width: 24, height: 24) + .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("wallet__payment_request_sent_title")) + Spacer(minLength: 8) + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + Spacer(minLength: 8) + DisplayText(t("subscriptions__proposal_sent_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) {} + .allowsHitTesting(false) + } + SubscriptionRow(subscription: subscription, now: Date()) + .padding(.top, 8) + Spacer(minLength: 24) + CustomButton(title: t("common__ok")) { + sheets.hideSheet(reason: "Subscription proposal created") + } + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheetBackground() + .navigationBarHidden(true) + .allowSwipeBack(false) + .accessibilityIdentifier("SubscriptionProposalSent") + } +} + +private enum SubscriptionFrequencyOption: String, CaseIterable, Identifiable, CustomStringConvertible { + case day + case week + case month + case year + + var id: String { + rawValue + } + + 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..8113d9e48 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,25 @@ 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 + $0.isPayer && $0.isExpired(at: now) && $0.wasAccepted } } + 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 { @@ -81,10 +89,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"), + icon: Image("plus").resizable().frame(width: 16, height: 16) + ) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } + .padding(.bottom, 16) + .accessibilityIdentifier("SubscriptionCreate") } } .padding(.horizontal, 16) @@ -152,6 +172,12 @@ struct SubscriptionsView: View { SubscriptionMetric(title: t("subscriptions__active"), icon: "arrows-clockwise") { BodyMSBText("\(active.count)") } + Rectangle() + .fill(Color.white16) + .frame(width: 1, height: 50) + SubscriptionMetric(title: t("subscriptions__created"), icon: "plus") { + BodyMSBText("\(created.count)") + } } } @@ -162,7 +188,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 +220,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() @@ -277,7 +303,9 @@ struct SubscriptionAvatar: View { } var body: some View { - if let contact { + if let iconURI = subscription.metadata.iconURI { + PubkyImage(uri: iconURI, size: size) + } else if let contact { PubkyContactAvatar(contact: contact, size: size) } else { ContactAvatarLetter(source: subscription.counterparty, size: size) @@ -358,7 +386,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 +396,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 +437,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 +448,7 @@ struct SubscriptionDetailView: View { request: payment, subtitleOverride: payment.createdAt.map(Self.dateFormatter.string), isHighlighted: false, - paymentDirection: .incoming + paymentDirection: payment.direction ) } } @@ -460,6 +500,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 +512,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 +578,7 @@ struct SubscriptionSheet: View { } now = Date() } - .interactiveDismissDisabled(isAccepting) + .interactiveDismissDisabled(isAccepting || paymentRequests.isCreatingRequest) } private func review(_ subscription: PaykitSubscription) -> some View { @@ -737,7 +803,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 +823,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 ) { @@ -910,7 +982,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 419c191a1..463c9f55d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -319,6 +319,116 @@ 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 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 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( @@ -2453,6 +2563,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathsByPublicKey[publicKey] ?? [] } + func uploadProfileAvatar(bytes _: Data, contentType _: String) -> String { + "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg" + } + func proposePaymentRequest( counterparty: String, counterpartyReceiverPath: String, diff --git a/changelog.d/next/subscription-requests.added.md b/changelog.d/next/subscription-requests.added.md new file mode 100644 index 000000000..6da1146f7 --- /dev/null +++ b/changelog.d/next/subscription-requests.added.md @@ -0,0 +1 @@ +Bitkit can now propose recurring subscriptions to Paykit contacts. From d6f259e7982e1ace9caed99dcc5ab89bdac0ee16 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 14:21:16 +0300 Subject: [PATCH 2/8] fix: harden subscription request delivery --- .../Contents.json | 12 ++ .../subscription-default-icon.png | Bin 0 -> 17038 bytes .../Localization/ar.lproj/Localizable.strings | 10 +- .../Localization/ca.lproj/Localizable.strings | 10 +- .../Localization/cs.lproj/Localizable.strings | 10 +- .../Localization/de.lproj/Localizable.strings | 10 +- .../Localization/el.lproj/Localizable.strings | 10 +- .../Localization/en.lproj/Localizable.strings | 10 +- .../es-419.lproj/Localizable.strings | 10 +- .../Localization/es.lproj/Localizable.strings | 10 +- .../Localization/fr.lproj/Localizable.strings | 10 +- .../Localization/it.lproj/Localizable.strings | 10 +- .../Localization/nl.lproj/Localizable.strings | 10 +- .../Localization/pl.lproj/Localizable.strings | 10 +- .../pt-BR.lproj/Localizable.strings | 10 +- .../Localization/pt.lproj/Localizable.strings | 10 +- .../Localization/ru.lproj/Localizable.strings | 10 +- .../PaykitPaymentRequestService.swift | 91 ++++++---- Bitkit/Services/PaykitSubscription.swift | 62 ++++--- .../Services/PaykitSubscriptionProposal.swift | 43 +++++ Bitkit/Services/PubkyService.swift | 10 +- .../CreatePaymentRequestView.swift | 37 ++-- .../CreateSubscriptionView.swift | 163 +++++++++--------- .../Subscriptions/SubscriptionsView.swift | 11 +- .../PaykitPaymentRequestServiceTests.swift | 128 +++++++++++++- .../PaykitSubscriptionProposalTests.swift | 64 +++++++ 26 files changed, 557 insertions(+), 214 deletions(-) create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png create mode 100644 Bitkit/Services/PaykitSubscriptionProposal.swift create mode 100644 BitkitTests/PaykitSubscriptionProposalTests.swift 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 0000000000000000000000000000000000000000..8301897232a103d857bd780976877a6b243e5f01 GIT binary patch literal 17038 zcmeHv`8U*m{I3}kqtKx2vTxb9>`X~^Lb5MeligUePE;iOE{4id$d;WL*|UtM7&{4* z>}22Wds;sCoO|vcaL+y89~|TTel5@E=Rn#V*$a3YPH*Y5;?R&z+3?TsqSF``Jxc|m6w zq!;=sFY-D>A3`j%SNwy|n2Pn$!Bub8D5Y~WS2^@H*&{&uVr&+X7e0pD5Os!gkl4eM z=Cj-O&e|CYk9=f4H2Ir+E14;GZ1i9MaCg|a=E1u!AKFLh!5}D{2n-7+g0K<&&&U6d za2SFi(~GSI{@EAK1DWPkW@R;Qz4gz~>KW*{tyTM_R-QL?2NICKBtUjRONGNig>z&M zM1t2b%@_Y$fdf*T$ac|h2(3DqDxCOF3Fvb)vIRSx<9`Ej}?go*) z=zli=U{b%XA7Ec4@4q=}(%XyucjLAMQfGK(7^}~_eJ|dBrWW*1syrTW(A(Ug{C3@G z2NowL*9-qC*a@LDItop)HU-vN9i^R!{BJD~lKbl-Kd{zHaa^5SE!BT(ae-ZmbNcd) zbfz6@T$tScGY~oof-3*+k&-5*VLTP*Rx<$o=aDMZqk7$neD5A&g_}YqTqmghdF*+Z ziS|+G4YS=23;&?qwidR3^wqv$V;T}#SiRQUSbVw?6LRApj6bg15WBS+m<`)c-f7zH zZDId!23tkR^bEV(gQT)X>mx_j|Muu2V=g^oO}(o~COy>>QeFO6DmEAJmxU8DY$5Mr z<53-Em*ua1_G&hl1A`NRouyMwSDG09iiC89y0YuhsjFl@e^J#=-VgpeoJ((Zcxyw+ z*)ZAt{z=y3%zwkV$g~rUpK9eMJh?_D?G~Sz5zFvbRHQvrkXx^X)9v9aiVtBD;sSqp z3sXeaEVM0Phj?dhuSf_;sQgv=Gj#w`u+^0D(>!Agi=(68Kl$ar9%-Eywd^u|RNjAC z>4*Il=;n2s;!=fMjN_4vC5n#Dh5v?|fcyHY5c!q5wJ*or3LRtq+W8S@MWW#0d&#dF zl73D7BhUZB1{Z}`acHe2bt&~eac^aKA@!Fq_p{MK_HA~6keE9w5-t*Ie}#;NnOJ;4 zap^eN#tvM3?@tcK+>K-SD=2*yT9D;H-uY*;)$Gx@thnpO<^o@{m;-Z{J;aZ*DGCk)X z9wLj#TwfW7B^N4@Ew1I-t(g3~UYQ=#Hiez!?Y21fFJ(devAHg-1F$#lwMYn zce1nDp6-9JOkhLe`ie*k-aXF6ZJ1;b4^rTve=vv!oGz4E$kOD;0W0l^H@aai`}%J) zve|p)!9NJ9~Wig0E;BOtJ6BVyw9xMWUOs9#vM1ki1D_^Vw~^^2(sU>BB4-bE+*F0 zU4a$ZP`}s=*J<9+)BdCRp2A$rAUFQfA32fK8Ny91T&u?Yh!fYA`clCh?Pg4{b6NJ` z!Rn-U+#h#>!9YA^)5(6HqhlW5@krZF-Hs5<2QxBe5Y}L`1PA+0g?wb(DRBwaTpHYA zq$mhgF36Y1Zjb}X0uG1B1im}s@@=xc)79&etv#0GQJ&0C{J&?PapUTP-yt4dJ=&5I zE?Ti)L7dAD8od6;dJi}%@(;g}T$|!8D0a7b|2(5pl_k>peRTlbt-M^Jkr_Y!x5In>DhQxxVyyD0eLSUfx(6&Oe%a zshJb_W6~#%<2SW#b08ZYEVDb8Ylz}jbrics;sJUCYh>#RD>qc0sPAS}Kd`vItdCyW8;|co-?^rF7)|~c1hd&CCuvIke zHW-eVyjg5KaTgmy5QXZj+D^FIm2EBo;*H7vNl zuCLt&q;x+V`{~Np{Ruu9aGNL7xqPceaKv;ds=ibPY24%CR^m1&>-ZtqKTaO^XBZ4= zR6wT{ba^1VF6_ERp@tm|kXaCMmLfAb%Y%Ug$>1Z9+N3?4X3*t6i#lZv9#XCZ1|Z4I zGj>~r%Hwm62>yc_GK%})G^54!>#V)Ffy28Jp z5t@@cx8C89CMlK4{dC2=yce*=72+!wuKe~zWImao(0U8+K+Wvl_R-OtxWu;~6VgYc z@z=|81GNKOf3KCqRZ-S__)ZeXF@sr;f2Ktlc5YnlXgppO7~@(hpi1yWE>#<~Ktzlr zZf?5I)_pSQH#pfZ5$MJ)kvto0Vj|l##{s?V8f6nQVKkd$-6Ly^!drS}wnuYGOpPB*|^A%}br%qOU6lX!-m6>R|GncXK7tbFr z^xHfMkZF;UE9r}7`9TLKPGVzxJyDOHQ%o~WMNkdyd!t+Qzf&MY1>O2BR42>j-Wq;S zcCIQ|H1SzCOhTe5)D>~kZ#Q5^Ng)3aA5!Y;kw|_6?f1~ou)7vAF+uykB;_Smx@B+} zg++zk-!U{67Et!^gH@Ib)!M>dyY1HEEsi9|A|OkC2*5W^*OddILdS`ipt1=?5=GkI3{0ij_%Nv~3|M=2`^ekNv}p@EKY<*}@@l1@ zKg{gT@8%)Ipgd}D-<#nt1r4-gfM9ACX~mIpv9;8qcJJ5dVl0%mM}N`(wt0%?Z03X2 z^Uh_RHR&^*f3NfDXR3zk`7&HL`B`hZ?Z$7pqVaSh7&Q`g;g-#5t^$*C*em?$U1I-@=I^ zo#o~NoXgHt=g!bY_g#G)?F~1AFkk4-x9;}_&>gj7AfPJ>RGihgbAUk|0d~OBmx%T4 zi{!{;c;a+gM;8vq&dtq<{dV#nS~e0PhyH*TRp~RQnme;C^K(a@OqtE*!H#=1qhfQs zC4-yGVyl&ZMr+!5xP34lccJe0Jz}xlnk(y=o03s0JmL`#JzitqZ2Uaz-Tg_dPs%#z zV6}2pERH~~`AHG)=4{ChZ^cu^Hy))?%Og5#KPs1DiHFVswpSdYIbn$fBZqn{| zNheyHPamw66?AW9@hmK+uZnD;2fMX!6k@Arom=qBywVO&?-%2?HleVnxC@w-%@FMJ zX}vGMfv(;CG|a$)R*O$$Bvqu9g>A&xC0B=LYeLLNu&GmK&+W=l83?ckkgM^1GcSM% zZPBO7Ts$7?f^>cf%4dSG<)G?yRJ532l6H@X4TfNIe}p}YneH&4^Jf#41BJ01@-KCO(MLt0=QWm=hu%Kh zcG8O4QK+e`m%RMVseJnTObvTM?gxWj)8lY-4@8E&*#Dcns*j-@`-8v(L~V8H<0~ zIfr?1CP%l#pYDirU{?pv;cprp(7H77Pjic7FLk;?$#rd>_?Ja?f0V1U5Udj9e^ekQ;77Pyss5cD{slvhV1 z7;N>XQpuoJmer67ABVVKrO>yEz|T-&Q*4F&VbBg$8~)5AOK10U_hDgrbm^I1kKdQq zv;+>+dJeyK?MI~NULqa;LeZ#HF(2aXNL0l@Gk8hv3{FR`fUv6;fB+P+rRvmhO@HzhAj zSZxr=L~h1(`B{fv?y}vV1}8=*5JKKMK-GtQMz&JE`!ul8d_~n!{K!y&DMY#R@cNQ{ z%hSUHm8vPFe6EQe2E5su{ufn!QoS{V8t^LHY*AB)?)X=tiw!f@QGGuzi+U9Ic7EOb zj7`X-t6b$fu+24~!e=4Mpb-|kzOml#2pc6q_m|x9>Ii-g)nf%y3tEwA1av@Z&9KA~#VD0^jGkA}Oq8pN{BfrEkBiu$ zv5PJ<>kAWt&YE+~^U;rTWS6rR#t8`wcwSZMp5kVyoh^JgIc2+5#h&@(;ixM^T6?UM zdt$D9Bx5A|s<*HluOW*^`{Ba)0waNW=g69kkG9iQo%Byi>hpKgJCR%bMdp~ofR7mn zY=i8IedbTEvU_C?K+q5+0-e>$=w&^)PTyIM=yW&-c;{10u)f^+aYU8li#`UqVEHhKHAnoIb5CZliN)I&It+0(BH=(_Fi27na<`m|V5mVjwsoLgPu~ zChL^b4X%CpGGOizP%dWQzREL zwPuFTdI|@gqz&g!&Jq{Rpoa%1=3b`Kl`FGJm8T7-Z{}8MI1H)r6^I7wGW=ntORftW z7`X!7=eJW%S(Mv9Ita% z9ENQD-qpJf-@1A#U7mR1(&1;`M)^*!z5OpQ2Z-^Fh|rt)#lhNi>skYn<5t2a<~ejN~davGd0h|`6DKU0t1NwXG; zy@wuletq^T$hca6)qP)X{Fuo(hZ(Wr(n4R(rK6!htH9&^+(|jtQr=R93VKt`M#MBY zzS{Z`e&6(2>r%^YuT$R2G9lcV@W@*oawSZg9cOpe5XK328)pFQ0o%_L46zs&sYD#F zTontvE2+=iKP2rU?S5buRu*JCil~68zEAId(ybm>^D-AxR#5g93VFm+5m|avSZyti zKOBqibZE$XZQNh~c6>_jeV-JHeSbCj+ezm~+T-afoL23aCdPg5O5n+z!Sm7 zJB;IbWNj$Dm_FM!Ro8$BKJ2}P1BJ&}e{*eBg}VxlH|U#Qj?FSIo5jG{=)ss|ZfQ*w zCPAHv?Szbe3Leb``j5Ekdy5C$=RGvjljY16{S=;LUFm+gvu?JW6$8)n0w480A}x4H zkyNI@k<hH~Kjn5}@7Lcckr;Woa2E3HOG&X-#4WR3ldX*NvA)pVdXl1F+qRr! zw~9KUf+bU!lqaG@H{O4{<7HY!KbW_N5?&4_&Ki=j(_7Ej7k>WqT#iv^>~_Z1D92h3 zxkZd+G8OcWu1(0!h8jO{WezfCEzC%_b(-?-U4J^wXy0sdUHugUd6!AMe!JLgSEt#m zmGS5BJaKSS@xnv_d5a&(-m_i-f!EX8?Jr6uT=F~Q+)0#Nf@2*G-c(sBjwmel4ET|h z4U~O`LNs|dC~NlLq+G`hqLcG^`%>mFdp`kBT)}5-pFZqFyhx$G(^3InMdg-#E*l1h zv!gxGQ>0nEkp~80I;f;z^}&tHF6$ZHjj#Z?S5C6Qt50)cOi1() z*i<}?nT#3NHHVf_rT^q zqFnOHOZ`YrpRMM7lx$Z*`_ndQYnE=?cxKstxHHOcIw}{h5&CMS-x}wM^M-(lX*PU2 z_B1kK;|%*;FX$+=#o|~()GC<@I^;x1(ghY^7 z={M=wUC{iaq&bIc<>}Uwos|+J4ccri?bCkPl*qo6Cj5JsMi+T7vJ7&ZI$KxJaH*9e z6vZyw=Tkt7zcnMf!skpyvhspQWX2_|Rpv{5(eRa$EAQuBd5LlRUqerqGNHku^vIf; z7p>IxXdiJIGYf;p;lh~j@ljF@O6;V~Y})*o)tGK!sdvdpmk%yVU}Pm-RNH_7iKaYp znABQ%k73fzeE($wTZ#yGS03c0TIUHQ_{3Xu8iMZUTtr0~;>T|Rk~E=lVpI&0eW zq-LE#1rF|MQF<;?cm1-J1T0a4$dxZwCL;E8CzMbZ6nNQyn!!p5B{U$^OS-woG*@-= zhODSg3$t2j?+&{+I*esa1PC^@ie02{&o!)mmu*83Cy62{7AA5F8o2lDPnCpuSk(pB z1bT9}33}U7=I`TPb~kn(+xxQDVHBkU;^5R=8`K9rMUr*-sQ#Iz&iRGt&LF#kZDU)V zvh);Ur?)u{1^m(7&E2Q=JA^4}0cCJ%jt%#NtqjRJdtgdybT1EC=U7?u9nE{3INrXK zSGm@2o40iQ45;HnaN(~64Q6H#kMZPO-e3v)Gvk7{yep!oCpRtbK38a=83-%CwlU0e zpwu5gKTkj7=${^~L3a>@Bf>qpevENr#jv1M5};(IJTF8N7t^XE_LphC2S2j7T2;@S z$;;B=zl;3BC%f@xYX$DC`LVa^Lw6(|hT&Gf2|;n1mnX-G4;d_A=`)jx4M%J{ zVK04utoN-;sz$ZCJIjSQz0Yqk8&8elSBF5XIGNVd4HPCA_IYIFRbj0hJXJ$3@nW*p zBJqgSQh`evqfnsqnSElK6F4v1iMT+`hvctX<}`R_!y9pF-MzNlcHl<2#K?Tu2kp~W z3X|$tN&QI@WeoEMyu=tWluJT5R8YvgMTGmFJi{>@AY*F->CDptJ@sg5@*6!MYq{Dc zNp7zJ&s8YW^v3b}*5MEud#o z-@ni_q!w;i1s>9Q3w%6M*|z;uO?^?4g*R@ezvGRL@rX)(Cdi|v#Q83U^#t9x!d$O* z(ByQL*n9FKjvkj)Wm+(V!t-GC)z3i!&{})f@yYSihM;wx7Z$oXx!gK0`DRt7y@D+Q zk573>4OK&$7VCEtW>(3MVd5Ag^=L@*vlesBsv3!UE$v;tLn)Vr^A?{niAN_1Ydq{5f7%C_{4HQ-Z-npn8of>FT`-<*eP|Y-p=7K&j53PEWh}1(AuDv?l^jJji9rS0%z%tP{RD0qu8I z$CRhG+|E)t-bj?GTMYMb2rL6D-d09%hR>L{3j}9k{6ZLLNM&cDPA*Ov23n#4kT_$!})WQP<x81Wg;U9a6H!C9lxZ<#6@pu_XU0dPUVL55uZUEO(I_6JLWra!C@L%=HWm9=I}D*t|Xr=rt}U; zm~_fr3!F>-F``+@Q{UAvLba_+f?`rs*O#&-Bg;%q1Djp6o1i|CZ=^HnDW{ zHz2!p)C@|+H{>O^1$%8;WTv4`Y?APe1_5nh!k_Xp|#Cp%NS5E3> zW!3L)-fY_i!h1em6O*aYBQSps7wk`RK|qNbPy)*MiafyK2bxvHG5^?aQ=P5xtGY;hkbtZC6>%zzqq19x+)Fb9d(#k~C7 zn^d)bhaa?RzcDjcahVj+^6E-1T1kog!0(R&MgfHRXRRyTw!ty0m3`kjF0zA zhpjcp?0F;fp7q)4-}DmYEOM^1E9hV(nTLOn^^ED7qKu&Ua(~y`%_o*C9hjxNGs#%J z?`o7=qe%pWTrQL}gr#Aw>s_A8U@El>eFE{oe0XkSUD)PJremtI)UwQZBl(EDa%iU3 zP$SPu?A%tcv*wno>^2=g@g-o7ZCTIfU2|Vk&OZ?3L7iG0On zR4rb~da2Y>a;$&WwWyn5aM@zzVV@7jZ$ zu+@q!KJD?%IvS2WnaTM&?+e0g;gr8H$O$Q3!{B=Ic&ha){+KpYj7#d&*NGeh5wr;@iZTKT_GV+PV%*D`WA{mFQcr<-pq_r(bS>)mXNm}bKd8&aM@MA-?( zTGA!l;2qzp)W$~LPvH?DS(r5?b5Z58maic-&ID(+1(dLOd(1#;bSsIH{`q}f61u29 z&D2DX!fzp8S5FM)D2Tn;rkOVi8e8n8xxL=`4seUeGX%u#T38mB7LN$0ft!=tHs_MC zS10tgvUGN>L|fV30zd$r={5(+6ckEoSwvXIM2E=}W5!TTYDpo!lGem^5x%dc zcyfevG)B@S(zO`W0ght(fhoc2nyN07>0^g8Qkl(ePO}XYh=6;WI5cb8mXxRbZQjw# z*qwa--d*oSM}A@`K!-yh%XJ<8N;#hq?4{%9#uEl$M|oE96>$i!s@|2;Ip3p8!msoU zQD!?3Qujxt1?G%$uhuF_?o56si`Ry66BR9ix_gt;#v`ALT2m<}G(L5QXj_n2sqPdo zcRii_n5m!_*gQ8ee{kZT`bwvrPp!c88{?{(&M|Airm?j7}I13UBbThWy zbho%$xV^HvE0esp(IZA0Y?qVXZT{(@*;>}>c#fU+(ZP!BG$Q21@zZHV!Bu~oxLivS z9*i63<+B$Q^B+^O%-^Fi9XZTD`jOp67pGanoR>EUReN>@5Z5f$WzRHZZ=HePB)5rl zIK&pgxVP8KwmSCC^G*X8_8_BWMfGa#c@Z+#nwipP zSIQ)1CoSWY-|x40s543^@LF0;(fu8ii~;Kh12vOUEY7?ezb4P5+?-S;ucgfy-)ot~ zYjva_^8g!w5VK~hbp?`An;7P@&3<1yJJfbAZP;q^5su40rmYXflhk!#Mk1&sCGsgq z7EXByNNsndt+;h`EWTSV$5g1hSz~rk+Y=qFsq+s%c33YQb-78-g}u+T*l|!E*ABC?yDD9mD`b&(J%Gq=@c9SF&q~$Rnl%0M% z{sH3T(r_}j!PSJVPN|t1<)X>WNu?ztxAFn#C;YNg?c#3(1;@}XAt0q)GrWRqm#oyG80rD+~i zl1xPbp<2(+)OL6bupYmI$cZbiMSkJO_9KXRnIa|HJueAA|yLII)Bf|Bvvb4yo9;~7HLK? z83fVB;XN@6D!mWoeqM7vAF&a%WrrL2+WuW5H~PRrE5{N2NaoWnrI%0(u>ZgYVs%gs^CGPCMh zAi#(0WPfYm9Le6uRx~$z#{A~4EySyJWxDPX_?x7-Vd3#@MmvRy%=$#)rG;=0{w7Nq z?kY(w1^)obXfY=s7+mcxsmXv_2n5w#O@(jA4kVGargvQ;b>+4q_~%B#Pi!dmYX>3< zpOoP}itt?$*AYUA#DY3Hs}RFjGRcWq-Jhp-oym_Y6J-*K0~gWy#rmN=7MgPn9SBWw z*;UzbgfI78P@lCB1w%)4mB%gaX=&UK^Why%Y(j$_(ApHn{-K2YqK=vU%tnl^fi-;qX5{H*D*`nllc%m-Ub5o*HC1%~95=9QhQF`lHZj#82s($cJ^o=QsBX#OG9*2A1fCuiv-ib6@hbUVRtCeuB}6!%EjG0B%NYL@s-`)7|ULF4-*I0{{~OX5UUjg_C+kbf^k%=)Eb zEI$dnY^$Dc=tbNa4pziH9Hh_;rh9Xh<*a7hT7WNJP1;dQifY2Lu=u%{o3Z?p*$Xqb zz0rHcA}4+|uhZuf%{Q1A8j3XsQ7#cFdOgKgnU#%Jg&Fd@+nIJAusqDIK5tb`W(?pF z#ZDS$NKv{n=-Zx-PmKFzu^?M={uE^wad`&3rR{7Qr~I<^RKUdJsf6n@N_mS1GoR*e z$`4fe2%hnuJpJXk#DWbfIMor&KXT|u;XTdTCRvg)u%2wv_w%F0uR$lTtU@|M-Gca9WYxFM7Y! zA4X@3Z9%5}@R`>QKWCmiSs3t|LhOiju23&g8C-06ixuy5+HBEznD+HTl*OE9P4G#n zPi^%|dDsaZ_8L~DfKHt?ZSkX&pYr;hQor;<9#mghA|(?mrT}I5I*G2=Ecb{0*QE=- z+Rp^`SD)DEr?4E+Vd~ubo5w?&zDggy(K@F4sDv%oP@HWVyS!biweaO0b|ByPSwoA_ z&s+-I#q^j}E1z-!9UXkcLIn{=*x`n0z}pCJ$%f))SrC4WLwCBf36BR;>j;!*8Z3(S{l(vz7m;5?H#ItH%SE6csJ5a%fCN}WVYkz^}2md@_R_#F&`b)q{T zg+8-OclKj=Mquhsu+29U!h?%PmDf^(#KX(?*H$a+&itpPoa{lPQmd^Brx+ zTyDi*in^EFbXfjlU0nrLy_Oo%qU}uKOkS1xe8u?1r$Om)rc#pp7NU*OjcnX{krKWD zKgiY58fL+nBSRGItQQp)XaveR2X{K=T~x7L_qZKyZLON%av#C8vnk+gSxT~s*B-0( zqc}DG+{rBU@SV?X>#=gPyRw2r+1;ok1ybGqBA05RhV9vF>4v8l?!A2P^kYXHYY}-4 z{c2RCAIWoiR!r^gIIkgXw9Cq|!+l*x)OB^6c5K7Ud8I0!S;lJoCsX;0Z;5?iv|Se> z5C8FT!=fw};gB@v!f|qW@iVqF=~qRap&$`0_rXW|^6jv*ML*B=jhhF(Sykh_W5d0b zHRZ{}dz$)5%Wl8ScRsgc?Js+x9n z;!>LB!>2rldb=s47xLdJ86S*?6a;*zoqT9u^(M?C?QU;EZP50B8))OsNNntaoV4&S z<)t%IqF+}wjA+|u1WHD<Qy~-|x z$8p)K$U#hqd?7-|ZZBYl^q9SCu^^bv9CPu9G*XC2DwH z@%-2xG!#-{Yy>GcbbptuA1dTC+{$}JKnVE$wtuymMPg?)t2{AnAbIzpgac@{UnPkH z4bIyO7iprOF7EIB5Y=oz7!4nv6KfmgP12Zy^)^V`LDfCg3urfr>wwIs^Er0*n7-QaMlbY>7OQ3PGM{?}#bUn%SB3W7imN3 zz)^s8zC6ep9QfX6sM)@Ct`vX}^8s787;x8P3Fa&c1(SX{F9J?jgD5z!3Mw`ob?dqB zI8ApSaZHg-mJPI+vk|%%Y;3?2&U&YeZ=ST(k;H-Jb!i@*;kfBO4CJy$Q0-^=*(n&e zhkFo_^j+V57DaR+{K2!w>zlcKG~9jKd+o!cAIbX56Kx@m&Uze?swEbyjStn+=3~LKbXsa-?txcqKz+1Ax?d5pr5a6^du z?e3OfY9!nT!1gAcIQbRc4GMK&9q_y@8xsB87Px%rd*}2$*amT!?#1>?rQzt&_>S2t zGH|wQfbDE!=y$Wwd-)e1fx$>vfV!UyFYvBNHT)d>&bN=FUSR8|5D1tPjUhswgCKZ# z(O?_zTPswwAmu{8P!pbe6cALu2A&6uybrA)isHFLFQ+@}ba+N~AI>HU9{fM=*aCvx z2G2X@A=^sV2wsB!IO0im+rB+kXDu!9MS~OE{`5Ujl%P%Ki{+!Tv#SV_dRAc&z7TN< z3BN?pkBa9xGp}dnX%RY!@+GyWyPO&z>~%ocOZPtZc!66Z0AYFIaU@sR`ps{KpF?xd zHlNQ8&uw`EXR`;--|zX{R!dNN+;fA+6MPAWCXNiWo9EMjQ2?NSjy$iTI{T0h&gKaW zodtbtRt2^cv~4TdAltwh?TdYyy5TLkM;_G^MoUC>P5@ze#(j3O;tK(@(|E?o8B;+a zdVnMw>>tryVE>Ps7Hu zXG+v@NyxRJK8g4xjZGrs!^nztX8>2!t+p5_w3G=Mn~@=nG!1Nu-xDK{*08$5SB*V` zgamkrWYf4Z&VPGC^a7ZLzyP*;DriVrs@udlSJuJ>9h;yAWeE>R04oh(5i!*Z<~lb1 zqW?G2tk9M@o21DCqcVBnu4@2fLbjt#?_i}wz$UlQn75-mqAx^A!69T7L_cDlUL@uc zHM;hJ58foo?*VT56!Oz~S{9bG$K~q7-flxRTXGl6CJCOmzfaYM(g3DpT%wYPsE6A! z+1wVFj?V5^0NMKFo`XNvZ(lUMQNc~m27@BsIdsb*M;|f3=re#=g4D0Hvw=)s&QQ!q z7D4*sm*r61+JFK2Kle@S;(AYpw{L~-fnX+v*N)AJ{A|FG^E5V~+w=^ikQ+8nRsz)6 zYHe<{Kq%9eTh57skv|CTBy3MVd1Ds-!K!#cQH1?AFS1~G4CL#lZVls026hy8#@?NR z+%T! z0+8b?Ci_YW=d66y$;{8JqJ`&V`vzaW7km+S3}220Xq8|B_j2mzg|tvn!uT@7J!ER^ zdfl+h5JqlbPyJ&bD=MU3iRIeRU8+g)7rw$F;wr6PvR;6O)E7Mq*WPio#(x6j7tRew z^vXVlv+)CWlFsuER>L+_%&exjQ0XxGC*g=F(o=GkIJ^HmH~|uj9qBc&z7hqup$8AL z{O)0fdjsNf(HSs5V6S*(6{aTl+JQ-gT@(t!KSy-SM#9-N2#b>o=+?jjP*!2T?fK^} zDH2{qn9F3w?85FDWwky**!YLqpC;@CNdO^@r*wETX!aW%Q*|?*8`4PS87E^51G*Xj zOK!1uwRwU2ex9vsriViz4`H4_8&kFb(2GEbI0vxrHW!fZejRQLtp}1J?}C5#d?N(` za`kZZ%Z%MX<9ya}gbhVOcnIz&`GP3{SEG6_zif2+ud}Y9aI_2v{|Es_!tVl7+Fy3h zX5p=q;d(^!T8$y@aZW$hFLc=rXoNA4u%=G{ zbiI#nYYHY4I<(oTw_fm>e zGA=`LE?jFp=wR04Wd}x3!-KO~kP8c&4OpS~+X|LXb2)L%73RX0berVhPJ&MiKIg>p zR=l!M&}%e3EaO@KM!Pk1q49Fk-X&7@+X;YQJVK8uGJys9zT~PmICwU*&)g{tVy`o3 z7PgUv6mz1%d2+SY%*sfkg-3KCrml5OzbgUX7DwMD<=|z=ksx3Tdf25V2Q3?lz|a<- z6IZehd_A2j>ex62<0JS49zyVu?kvv zd~6xiO2Ex;-Ovl|9J2_JaFUXQ;QOlDuGRy2o@GZJ5dJ?ES->2kT0hC0IQg-HAnTw0 zFMZxQ4&?bmJJbL}1&g)Mi9+}N9rY9@1>ZCT>{Etq7nDdlffZHQtN?P%#j>PH>!H&# z2hz7z!Vm4fksj?wD$i69n!=tC3#h z(-){m4F_`gEsG}|ohiV9)By3FCN~=y!KjSEgf%0bZPU8O_~tXD&t@tAtO7)n3ozHs z49RmKstw4F;!?{HUo3ig7+Y8HMaw1e39yYAEfE(8@`-mg`sbac&;{un%a}9v_*R~r ztl#E*2AJ~=z370G7MfmOVC;TG`H02ih`!Zyy zt)M1DPh~y1q+6qC{<}EpMjipLp9?x32Y?8ErN5N75h3^EFX9q&84vGMMiN0tfCmE` zHtiJ%GLbC!h7hxux4@FLXNrY_c!=~S3_Jj2oS|Uy3N}_SYg&mK{=54?=!C6j$V zO5n8Q6T)mhS7r$EC0Q_BE!66h$3zS)t24b|-J&|$7-*j#1_*aYCu5g=NMjb}wiVgE zAM|spCD7I@oX}$YfC~;Prn+WHpqu&5LzE@~Kb8?zJ- zPlTM~{Oez^w}y0g$O8RFu>0YF9ZB-Oa07O1zE!SBwdfOn#J#W{pC6^>nwfwDG>n^Fx8yvA42M zlwnbczzF)+PX^vnk6w?<5wYaIPHLR2w_SkI8j`kn1Wy6vyUB3vxo!sf+SV$^5+m6` z{cgRUIoCW*8Jz7Qu>Grx?37&UHZ%vn43|nF!4q&L3&AN-khLcWtU-vIcVxVH*ul$v zbRytu&qj*|x1zw1xlwEnbnnC_wKxPm_ z8aW)D!{XKE+jXmUc(Io`s~8N~6IclG@G5{jq)(IoG(>EDd`A@ zp1OjTZe>CrE*uyEx&HAcFk&MjLN~Fc!NL5C$i*ZOuLdHt>fRTzyfEk`9>4=c#VNz# z-ysU0QGAN^URqsUN_uKSU_Or|f(C0}W43(HVig>G{o(zA6D$jG-T$%8y^!vGzrhWTnUoIz(djg&E`PtRlSaFvZx}>87`n>9@zspSd>C|#zi1n zczj(h7Vg}xIcez%n@KwjAOmzHRAU`&?LJT+_S>BU0l~(ttXkIN1pBjRkKkQ$%DUzgr`Y!glfF%Vd-U*UBb zECivQ9``S>NJF`4TTjc8+%6( z5rI|+{|kVa__xIlsq5?3i-Mv5b4vf;p=H47{y+Js{uF&iT4ESSsbMv#PxxOD8mhWC J%Wl{_`d_n;H(US! literal 0 HcmV?d00001 diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 7b3d0d1e7..4202b9505 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -72,22 +72,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index b3c4e4870..5b8656838 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1028,22 +1028,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 1c3d40710..eb4be01b4 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1186,22 +1186,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index c701e24dd..c8e8f6583 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1182,22 +1182,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 7f322d0e5..fb15f0bc7 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -890,22 +890,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 3ce580a91..b8bc5e5e4 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1534,11 +1534,13 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__daily" = "Daily"; "subscriptions__daily_subscription" = "Daily Subscription"; @@ -1563,7 +1565,7 @@ "subscriptions__monthly" = "Monthly"; "subscriptions__monthly_cost" = "Monthly Cost"; "subscriptions__monthly_subscription" = "Monthly Subscription"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "subscriptions__ongoing" = "Ongoing"; "subscriptions__overview" = "Overview"; @@ -1576,9 +1578,11 @@ "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__proposals" = "Proposals"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 3850ff053..8cabd23f5 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1198,22 +1198,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 5f19f81c4..184992f0d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1057,22 +1057,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 10b74e169..8683ad49a 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1212,22 +1212,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 900b26e9f..92990f585 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1166,22 +1166,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 8caca8ea8..24bb3af90 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1206,22 +1206,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 29252d1cb..92bc101d4 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1213,22 +1213,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 824acf630..10418ba75 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1214,22 +1214,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 43d32639e..ee8ee8c57 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -50,22 +50,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index cddb23f94..6cf1f6d93 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1200,22 +1200,26 @@ "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" = "Custom Icon"; +"subscriptions__custom_icon" = "Icon"; "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__delete_subscription" = "Delete Subscription"; "subscriptions__description" = "Description"; "subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Name"; +"subscriptions__name" = "Subscription Name"; "subscriptions__name_placeholder" = "Subscription name"; "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."; -"subscriptions__proposal_sent_headline" = "Sent Proposal"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; "subscriptions__proposal_sent_status" = "Proposal sent"; "subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__subscribers" = "Subscribers"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 73fb055e8..17e145853 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,4 +1,5 @@ import Foundation +import ImageIO import Paykit import UIKit @@ -379,6 +380,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { case requestExpired case operationInProgress case amountMismatch + case subscriptionTooLong var errorDescription: String? { switch self { @@ -390,6 +392,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") } } } @@ -407,7 +411,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord - func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String + func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -565,8 +569,7 @@ struct PaykitPaymentRequestService { ) async throws -> PaykitSubscription { let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() let validationDate = now() - let name = String(draft.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(256)) - let description = String(draft.description.trimmingCharacters(in: .whitespacesAndNewlines).prefix(1024)) + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) guard draft.amountSats > 0, !name.isEmpty, draft.frequency.isSupported, @@ -581,10 +584,17 @@ struct PaykitPaymentRequestService { 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" + contentType: "image/jpeg", + expectedIdentity: expectedIdentity ) } else { nil @@ -593,16 +603,45 @@ struct PaykitPaymentRequestService { 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": description, + "description": draft.description.trimmingCharacters(in: .whitespacesAndNewlines), "benefits": [], ] if let iconURI { subscriptionMetadata["icon_uri"] = iconURI } let metadataData = try JSONSerialization.data(withJSONObject: [ - "note": name, + "note": draft.name.trimmingCharacters(in: .whitespacesAndNewlines), "subscription": subscriptionMetadata, ]) let metadataText = String(decoding: metadataData, as: UTF8.self) @@ -614,28 +653,14 @@ struct PaykitPaymentRequestService { anchor: timestamp, endsAt: nil ) - let terms = try Paykit.PaymentRequestTerms( + 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: acceptedPaymentEndpointIdentifiers, + acceptedPaymentEndpointIdentifiers: endpoints, metadata: Paykit.PrivateJsonObject(text: metadataText) ) - 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 } func accept(_ request: PaykitPaymentRequest) async throws { @@ -728,21 +753,17 @@ struct PaykitPaymentRequestService { return formatter.string(from: date) } - private static func compressedSubscriptionIcon(_ data: Data) throws -> Data { - guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else { + 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 } - let maximumDimension = CGFloat(400) - let scale = min(maximumDimension / image.size.width, maximumDimension / image.size.height, 1) - let size = CGSize( - width: max(1, image.size.width * scale), - height: max(1, image.size.height * scale) - ) - let renderer = UIGraphicsImageRenderer(size: size) - let resized = renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: size)) - } - guard let compressed = resized.jpegData(compressionQuality: 0.8) else { + guard let compressed = UIImage(cgImage: thumbnail).jpegData(compressionQuality: 0.8) else { throw PaykitPaymentRequestError.requestUnavailable } return compressed diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 1d4132043..f08376708 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 @@ -202,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] = [] @@ -299,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) @@ -320,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) @@ -527,13 +537,17 @@ struct PaykitSubscription: Identifiable, Hashable { ? 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] { 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 70af505b8..87496a24e 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -486,9 +486,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/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index a3a30662e..f1089236d 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -241,18 +241,8 @@ struct PaykitRecipientPicker: View { @ViewBuilder private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { if let contact = contact(for: target) { - ZStack(alignment: .trailing) { - PubkyContactRow(contact: contact, verticalPadding: 20) { - onSelect(target) - } - if selectedTarget == target { - Image("check-mark") - .resizable() - .frame(width: 24, height: 24) - .foregroundColor(.purpleAccent) - .padding(.trailing, 16) - .accessibilityHidden(true) - } + PubkyContactRow(contact: contact, verticalPadding: 20, isSelected: selectedTarget == target) { + onSelect(target) } .accessibilityIdentifier("\(testIdentifierPrefix)Contact-\(contact.publicKey)") } else { @@ -267,7 +257,7 @@ struct PaykitRecipientPicker: View { Image("check-mark") .resizable() .frame(width: 24, height: 24) - .foregroundColor(.purpleAccent) + .foregroundColor(.brandAccent) .accessibilityHidden(true) } } @@ -286,8 +276,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() @@ -296,14 +288,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() @@ -324,7 +317,7 @@ struct PaymentRequestAmountView: View { ) ) } - .accessibilityIdentifier("PaymentRequestAmountContinue") + .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue") } .padding(.horizontal, 16) .sheetBackground() @@ -336,10 +329,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 index b22184a16..4b7c676d0 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -2,17 +2,23 @@ import PhotosUI import SwiftUI struct CreateSubscriptionView: View { - @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var app: AppViewModel @Binding var draft: PaykitSubscriptionDraft let onEditAmount: () -> Void let onChooseRecipient: () -> Void @State private var selectedPhotoItem: PhotosPickerItem? - @State private var iconImage: UIImage? - @State private var isLoadingIcon = false @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")) @@ -30,7 +36,6 @@ struct CreateSubscriptionView: View { CustomButton( title: t("subscriptions__choose_recipient"), - icon: Image("user-plus").resizable().frame(width: 16, height: 16), isDisabled: draft.amountSats == 0 || draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoadingIcon @@ -53,15 +58,15 @@ struct CreateSubscriptionView: View { draft.description = String(value.prefix(1024)) } } - .onChange(of: selectedPhotoItem) { _, item in - Task { await loadIcon(item) } + .task(id: selectedPhotoItem) { + await loadIcon(selectedPhotoItem) } .accessibilityIdentifier("CreateSubscription") } private var amount: some View { VStack(alignment: .leading, spacing: 8) { - CaptionMText(currency.convert(sats: draft.amountSats)?.formatted ?? "", textColor: .white64) + CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64) Button(action: onEditAmount) { HStack(spacing: 8) { MoneyText( @@ -140,86 +145,60 @@ struct CreateSubscriptionView: View { .resizable() .scaledToFill() } else { - Image("house") + Image("subscription-default-icon") .resizable() .scaledToFit() - .padding(10) - .foregroundColor(.purpleAccent) + .padding(5) + .background(Color.white) } } - .frame(width: 48, height: 48) - .background(Color.white08) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) - VStack(alignment: .leading, spacing: 4) { - BodyMSBText(t("subscriptions__custom_icon")) - CaptionText(t("subscriptions__custom_icon_description"), textColor: .white64) - } + BodyMText(t("subscriptions__custom_icon_description"), textColor: .white64) Spacer() - Image("chevron") - .resizable() - .frame(width: 24, height: 24) - .foregroundColor(.white64) } .padding(16) .background(Color.gray6) .clipShape(RoundedRectangle(cornerRadius: 16)) } + .disabled(isLoadingIcon) .accessibilityIdentifier("SubscriptionIconPicker") } } private func loadIcon(_ item: PhotosPickerItem?) async { guard let item else { return } - isLoadingIcon = true - defer { - isLoadingIcon = false - selectedPhotoItem = nil + 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")) } - guard let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) - else { return } - iconImage = image - draft.iconData = data } } struct SubscriptionAmountView: View { - @EnvironmentObject private var currency: CurrencyViewModel - let initialAmountSats: UInt64 let onBack: () -> Void let onContinue: (UInt64) -> Void - @State private var amountViewModel = AmountInputViewModel() - var body: some View { - VStack(spacing: 0) { - SheetHeader(title: t("wallet__payment_request_amount"), showBackButton: true, onBack: onBack) - NumberPadTextField( - viewModel: amountViewModel, - showEditButton: false, - isFocused: true, - testIdentifier: "SubscriptionAmountField" - ) - Spacer() - NumberPad( - type: amountViewModel.getNumberPadType(currency: currency), - errorKey: amountViewModel.errorKey - ) { key in - amountViewModel.handleNumberPadInput(key, currency: currency) - } - CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) { - onContinue(amountViewModel.amountSats) - } - .accessibilityIdentifier("SubscriptionAmountContinue") - } - .padding(.horizontal, 16) - .sheetBackground() - .navigationBarHidden(true) - .task { - amountViewModel.updateFromSats(initialAmountSats, currency: currency) - } + PaymentRequestAmountView( + initialDraft: PaykitPaymentRequestDraft(amountSats: initialAmountSats, note: "", expiresAt: .distantFuture), + target: nil, + onContinue: { onContinue($0.amountSats) }, + onBack: onBack, + testIdentifierPrefix: "Subscription" + ) .accessibilityIdentifier("SubscriptionAmount") } } @@ -302,34 +281,50 @@ struct SubscriptionProposalSentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__payment_request_sent_title")) - Spacer(minLength: 8) - Image("check") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - Spacer(minLength: 8) - DisplayText(t("subscriptions__proposal_sent_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) {} - .allowsHitTesting(false) + SheetHeader(title: t(subscription.deliveryStatus == .sent + ? "wallet__payment_request_sent_title" + : "subscriptions__proposal_queued_title")) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + Spacer().frame(height: 16) + 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.frequencyValue + ) + .padding(.top, 8) + Spacer().frame(height: 24) + } + .padding(.top, 16) } - SubscriptionRow(subscription: subscription, now: Date()) - .padding(.top, 8) - Spacer(minLength: 24) CustomButton(title: t("common__ok")) { sheets.hideSheet(reason: "Subscription proposal created") } + .padding(.bottom, 16) } .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 8113d9e48..f7eccd07b 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -265,6 +265,7 @@ private struct SubscriptionMetric: View { struct SubscriptionRow: View { let subscription: PaykitSubscription let now: Date + var subtitle: String? var body: some View { HStack(spacing: 16) { @@ -273,7 +274,7 @@ struct SubscriptionRow: View { VStack(alignment: .leading, spacing: 4) { BodyMSBText(subscription.note ?? t("subscriptions__subscription")) .lineLimit(1) - CaptionText(subscription.rowSubtitle(at: now), textColor: .white64) + CaptionText(subtitle ?? subscription.rowSubtitle(at: now), textColor: .white64) .lineLimit(1) } @@ -305,6 +306,14 @@ struct SubscriptionAvatar: View { var body: some View { if let iconURI = subscription.metadata.iconURI { PubkyImage(uri: iconURI, size: size) + } else if subscription.isCreatedByUser { + Image("subscription-default-icon") + .resizable() + .scaledToFit() + .padding(size / 8) + .frame(width: size, height: size) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: size / 5)) } else if let contact { PubkyContactAvatar(contact: contact, size: size) } else { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 463c9f55d..6d2cf051e 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -351,6 +351,85 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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 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) @@ -400,6 +479,44 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.subscriptions, [subscription]) } + 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( @@ -2478,6 +2595,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var receiverPathsByPublicKey: [String: [String]] = [:] private var liveSessionAvailable = true private var proposalResult: PaymentRequestRecord? + private var uploadCount = 0 private var processCallCount = 0 private var receiveCallCount = 0 private var processFailuresRemaining = 0 @@ -2563,8 +2681,12 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathsByPublicKey[publicKey] ?? [] } - func uploadProfileAvatar(bytes _: Data, contentType _: String) -> String { - "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg" + func uploadProfileAvatar(bytes _: Data, contentType _: String, expectedIdentity: String?) throws -> String { + guard expectedIdentity == nil || PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity ?? "") else { + throw PaykitPaymentRequestError.requestUnavailable + } + uploadCount += 1 + return "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg" } func proposePaymentRequest( @@ -2793,6 +2915,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { func snapshot() -> PaymentRequestSdkSnapshot { PaymentRequestSdkSnapshot( + uploadCount: uploadCount, processCallCount: processCallCount, receiveCallCount: receiveCallCount, acceptedRequests: acceptedRequests, @@ -2818,6 +2941,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)) + ) + } +} From 108d6be02d28e52918ee21dddbe9ec88dc082ff8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 17:28:49 +0300 Subject: [PATCH 3/8] fix: align subscription request ui with designs --- .../Contents.json | 13 ++ .../subscription-intro-clock.png | Bin 0 -> 31061 bytes .../icons/asterisk.imageset/Contents.json | 16 ++ .../icons/asterisk.imageset/asterisk.svg | 7 + .../timer-outline.imageset/Contents.json | 16 ++ .../timer-outline.imageset/timer-outline.svg | 8 + Bitkit/Components/MoneyCell.swift | 2 + Bitkit/Components/MoneyText.swift | 4 +- Bitkit/Components/NoteTextEditor.swift | 7 +- Bitkit/Components/PubkyContactRow.swift | 4 +- Bitkit/Components/PubkyImage.swift | 5 +- Bitkit/Components/SegmentedControl.swift | 11 +- Bitkit/Components/TextField.swift | 7 +- .../Localization/ar.lproj/Localizable.strings | 2 +- .../Localization/ca.lproj/Localizable.strings | 2 +- .../Localization/cs.lproj/Localizable.strings | 2 +- .../Localization/de.lproj/Localizable.strings | 2 +- .../Localization/el.lproj/Localizable.strings | 2 +- .../Localization/en.lproj/Localizable.strings | 10 +- .../es-419.lproj/Localizable.strings | 2 +- .../Localization/es.lproj/Localizable.strings | 2 +- .../Localization/fr.lproj/Localizable.strings | 2 +- .../Localization/it.lproj/Localizable.strings | 2 +- .../Localization/nl.lproj/Localizable.strings | 2 +- .../Localization/pl.lproj/Localizable.strings | 2 +- .../pt-BR.lproj/Localizable.strings | 2 +- .../Localization/pt.lproj/Localizable.strings | 2 +- .../Localization/ru.lproj/Localizable.strings | 2 +- Bitkit/Styles/TextStyle.swift | 7 +- .../CreatePaymentRequestView.swift | 18 +- .../CreateSubscriptionView.swift | 162 +++++++++--------- .../Subscriptions/SubscriptionsView.swift | 55 +++--- 32 files changed, 238 insertions(+), 142 deletions(-) create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png create mode 100644 Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg create mode 100644 Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg 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 0000000000000000000000000000000000000000..bedb798955a19f97c2ad472f88449d3de6411e86 GIT binary patch literal 31061 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 VW0(z0F$P@$$Ve!NSBV+~{0|w52Pyyn literal 0 HcmV?d00001 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 f8788cd1b..3f623b7d1 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 @@ -103,7 +104,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..7a9a11381 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -16,18 +16,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 +43,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 +57,7 @@ struct SegmentedControl: View { ZStack { Rectangle() .frame(height: 2) - .foregroundColor(Color.white64) + .foregroundColor(inactiveColor ?? .white64) if selectedTab == tabItem.tab { Rectangle() 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/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 4202b9505..ee98d8d23 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -90,7 +90,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 5b8656838..3ac647050 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1046,7 +1046,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index eb4be01b4..4975b0582 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1204,7 +1204,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index c8e8f6583..ca14def23 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1200,7 +1200,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index fb15f0bc7..b3a953c4f 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -908,7 +908,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b8bc5e5e4..46c33c41a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1543,7 +1543,7 @@ "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"; @@ -1564,7 +1564,7 @@ "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"; @@ -1581,7 +1581,7 @@ "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."; +"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"; @@ -1604,9 +1604,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/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 8cabd23f5..6b3d310bd 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1216,7 +1216,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 184992f0d..5ea18f007 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1075,7 +1075,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 8683ad49a..526af1eed 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1230,7 +1230,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 92990f585..5d2dd6c37 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1184,7 +1184,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 24bb3af90..1fb76dec3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1224,7 +1224,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 92bc101d4..d3b1c63ef 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1231,7 +1231,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 10418ba75..081cf8126 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1232,7 +1232,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index ee8ee8c57..148a91448 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -68,7 +68,7 @@ "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."; +"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"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 6cf1f6d93..859b81f1b 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1218,7 +1218,7 @@ "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."; +"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"; 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 f1089236d..f84dd3262 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -170,12 +170,12 @@ struct PaykitRecipientPicker: View { 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) @@ -200,9 +200,10 @@ struct PaykitRecipientPicker: View { t("wallet__payment_request_enter_pubky"), text: $recipientQuery, backgroundColor: .clear, - font: .custom(Fonts.regular, size: 17), - testIdentifier: "\(testIdentifierPrefix)RecipientFilter" + testIdentifier: "\(testIdentifierPrefix)RecipientFilter", + contentPadding: 0 ) + .frame(minHeight: 20) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) @@ -218,14 +219,15 @@ struct PaykitRecipientPicker: 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("\(testIdentifierPrefix)RecipientPaste") } .padding(16) - .background(Color.white08) + .background(Color.white10) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -241,7 +243,7 @@ struct PaykitRecipientPicker: View { @ViewBuilder private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { if let contact = contact(for: target) { - PubkyContactRow(contact: contact, verticalPadding: 20, isSelected: selectedTarget == target) { + PubkyContactRow(contact: contact, verticalPadding: 24, isSelected: selectedTarget == target) { onSelect(target) } .accessibilityIdentifier("\(testIdentifierPrefix)Contact-\(contact.publicKey)") @@ -261,7 +263,7 @@ struct PaykitRecipientPicker: View { .accessibilityHidden(true) } } - .padding(.vertical, 20) + .padding(.vertical, 24) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index 4b7c676d0..e5e42a22d 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -9,6 +9,7 @@ struct CreateSubscriptionView: View { let onChooseRecipient: () -> Void @State private var selectedPhotoItem: PhotosPickerItem? + @FocusState private var isNameFocused: Bool @FocusState private var isDescriptionFocused: Bool private var isLoadingIcon: Bool { @@ -23,15 +24,27 @@ struct CreateSubscriptionView: View { VStack(spacing: 0) { SheetHeader(title: t("subscriptions__create_subscription")) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 20) { - amount - frequency - name - description - customIcon + 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) + } + } + } } - .padding(.bottom, 24) } CustomButton( @@ -42,7 +55,7 @@ struct CreateSubscriptionView: View { ) { onChooseRecipient() } - .buttonBottomPadding(isFocused: isDescriptionFocused) + .buttonBottomPadding(isFocused: isNameFocused || isDescriptionFocused) .accessibilityIdentifier("SubscriptionChooseRecipient") } .padding(.horizontal, 16) @@ -65,7 +78,7 @@ struct CreateSubscriptionView: View { } private var amount: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64) Button(action: onEditAmount) { HStack(spacing: 8) { @@ -75,8 +88,11 @@ struct CreateSubscriptionView: View { size: .display, symbol: true, color: .textPrimary, - symbolColor: .textSecondary + symbolColor: .textSecondary, + fillsWidth: false ) + .lineLimit(1) + .minimumScaleFactor(0.5) Image("pencil") .resizable() .frame(width: 24, height: 24) @@ -91,23 +107,14 @@ struct CreateSubscriptionView: View { private var frequency: some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("subscriptions__frequency").localizedUppercase, textColor: .white64) - HStack(spacing: 8) { - ForEach(SubscriptionFrequencyOption.allCases) { option in - Button { - draft.frequency = option.unit - } label: { - VStack(spacing: 8) { - CaptionBText(option.description, textColor: draft.frequency == option.unit ? .white : .secondary) - .frame(maxWidth: .infinity) - Rectangle() - .fill(draft.frequency == option.unit ? Color.white : Color.white16) - .frame(height: 2) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("SubscriptionFrequency-\(option.rawValue)") - } - } + SegmentedControl( + selectedTab: Binding( + get: { SubscriptionFrequencyOption.allCases.first { $0.unit == draft.frequency } ?? .month }, + set: { draft.frequency = $0.unit } + ), + tabs: SubscriptionFrequencyOption.allCases, + inactiveColor: .white.opacity(0.5) + ) } } @@ -119,6 +126,7 @@ struct CreateSubscriptionView: View { text: $draft.name, testIdentifier: "SubscriptionName" ) + .focused($isNameFocused) } } @@ -129,7 +137,10 @@ struct CreateSubscriptionView: View { text: $draft.description, placeholder: t("subscriptions__description_placeholder"), testIdentifier: "SubscriptionDescription", - isFocused: $isDescriptionFocused + isFocused: $isDescriptionFocused, + minHeight: 60, + maxHeight: 60, + backgroundColor: .white10 ) } } @@ -145,22 +156,18 @@ struct CreateSubscriptionView: View { .resizable() .scaledToFill() } else { - Image("subscription-default-icon") - .resizable() - .scaledToFit() - .padding(5) - .background(Color.white) + SubscriptionDefaultIcon(size: 40) } } .frame(width: 40, height: 40) .clipShape(RoundedRectangle(cornerRadius: 8)) - BodyMText(t("subscriptions__custom_icon_description"), textColor: .white64) + CaptionBText(t("subscriptions__custom_icon_description"), textColor: .white64) Spacer() } .padding(16) - .background(Color.gray6) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .background(Color.white10) + .clipShape(RoundedRectangle(cornerRadius: 8)) } .disabled(isLoadingIcon) .accessibilityIdentifier("SubscriptionIconPicker") @@ -228,7 +235,6 @@ struct SubscriptionRecipientView: View { } footer: { CustomButton( title: t("subscriptions__propose_subscription"), - icon: Image("airplane").resizable().frame(width: 16, height: 16), isDisabled: selectedTarget == nil, isLoading: paymentRequests.isCreatingRequest ) { @@ -248,9 +254,10 @@ struct SubscriptionRecipientView: View { } } } label: { - Image("timer") + Image("timer-outline") .resizable() - .frame(width: 24, height: 24) + .frame(width: 18, height: 21) + .frame(width: 24, height: 24, alignment: .top) .foregroundColor(.textPrimary) } .accessibilityLabel(t("wallet__payment_request_expires")) @@ -284,42 +291,43 @@ struct SubscriptionProposalSentView: View { SheetHeader(title: t(subscription.deliveryStatus == .sent ? "wallet__payment_request_sent_title" : "subscriptions__proposal_queued_title")) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Image("check") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - Spacer().frame(height: 16) - 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) + 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) } - SubscriptionRow( - subscription: subscription, - now: Date(), - subtitle: subscription.recurrence.frequencyValue - ) - .padding(.top, 8) - Spacer().frame(height: 24) + .frame(minHeight: geometry.size.height, alignment: .bottom) } - .padding(.top, 16) } CustomButton(title: t("common__ok")) { sheets.hideSheet(reason: "Subscription proposal created") @@ -335,16 +343,12 @@ struct SubscriptionProposalSentView: View { } } -private enum SubscriptionFrequencyOption: String, CaseIterable, Identifiable, CustomStringConvertible { +private enum SubscriptionFrequencyOption: String, CaseIterable, CustomStringConvertible { case day case week case month case year - var id: String { - rawValue - } - var unit: PaykitSubscriptionRecurrence.Unit { switch self { case .day: .day diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index f7eccd07b..12deeb93e 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -75,7 +75,8 @@ struct SubscriptionsView: View { tabItems: [ TabItem(.overview), TabItem(.payments, badge: paymentRequests.pendingRequests.count), - ] + ], + inactiveColor: .white.opacity(0.5) ) if selectedTab == .payments { @@ -99,7 +100,7 @@ struct SubscriptionsView: View { if selectedTab == .overview { CustomButton( title: t("subscriptions__create"), - icon: Image("plus").resizable().frame(width: 16, height: 16) + variant: .secondary ) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } @@ -137,21 +138,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 { @@ -161,23 +162,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: "plus") { + SubscriptionMetric(title: t("subscriptions__created"), icon: "asterisk") { BodyMSBText("\(created.count)") } + .fixedSize(horizontal: true, vertical: false) } } @@ -248,7 +254,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) @@ -258,7 +264,6 @@ private struct SubscriptionMetric: View { content() } } - .frame(maxWidth: .infinity, alignment: .leading) } } @@ -271,16 +276,16 @@ struct SubscriptionRow: 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(subtitle ?? 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) @@ -305,15 +310,9 @@ struct SubscriptionAvatar: View { var body: some View { if let iconURI = subscription.metadata.iconURI { - PubkyImage(uri: iconURI, size: size) + PubkyImage(uri: iconURI, size: size, cornerRadius: size / 5) } else if subscription.isCreatedByUser { - Image("subscription-default-icon") - .resizable() - .scaledToFit() - .padding(size / 8) - .frame(width: size, height: size) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: size / 5)) + SubscriptionDefaultIcon(size: size) } else if let contact { PubkyContactAvatar(contact: contact, size: size) } else { @@ -322,6 +321,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 From 3c6d3016f4cd0c40b6c65612866915d4ed4cd64b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 17:31:16 +0300 Subject: [PATCH 4/8] chore: rename changelog fragment --- changelog.d/next/{subscription-requests.added.md => 736.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{subscription-requests.added.md => 736.added.md} (100%) diff --git a/changelog.d/next/subscription-requests.added.md b/changelog.d/next/736.added.md similarity index 100% rename from changelog.d/next/subscription-requests.added.md rename to changelog.d/next/736.added.md From efff6c9571a55f36b994ea69b8db03300e5b97fd Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 9 Sep 2026 16:29:49 +0100 Subject: [PATCH 5/8] fix: preserve subscription history and translation fallback --- .../Localization/ar.lproj/Localizable.strings | 27 --------------- .../Localization/ca.lproj/Localizable.strings | 27 --------------- .../Localization/cs.lproj/Localizable.strings | 27 --------------- .../Localization/de.lproj/Localizable.strings | 27 --------------- .../Localization/el.lproj/Localizable.strings | 27 --------------- .../es-419.lproj/Localizable.strings | 27 --------------- .../Localization/es.lproj/Localizable.strings | 27 --------------- .../Localization/fr.lproj/Localizable.strings | 27 --------------- .../Localization/it.lproj/Localizable.strings | 27 --------------- .../Localization/nl.lproj/Localizable.strings | 27 --------------- .../Localization/pl.lproj/Localizable.strings | 27 --------------- .../pt-BR.lproj/Localizable.strings | 27 --------------- .../Localization/pt.lproj/Localizable.strings | 27 --------------- .../Localization/ru.lproj/Localizable.strings | 27 --------------- Bitkit/Services/PaykitSubscription.swift | 4 +++ .../Subscriptions/SubscriptionsView.swift | 4 +-- .../PaykitPaymentRequestServiceTests.swift | 34 +++++++++++++++++++ 17 files changed, 39 insertions(+), 381 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index ee98d8d23..03065b3f0 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -69,30 +69,3 @@ "widgets__weather__next_block" = "تضمين الكتلة التالية"; "wallet__payment_request" = "طلب دفع"; "wallet__send_error_support" = "الدعم"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 3ac647050..2a33bf72d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1025,30 +1025,3 @@ "widgets__weather__condition__average__short_title" = "Mitjanes"; "widgets__weather__condition__poor__short_title" = "Dolentes"; "wallet__send_error_support" = "Contacta amb el suport"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 4975b0582..d24013cb8 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1183,30 +1183,3 @@ "widgets__weather__condition__good__short_title" = "Příznivé"; "widgets__weather__condition__average__short_title" = "Průměrné"; "widgets__weather__condition__poor__short_title" = "Špatné"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index ca14def23..58d0dbb5b 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1179,30 +1179,3 @@ "widgets__weather__condition__good__short_title" = "Günstig"; "widgets__weather__condition__average__short_title" = "Durchschnittlich"; "widgets__weather__condition__poor__short_title" = "Schlecht"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index b3a953c4f..81eb72f6c 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -887,30 +887,3 @@ "widgets__weather__condition__average__short_title" = "Μέσες"; "widgets__weather__condition__poor__short_title" = "Κακές"; "wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 6b3d310bd..1518392f1 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1195,30 +1195,3 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Promedio"; "widgets__weather__condition__poor__short_title" = "Malas"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 5ea18f007..2e012ae75 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1054,30 +1054,3 @@ "widgets__weather__condition__average__short_title" = "Promedio"; "widgets__weather__condition__poor__short_title" = "Desfavorables"; "wallet__send_error_support" = "Contactar con el servicio de asistencia"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 526af1eed..c0be9761c 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1209,30 +1209,3 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Moyennes"; "widgets__weather__condition__poor__short_title" = "Mauvaises"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 5d2dd6c37..7e6b09c3f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1163,30 +1163,3 @@ "widgets__weather__condition__good__short_title" = "Favorevoli"; "widgets__weather__condition__average__short_title" = "Medie"; "widgets__weather__condition__poor__short_title" = "Sfavorevoli"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 1fb76dec3..a52d6fd05 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1203,30 +1203,3 @@ "widgets__weather__condition__good__short_title" = "Gunstig"; "widgets__weather__condition__average__short_title" = "Gemiddeld"; "widgets__weather__condition__poor__short_title" = "Slecht"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index d3b1c63ef..7f1c1fbd9 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1210,30 +1210,3 @@ "widgets__weather__condition__good__short_title" = "Korzystne"; "widgets__weather__condition__average__short_title" = "Średnie"; "widgets__weather__condition__poor__short_title" = "Niekorzystne"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 081cf8126..47500e956 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1211,30 +1211,3 @@ "widgets__weather__condition__good__short_title" = "Favoráveis"; "widgets__weather__condition__average__short_title" = "Normais"; "widgets__weather__condition__poor__short_title" = "Ruins"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 148a91448..89177e45b 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -47,30 +47,3 @@ "widgets__weather__current_fee" = "Tarifa média atual"; "widgets__weather__next_block" = "Inclusão no próximo bloco"; "wallet__payment_request" = "Pedido de pagamento"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 859b81f1b..80c6456c8 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1197,30 +1197,3 @@ "widgets__weather__condition__good__short_title" = "Благоприятные"; "widgets__weather__condition__average__short_title" = "Средние"; "widgets__weather__condition__poor__short_title" = "Плохие"; - -"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__delete_subscription" = "Delete Subscription"; -"subscriptions__description" = "Description"; -"subscriptions__description_placeholder" = "What is this subscription for?"; -"subscriptions__name" = "Subscription Name"; -"subscriptions__name_placeholder" = "Subscription name"; -"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__subscribers" = "Subscribers"; -"subscriptions__swipe_to_delete" = "Swipe To Delete"; diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index f08376708..846ecae5a 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -471,6 +471,10 @@ struct PaykitSubscription: Identifiable, Hashable { 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 { diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 12deeb93e..c91a2e40d 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -54,9 +54,7 @@ struct SubscriptionsView: View { } private var expired: [PaykitSubscription] { - paymentRequests.subscriptions.filter { - $0.isPayer && $0.isExpired(at: now) && $0.wasAccepted - } + paymentRequests.subscriptions.filter { $0.isExpiredVisible(at: now) } } private var created: [PaykitSubscription] { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 6d2cf051e..f71baea6f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -384,6 +384,40 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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( From 6a82651c29ea51420db2f2050257f109f2936407 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 10 Sep 2026 08:16:38 +0100 Subject: [PATCH 6/8] fix: preserve subscription proposal completion --- .../PaykitPaymentRequestService.swift | 24 +++- .../PaykitPaymentRequestServiceTests.swift | 131 +++++++++++++++++- 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 17e145853..0343081fb 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -565,7 +565,8 @@ struct PaykitPaymentRequestService { _ draft: PaykitSubscriptionDraft, to target: PaykitPaymentRequestTarget, savedPublicKeys: [String], - expectedIdentity: String + expectedIdentity: String, + validateBeforeProposing: @MainActor () throws -> Void ) async throws -> PaykitSubscription { let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() let validationDate = now() @@ -599,6 +600,13 @@ struct PaykitPaymentRequestService { } 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 @@ -1104,12 +1112,20 @@ final class PaykitPaymentRequestManager { draft, to: target, savedPublicKeys: savedPublicKeysSnapshot, - expectedIdentity: activeIdentity + 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), - savedPublicKeysSnapshot == savedPublicKeys + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) { invalidateRefresh() subscriptions.removeAll { $0.id == subscription.id } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index f71baea6f..0d86244e9 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 @@ -513,6 +514,112 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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))" @@ -2630,6 +2737,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { 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 @@ -2715,7 +2825,26 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathsByPublicKey[publicKey] ?? [] } - func uploadProfileAvatar(bytes _: Data, contentType _: String, expectedIdentity: String?) throws -> String { + 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 } From 123ccf51f6f9b4f5a35f34f4d692836bd49042d7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 10 Sep 2026 10:59:09 +0100 Subject: [PATCH 7/8] fix: show subscription sender on review --- .../Subscriptions/SubscriptionsView.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index c91a2e40d..2b77f7360 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -606,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) @@ -913,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) { @@ -936,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 { From 1833d00a7e8605dbb44eb1c611a2b82634050011 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 11 Sep 2026 19:30:14 +0100 Subject: [PATCH 8/8] fix: align subscription accessibility IDs --- Bitkit/Components/SegmentedControl.swift | 6 ++++-- .../Views/PaymentRequests/CreatePaymentRequestView.swift | 3 ++- Bitkit/Views/Subscriptions/CreateSubscriptionView.swift | 8 ++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index 7a9a11381..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 } } @@ -71,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/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index f84dd3262..5cbc7bfb0 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -188,6 +188,7 @@ struct PaykitRecipientPicker: View { .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) + .accessibilityElement(children: .contain) .accessibilityIdentifier(accessibilityIdentifier) } @@ -246,7 +247,7 @@ struct PaykitRecipientPicker: View { PubkyContactRow(contact: contact, verticalPadding: 24, isSelected: selectedTarget == target) { onSelect(target) } - .accessibilityIdentifier("\(testIdentifierPrefix)Contact-\(contact.publicKey)") + .accessibilityIdentifier("\(testIdentifierPrefix)Contact\(contact.publicKey)") } else { Button { onSelect(target) diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift index e5e42a22d..6284a3cc1 100644 --- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -74,6 +74,7 @@ struct CreateSubscriptionView: View { .task(id: selectedPhotoItem) { await loadIcon(selectedPhotoItem) } + .accessibilityElement(children: .contain) .accessibilityIdentifier("CreateSubscription") } @@ -112,7 +113,9 @@ struct CreateSubscriptionView: View { get: { SubscriptionFrequencyOption.allCases.first { $0.unit == draft.frequency } ?? .month }, set: { draft.frequency = $0.unit } ), - tabs: SubscriptionFrequencyOption.allCases, + tabItems: SubscriptionFrequencyOption.allCases.map { + TabItem($0, accessibilityIdentifier: "Tab-\($0.rawValue)") + }, inactiveColor: .white.opacity(0.5) ) } @@ -204,8 +207,9 @@ struct SubscriptionAmountView: View { target: nil, onContinue: { onContinue($0.amountSats) }, onBack: onBack, - testIdentifierPrefix: "Subscription" + testIdentifierPrefix: "PaymentRequest" ) + .accessibilityElement(children: .contain) .accessibilityIdentifier("SubscriptionAmount") } }